Merge pull request #468 from acelaya-forks/feature/tags-global-search

Feature/tags global search
This commit is contained in:
Alejandro Celaya 2021-08-15 18:27:16 +02:00 committed by GitHub
commit 2b5420a429
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 126 additions and 34 deletions

View file

@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Added ### Added
* [#460](https://github.com/shlinkio/shlink-web-client/pull/460) Added dynamic title on hover for tags with a very long title. * [#460](https://github.com/shlinkio/shlink-web-client/pull/460) Added dynamic title on hover for tags with a very long title.
* [#462](https://github.com/shlinkio/shlink-web-client/pull/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags. * [#462](https://github.com/shlinkio/shlink-web-client/pull/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags.
* [#463](https://github.com/shlinkio/shlink-web-client/pull/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured:
* `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far.
* `includes`: Suggests tags that contain the input.
### Changed ### Changed
* *Nothing* * *Nothing*

View file

@ -20,8 +20,8 @@ const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC,
<NoMenuLayout> <NoMenuLayout>
<SettingsSections <SettingsSections
items={[ items={[
[ <UserInterface />, <ShortUrlCreation /> ], // eslint-disable-line react/jsx-key [ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
[ <Visits />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key [ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
]} ]}
/> />
</NoMenuLayout> </NoMenuLayout>

View file

@ -1,29 +1,62 @@
import { FC } from 'react'; import { FC, ReactNode } from 'react';
import { FormGroup } from 'reactstrap'; import { DropdownItem, FormGroup } from 'reactstrap';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import ToggleSwitch from '../utils/ToggleSwitch'; import ToggleSwitch from '../utils/ToggleSwitch';
import { Settings, ShortUrlCreationSettings } from './reducers/settings'; import { DropdownBtn } from '../utils/DropdownBtn';
import { Settings, ShortUrlCreationSettings, TagFilteringMode } from './reducers/settings';
interface ShortUrlCreationProps { interface ShortUrlCreationProps {
settings: Settings; settings: Settings;
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void; setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
} }
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ( const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
{ settings: { shortUrlCreation }, setShortUrlCreationSettings }, tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
) => ( const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
<SimpleCard title="Short URLs creation" className="h-100"> tagFilteringMode === 'includes'
<FormGroup className="mb-0"> ? <>The list of suggested tags will contain existing ones <b>including</b> provided input.</>
<ToggleSwitch : <>The list of suggested tags will contain existing ones <b>starting with</b> provided input.</>;
checked={shortUrlCreation?.validateUrls ?? false}
onChange={(validateUrls) => setShortUrlCreationSettings({ validateUrls })} export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
> const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
By default, request validation on long URLs when creating new short URLs. const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
);
return (
<SimpleCard title="Short URLs creation" className="h-100">
<FormGroup>
<ToggleSwitch
checked={shortUrlCreation.validateUrls ?? false}
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
>
By default, request validation on long URLs when creating new short URLs.
<small className="form-text text-muted">
The initial state of the <b>Validate URL</b> checkbox will
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
</small>
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label>Tag suggestions search mode:</label>
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
<DropdownItem
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
onClick={changeTagsFilteringMode('startsWith')}
>
{tagFilteringModeText('startsWith')}
</DropdownItem>
<DropdownItem
active={shortUrlCreation.tagFilteringMode === 'includes'}
onClick={changeTagsFilteringMode('includes')}
>
{tagFilteringModeText('includes')}
</DropdownItem>
</DropdownBtn>
<small className="form-text text-muted"> <small className="form-text text-muted">
The initial state of the <b>Validate URL</b> checkbox will {tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
be <b>{shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}</b>.
</small> </small>
</ToggleSwitch> </FormGroup>
</FormGroup> </SimpleCard>
</SimpleCard> );
); };

View file

@ -17,8 +17,11 @@ interface RealTimeUpdatesSettings {
interval?: number; interval?: number;
} }
export type TagFilteringMode = 'startsWith' | 'includes';
export interface ShortUrlCreationSettings { export interface ShortUrlCreationSettings {
validateUrls: boolean; validateUrls: boolean;
tagFilteringMode?: TagFilteringMode;
} }
export interface UiSettings { export interface UiSettings {

View file

@ -1,6 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete'; import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
import ColorGenerator from '../../utils/services/ColorGenerator'; import ColorGenerator from '../../utils/services/ColorGenerator';
import { Settings } from '../../settings/reducers/settings';
import { TagsList } from '../reducers/tagsList'; import { TagsList } from '../reducers/tagsList';
import TagBullet from './TagBullet'; import TagBullet from './TagBullet';
import Tag from './Tag'; import Tag from './Tag';
@ -14,17 +15,19 @@ export interface TagsSelectorProps {
interface TagsSelectorConnectProps extends TagsSelectorProps { interface TagsSelectorConnectProps extends TagsSelectorProps {
listTags: Function; listTags: Function;
tagsList: TagsList; tagsList: TagsList;
settings: Settings;
} }
const toComponentTag = (tag: string) => ({ id: tag, name: tag }); const toComponentTag = (tag: string) => ({ id: tag, name: tag });
const TagsSelector = (colorGenerator: ColorGenerator) => ( const TagsSelector = (colorGenerator: ColorGenerator) => (
{ selectedTags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps, { selectedTags, onChange, placeholder, listTags, tagsList, settings }: TagsSelectorConnectProps,
) => { ) => {
useEffect(() => { useEffect(() => {
listTags(); listTags();
}, []); }, []);
const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith';
const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) => const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
<Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />; <Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => ( const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
@ -42,9 +45,14 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
suggestionComponent={ReactTagsSuggestion} suggestionComponent={ReactTagsSuggestion}
allowNew allowNew
addOnBlur addOnBlur
placeholderText={placeholder} placeholderText={placeholder ?? 'Add tags to the URL'}
minQueryLength={1} minQueryLength={1}
delimiters={[ 'Enter', 'Tab', ',' ]} delimiters={[ 'Enter', 'Tab', ',' ]}
suggestionsTransform={
searchMode === 'includes'
? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query))
: undefined
}
onDelete={(removedTagIndex) => { onDelete={(removedTagIndex) => {
const tagsCopy = [ ...selectedTags ]; const tagsCopy = [ ...selectedTags ];

View file

@ -12,7 +12,7 @@ import { ConnectDecorator } from '../../container/types';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator'); bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ])); bottle.decorator('TagsSelector', connect([ 'tagsList', 'settings' ], [ 'listTags' ]));
bottle.serviceFactory( bottle.serviceFactory(
'TagCard', 'TagCard',

View file

@ -1,8 +1,10 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { DropdownItem } from 'reactstrap';
import { ShortUrlCreationSettings, Settings } from '../../src/settings/reducers/settings'; import { ShortUrlCreationSettings, Settings } from '../../src/settings/reducers/settings';
import { ShortUrlCreation } from '../../src/settings/ShortUrlCreation'; import { ShortUrlCreation } from '../../src/settings/ShortUrlCreation';
import ToggleSwitch from '../../src/utils/ToggleSwitch'; import ToggleSwitch from '../../src/utils/ToggleSwitch';
import { DropdownBtn } from '../../src/utils/DropdownBtn';
describe('<ShortUrlCreation />', () => { describe('<ShortUrlCreation />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
@ -25,13 +27,41 @@ describe('<ShortUrlCreation />', () => {
[{ validateUrls: true }, true ], [{ validateUrls: true }, true ],
[{ validateUrls: false }, false ], [{ validateUrls: false }, false ],
[ undefined, false ], [ undefined, false ],
])('switch is toggled if option is true', (shortUrlCreation, expectedChecked) => { ])('URL validation switch is toggled if option is true', (shortUrlCreation, expectedChecked) => {
const wrapper = createWrapper(shortUrlCreation); const wrapper = createWrapper(shortUrlCreation);
const toggle = wrapper.find(ToggleSwitch); const toggle = wrapper.find(ToggleSwitch);
expect(toggle.prop('checked')).toEqual(expectedChecked); expect(toggle.prop('checked')).toEqual(expectedChecked);
}); });
it.each([
[{ validateUrls: true }, 'checkbox will be checked' ],
[{ validateUrls: false }, 'checkbox will be unchecked' ],
[ undefined, 'checkbox will be unchecked' ],
])('shows expected helper text for URL validation', (shortUrlCreation, expectedText) => {
const wrapper = createWrapper(shortUrlCreation);
const text = wrapper.find('.form-text').first();
expect(text.text()).toContain(expectedText);
});
it.each([
[ { tagFilteringMode: 'includes' } as ShortUrlCreationSettings, 'Suggest tags including input', 'including' ],
[
{ tagFilteringMode: 'startsWith' } as ShortUrlCreationSettings,
'Suggest tags starting with input',
'starting with',
],
[ undefined, 'Suggest tags starting with input', 'starting with' ],
])('shows expected texts for tags suggestions', (shortUrlCreation, expectedText, expectedHint) => {
const wrapper = createWrapper(shortUrlCreation);
const hintText = wrapper.find('.form-text').last();
const dropdown = wrapper.find(DropdownBtn);
expect(dropdown.prop('text')).toEqual(expectedText);
expect(hintText.text()).toContain(expectedHint);
});
it.each([[ true ], [ false ]])('invokes setShortUrlCreationSettings when toggle value changes', (validateUrls) => { it.each([[ true ], [ false ]])('invokes setShortUrlCreationSettings when toggle value changes', (validateUrls) => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const toggle = wrapper.find(ToggleSwitch); const toggle = wrapper.find(ToggleSwitch);
@ -41,14 +71,21 @@ describe('<ShortUrlCreation />', () => {
expect(setShortUrlCreationSettings).toHaveBeenCalledWith({ validateUrls }); expect(setShortUrlCreationSettings).toHaveBeenCalledWith({ validateUrls });
}); });
it.each([ it('invokes setShortUrlCreationSettings when dropdown value changes', () => {
[{ validateUrls: true }, 'checkbox will be checked' ], const wrapper = createWrapper();
[{ validateUrls: false }, 'checkbox will be unchecked' ], const firstDropdownItem = wrapper.find(DropdownItem).first();
[ undefined, 'checkbox will be unchecked' ], const secondDropdownItem = wrapper.find(DropdownItem).last();
])('shows expected helper text', (shortUrlCreation, expectedText) => {
const wrapper = createWrapper(shortUrlCreation);
const text = wrapper.find('.form-text');
expect(text.text()).toContain(expectedText); expect(setShortUrlCreationSettings).not.toHaveBeenCalled();
firstDropdownItem.simulate('click');
expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining(
{ tagFilteringMode: 'startsWith' },
));
secondDropdownItem.simulate('click');
expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining(
{ tagFilteringMode: 'includes' },
));
}); });
}); });

View file

@ -3,6 +3,7 @@ import { Mock } from 'ts-mockery';
import createTagsSelector from '../../../src/tags/helpers/TagsSelector'; import createTagsSelector from '../../../src/tags/helpers/TagsSelector';
import ColorGenerator from '../../../src/utils/services/ColorGenerator'; import ColorGenerator from '../../../src/utils/services/ColorGenerator';
import { TagsList } from '../../../src/tags/reducers/tagsList'; import { TagsList } from '../../../src/tags/reducers/tagsList';
import { Settings } from '../../../src/settings/reducers/settings';
describe('<TagsSelector />', () => { describe('<TagsSelector />', () => {
const onChange = jest.fn(); const onChange = jest.fn();
@ -14,7 +15,13 @@ describe('<TagsSelector />', () => {
beforeEach(jest.clearAllMocks); beforeEach(jest.clearAllMocks);
beforeEach(() => { beforeEach(() => {
wrapper = shallow( wrapper = shallow(
<TagsSelector selectedTags={tags} tagsList={tagsList} listTags={jest.fn()} onChange={onChange} />, <TagsSelector
selectedTags={tags}
tagsList={tagsList}
settings={Mock.all<Settings>()}
listTags={jest.fn()}
onChange={onChange}
/>,
); );
}); });