Repository: shlinkio/shlink-web-client (ts)

Issue:
Support global search in the TagSelector
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.

With that said, please fill in the information requested next. More information might be requested once the issue is open.
-->

#### Summary

Currently, autocompletion in the TagSelector works in a way that it only shows tags starting with the input.

Allow to configure this so that users can choose between search by "starts with" or search by "contains".

Pull request description:
Feature/tags global search
Closes #463 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 128b91bd..ccd3121f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 ### Added
 * [#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.
+* [#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
 * *Nothing*
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index 294115a0..fc8ea340 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -20,8 +20,8 @@ const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC,
   <NoMenuLayout>
     <SettingsSections
       items={[
-        [ <UserInterface />, <ShortUrlCreation /> ], // eslint-disable-line react/jsx-key
-        [ <Visits />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
+        [ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
+        [ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
       ]}
     />
   </NoMenuLayout>
diff --git a/src/settings/ShortUrlCreation.tsx b/src/settings/ShortUrlCreation.tsx
index b91ed500..2caaa329 100644
--- a/src/settings/ShortUrlCreation.tsx
+++ b/src/settings/ShortUrlCreation.tsx
@@ -1,29 +1,62 @@
-import { FC } from 'react';
-import { FormGroup } from 'reactstrap';
+import { FC, ReactNode } from 'react';
+import { DropdownItem, FormGroup } from 'reactstrap';
 import { SimpleCard } from '../utils/SimpleCard';
 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 {
   settings: Settings;
   setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
 }
 
-export const ShortUrlCreation: FC<ShortUrlCreationProps> = (
-  { settings: { shortUrlCreation }, setShortUrlCreationSettings },
-) => (
-  <SimpleCard title="Short URLs creation" className="h-100">
-    <FormGroup className="mb-0">
-      <ToggleSwitch
-        checked={shortUrlCreation?.validateUrls ?? false}
-        onChange={(validateUrls) => setShortUrlCreationSettings({ validateUrls })}
-      >
-        By default, request validation on long URLs when creating new short URLs.
+const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
+  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">
+      <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">
-          The initial state of the <b>Validate URL</b> checkbox will
-          be <b>{shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}</b>.
+          {tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
         </small>
-      </ToggleSwitch>
-    </FormGroup>
-  </SimpleCard>
-);
+      </FormGroup>
+    </SimpleCard>
+  );
+};
diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts
index 0ba6f106..a798697b 100644
--- a/src/settings/reducers/settings.ts
+++ b/src/settings/reducers/settings.ts
@@ -17,8 +17,11 @@ interface RealTimeUpdatesSettings {
   interval?: number;
 }
 
+export type TagFilteringMode = 'startsWith' | 'includes';
+
 export interface ShortUrlCreationSettings {
   validateUrls: boolean;
+  tagFilteringMode?: TagFilteringMode;
 }
 
 export interface UiSettings {
diff --git a/src/tags/helpers/TagsSelector.tsx b/src/tags/helpers/TagsSelector.tsx
index c7041576..8b585fe2 100644
--- a/src/tags/helpers/TagsSelector.tsx
+++ b/src/tags/helpers/TagsSelector.tsx
@@ -1,6 +1,7 @@
 import { useEffect } from 'react';
 import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
 import ColorGenerator from '../../utils/services/ColorGenerator';
+import { Settings } from '../../settings/reducers/settings';
 import { TagsList } from '../reducers/tagsList';
 import TagBullet from './TagBullet';
 import Tag from './Tag';
@@ -14,17 +15,19 @@ export interface TagsSelectorProps {
 interface TagsSelectorConnectProps extends TagsSelectorProps {
   listTags: Function;
   tagsList: TagsList;
+  settings: Settings;
 }
 
 const toComponentTag = (tag: string) => ({ id: tag, name: tag });
 
 const TagsSelector = (colorGenerator: ColorGenerator) => (
-  { selectedTags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
+  { selectedTags, onChange, placeholder, listTags, tagsList, settings }: TagsSelectorConnectProps,
 ) => {
   useEffect(() => {
     listTags();
   }, []);
 
+  const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith';
   const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
     <Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
   const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
@@ -42,9 +45,14 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
       suggestionComponent={ReactTagsSuggestion}
       allowNew
       addOnBlur
-      placeholderText={placeholder}
+      placeholderText={placeholder ?? 'Add tags to the URL'}
       minQueryLength={1}
       delimiters={[ 'Enter', 'Tab', ',' ]}
+      suggestionsTransform={
+        searchMode === 'includes'
+          ? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query))
+          : undefined
+      }
       onDelete={(removedTagIndex) => {
         const tagsCopy = [ ...selectedTags ];
 
diff --git a/src/tags/services/provideServices.ts b/src/tags/services/provideServices.ts
index 3e71c32f..ba5951c1 100644
--- a/src/tags/services/provideServices.ts
+++ b/src/tags/services/provideServices.ts
@@ -12,7 +12,7 @@ import { ConnectDecorator } from '../../container/types';
 const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
   // Components
   bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
-  bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
+  bottle.decorator('TagsSelector', connect([ 'tagsList', 'settings' ], [ 'listTags' ]));
 
   bottle.serviceFactory(
     'TagCard',


Test patch:
diff --git a/test/settings/ShortUrlCreation.test.tsx b/test/settings/ShortUrlCreation.test.tsx
index 1cb92d9a..3a61f5dd 100644
--- a/test/settings/ShortUrlCreation.test.tsx
+++ b/test/settings/ShortUrlCreation.test.tsx
@@ -1,8 +1,10 @@
 import { shallow, ShallowWrapper } from 'enzyme';
 import { Mock } from 'ts-mockery';
+import { DropdownItem } from 'reactstrap';
 import { ShortUrlCreationSettings, Settings } from '../../src/settings/reducers/settings';
 import { ShortUrlCreation } from '../../src/settings/ShortUrlCreation';
 import ToggleSwitch from '../../src/utils/ToggleSwitch';
+import { DropdownBtn } from '../../src/utils/DropdownBtn';
 
 describe('<ShortUrlCreation />', () => {
   let wrapper: ShallowWrapper;
@@ -25,13 +27,41 @@ describe('<ShortUrlCreation />', () => {
     [{ validateUrls: true }, true ],
     [{ validateUrls: false }, 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 toggle = wrapper.find(ToggleSwitch);
 
     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) => {
     const wrapper = createWrapper();
     const toggle = wrapper.find(ToggleSwitch);
@@ -41,14 +71,21 @@ describe('<ShortUrlCreation />', () => {
     expect(setShortUrlCreationSettings).toHaveBeenCalledWith({ validateUrls });
   });
 
-  it.each([
-    [{ validateUrls: true }, 'checkbox will be checked' ],
-    [{ validateUrls: false }, 'checkbox will be unchecked' ],
-    [ undefined, 'checkbox will be unchecked' ],
-  ])('shows expected helper text', (shortUrlCreation, expectedText) => {
-    const wrapper = createWrapper(shortUrlCreation);
-    const text = wrapper.find('.form-text');
+  it('invokes setShortUrlCreationSettings when dropdown value changes', () => {
+    const wrapper = createWrapper();
+    const firstDropdownItem = wrapper.find(DropdownItem).first();
+    const secondDropdownItem = wrapper.find(DropdownItem).last();
 
-    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' },
+    ));
   });
 });
diff --git a/test/tags/helpers/TagsSelector.test.tsx b/test/tags/helpers/TagsSelector.test.tsx
index 3c3739cb..91934aca 100644
--- a/test/tags/helpers/TagsSelector.test.tsx
+++ b/test/tags/helpers/TagsSelector.test.tsx
@@ -3,6 +3,7 @@ import { Mock } from 'ts-mockery';
 import createTagsSelector from '../../../src/tags/helpers/TagsSelector';
 import ColorGenerator from '../../../src/utils/services/ColorGenerator';
 import { TagsList } from '../../../src/tags/reducers/tagsList';
+import { Settings } from '../../../src/settings/reducers/settings';
 
 describe('<TagsSelector />', () => {
   const onChange = jest.fn();
@@ -14,7 +15,13 @@ describe('<TagsSelector />', () => {
   beforeEach(jest.clearAllMocks);
   beforeEach(() => {
     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}
+      />,
     );
   });