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());
+ });
});