mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-03 14:57:22 +03:00
Increase coverage in TagSelector
This commit is contained in:
parent
5b15c184eb
commit
ee62d9a5f0
4 changed files with 103 additions and 45 deletions
|
@ -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 = () => {};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,21 +28,12 @@ const isSelectableOption = (tag: string) => tag !== NOT_FOUND_TAG;
|
|||
const isNewOption = (tag: string) => tag === NEW_TAG;
|
||||
const toTagObject = (tag: string): TagSuggestion => ({ label: tag, value: tag });
|
||||
|
||||
export const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||
{ selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
listTags();
|
||||
}, []);
|
||||
|
||||
const shortUrlCreation = useSetting('shortUrlCreation');
|
||||
const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith';
|
||||
const apiRef = useRef<ReactTagsAPI>(null);
|
||||
|
||||
const ReactTagsTag = ({ tag, onClick: deleteTag }: TagRendererProps) => (
|
||||
const buildTagRenderer = (colorGenerator: ColorGenerator) => ({ tag, onClick: deleteTag }: TagRendererProps) => (
|
||||
<Tag colorGenerator={colorGenerator} text={tag.label} clearable className="react-tags__tag" onClose={deleteTag} />
|
||||
);
|
||||
const ReactTagsSuggestion = ({ option, classNames: classes, ...rest }: OptionRendererProps) => {
|
||||
);
|
||||
const buildOptionRenderer = (colorGenerator: ColorGenerator, api: ReactTagsAPI | null) => (
|
||||
{ option, classNames: classes, ...rest }: OptionRendererProps,
|
||||
) => {
|
||||
const isSelectable = isSelectableOption(option.label);
|
||||
const isNew = isNewOption(option.label);
|
||||
|
||||
|
@ -56,20 +48,31 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => (
|
|||
{!isSelectable ? <i>{option.label}</i> : (
|
||||
<>
|
||||
{!isNew && <TagBullet tag={`${option.label}`} colorGenerator={colorGenerator} />}
|
||||
{!isNew ? option.label : <i>Add "{normalizeTag(apiRef.current?.input.value ?? '')}"</i>}
|
||||
{!isNew ? option.label : <i>Add "{normalizeTag(api?.input.value ?? '')}"</i>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||
{ selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
listTags();
|
||||
}, []);
|
||||
|
||||
const shortUrlCreation = useSetting('shortUrlCreation');
|
||||
const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith';
|
||||
const apiRef = useElementRef<ReactTagsAPI>();
|
||||
|
||||
return (
|
||||
<ReactTags
|
||||
ref={apiRef}
|
||||
selected={selectedTags.map(toTagObject)}
|
||||
suggestions={tagsList.tags.filter((tag) => !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)],
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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('<TagsSelector />', () => {
|
||||
const onChange = vi.fn();
|
||||
const TagsSelector = createTagsSelector(colorGeneratorMock);
|
||||
const tags = ['foo', 'bar'];
|
||||
const tagsList = fromPartial<TagsList>({ tags: [...tags, 'baz'] });
|
||||
const setUp = () => renderWithEvents(
|
||||
<SettingsProvider value={fromPartial({})}>
|
||||
const setUp = ({ allowNew = true, allTags, tagFilteringMode }: SetUpOptions = {}) => renderWithEvents(
|
||||
<SettingsProvider
|
||||
value={fromPartial({
|
||||
shortUrlCreation: { tagFilteringMode },
|
||||
})}
|
||||
>
|
||||
<TagsSelector
|
||||
selectedTags={tags}
|
||||
tagsList={tagsList}
|
||||
tagsList={fromPartial<TagsList>({ tags: allTags ?? [...tags, 'baz'] })}
|
||||
listTags={vi.fn()}
|
||||
onChange={onChange}
|
||||
allowNew={allowNew}
|
||||
/>
|
||||
</SettingsProvider>,
|
||||
);
|
||||
|
@ -37,18 +48,36 @@ describe('<TagsSelector />', () => {
|
|||
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('<TagsSelector />', () => {
|
|||
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());
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue