From 9f02bc64963616ba76968317f51f8a06b6b5cbe5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 10:55:58 +0200 Subject: [PATCH 1/4] Added new settings to determine how to search on tags during short URL creation, and how many suggestions to display --- src/settings/reducers/settings.ts | 4 ++++ src/tags/helpers/TagsSelector.tsx | 14 ++++++++++++-- src/tags/services/provideServices.ts | 2 +- test/tags/helpers/TagsSelector.test.tsx | 9 ++++++++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index 0ba6f106..df405e2a 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -17,8 +17,12 @@ interface RealTimeUpdatesSettings { interval?: number; } +type TagFilteringMode = 'startsWith' | 'includes'; + export interface ShortUrlCreationSettings { validateUrls: boolean; + tagFilteringMode?: TagFilteringMode; + maxTagSuggestions?: number; } export interface UiSettings { diff --git a/src/tags/helpers/TagsSelector.tsx b/src/tags/helpers/TagsSelector.tsx index c7041576..83e62b15 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,20 @@ 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 maxSuggestions = settings.shortUrlCreation?.maxTagSuggestions; const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) => ; const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => ( @@ -42,9 +46,15 @@ const TagsSelector = (colorGenerator: ColorGenerator) => ( suggestionComponent={ReactTagsSuggestion} allowNew addOnBlur - placeholderText={placeholder} + placeholderText={placeholder ?? 'Add tags to the URL'} minQueryLength={1} + maxSuggestionsLength={maxSuggestions} 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', 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('', () => { const onChange = jest.fn(); @@ -14,7 +15,13 @@ describe('', () => { beforeEach(jest.clearAllMocks); beforeEach(() => { wrapper = shallow( - , + ()} + listTags={jest.fn()} + onChange={onChange} + />, ); }); From 322396a366564483f2ce621e52586835a36f1b50 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 18:13:13 +0200 Subject: [PATCH 2/4] Allowed to dynamically determine how short URL suggestions are calculated --- src/settings/Settings.tsx | 4 +- src/settings/ShortUrlCreation.tsx | 71 ++++++++++++++++++------- src/settings/reducers/settings.ts | 5 +- src/tags/helpers/TagsSelector.tsx | 2 - test/settings/ShortUrlCreation.test.tsx | 55 +++++++++++++++---- 5 files changed, 102 insertions(+), 35 deletions(-) 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, , ], // eslint-disable-line react/jsx-key - [ , ], // eslint-disable-line react/jsx-key + [ , ], // eslint-disable-line react/jsx-key + [ , ], // eslint-disable-line react/jsx-key ]} /> diff --git a/src/settings/ShortUrlCreation.tsx b/src/settings/ShortUrlCreation.tsx index b91ed500..f180a3ed 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 = ( - { settings: { shortUrlCreation }, setShortUrlCreationSettings }, -) => ( - - - 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 including provided input. + : <>The list of suggested tags will contain existing ones starting with provided input.; + +export const ShortUrlCreation: FC = ({ settings, setShortUrlCreationSettings }) => { + const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false }; + const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings( + { ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode }, + ); + + return ( + + + setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })} + > + By default, request validation on long URLs when creating new short URLs. + + The initial state of the Validate URL checkbox will + be {shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}. + + + + + + + + {tagFilteringModeText('startsWith')} + + + {tagFilteringModeText('includes')} + + - The initial state of the Validate URL checkbox will - be {shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}. + {tagFilteringModeHint(shortUrlCreation.tagFilteringMode)} - - - -); + + + ); +} diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index df405e2a..f052893c 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -17,12 +17,11 @@ interface RealTimeUpdatesSettings { interval?: number; } -type TagFilteringMode = 'startsWith' | 'includes'; +export type TagFilteringMode = 'startsWith' | 'includes'; export interface ShortUrlCreationSettings { validateUrls: boolean; - tagFilteringMode?: TagFilteringMode; - maxTagSuggestions?: number; + tagFilteringMode?: TagFilteringMode } export interface UiSettings { diff --git a/src/tags/helpers/TagsSelector.tsx b/src/tags/helpers/TagsSelector.tsx index 83e62b15..8b585fe2 100644 --- a/src/tags/helpers/TagsSelector.tsx +++ b/src/tags/helpers/TagsSelector.tsx @@ -28,7 +28,6 @@ const TagsSelector = (colorGenerator: ColorGenerator) => ( }, []); const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith'; - const maxSuggestions = settings.shortUrlCreation?.maxTagSuggestions; const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) => ; const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => ( @@ -48,7 +47,6 @@ const TagsSelector = (colorGenerator: ColorGenerator) => ( addOnBlur placeholderText={placeholder ?? 'Add tags to the URL'} minQueryLength={1} - maxSuggestionsLength={maxSuggestions} delimiters={[ 'Enter', 'Tab', ',' ]} suggestionsTransform={ searchMode === 'includes' diff --git a/test/settings/ShortUrlCreation.test.tsx b/test/settings/ShortUrlCreation.test.tsx index 1cb92d9a..aee7df18 100644 --- a/test/settings/ShortUrlCreation.test.tsx +++ b/test/settings/ShortUrlCreation.test.tsx @@ -3,6 +3,8 @@ import { Mock } from 'ts-mockery'; 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'; +import { DropdownItem } from 'reactstrap'; describe('', () => { let wrapper: ShallowWrapper; @@ -25,13 +27,41 @@ describe('', () => { [{ 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('', () => { 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' }, + )); }); }); From edd536cc1eec29649f1b2d1f1797fd7bed8316ae Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 18:17:05 +0200 Subject: [PATCH 3/4] Updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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* From 3484e74559963eec0ac1706aa02bc725d988bc7c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 15 Aug 2021 18:21:36 +0200 Subject: [PATCH 4/4] Fixed coding styles --- src/settings/ShortUrlCreation.tsx | 2 +- src/settings/reducers/settings.ts | 2 +- test/settings/ShortUrlCreation.test.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/settings/ShortUrlCreation.tsx b/src/settings/ShortUrlCreation.tsx index f180a3ed..2caaa329 100644 --- a/src/settings/ShortUrlCreation.tsx +++ b/src/settings/ShortUrlCreation.tsx @@ -59,4 +59,4 @@ export const ShortUrlCreation: FC = ({ settings, setShort ); -} +}; diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index f052893c..a798697b 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -21,7 +21,7 @@ export type TagFilteringMode = 'startsWith' | 'includes'; export interface ShortUrlCreationSettings { validateUrls: boolean; - tagFilteringMode?: TagFilteringMode + tagFilteringMode?: TagFilteringMode; } export interface UiSettings { diff --git a/test/settings/ShortUrlCreation.test.tsx b/test/settings/ShortUrlCreation.test.tsx index aee7df18..3a61f5dd 100644 --- a/test/settings/ShortUrlCreation.test.tsx +++ b/test/settings/ShortUrlCreation.test.tsx @@ -1,10 +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'; -import { DropdownItem } from 'reactstrap'; describe('', () => { let wrapper: ShallowWrapper;