From ee62d9a5f03b6ac053ec1551909dd6d59c6ee34d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Aug 2023 13:53:08 +0200 Subject: [PATCH] Increase coverage in TagSelector --- config/test/setupTests.ts | 1 + .../src/short-urls/ShortUrlForm.tsx | 3 +- .../src/tags/helpers/TagsSelector.tsx | 74 ++++++++++--------- .../test/tags/helpers/TagsSelector.test.tsx | 70 ++++++++++++++++-- 4 files changed, 103 insertions(+), 45 deletions(-) diff --git a/config/test/setupTests.ts b/config/test/setupTests.ts index 3411ca8b..f8539125 100644 --- a/config/test/setupTests.ts +++ b/config/test/setupTests.ts @@ -25,3 +25,4 @@ afterEach(() => { (global as any).scrollTo = () => {}; (global as any).prompt = () => {}; (global as any).matchMedia = (media: string) => ({ matches: false, media }); +(global as any).HTMLElement.prototype.scrollIntoView = () => {}; diff --git a/shlink-web-component/src/short-urls/ShortUrlForm.tsx b/shlink-web-component/src/short-urls/ShortUrlForm.tsx index cc98bf7e..85c73ba4 100644 --- a/shlink-web-component/src/short-urls/ShortUrlForm.tsx +++ b/shlink-web-component/src/short-urls/ShortUrlForm.tsx @@ -11,7 +11,6 @@ import { Button, FormGroup, Input, Row } from 'reactstrap'; import type { InputType } from 'reactstrap/types/lib/Input'; import type { ShlinkCreateShortUrlData, ShlinkDeviceLongUrls, ShlinkEditShortUrlData } from '../api-contract'; import type { DomainSelectorProps } from '../domains/DomainSelector'; -import { normalizeTag } from '../tags/helpers'; import type { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import { IconInput } from '../utils/components/IconInput'; import type { DateTimeInputProps } from '../utils/dates/DateTimeInput'; @@ -54,7 +53,7 @@ export const ShortUrlForm = ( const isEdit = mode === 'edit'; const isCreation = isCreationData(shortUrlData); const isBasicMode = mode === 'create-basic'; - const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); + const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags }); const setResettableValue = (value: string, initialValue?: any) => { if (hasValue(value)) { return value; diff --git a/shlink-web-component/src/tags/helpers/TagsSelector.tsx b/shlink-web-component/src/tags/helpers/TagsSelector.tsx index f68b75f2..a789a07b 100644 --- a/shlink-web-component/src/tags/helpers/TagsSelector.tsx +++ b/shlink-web-component/src/tags/helpers/TagsSelector.tsx @@ -1,5 +1,6 @@ +import { useElementRef } from '@shlinkio/shlink-frontend-kit'; import classNames from 'classnames'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import type { OptionRendererProps, ReactTagsAPI, TagRendererProps, TagSuggestion } from 'react-tag-autocomplete'; import { ReactTags } from 'react-tag-autocomplete'; import type { ColorGenerator } from '../../utils/services/ColorGenerator'; @@ -9,17 +10,17 @@ import { normalizeTag } from './index'; import { Tag } from './Tag'; import { TagBullet } from './TagBullet'; -export interface TagsSelectorProps { +export type TagsSelectorProps = { selectedTags: string[]; onChange: (tags: string[]) => void; placeholder?: string; allowNew?: boolean; -} +}; -interface TagsSelectorConnectProps extends TagsSelectorProps { +type TagsSelectorConnectProps = TagsSelectorProps & { listTags: () => void; tagsList: TagsList; -} +}; const NOT_FOUND_TAG = 'Tag not found'; const NEW_TAG = 'Add tag'; @@ -27,6 +28,33 @@ const isSelectableOption = (tag: string) => tag !== NOT_FOUND_TAG; const isNewOption = (tag: string) => tag === NEW_TAG; const toTagObject = (tag: string): TagSuggestion => ({ label: tag, value: tag }); +const buildTagRenderer = (colorGenerator: ColorGenerator) => ({ tag, onClick: deleteTag }: TagRendererProps) => ( + +); +const buildOptionRenderer = (colorGenerator: ColorGenerator, api: ReactTagsAPI | null) => ( + { option, classNames: classes, ...rest }: OptionRendererProps, +) => { + const isSelectable = isSelectableOption(option.label); + const isNew = isNewOption(option.label); + + return ( +
+ {!isSelectable ? {option.label} : ( + <> + {!isNew && } + {!isNew ? option.label : Add "{normalizeTag(api?.input.value ?? '')}"} + + )} +
+ ); +}; + export const TagsSelector = (colorGenerator: ColorGenerator) => ( { selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps, ) => { @@ -36,40 +64,15 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( const shortUrlCreation = useSetting('shortUrlCreation'); const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith'; - const apiRef = useRef(null); - - const ReactTagsTag = ({ tag, onClick: deleteTag }: TagRendererProps) => ( - - ); - const ReactTagsSuggestion = ({ option, classNames: classes, ...rest }: OptionRendererProps) => { - const isSelectable = isSelectableOption(option.label); - const isNew = isNewOption(option.label); - - return ( -
- {!isSelectable ? {option.label} : ( - <> - {!isNew && } - {!isNew ? option.label : Add "{normalizeTag(apiRef.current?.input.value ?? '')}"} - - )} -
- ); - }; + const apiRef = useElementRef(); return ( !selectedTags.includes(tag)).map(toTagObject)} - renderTag={ReactTagsTag} - renderOption={ReactTagsSuggestion} + renderTag={buildTagRenderer(colorGenerator)} + renderOption={buildOptionRenderer(colorGenerator, apiRef.current)} activateFirstOption allowNew={allowNew} newOptionText={NEW_TAG} @@ -90,9 +93,8 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( onChange(tagsCopy); }} onAdd={({ label: newTag }) => onChange( - // * Avoid duplicated tags (thanks to the Set), - // * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once. - [...new Set([...selectedTags, ...newTag.split(',')])], + // Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once. + [...selectedTags, ...newTag.split(',').map(normalizeTag)], )} /> ); diff --git a/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx b/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx index 86b28411..e4326133 100644 --- a/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx +++ b/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx @@ -2,22 +2,33 @@ import { screen } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import { TagsSelector as createTagsSelector } from '../../../src/tags/helpers/TagsSelector'; import type { TagsList } from '../../../src/tags/reducers/tagsList'; +import type { TagFilteringMode } from '../../../src/utils/settings'; import { SettingsProvider } from '../../../src/utils/settings'; import { renderWithEvents } from '../../__helpers__/setUpTest'; import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock'; +type SetUpOptions = { + allowNew?: boolean; + allTags?: string[]; + tagFilteringMode?: TagFilteringMode; +}; + describe('', () => { const onChange = vi.fn(); const TagsSelector = createTagsSelector(colorGeneratorMock); const tags = ['foo', 'bar']; - const tagsList = fromPartial({ tags: [...tags, 'baz'] }); - const setUp = () => renderWithEvents( - + const setUp = ({ allowNew = true, allTags, tagFilteringMode }: SetUpOptions = {}) => renderWithEvents( + ({ tags: allTags ?? [...tags, 'baz'] })} listTags={vi.fn()} onChange={onChange} + allowNew={allowNew} /> , ); @@ -37,18 +48,36 @@ describe('', () => { it('contains expected suggestions', async () => { const { container, user } = setUp(); - expect(container.querySelector('.react-tags__suggestions')).not.toBeInTheDocument(); + expect(container.querySelector('.react-tags__listbox')).not.toBeInTheDocument(); expect(screen.queryByText('baz')).not.toBeInTheDocument(); await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'ba'); - expect(container.querySelector('.react-tags__combobox')).toBeInTheDocument(); + expect(container.querySelector('.react-tags__listbox')).toBeInTheDocument(); expect(screen.getByText('baz')).toBeInTheDocument(); }); + it('limits the amount of suggestions', async () => { + const { user } = setUp({ allTags: ['foo', 'foo1', 'foo2', 'foo3', 'foo4', 'foo5', 'foo6', 'foo7'] }); + + await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'fo'); + + // First results are in the document + expect(screen.getByText('foo')).toBeInTheDocument(); + expect(screen.getByText('foo1')).toBeInTheDocument(); + expect(screen.getByText('foo2')).toBeInTheDocument(); + expect(screen.getByText('foo3')).toBeInTheDocument(); + expect(screen.getByText('foo4')).toBeInTheDocument(); + expect(screen.getByText('foo5')).toBeInTheDocument(); + // While the last ones are not + expect(screen.queryByText('foo6')).not.toBeInTheDocument(); + expect(screen.queryByText('foo7')).not.toBeInTheDocument(); + }); + it.each([ ['The-New-Tag', [...tags, 'the-new-tag']], - ['foo', tags], + ['AnOTH er tag ', [...tags, 'anoth-er-tag']], + // ['foo', tags], TODO Test that existing tags are ignored ])('invokes onChange when new tags are added', async (newTag, expectedTags) => { const { user } = setUp(); @@ -77,4 +106,31 @@ describe('', () => { await user.click(screen.getByLabelText(`Remove ${removedLabel}`)); expect(onChange).toHaveBeenCalledWith([expected]); }); + + it('displays "Add tag" option for new tags', async () => { + const { user } = setUp(); + + expect(screen.queryByText(/^Add "/)).not.toBeInTheDocument(); + await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'new-tag'); + expect(screen.getByText(/^Add "/)).toBeInTheDocument(); + }); + + it('displays "Tag not found" for unknown tags when add is not allowed', async () => { + const { user } = setUp({ allowNew: false }); + + expect(screen.queryByText('Tag not found')).not.toBeInTheDocument(); + await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'not-found-tag'); + expect(screen.getByText('Tag not found')).toBeInTheDocument(); + }); + + it.each([ + ['startsWith' as TagFilteringMode, ['foo', 'foobar']], + ['includes' as TagFilteringMode, ['foo', 'barfoo', 'foobar']], + ])('filters suggestions with different algorithm based on filtering mode', async (tagFilteringMode, expectedTags) => { + const { user } = setUp({ tagFilteringMode, allTags: ['foo', 'barfoo', 'foobar'] }); + + await user.type(screen.getByPlaceholderText('Add tags to the URL'), ' Foo'); + + expectedTags.forEach((tag) => expect(screen.getByText(tag)).toBeInTheDocument()); + }); });