diff --git a/src/domains/helpers/DomainStatusIcon.tsx b/src/domains/helpers/DomainStatusIcon.tsx index 03ebf168..79fe86ea 100644 --- a/src/domains/helpers/DomainStatusIcon.tsx +++ b/src/domains/helpers/DomainStatusIcon.tsx @@ -8,6 +8,7 @@ import { faCircleNotch as loadingStatusIcon, } from '@fortawesome/free-solid-svg-icons'; import { MediaMatcher } from '../../utils/types'; +import { mutableRefToElementRef } from '../../utils/helpers/components'; import { DomainStatus } from '../data'; interface DomainStatusIconProps { @@ -34,11 +35,7 @@ export const DomainStatusIcon: FC = ({ status, matchMedia return ( <> - { - ref.current = el; - }} - > + {status === 'valid' ? : } diff --git a/src/servers/helpers/ImportServersBtn.tsx b/src/servers/helpers/ImportServersBtn.tsx index a5673623..7df560ac 100644 --- a/src/servers/helpers/ImportServersBtn.tsx +++ b/src/servers/helpers/ImportServersBtn.tsx @@ -1,16 +1,15 @@ -import { useRef, RefObject, ChangeEvent, MutableRefObject, useState, useEffect, FC, PropsWithChildren } from 'react'; +import { useRef, ChangeEvent, useState, useEffect, FC, PropsWithChildren } from 'react'; import { Button, UncontrolledTooltip } from 'reactstrap'; import { complement, pipe } from 'ramda'; import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useToggle } from '../../utils/helpers/hooks'; +import { mutableRefToElementRef } from '../../utils/helpers/components'; import { ServersImporter } from '../services/ServersImporter'; import { ServerData, ServersMap } from '../data'; import { DuplicatedServersModal } from './DuplicatedServersModal'; import './ImportServersBtn.scss'; -type Ref = RefObject | MutableRefObject; - export type ImportServersBtnProps = PropsWithChildren<{ onImport?: () => void; onImportError?: (error: Error) => void; @@ -21,7 +20,6 @@ export type ImportServersBtnProps = PropsWithChildren<{ interface ImportServersBtnConnectProps extends ImportServersBtnProps { createServers: (servers: ServerData[]) => void; servers: ServersMap; - fileRef: Ref; } const serversFiltering = (servers: ServerData[]) => @@ -30,14 +28,13 @@ const serversFiltering = (servers: ServerData[]) => export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC => ({ createServers, servers, - fileRef, children, onImport = () => {}, onImportError = () => {}, tooltipPlacement = 'bottom', className = '', }) => { - const ref = fileRef ?? useRef(); + const ref = useRef(); const [serversToCreate, setServersToCreate] = useState(); const [duplicatedServers, setDuplicatedServers] = useState([]); const [isModalOpen,, showModal, hideModal] = useToggle(); @@ -78,7 +75,13 @@ export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC You can create servers by importing a CSV file with columns name, apiKey and url. - + (); + const tooltipRef = useRef(); return ( <> @@ -43,9 +44,7 @@ export const ShortUrlVisitsCount = ( {visitsLink} { - tooltipRef.current = el; - }} + ref={mutableRefToElementRef(tooltipRef)} > {' '}/ {prettifiedMaxVisits}{' '} diff --git a/src/tags/TagCard.tsx b/src/tags/TagCard.tsx index a82eb3e1..03d5682d 100644 --- a/src/tags/TagCard.tsx +++ b/src/tags/TagCard.tsx @@ -10,6 +10,7 @@ import { getServerId, SelectedServer } from '../servers/data'; import { TagBullet } from './helpers/TagBullet'; import { NormalizedTag, TagModalProps } from './data'; import './TagCard.scss'; +import { mutableRefToElementRef } from '../utils/helpers/components'; export interface TagCardProps { tag: NormalizedTag; @@ -28,7 +29,7 @@ export const TagCard = ( const [isDeleteModalOpen, toggleDelete] = useToggle(); const [isEditModalOpen, toggleEdit] = useToggle(); const [hasTitle,, displayTitle] = useToggle(); - const titleRef = useRef(); + const titleRef = useRef(); const serverId = getServerId(selectedServer); useEffect(() => { @@ -55,9 +56,7 @@ export const TagCard = (
{ - titleRef.current = el ?? undefined; - }} + ref={mutableRefToElementRef(titleRef)} > {tag.tag} diff --git a/src/utils/InfoTooltip.tsx b/src/utils/InfoTooltip.tsx index 34cc7239..2070c76e 100644 --- a/src/utils/InfoTooltip.tsx +++ b/src/utils/InfoTooltip.tsx @@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { UncontrolledTooltip } from 'reactstrap'; import { Placement } from '@popperjs/core'; +import { mutableRefToElementRef } from './helpers/components'; type InfoTooltipProps = PropsWithChildren<{ className?: string; @@ -10,14 +11,11 @@ type InfoTooltipProps = PropsWithChildren<{ }>; export const InfoTooltip: FC = ({ className = '', placement, children }) => { - const ref = useRef(); - const refCallback = (el: HTMLSpanElement) => { - ref.current = el; - }; + const ref = useRef(); return ( <> - + ref.current) as any} placement={placement}>{children} diff --git a/src/utils/helpers/components.ts b/src/utils/helpers/components.ts new file mode 100644 index 00000000..0abe4bbc --- /dev/null +++ b/src/utils/helpers/components.ts @@ -0,0 +1,5 @@ +import { MutableRefObject, Ref } from 'react'; + +export const mutableRefToElementRef = (ref: MutableRefObject): Ref => (el) => { + ref.current = el ?? undefined; // eslint-disable-line no-param-reassign +}; diff --git a/test/servers/helpers/HighlightCard.test.tsx b/test/servers/helpers/HighlightCard.test.tsx index 52815fd9..199fe4c9 100644 --- a/test/servers/helpers/HighlightCard.test.tsx +++ b/test/servers/helpers/HighlightCard.test.tsx @@ -1,32 +1,23 @@ -import { shallow, ShallowWrapper } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { ReactNode } from 'react'; -import { Card, CardText, CardTitle } from 'reactstrap'; -import { Link } from 'react-router-dom'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { MemoryRouter } from 'react-router-dom'; import { HighlightCard, HighlightCardProps } from '../../../src/servers/helpers/HighlightCard'; describe('', () => { - let wrapper: ShallowWrapper; - const createWrapper = (props: HighlightCardProps & { children?: ReactNode }) => { - wrapper = shallow(); - - return wrapper; - }; - - afterEach(() => wrapper?.unmount()); + const setUp = (props: HighlightCardProps & { children?: ReactNode }) => render( + + + , + ); it.each([ [undefined], [false], - ])('renders expected components', (link) => { - const wrapper = createWrapper({ title: 'foo', link: link as undefined | false }); + ])('does not render icon when there is no link', (link) => { + setUp({ title: 'foo', link: link as undefined | false }); - expect(wrapper.find(Card)).toHaveLength(1); - expect(wrapper.find(CardTitle)).toHaveLength(1); - expect(wrapper.find(CardText)).toHaveLength(1); - expect(wrapper.find(FontAwesomeIcon)).toHaveLength(0); - expect(wrapper.prop('tag')).not.toEqual(Link); - expect(wrapper.prop('to')).not.toBeDefined(); + expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); it.each([ @@ -34,10 +25,8 @@ describe('', () => { ['bar'], ['baz'], ])('renders provided title', (title) => { - const wrapper = createWrapper({ title }); - const cardTitle = wrapper.find(CardTitle); - - expect(cardTitle.html()).toContain(`>${title}<`); + setUp({ title }); + expect(screen.getByText(title)).toHaveAttribute('class', expect.stringContaining('highlight-card__title')); }); it.each([ @@ -45,10 +34,8 @@ describe('', () => { ['bar'], ['baz'], ])('renders provided children', (children) => { - const wrapper = createWrapper({ title: 'foo', children }); - const cardText = wrapper.find(CardText); - - expect(cardText.html()).toContain(`>${children}<`); + setUp({ title: 'title', children }); + expect(screen.getByText(children)).toHaveAttribute('class', expect.stringContaining('card-text')); }); it.each([ @@ -56,10 +43,9 @@ describe('', () => { ['bar'], ['baz'], ])('adds extra props when a link is provided', (link) => { - const wrapper = createWrapper({ title: 'foo', link }); + setUp({ title: 'title', link }); - expect(wrapper.find(FontAwesomeIcon)).toHaveLength(1); - expect(wrapper.prop('tag')).toEqual(Link); - expect(wrapper.prop('to')).toEqual(link); + expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute('href', `/${link}`); }); }); diff --git a/test/servers/helpers/ImportServersBtn.test.tsx b/test/servers/helpers/ImportServersBtn.test.tsx index 88e1e345..de3d2514 100644 --- a/test/servers/helpers/ImportServersBtn.test.tsx +++ b/test/servers/helpers/ImportServersBtn.test.tsx @@ -1,46 +1,41 @@ -import { ReactNode } from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; -import { UncontrolledTooltip } from 'reactstrap'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Mock } from 'ts-mockery'; import { ImportServersBtn as createImportServersBtn, ImportServersBtnProps, } from '../../../src/servers/helpers/ImportServersBtn'; import { ServersImporter } from '../../../src/servers/services/ServersImporter'; -import { DuplicatedServersModal } from '../../../src/servers/helpers/DuplicatedServersModal'; +import { ServersMap, ServerWithId } from '../../../src/servers/data'; describe('', () => { - let wrapper: ShallowWrapper; const onImportMock = jest.fn(); const createServersMock = jest.fn(); const importServersFromFile = jest.fn().mockResolvedValue([]); const serversImporterMock = Mock.of({ importServersFromFile }); - const click = jest.fn(); - const fileRef = { current: Mock.of({ click }) }; const ImportServersBtn = createImportServersBtn(serversImporterMock); - const createWrapper = (props: Partial = {}) => { - wrapper = shallow( + const setUp = (props: Partial = {}, servers: ServersMap = {}) => ({ + user: userEvent.setup(), + ...render( , - ); - - return wrapper; - }; + ), + }); afterEach(jest.clearAllMocks); - afterEach(() => wrapper.unmount()); - it('renders a button, a tooltip and a file input', () => { - const wrapper = createWrapper(); + it('shows tooltip on button hover', async () => { + const { user } = setUp(); - expect(wrapper.find('#importBtn')).toHaveLength(1); - expect(wrapper.find(UncontrolledTooltip)).toHaveLength(1); - expect(wrapper.find('.import-servers-btn__csv-select')).toHaveLength(1); + expect(screen.queryByText(/^You can create servers by importing a CSV file/)).not.toBeInTheDocument(); + await user.hover(screen.getByRole('button')); + await waitFor( + () => expect(screen.getByText(/^You can create servers by importing a CSV file/)).toBeInTheDocument(), + ); }); it.each([ @@ -48,53 +43,43 @@ describe('', () => { ['foo', 'foo'], ['bar', 'bar'], ])('allows a class name to be provided', (providedClassName, expectedClassName) => { - const wrapper = createWrapper({ className: providedClassName }); - - expect(wrapper.find('#importBtn').prop('className')).toEqual(expectedClassName); + setUp({ className: providedClassName }); + expect(screen.getByRole('button')).toHaveAttribute('class', expect.stringContaining(expectedClassName)); }); it.each([ - [undefined, true], - ['foo', false], - ['bar', false], - ])('has expected text', (children, expectToHaveDefaultText) => { - const wrapper = createWrapper({ children }); - - if (expectToHaveDefaultText) { - expect(wrapper.find('#importBtn').html()).toContain('Import from file'); - } else { - expect(wrapper.find('#importBtn').html()).toContain(children); - expect(wrapper.find('#importBtn').html()).not.toContain('Import from file'); - } - }); - - it('triggers click on file ref when button is clicked', () => { - const wrapper = createWrapper(); - const btn = wrapper.find('#importBtn'); - - btn.simulate('click'); - - expect(click).toHaveBeenCalledTimes(1); + [undefined, 'Import from file'], + ['foo', 'foo'], + ['bar', 'bar'], + ])('has expected text', (children, expectedText) => { + setUp({ children }); + expect(screen.getByRole('button')).toHaveTextContent(expectedText); }); it('imports servers when file input changes', async () => { - const wrapper = createWrapper(); - const file = wrapper.find('.import-servers-btn__csv-select'); - - await file.simulate('change', { target: { files: [''] } }); + const { container } = setUp(); + const input = container.querySelector('[type=file]'); + input && fireEvent.change(input, { target: { files: [''] } }); expect(importServersFromFile).toHaveBeenCalledTimes(1); }); it.each([ - ['discard'], - ['save'], - ])('invokes callback in DuplicatedServersModal events', (event) => { - const wrapper = createWrapper(); + ['Save anyway', true], + ['Discard', false], + ])('creates expected servers depending on selected option in modal', async (btnName, savesDuplicatedServers) => { + const existingServer = Mock.of({ id: 'abc', url: 'existingUrl', apiKey: 'existingApiKey' }); + const newServer = Mock.of({ url: 'newUrl', apiKey: 'newApiKey' }); + const { container, user } = setUp({}, { abc: existingServer }); + const input = container.querySelector('[type=file]'); + importServersFromFile.mockResolvedValue([existingServer, newServer]); - wrapper.find(DuplicatedServersModal).simulate(event); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + input && fireEvent.change(input, { target: { files: [''] } }); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: btnName })); - expect(createServersMock).toHaveBeenCalledTimes(1); + expect(createServersMock).toHaveBeenCalledWith(savesDuplicatedServers ? [existingServer, newServer] : [newServer]); expect(onImportMock).toHaveBeenCalledTimes(1); }); }); diff --git a/test/settings/Settings.test.tsx b/test/settings/Settings.test.tsx index 3a94fb07..593dd943 100644 --- a/test/settings/Settings.test.tsx +++ b/test/settings/Settings.test.tsx @@ -1,29 +1,48 @@ -import { shallow } from 'enzyme'; -import { Route } from 'react-router-dom'; +import { render, screen } from '@testing-library/react'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; import { Settings as createSettings } from '../../src/settings/Settings'; -import { NoMenuLayout } from '../../src/common/NoMenuLayout'; -import { NavPillItem } from '../../src/utils/NavPills'; describe('', () => { - const Component = () => null; - const Settings = createSettings(Component, Component, Component, Component, Component, Component); + const Settings = createSettings( + () => RealTimeUpdates, + () => ShortUrlCreation, + () => ShortUrlsList, + () => UserInterface, + () => Visits, + () => Tags, + ); + const setUp = (activeRoute = '/') => { + const history = createMemoryHistory(); + history.push(activeRoute); + return render(); + }; - it('renders a no-menu layout with the expected settings sections', () => { - const wrapper = shallow(); - const layout = wrapper.find(NoMenuLayout); - const sections = wrapper.find(Route); + it.each([ + ['/general', { + visibleComps: ['UserInterface', 'RealTimeUpdates'], + hiddenComps: ['ShortUrlCreation', 'ShortUrlsList', 'Tags', 'Visits'], + }], + ['/short-urls', { + visibleComps: ['ShortUrlCreation', 'ShortUrlsList'], + hiddenComps: ['UserInterface', 'RealTimeUpdates', 'Tags', 'Visits'], + }], + ['/other-items', { + visibleComps: ['Tags', 'Visits'], + hiddenComps: ['UserInterface', 'RealTimeUpdates', 'ShortUrlCreation', 'ShortUrlsList'], + }], + ])('renders expected sections based on route', (activeRoute, { visibleComps, hiddenComps }) => { + setUp(activeRoute); - expect(layout).toHaveLength(1); - expect(sections).toHaveLength(4); + visibleComps.forEach((comp) => expect(screen.getByText(comp)).toBeInTheDocument()); + hiddenComps.forEach((comp) => expect(screen.queryByText(comp)).not.toBeInTheDocument()); }); it('renders expected menu', () => { - const wrapper = shallow(); - const items = wrapper.find(NavPillItem); + setUp(); - expect(items).toHaveLength(3); - expect(items.first().prop('to')).toEqual('general'); - expect(items.at(1).prop('to')).toEqual('short-urls'); - expect(items.last().prop('to')).toEqual('other-items'); + expect(screen.getByRole('link', { name: 'General' })).toHaveAttribute('href', '/general'); + expect(screen.getByRole('link', { name: 'Short URLs' })).toHaveAttribute('href', '/short-urls'); + expect(screen.getByRole('link', { name: 'Other items' })).toHaveAttribute('href', '/other-items'); }); });