From 94ba244ae195bb2958f7ca44a4b56b2ebe981ac6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Aug 2023 10:26:42 +0200 Subject: [PATCH 1/5] Update to react-tag-autocomplete 7 --- .github/workflows/ci.yml | 3 +- .github/workflows/deploy-preview.yml | 6 ++-- .github/workflows/publish-release.yml | 6 ++-- Dockerfile | 4 +-- docker-compose.yml | 4 +-- package-lock.json | 20 +++++------ package.json | 2 +- .../src/tags/helpers/TagsSelector.tsx | 35 ++++++++++--------- 8 files changed, 39 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1023daa8..c112dac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,5 @@ jobs: ci: uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main with: - node-version: 20.2 + node-version: 20.5 publish-coverage: true - force-install: true diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 02d402ae..3e5ef986 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -5,7 +5,7 @@ on: jobs: deploy: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 continue-on-error: true steps: - name: Checkout code @@ -16,10 +16,10 @@ jobs: - name: Use node.js uses: actions/setup-node@v3 with: - node-version: 20.2 + node-version: 20.5 - name: Build run: | - npm ci --force && \ + npm ci && \ node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \ npm run build - name: Deploy preview diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 6241ec6c..d28c30a7 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -7,16 +7,16 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v3 - name: Use node.js uses: actions/setup-node@v3 with: - node-version: 20.2 + node-version: 20.5 - name: Generate release assets - run: npm ci --force && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist + run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist - name: Publish release with assets uses: docker://antonyurchenko/git-release:latest env: diff --git a/Dockerfile b/Dockerfile index 214c2292..9cdf969e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM node:20.2-alpine as node +FROM node:20.5-alpine as node COPY . /shlink-web-client ARG VERSION="latest" ENV VERSION ${VERSION} -RUN cd /shlink-web-client && npm ci --force && npm run build +RUN cd /shlink-web-client && npm ci && npm run build FROM nginx:1.23-alpine LABEL maintainer="Alejandro Celaya " diff --git a/docker-compose.yml b/docker-compose.yml index b9bbf511..07ed7220 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,8 @@ version: '3' services: shlink_web_client_node: container_name: shlink_web_client_node - image: node:20.2-alpine - command: /bin/sh -c "cd /home/shlink/www && npm install --force && npm run start" + image: node:20.5-alpine + command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start" volumes: - ./:/home/shlink/www ports: diff --git a/package-lock.json b/package-lock.json index 928eeb18..199e96bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "react-redux": "^8.1.2", "react-router-dom": "^6.14.2", "react-swipeable": "^7.0.1", - "react-tag-autocomplete": "^6.3.0", + "react-tag-autocomplete": "^7.0.0", "reactstrap": "^9.2.0", "redux-localstorage-simple": "^2.5.1", "uuid": "^9.0.0", @@ -8900,16 +8900,14 @@ } }, "node_modules/react-tag-autocomplete": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-6.3.0.tgz", - "integrity": "sha512-MUBVUFh5eOqshUm5NM20qp7zXk8TzSiKO4GoktlFzBLIOLs356npaMKtL02bm0nFV2f1zInUrXn1fq6+i5YX0w==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.0.0.tgz", + "integrity": "sha512-PFxT7fpMB8Au+S9cJYAGRVTnacZpeXybc5SkpTCyuJHmUN1Bt8gHb9vZi3f+aX/eDX44x2WIwYiqfRBi2E5AMg==", "engines": { - "node": ">= 10.0.0" + "node": ">= 16.12.0" }, "peerDependencies": { - "prop-types": "^15.5.0", - "react": "^16.5.0 || ^17.0.0", - "react-dom": "^16.5.0 || ^17.0.0" + "react": "^18.0.0" } }, "node_modules/react-transition-group": { @@ -17211,9 +17209,9 @@ "requires": {} }, "react-tag-autocomplete": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-6.3.0.tgz", - "integrity": "sha512-MUBVUFh5eOqshUm5NM20qp7zXk8TzSiKO4GoktlFzBLIOLs356npaMKtL02bm0nFV2f1zInUrXn1fq6+i5YX0w==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.0.0.tgz", + "integrity": "sha512-PFxT7fpMB8Au+S9cJYAGRVTnacZpeXybc5SkpTCyuJHmUN1Bt8gHb9vZi3f+aX/eDX44x2WIwYiqfRBi2E5AMg==", "requires": {} }, "react-transition-group": { diff --git a/package.json b/package.json index 48296338..15f5cc67 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "react-redux": "^8.1.2", "react-router-dom": "^6.14.2", "react-swipeable": "^7.0.1", - "react-tag-autocomplete": "^6.3.0", + "react-tag-autocomplete": "^7.0.0", "reactstrap": "^9.2.0", "redux-localstorage-simple": "^2.5.1", "uuid": "^9.0.0", diff --git a/shlink-web-component/src/tags/helpers/TagsSelector.tsx b/shlink-web-component/src/tags/helpers/TagsSelector.tsx index 72d74fcc..53f0198e 100644 --- a/shlink-web-component/src/tags/helpers/TagsSelector.tsx +++ b/shlink-web-component/src/tags/helpers/TagsSelector.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import type { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete'; -import ReactTags from 'react-tag-autocomplete'; +import type { OptionRendererProps, TagRendererProps, TagSuggestion } from 'react-tag-autocomplete'; +import { ReactTags } from 'react-tag-autocomplete'; import type { ColorGenerator } from '../../utils/services/ColorGenerator'; import { useSetting } from '../../utils/settings'; import type { TagsList } from '../reducers/tagsList'; @@ -19,7 +19,7 @@ interface TagsSelectorConnectProps extends TagsSelectorProps { tagsList: TagsList; } -const toComponentTag = (tag: string) => ({ id: tag, name: tag }); +const toTagSuggestion = (tag: string): TagSuggestion => ({ label: tag, value: tag }); export const TagsSelector = (colorGenerator: ColorGenerator) => ( { selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps, @@ -30,29 +30,30 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( }, []); const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith'; - const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) => - ; - const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => ( + const ReactTagsTag = ({ tag, onClick: deleteTag }: TagRendererProps) => ( + + ); + const ReactTagsSuggestion = ({ option }: OptionRendererProps) => ( <> - - {item.name} + + {option.label} ); return ( !selectedTags.includes(tag)).map(toComponentTag)} - suggestionComponent={ReactTagsSuggestion} + selected={selectedTags.map(toTagSuggestion)} + suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toTagSuggestion)} + renderTag={ReactTagsTag} + renderOption={ReactTagsSuggestion} allowNew={allowNew} - addOnBlur + // addOnBlur TODO Implement manually placeholderText={placeholder ?? 'Add tags to the URL'} - minQueryLength={1} - delimiters={['Enter', 'Tab', ',']} + onShouldExpand={(value) => value.length > 1} + delimiterKeys={['Enter', 'Tab', ',']} suggestionsTransform={ searchMode === 'includes' - ? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query)) + ? (query, suggestions) => suggestions.filter(({ label }) => label.includes(query)) : undefined } onDelete={(removedTagIndex) => { @@ -61,7 +62,7 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( tagsCopy.splice(removedTagIndex, 1); onChange(tagsCopy); }} - onAddition={({ name: newTag }) => onChange( + 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.toLowerCase().split(',')])], From 3436b52c06aaaaf49621cd365fca01e3a6076def Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Aug 2023 13:50:10 +0200 Subject: [PATCH 2/5] Use ReactTags.suggestionsTransform to enforce at least 1 char --- .../src/tags/helpers/TagsSelector.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/shlink-web-component/src/tags/helpers/TagsSelector.tsx b/shlink-web-component/src/tags/helpers/TagsSelector.tsx index 53f0198e..0ab01e26 100644 --- a/shlink-web-component/src/tags/helpers/TagsSelector.tsx +++ b/shlink-web-component/src/tags/helpers/TagsSelector.tsx @@ -24,12 +24,13 @@ const toTagSuggestion = (tag: string): TagSuggestion => ({ label: tag, value: ta export const TagsSelector = (colorGenerator: ColorGenerator) => ( { selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps, ) => { - const shortUrlCreation = useSetting('shortUrlCreation'); useEffect(() => { listTags(); }, []); + const shortUrlCreation = useSetting('shortUrlCreation'); const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith'; + const ReactTagsTag = ({ tag, onClick: deleteTag }: TagRendererProps) => ( ); @@ -49,16 +50,17 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( allowNew={allowNew} // addOnBlur TODO Implement manually placeholderText={placeholder ?? 'Add tags to the URL'} - onShouldExpand={(value) => value.length > 1} delimiterKeys={['Enter', 'Tab', ',']} suggestionsTransform={ - searchMode === 'includes' - ? (query, suggestions) => suggestions.filter(({ label }) => label.includes(query)) - : undefined + (query, suggestions) => { + const searchTerm = query.toLowerCase().trim(); + return searchTerm.length < 1 ? [] : suggestions.filter( + ({ label }) => (searchMode === 'includes' ? label.includes(searchTerm) : label.startsWith(searchTerm)), + ); + } } onDelete={(removedTagIndex) => { const tagsCopy = [...selectedTags]; - tagsCopy.splice(removedTagIndex, 1); onChange(tagsCopy); }} From e4aec16ba56aa7d00b86252b8bab41a0fe8f072d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Aug 2023 19:32:29 +0200 Subject: [PATCH 3/5] Define react-tag-autocomplete styles for v7 --- .../src/short-urls/ShortUrlForm.tsx | 4 +- .../src/tags/helpers/TagsSelector.tsx | 48 ++++++++--- .../src/tags/helpers/index.ts | 3 + .../src/tags/react-tag-autocomplete.scss | 83 +++++++------------ 4 files changed, 73 insertions(+), 65 deletions(-) create mode 100644 shlink-web-component/src/tags/helpers/index.ts diff --git a/shlink-web-component/src/short-urls/ShortUrlForm.tsx b/shlink-web-component/src/short-urls/ShortUrlForm.tsx index 16e2fead..cc98bf7e 100644 --- a/shlink-web-component/src/short-urls/ShortUrlForm.tsx +++ b/shlink-web-component/src/short-urls/ShortUrlForm.tsx @@ -4,13 +4,14 @@ import { faDesktop } from '@fortawesome/free-solid-svg-icons'; import { Checkbox, SimpleCard } from '@shlinkio/shlink-frontend-kit'; import classNames from 'classnames'; import { parseISO } from 'date-fns'; -import { isEmpty, pipe, replace, trim } from 'ramda'; +import { isEmpty } from 'ramda'; import type { ChangeEvent, FC } from 'react'; import { useEffect, useState } from 'react'; 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'; @@ -35,7 +36,6 @@ export interface ShortUrlFormProps Promise; } -const normalizeTag = pipe(trim, replace(/ /g, '-')); const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date); const isCreationData = (data: ShlinkCreateShortUrlData | ShlinkEditShortUrlData): data is ShlinkCreateShortUrlData => diff --git a/shlink-web-component/src/tags/helpers/TagsSelector.tsx b/shlink-web-component/src/tags/helpers/TagsSelector.tsx index 0ab01e26..527fc46a 100644 --- a/shlink-web-component/src/tags/helpers/TagsSelector.tsx +++ b/shlink-web-component/src/tags/helpers/TagsSelector.tsx @@ -1,9 +1,11 @@ -import { useEffect } from 'react'; -import type { OptionRendererProps, TagRendererProps, TagSuggestion } from 'react-tag-autocomplete'; +import resolveClasses from 'classnames'; +import { useEffect, useRef } 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'; import { useSetting } from '../../utils/settings'; import type { TagsList } from '../reducers/tagsList'; +import { normalizeTag } from './index'; import { Tag } from './Tag'; import { TagBullet } from './TagBullet'; @@ -19,6 +21,10 @@ interface TagsSelectorConnectProps extends TagsSelectorProps { tagsList: TagsList; } +const NOT_FOUND_TAG = 'Tag not found'; +const NEW_TAG = 'Add tag'; +const isSelectableOption = (tag: string) => tag !== NOT_FOUND_TAG; +const isNewOption = (tag: string) => tag === NEW_TAG; const toTagSuggestion = (tag: string): TagSuggestion => ({ label: tag, value: tag }); export const TagsSelector = (colorGenerator: ColorGenerator) => ( @@ -30,33 +36,51 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( const shortUrlCreation = useSetting('shortUrlCreation'); const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith'; + const apiRef = useRef(null); const ReactTagsTag = ({ tag, onClick: deleteTag }: TagRendererProps) => ( ); - const ReactTagsSuggestion = ({ option }: OptionRendererProps) => ( - <> - - {option.label} - - ); + const ReactTagsSuggestion = ({ option, classNames }: OptionRendererProps) => { + const isSelectable = isSelectableOption(option.label); + const isNew = isNewOption(option.label); + + return ( +
+ {!isSelectable ? {option.label} : ( + <> + {!isNew && } + {!isNew ? option.label : Add "{normalizeTag(apiRef.current?.input.value ?? '')}"} + + )} +
+ ); + }; return ( !selectedTags.includes(tag)).map(toTagSuggestion)} renderTag={ReactTagsTag} renderOption={ReactTagsSuggestion} + activateFirstOption allowNew={allowNew} - // addOnBlur TODO Implement manually + newOptionText={NEW_TAG} + noOptionsText={NOT_FOUND_TAG} placeholderText={placeholder ?? 'Add tags to the URL'} delimiterKeys={['Enter', 'Tab', ',']} suggestionsTransform={ (query, suggestions) => { const searchTerm = query.toLowerCase().trim(); - return searchTerm.length < 1 ? [] : suggestions.filter( + return searchTerm.length < 1 ? [] : [...suggestions.filter( ({ label }) => (searchMode === 'includes' ? label.includes(searchTerm) : label.startsWith(searchTerm)), - ); + )].slice(0, 5); } } onDelete={(removedTagIndex) => { @@ -67,7 +91,7 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( 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.toLowerCase().split(',')])], + [...new Set([...selectedTags, ...newTag.split(',')])], )} /> ); diff --git a/shlink-web-component/src/tags/helpers/index.ts b/shlink-web-component/src/tags/helpers/index.ts new file mode 100644 index 00000000..6c273699 --- /dev/null +++ b/shlink-web-component/src/tags/helpers/index.ts @@ -0,0 +1,3 @@ +const ONE_OR_MORE_SPACES_REGEX = /\s+/g; + +export const normalizeTag = (tag: string) => tag.trim().toLowerCase().replace(ONE_OR_MORE_SPACES_REGEX, '-'); diff --git a/shlink-web-component/src/tags/react-tag-autocomplete.scss b/shlink-web-component/src/tags/react-tag-autocomplete.scss index 7c4baf5c..c2cf9632 100644 --- a/shlink-web-component/src/tags/react-tag-autocomplete.scss +++ b/shlink-web-component/src/tags/react-tag-autocomplete.scss @@ -1,5 +1,6 @@ @import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base'; +// Main wrapper .react-tags { position: relative; padding: 5px 0 0 6px; @@ -26,45 +27,35 @@ background-color: var(--input-color); } -.react-tags.is-focused { +// Mimic bootstrap input focus ring +.react-tags.is-active { box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%); } +.react-tags__label { + display: none; +} + .react-tags__tag { font-size: 100%; } -.react-tags__selected { +.react-tags__list { display: inline; vertical-align: 2px; + padding: 0; + list-style-type: none; } -.react-tags__selected-tag { +.react-tags__list-item { display: inline-block; - box-sizing: border-box; - margin: 0 6px 6px 0; - padding: 6px 8px; - border: 1px solid var(--input-border-color); - border-radius: .25rem; - background: #f1f1f1; - - /* match the font styles */ - font-size: inherit; - line-height: inherit; +} +.react-tags__list-item:not(:last-child) { + margin-right: 3px; } -.react-tags__selected-tag:after { - content: '\2715'; - color: #aaaaaa; - margin-left: 8px; -} - -.react-tags__selected-tag:hover, -.react-tags__selected-tag:focus { - border-color: var(--input-border-color); -} - -.react-tags__search { +// The block to search +.react-tags__combobox { display: inline-block; /* match tag layout */ @@ -76,13 +67,13 @@ } @media screen and (min-width: $smMin) { - .react-tags__search { + .react-tags__combobox { /* this will become the offsetParent for suggestions */ position: relative; } } -.react-tags__search-input { +.react-tags__combobox-input { font-size: 1.25rem; line-height: inherit; color: var(--input-text-color); @@ -98,62 +89,52 @@ outline: none; } -.react-tags__search-input::placeholder { +.react-tags__combobox-input::placeholder { color: #6c757d; } -.react-tags__search-input::-ms-clear { +.react-tags__combobox-input::-ms-clear { display: none; } -.react-tags__suggestions { +.react-tags__listbox { position: absolute; top: 100%; left: 0; width: 100%; z-index: 10; -} - -@media screen and (min-width: $smMin) { - .react-tags__suggestions { - width: 240px; - } -} - -.react-tags__suggestions ul { margin: 4px -1px; padding: 0; - list-style: none; background: var(--primary-color); border: 1px solid var(--border-color); border-radius: .25rem; box-shadow: 0 2px 6px rgb(0 0 0 / .2); } -.react-tags__suggestions li { +@media screen and (min-width: $smMin) { + .react-tags__listbox { + width: 240px; + } +} + +.react-tags__listbox .react-tags__listbox-option { padding: 8px 10px; } -.react-tags__suggestions li:not(:last-child) { +.react-tags__listbox .react-tags__listbox-option:not(:last-child) { border-bottom: 1px solid var(--border-color); } -.react-tags__suggestions li mark { - text-decoration: underline; - background: none; - font-weight: 600; -} - -.react-tags__suggestions li:hover { +.react-tags__listbox .react-tags__listbox-option:hover:not(.react-tags__listbox-option--not-selectable) { cursor: pointer; background-color: var(--active-color); } -.react-tags__suggestions li.is-active { +.react-tags__listbox .react-tags__listbox-option.is-active { background-color: var(--active-color); } -.react-tags__suggestions li.is-disabled { +.react-tags__listbox .react-tags__listbox-option.is-disabled { opacity: .5; cursor: auto; } From 5b15c184eb0ae6599319e16914fcdae94949e6c1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Aug 2023 19:51:18 +0200 Subject: [PATCH 4/5] Make sure suggestions are selectable in TagsSelector --- .../src/tags/helpers/TagsSelector.tsx | 15 ++++++++------- .../test/tags/helpers/TagsSelector.test.tsx | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/shlink-web-component/src/tags/helpers/TagsSelector.tsx b/shlink-web-component/src/tags/helpers/TagsSelector.tsx index 527fc46a..f68b75f2 100644 --- a/shlink-web-component/src/tags/helpers/TagsSelector.tsx +++ b/shlink-web-component/src/tags/helpers/TagsSelector.tsx @@ -1,4 +1,4 @@ -import resolveClasses from 'classnames'; +import classNames from 'classnames'; import { useEffect, useRef } from 'react'; import type { OptionRendererProps, ReactTagsAPI, TagRendererProps, TagSuggestion } from 'react-tag-autocomplete'; import { ReactTags } from 'react-tag-autocomplete'; @@ -25,7 +25,7 @@ const NOT_FOUND_TAG = 'Tag not found'; const NEW_TAG = 'Add tag'; const isSelectableOption = (tag: string) => tag !== NOT_FOUND_TAG; const isNewOption = (tag: string) => tag === NEW_TAG; -const toTagSuggestion = (tag: string): TagSuggestion => ({ label: tag, value: tag }); +const toTagObject = (tag: string): TagSuggestion => ({ label: tag, value: tag }); export const TagsSelector = (colorGenerator: ColorGenerator) => ( { selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps, @@ -41,16 +41,17 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( const ReactTagsTag = ({ tag, onClick: deleteTag }: TagRendererProps) => ( ); - const ReactTagsSuggestion = ({ option, classNames }: OptionRendererProps) => { + const ReactTagsSuggestion = ({ option, classNames: classes, ...rest }: OptionRendererProps) => { const isSelectable = isSelectableOption(option.label); const isNew = isNewOption(option.label); return (
{!isSelectable ? {option.label} : ( <> @@ -65,8 +66,8 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( return ( !selectedTags.includes(tag)).map(toTagSuggestion)} + selected={selectedTags.map(toTagObject)} + suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toTagObject)} renderTag={ReactTagsTag} renderOption={ReactTagsSuggestion} activateFirstOption diff --git a/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx b/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx index 29de7d5c..86b28411 100644 --- a/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx +++ b/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx @@ -42,7 +42,7 @@ describe('', () => { await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'ba'); - expect(container.querySelector('.react-tags__suggestions')).toBeInTheDocument(); + expect(container.querySelector('.react-tags__combobox')).toBeInTheDocument(); expect(screen.getByText('baz')).toBeInTheDocument(); }); From ee62d9a5f03b6ac053ec1551909dd6d59c6ee34d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 13 Aug 2023 13:53:08 +0200 Subject: [PATCH 5/5] Increase coverage in TagSelector --- config/test/setupTests.ts | 1 + .../src/short-urls/ShortUrlForm.tsx | 3 +- .../src/tags/helpers/TagsSelector.tsx | 74 ++++++++++--------- .../test/tags/helpers/TagsSelector.test.tsx | 70 ++++++++++++++++-- 4 files changed, 103 insertions(+), 45 deletions(-) diff --git a/config/test/setupTests.ts b/config/test/setupTests.ts index 3411ca8b..f8539125 100644 --- a/config/test/setupTests.ts +++ b/config/test/setupTests.ts @@ -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 = () => {}; diff --git a/shlink-web-component/src/short-urls/ShortUrlForm.tsx b/shlink-web-component/src/short-urls/ShortUrlForm.tsx index cc98bf7e..85c73ba4 100644 --- a/shlink-web-component/src/short-urls/ShortUrlForm.tsx +++ b/shlink-web-component/src/short-urls/ShortUrlForm.tsx @@ -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; diff --git a/shlink-web-component/src/tags/helpers/TagsSelector.tsx b/shlink-web-component/src/tags/helpers/TagsSelector.tsx index f68b75f2..a789a07b 100644 --- a/shlink-web-component/src/tags/helpers/TagsSelector.tsx +++ b/shlink-web-component/src/tags/helpers/TagsSelector.tsx @@ -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,6 +28,33 @@ const isSelectableOption = (tag: string) => tag !== NOT_FOUND_TAG; const isNewOption = (tag: string) => tag === NEW_TAG; const toTagObject = (tag: string): TagSuggestion => ({ label: tag, value: tag }); +const buildTagRenderer = (colorGenerator: ColorGenerator) => ({ tag, onClick: deleteTag }: TagRendererProps) => ( + +); +const buildOptionRenderer = (colorGenerator: ColorGenerator, api: ReactTagsAPI | null) => ( + { option, classNames: classes, ...rest }: OptionRendererProps, +) => { + const isSelectable = isSelectableOption(option.label); + const isNew = isNewOption(option.label); + + return ( +
+ {!isSelectable ? {option.label} : ( + <> + {!isNew && } + {!isNew ? option.label : Add "{normalizeTag(api?.input.value ?? '')}"} + + )} +
+ ); +}; + export const TagsSelector = (colorGenerator: ColorGenerator) => ( { selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps, ) => { @@ -36,40 +64,15 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( const shortUrlCreation = useSetting('shortUrlCreation'); const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith'; - const apiRef = useRef(null); - - const ReactTagsTag = ({ tag, onClick: deleteTag }: TagRendererProps) => ( - - ); - const ReactTagsSuggestion = ({ option, classNames: classes, ...rest }: OptionRendererProps) => { - const isSelectable = isSelectableOption(option.label); - const isNew = isNewOption(option.label); - - return ( -
- {!isSelectable ? {option.label} : ( - <> - {!isNew && } - {!isNew ? option.label : Add "{normalizeTag(apiRef.current?.input.value ?? '')}"} - - )} -
- ); - }; + const apiRef = useElementRef(); return ( !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)], )} /> ); diff --git a/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx b/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx index 86b28411..e4326133 100644 --- a/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx +++ b/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx @@ -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('', () => { const onChange = vi.fn(); const TagsSelector = createTagsSelector(colorGeneratorMock); const tags = ['foo', 'bar']; - const tagsList = fromPartial({ tags: [...tags, 'baz'] }); - const setUp = () => renderWithEvents( - + const setUp = ({ allowNew = true, allTags, tagFilteringMode }: SetUpOptions = {}) => renderWithEvents( + ({ tags: allTags ?? [...tags, 'baz'] })} listTags={vi.fn()} onChange={onChange} + allowNew={allowNew} /> , ); @@ -37,18 +48,36 @@ describe('', () => { 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('', () => { 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()); + }); });