mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #468 from acelaya-forks/feature/tags-global-search
Feature/tags global search
This commit is contained in:
commit
2b5420a429
8 changed files with 126 additions and 34 deletions
|
@ -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*
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 =>
|
||||||
|
tagFilteringMode === 'includes'
|
||||||
|
? <>The list of suggested tags will contain existing ones <b>including</b> provided input.</>
|
||||||
|
: <>The list of suggested tags will contain existing ones <b>starting with</b> provided input.</>;
|
||||||
|
|
||||||
|
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||||
|
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||||
|
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
|
||||||
|
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
<SimpleCard title="Short URLs creation" className="h-100">
|
<SimpleCard title="Short URLs creation" className="h-100">
|
||||||
<FormGroup className="mb-0">
|
<FormGroup>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={shortUrlCreation?.validateUrls ?? false}
|
checked={shortUrlCreation.validateUrls ?? false}
|
||||||
onChange={(validateUrls) => setShortUrlCreationSettings({ validateUrls })}
|
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
|
||||||
>
|
>
|
||||||
By default, request validation on long URLs when creating new short URLs.
|
By default, request validation on long URLs when creating new short URLs.
|
||||||
<small className="form-text text-muted">
|
<small className="form-text text-muted">
|
||||||
The initial state of the <b>Validate URL</b> checkbox will
|
The initial state of the <b>Validate URL</b> checkbox will
|
||||||
be <b>{shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}</b>.
|
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
|
||||||
</small>
|
</small>
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
</FormGroup>
|
</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">
|
||||||
|
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
|
||||||
|
</small>
|
||||||
|
</FormGroup>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 ];
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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' },
|
||||||
|
));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue