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/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/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/short-urls/ShortUrlForm.tsx b/shlink-web-component/src/short-urls/ShortUrlForm.tsx index 16e2fead..85c73ba4 100644 --- a/shlink-web-component/src/short-urls/ShortUrlForm.tsx +++ b/shlink-web-component/src/short-urls/ShortUrlForm.tsx @@ -4,7 +4,7 @@ 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'; @@ -35,7 +35,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 => @@ -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 72d74fcc..a789a07b 100644 --- a/shlink-web-component/src/tags/helpers/TagsSelector.tsx +++ b/shlink-web-component/src/tags/helpers/TagsSelector.tsx @@ -1,70 +1,100 @@ +import { useElementRef } from '@shlinkio/shlink-frontend-kit'; +import classNames from 'classnames'; import { useEffect } from 'react'; -import type { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete'; -import ReactTags from 'react-tag-autocomplete'; +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'; -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 toComponentTag = (tag: string) => ({ id: tag, name: tag }); +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 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, ) => { - const shortUrlCreation = useSetting('shortUrlCreation'); useEffect(() => { listTags(); }, []); + const shortUrlCreation = useSetting('shortUrlCreation'); const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith'; - const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) => - ; - const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => ( - <> - - {item.name} - - ); + const apiRef = useElementRef(); return ( !selectedTags.includes(tag)).map(toComponentTag)} - suggestionComponent={ReactTagsSuggestion} + ref={apiRef} + selected={selectedTags.map(toTagObject)} + suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toTagObject)} + renderTag={buildTagRenderer(colorGenerator)} + renderOption={buildOptionRenderer(colorGenerator, apiRef.current)} + activateFirstOption allowNew={allowNew} - addOnBlur + newOptionText={NEW_TAG} + noOptionsText={NOT_FOUND_TAG} placeholderText={placeholder ?? 'Add tags to the URL'} - minQueryLength={1} - delimiters={['Enter', 'Tab', ',']} + delimiterKeys={['Enter', 'Tab', ',']} suggestionsTransform={ - searchMode === 'includes' - ? (query, suggestions) => suggestions.filter(({ name }) => name.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)), + )].slice(0, 5); + } } onDelete={(removedTagIndex) => { const tagsCopy = [...selectedTags]; - tagsCopy.splice(removedTagIndex, 1); onChange(tagsCopy); }} - onAddition={({ name: 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(',')])], + onAdd={({ label: newTag }) => onChange( + // 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/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; } diff --git a/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx b/shlink-web-component/test/tags/helpers/TagsSelector.test.tsx index 29de7d5c..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__suggestions')).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()); + }); });