diff --git a/src/servers/CreateServer.tsx b/src/servers/CreateServer.tsx index bcc60e46..df96e838 100644 --- a/src/servers/CreateServer.tsx +++ b/src/servers/CreateServer.tsx @@ -4,7 +4,7 @@ import { Button } from 'reactstrap'; import { useNavigate } from 'react-router-dom'; import { Result } from '../utils/Result'; import { NoMenuLayout } from '../common/NoMenuLayout'; -import { StateFlagTimeout, useGoBack, useToggle } from '../utils/helpers/hooks'; +import { TimeoutToggle, useGoBack, useToggle } from '../utils/helpers/hooks'; import { ServerForm } from './helpers/ServerForm'; import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { ServerData, ServersMap, ServerWithId } from './data'; @@ -26,14 +26,14 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => ( ); -export const CreateServer = (ImportServersBtn: FC, useStateFlagTimeout: StateFlagTimeout) => ( +export const CreateServer = (ImportServersBtn: FC, useTimeoutToggle: TimeoutToggle) => ( { servers, createServer }: CreateServerProps, ) => { const navigate = useNavigate(); const goBack = useGoBack(); const hasServers = !!Object.keys(servers).length; - const [serversImported, setServersImported] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); - const [errorImporting, setErrorImporting] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); + const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME); + const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME); const [isConfirmModalOpen, toggleConfirmModal] = useToggle(); const [serverData, setServerData] = useState(); const save = () => { diff --git a/src/servers/ManageServers.tsx b/src/servers/ManageServers.tsx index 53c364ce..71c859a8 100644 --- a/src/servers/ManageServers.tsx +++ b/src/servers/ManageServers.tsx @@ -7,7 +7,7 @@ import { NoMenuLayout } from '../common/NoMenuLayout'; import { SimpleCard } from '../utils/SimpleCard'; import { SearchField } from '../utils/SearchField'; import { Result } from '../utils/Result'; -import { StateFlagTimeout } from '../utils/helpers/hooks'; +import { TimeoutToggle } from '../utils/helpers/hooks'; import { ImportServersBtnProps } from './helpers/ImportServersBtn'; import { ServersMap } from './data'; import { ManageServersRowProps } from './ManageServersRow'; @@ -22,7 +22,7 @@ const SHOW_IMPORT_MSG_TIME = 4000; export const ManageServers = ( serversExporter: ServersExporter, ImportServersBtn: FC, - useStateFlagTimeout: StateFlagTimeout, + useTimeoutToggle: TimeoutToggle, ManageServersRow: FC, ): FC => ({ servers }) => { const allServers = Object.values(servers); @@ -31,7 +31,7 @@ export const ManageServers = ( allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)), ); const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect); - const [errorImporting, setErrorImporting] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME); + const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME); useEffect(() => { setServersList(Object.values(servers)); diff --git a/src/servers/services/provideServices.ts b/src/servers/services/provideServices.ts index 1d6b0489..6f56cc84 100644 --- a/src/servers/services/provideServices.ts +++ b/src/servers/services/provideServices.ts @@ -25,7 +25,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { ManageServers, 'ServersExporter', 'ImportServersBtn', - 'useStateFlagTimeout', + 'useTimeoutToggle', 'ManageServersRow', ); bottle.decorator('ManageServers', withoutSelectedServer); @@ -36,7 +36,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal'); bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect'])); - bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout'); + bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useTimeoutToggle'); bottle.decorator('CreateServer', withoutSelectedServer); bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServer', 'resetSelectedServer'])); diff --git a/src/short-urls/helpers/CreateShortUrlResult.tsx b/src/short-urls/helpers/CreateShortUrlResult.tsx index 3345a4ee..73d60ba6 100644 --- a/src/short-urls/helpers/CreateShortUrlResult.tsx +++ b/src/short-urls/helpers/CreateShortUrlResult.tsx @@ -6,7 +6,7 @@ import { useEffect } from 'react'; import CopyToClipboard from 'react-copy-to-clipboard'; import { Tooltip } from 'reactstrap'; import { ShortUrlCreation } from '../reducers/shortUrlCreation'; -import { StateFlagTimeout } from '../../utils/helpers/hooks'; +import { TimeoutToggle } from '../../utils/helpers/hooks'; import { Result } from '../../utils/Result'; import './CreateShortUrlResult.scss'; import { ShlinkApiError } from '../../api/ShlinkApiError'; @@ -16,10 +16,10 @@ export interface CreateShortUrlResultProps extends ShortUrlCreation { canBeClosed?: boolean; } -export const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => ( +export const CreateShortUrlResult = (useTimeoutToggle: TimeoutToggle) => ( { error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps, ) => { - const [showCopyTooltip, setShowCopyTooltip] = useStateFlagTimeout(); + const [showCopyTooltip, setShowCopyTooltip] = useTimeoutToggle(); useEffect(() => { resetCreateShortUrl(); diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx index a15d4692..eed961af 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/src/short-urls/helpers/ShortUrlsRow.tsx @@ -2,7 +2,7 @@ import { FC, useEffect, useRef } from 'react'; import { isEmpty } from 'ramda'; import { ExternalLink } from 'react-external-link'; import { ColorGenerator } from '../../utils/services/ColorGenerator'; -import { StateFlagTimeout } from '../../utils/helpers/hooks'; +import { TimeoutToggle } from '../../utils/helpers/hooks'; import { Tag } from '../../tags/helpers/Tag'; import { SelectedServer } from '../../servers/data'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; @@ -21,10 +21,10 @@ export interface ShortUrlsRowProps { export const ShortUrlsRow = ( ShortUrlsRowMenu: FC, colorGenerator: ColorGenerator, - useStateFlagTimeout: StateFlagTimeout, + useTimeoutToggle: TimeoutToggle, ) => ({ shortUrl, selectedServer, onTagClick }: ShortUrlsRowProps) => { - const [copiedToClipboard, setCopiedToClipboard] = useStateFlagTimeout(); - const [active, setActive] = useStateFlagTimeout(false, 500); + const [copiedToClipboard, setCopiedToClipboard] = useTimeoutToggle(); + const [active, setActive] = useTimeoutToggle(false, 500); const isFirstRun = useRef(true); const renderTags = (tags: string[]) => { diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index f6d1533f..a19ca9b6 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -27,9 +27,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { )); bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow'); - bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout'); + bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useTimeoutToggle'); bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal'); - bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout'); + bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useTimeoutToggle'); bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector'); bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult'); diff --git a/src/utils/forms/InputFormGroup.tsx b/src/utils/forms/InputFormGroup.tsx index 9e047d3a..9dabb6b1 100644 --- a/src/utils/forms/InputFormGroup.tsx +++ b/src/utils/forms/InputFormGroup.tsx @@ -1,6 +1,7 @@ import { FC, PropsWithChildren } from 'react'; import { InputType } from 'reactstrap/types/lib/Input'; import { LabeledFormGroup } from './LabeledFormGroup'; +import { useDomId } from '../helpers/hooks'; export type InputFormGroupProps = PropsWithChildren<{ value: string; @@ -14,15 +15,20 @@ export type InputFormGroupProps = PropsWithChildren<{ export const InputFormGroup: FC = ( { children, value, onChange, type, required, placeholder, className, labelClassName }, -) => ( - {children}:} className={className ?? ''} labelClassName={labelClassName}> - onChange(e.target.value)} - /> - -); +) => { + const id = useDomId(); + + return ( + {children}:} className={className ?? ''} labelClassName={labelClassName} id={id}> + onChange(e.target.value)} + /> + + ); +}; diff --git a/src/utils/forms/LabeledFormGroup.tsx b/src/utils/forms/LabeledFormGroup.tsx index ea11c1ee..dfc68729 100644 --- a/src/utils/forms/LabeledFormGroup.tsx +++ b/src/utils/forms/LabeledFormGroup.tsx @@ -5,14 +5,15 @@ type LabeledFormGroupProps = PropsWithChildren<{ noMargin?: boolean; className?: string; labelClassName?: string; + id?: string; }>; /* eslint-disable jsx-a11y/label-has-associated-control */ export const LabeledFormGroup: FC = ( - { children, label, className = '', labelClassName = '', noMargin = false }, + { children, label, className = '', labelClassName = '', noMargin = false, id }, ) => (
- + {children}
); diff --git a/src/utils/helpers/hooks.ts b/src/utils/helpers/hooks.ts index 90c232d2..877f8412 100644 --- a/src/utils/helpers/hooks.ts +++ b/src/utils/helpers/hooks.ts @@ -6,12 +6,12 @@ import { parseQuery, stringifyQuery } from './query'; const DEFAULT_DELAY = 2000; -export type StateFlagTimeout = (initialValue?: boolean, delay?: number) => [ boolean, () => void ]; +export type TimeoutToggle = (initialValue?: boolean, delay?: number) => [boolean, () => void]; -export const useStateFlagTimeout = ( +export const useTimeoutToggle = ( setTimeout: (callback: Function, timeout: number) => number, clearTimeout: (timer: number) => void, -): StateFlagTimeout => (initialValue = false, delay = DEFAULT_DELAY) => { +): TimeoutToggle => (initialValue = false, delay = DEFAULT_DELAY) => { const [flag, setFlag] = useState(initialValue); const timeout = useRef(undefined); const callback = () => { @@ -31,7 +31,6 @@ type ToggleResult = [ boolean, () => void, () => void, () => void ]; export const useToggle = (initialValue = false): ToggleResult => { const [flag, setFlag] = useState(initialValue); - return [flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false)]; }; @@ -80,7 +79,6 @@ export const useEffectExceptFirstTime = (callback: EffectCallback, deps: Depende export const useGoBack = () => { const navigate = useNavigate(); - return () => navigate(-1); }; diff --git a/src/utils/services/provideServices.ts b/src/utils/services/provideServices.ts index 578a945c..53037c02 100644 --- a/src/utils/services/provideServices.ts +++ b/src/utils/services/provideServices.ts @@ -1,5 +1,5 @@ import Bottle from 'bottlejs'; -import { useStateFlagTimeout } from '../helpers/hooks'; +import { useTimeoutToggle } from '../helpers/hooks'; import { LocalStorage } from './LocalStorage'; import { ColorGenerator } from './ColorGenerator'; import { csvToJson, jsonToCsv } from '../helpers/csvjson'; @@ -14,7 +14,7 @@ const provideServices = (bottle: Bottle) => { bottle.constant('setTimeout', global.setTimeout); bottle.constant('clearTimeout', global.clearTimeout); - bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout'); + bottle.serviceFactory('useTimeoutToggle', useTimeoutToggle, 'setTimeout', 'clearTimeout'); }; export default provideServices; diff --git a/test/servers/CreateServer.test.tsx b/test/servers/CreateServer.test.tsx index d23a6d1e..54695846 100644 --- a/test/servers/CreateServer.test.tsx +++ b/test/servers/CreateServer.test.tsx @@ -1,83 +1,84 @@ -import { shallow, ShallowWrapper } from 'enzyme'; +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Mock } from 'ts-mockery'; import { useNavigate } from 'react-router-dom'; import { CreateServer as createCreateServer } from '../../src/servers/CreateServer'; -import { ServerForm } from '../../src/servers/helpers/ServerForm'; import { ServerWithId } from '../../src/servers/data'; -import { DuplicatedServersModal } from '../../src/servers/helpers/DuplicatedServersModal'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: jest.fn() })); describe('', () => { - let wrapper: ShallowWrapper; - const ImportServersBtn = () => null; const createServerMock = jest.fn(); const navigate = jest.fn(); - const servers = { foo: Mock.all() }; - const createWrapper = (serversImported = false, importFailed = false) => { + const servers = { foo: Mock.of({ url: 'https://existing_url.com', apiKey: 'existing_api_key' }) }; + const setUp = (serversImported = false, importFailed = false) => { (useNavigate as any).mockReturnValue(navigate); - const useStateFlagTimeout = jest.fn() - .mockReturnValueOnce([serversImported, () => '']) - .mockReturnValueOnce([importFailed, () => '']) - .mockReturnValue([]); - const CreateServer = createCreateServer(ImportServersBtn, useStateFlagTimeout); + let callCount = 0; + const useTimeoutToggle = jest.fn().mockImplementation(() => { + const result = [callCount % 2 === 0 ? serversImported : importFailed, () => null]; + callCount += 1; + return result; + }); + const CreateServer = createCreateServer(() => <>ImportServersBtn, useTimeoutToggle); - wrapper = shallow(); - - return wrapper; + return { + user: userEvent.setup(), + ...render(), + }; }; beforeEach(jest.clearAllMocks); - afterEach(() => wrapper?.unmount()); - - it('renders components', () => { - const wrapper = createWrapper(); - - expect(wrapper.find(ServerForm)).toHaveLength(1); - expect(wrapper.find('ImportResult')).toHaveLength(0); - }); it('shows success message when imported is true', () => { - const wrapper = createWrapper(true); - const result = wrapper.find('ImportResult'); + setUp(true); - expect(result).toHaveLength(1); - expect(result.prop('type')).toEqual('success'); + expect(screen.getByText('Servers properly imported. You can now select one from the list :)')).toBeInTheDocument(); + expect( + screen.queryByText('The servers could not be imported. Make sure the format is correct.'), + ).not.toBeInTheDocument(); }); it('shows error message when import failed', () => { - const wrapper = createWrapper(false, true); - const result = wrapper.find('ImportResult'); + setUp(false, true); - expect(result).toHaveLength(1); - expect(result.prop('type')).toEqual('error'); + expect( + screen.queryByText('Servers properly imported. You can now select one from the list :)'), + ).not.toBeInTheDocument(); + expect(screen.getByText('The servers could not be imported. Make sure the format is correct.')).toBeInTheDocument(); }); - it('creates server data when form is submitted', () => { - const wrapper = createWrapper(); - const form = wrapper.find(ServerForm); + it('creates server data when form is submitted', async () => { + const { user } = setUp(); - expect(wrapper.find(DuplicatedServersModal).prop('duplicatedServers')).toEqual([]); - form.simulate('submit', {}); - expect(wrapper.find(DuplicatedServersModal).prop('duplicatedServers')).toEqual([{}]); + expect(createServerMock).not.toHaveBeenCalled(); + + await user.type(screen.getByLabelText(/^Name/), 'the_name'); + await user.type(screen.getByLabelText(/^URL/), 'https://the_url.com'); + await user.type(screen.getByLabelText(/^API key/), 'the_api_key'); + fireEvent.submit(screen.getByRole('form')); + + expect(createServerMock).toHaveBeenCalledWith(expect.objectContaining({ + name: 'the_name', + url: 'https://the_url.com', + apiKey: 'the_api_key', + })); + expect(navigate).toHaveBeenCalledWith(expect.stringMatching(/^\/server\//)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('saves server and redirects on modal save', () => { - const wrapper = createWrapper(); + it('displays dialog when trying to create a duplicated server', async () => { + const { user } = setUp(); - wrapper.find(ServerForm).simulate('submit', {}); - wrapper.find(DuplicatedServersModal).simulate('save'); + await user.type(screen.getByLabelText(/^Name/), 'the_name'); + await user.type(screen.getByLabelText(/^URL/), 'https://existing_url.com'); + await user.type(screen.getByLabelText(/^API key/), 'existing_api_key'); + fireEvent.submit(screen.getByRole('form')); - expect(createServerMock).toHaveBeenCalledTimes(1); - expect(navigate).toHaveBeenCalledTimes(1); - }); - - it('goes back on modal discard', () => { - const wrapper = createWrapper(); - - wrapper.find(DuplicatedServersModal).simulate('discard'); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Discard' })); + expect(createServerMock).not.toHaveBeenCalled(); expect(navigate).toHaveBeenCalledWith(-1); }); }); diff --git a/test/servers/ManageServers.test.tsx b/test/servers/ManageServers.test.tsx index 49292a1e..5cbb81e6 100644 --- a/test/servers/ManageServers.test.tsx +++ b/test/servers/ManageServers.test.tsx @@ -12,8 +12,8 @@ describe('', () => { const serversExporter = Mock.of({ exportServers }); const ImportServersBtn = () => null; const ManageServersRow = () => null; - const useStateFlagTimeout = jest.fn().mockReturnValue([false, jest.fn()]); - const ManageServers = createManageServers(serversExporter, ImportServersBtn, useStateFlagTimeout, ManageServersRow); + const useTimeoutToggle = jest.fn().mockReturnValue([false, jest.fn()]); + const ManageServers = createManageServers(serversExporter, ImportServersBtn, useTimeoutToggle, ManageServersRow); let wrapper: ShallowWrapper; const createServerMock = (value: string, autoConnect = false) => Mock.of( { id: value, name: value, url: value, autoConnect }, @@ -82,7 +82,7 @@ describe('', () => { }); it('shows an error message if an error occurs while importing servers', () => { - useStateFlagTimeout.mockReturnValue([true, jest.fn()]); + useTimeoutToggle.mockReturnValue([true, jest.fn()]); const wrapper = createWrapper({ foo: createServerMock('foo') }); const result = wrapper.find(Result); diff --git a/test/short-urls/helpers/CreateShortUrlResult.test.tsx b/test/short-urls/helpers/CreateShortUrlResult.test.tsx index 7dd45c30..1fbe1af0 100644 --- a/test/short-urls/helpers/CreateShortUrlResult.test.tsx +++ b/test/short-urls/helpers/CreateShortUrlResult.test.tsx @@ -4,14 +4,14 @@ import { Tooltip } from 'reactstrap'; import { Mock } from 'ts-mockery'; import { CreateShortUrlResult as createResult } from '../../../src/short-urls/helpers/CreateShortUrlResult'; import { ShortUrl } from '../../../src/short-urls/data'; -import { StateFlagTimeout } from '../../../src/utils/helpers/hooks'; +import { TimeoutToggle } from '../../../src/utils/helpers/hooks'; import { Result } from '../../../src/utils/Result'; describe('', () => { let wrapper: ShallowWrapper; const copyToClipboard = jest.fn(); - const useStateFlagTimeout = jest.fn(() => [false, copyToClipboard]) as StateFlagTimeout; - const CreateShortUrlResult = createResult(useStateFlagTimeout); + const useTimeoutToggle = jest.fn(() => [false, copyToClipboard]) as TimeoutToggle; + const CreateShortUrlResult = createResult(useTimeoutToggle); const createWrapper = (result: ShortUrl | null = null, error = false) => { wrapper = shallow( {}} result={result} error={error} saving={false} />, diff --git a/test/short-urls/helpers/ShortUrlsRow.test.tsx b/test/short-urls/helpers/ShortUrlsRow.test.tsx index fd917cf5..8812f962 100644 --- a/test/short-urls/helpers/ShortUrlsRow.test.tsx +++ b/test/short-urls/helpers/ShortUrlsRow.test.tsx @@ -6,7 +6,7 @@ import { formatISO } from 'date-fns'; import { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow'; import { Tag } from '../../../src/tags/helpers/Tag'; import { ColorGenerator } from '../../../src/utils/services/ColorGenerator'; -import { StateFlagTimeout } from '../../../src/utils/helpers/hooks'; +import { TimeoutToggle } from '../../../src/utils/helpers/hooks'; import { ShortUrl } from '../../../src/short-urls/data'; import { ReachableServer } from '../../../src/servers/data'; import { CopyToClipboardIcon } from '../../../src/utils/CopyToClipboardIcon'; @@ -17,8 +17,8 @@ describe('', () => { let wrapper: ShallowWrapper; const mockFunction = () => null; const ShortUrlsRowMenu = mockFunction; - const stateFlagTimeout = jest.fn(() => true); - const useStateFlagTimeout = jest.fn(() => [false, stateFlagTimeout]) as StateFlagTimeout; + const timeoutToggle = jest.fn(() => true); + const useTimeoutToggle = jest.fn(() => [false, timeoutToggle]) as TimeoutToggle; const colorGenerator = Mock.of({ getColorForKey: jest.fn(), setColorForKey: jest.fn(), @@ -38,7 +38,7 @@ describe('', () => { maxVisits: null, }, }; - const ShortUrlsRow = createShortUrlsRow(ShortUrlsRowMenu, colorGenerator, useStateFlagTimeout); + const ShortUrlsRow = createShortUrlsRow(ShortUrlsRowMenu, colorGenerator, useTimeoutToggle); const createWrapper = (title?: string | null) => { wrapper = shallow( , @@ -129,8 +129,8 @@ describe('', () => { const menu = col.find(CopyToClipboardIcon); expect(menu).toHaveLength(1); - expect(stateFlagTimeout).not.toHaveBeenCalled(); + expect(timeoutToggle).not.toHaveBeenCalled(); menu.simulate('copy'); - expect(stateFlagTimeout).toHaveBeenCalledTimes(1); + expect(timeoutToggle).toHaveBeenCalledTimes(1); }); });