mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 10:17:28 +03:00
Merge pull request #858 from acelaya-forks/feature/react-tag-autocomplete-7
Update to react-tag-autocomplete 7
This commit is contained in:
commit
8aa149cac4
13 changed files with 183 additions and 116 deletions
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
@ -11,6 +11,5 @@ jobs:
|
||||||
ci:
|
ci:
|
||||||
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||||
with:
|
with:
|
||||||
node-version: 20.2
|
node-version: 20.5
|
||||||
publish-coverage: true
|
publish-coverage: true
|
||||||
force-install: true
|
|
||||||
|
|
6
.github/workflows/deploy-preview.yml
vendored
6
.github/workflows/deploy-preview.yml
vendored
|
@ -5,7 +5,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
@ -16,10 +16,10 @@ jobs:
|
||||||
- name: Use node.js
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20.2
|
node-version: 20.5
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
npm ci --force && \
|
npm ci && \
|
||||||
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||||
npm run build
|
npm run build
|
||||||
- name: Deploy preview
|
- name: Deploy preview
|
||||||
|
|
6
.github/workflows/publish-release.yml
vendored
6
.github/workflows/publish-release.yml
vendored
|
@ -7,16 +7,16 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: Use node.js
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20.2
|
node-version: 20.5
|
||||||
- name: Generate release assets
|
- 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
|
- name: Publish release with assets
|
||||||
uses: docker://antonyurchenko/git-release:latest
|
uses: docker://antonyurchenko/git-release:latest
|
||||||
env:
|
env:
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
FROM node:20.2-alpine as node
|
FROM node:20.5-alpine as node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION ${VERSION}
|
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
|
FROM nginx:1.23-alpine
|
||||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||||
|
|
|
@ -25,3 +25,4 @@ afterEach(() => {
|
||||||
(global as any).scrollTo = () => {};
|
(global as any).scrollTo = () => {};
|
||||||
(global as any).prompt = () => {};
|
(global as any).prompt = () => {};
|
||||||
(global as any).matchMedia = (media: string) => ({ matches: false, media });
|
(global as any).matchMedia = (media: string) => ({ matches: false, media });
|
||||||
|
(global as any).HTMLElement.prototype.scrollIntoView = () => {};
|
||||||
|
|
|
@ -3,8 +3,8 @@ version: '3'
|
||||||
services:
|
services:
|
||||||
shlink_web_client_node:
|
shlink_web_client_node:
|
||||||
container_name: shlink_web_client_node
|
container_name: shlink_web_client_node
|
||||||
image: node:20.2-alpine
|
image: node:20.5-alpine
|
||||||
command: /bin/sh -c "cd /home/shlink/www && npm install --force && npm run start"
|
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/home/shlink/www
|
- ./:/home/shlink/www
|
||||||
ports:
|
ports:
|
||||||
|
|
20
package-lock.json
generated
20
package-lock.json
generated
|
@ -39,7 +39,7 @@
|
||||||
"react-redux": "^8.1.2",
|
"react-redux": "^8.1.2",
|
||||||
"react-router-dom": "^6.14.2",
|
"react-router-dom": "^6.14.2",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-swipeable": "^7.0.1",
|
||||||
"react-tag-autocomplete": "^6.3.0",
|
"react-tag-autocomplete": "^7.0.0",
|
||||||
"reactstrap": "^9.2.0",
|
"reactstrap": "^9.2.0",
|
||||||
"redux-localstorage-simple": "^2.5.1",
|
"redux-localstorage-simple": "^2.5.1",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
|
@ -8900,16 +8900,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-tag-autocomplete": {
|
"node_modules/react-tag-autocomplete": {
|
||||||
"version": "6.3.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.0.0.tgz",
|
||||||
"integrity": "sha512-MUBVUFh5eOqshUm5NM20qp7zXk8TzSiKO4GoktlFzBLIOLs356npaMKtL02bm0nFV2f1zInUrXn1fq6+i5YX0w==",
|
"integrity": "sha512-PFxT7fpMB8Au+S9cJYAGRVTnacZpeXybc5SkpTCyuJHmUN1Bt8gHb9vZi3f+aX/eDX44x2WIwYiqfRBi2E5AMg==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 16.12.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"prop-types": "^15.5.0",
|
"react": "^18.0.0"
|
||||||
"react": "^16.5.0 || ^17.0.0",
|
|
||||||
"react-dom": "^16.5.0 || ^17.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-transition-group": {
|
"node_modules/react-transition-group": {
|
||||||
|
@ -17211,9 +17209,9 @@
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"react-tag-autocomplete": {
|
"react-tag-autocomplete": {
|
||||||
"version": "6.3.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-7.0.0.tgz",
|
||||||
"integrity": "sha512-MUBVUFh5eOqshUm5NM20qp7zXk8TzSiKO4GoktlFzBLIOLs356npaMKtL02bm0nFV2f1zInUrXn1fq6+i5YX0w==",
|
"integrity": "sha512-PFxT7fpMB8Au+S9cJYAGRVTnacZpeXybc5SkpTCyuJHmUN1Bt8gHb9vZi3f+aX/eDX44x2WIwYiqfRBi2E5AMg==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"react-transition-group": {
|
"react-transition-group": {
|
||||||
|
|
|
@ -55,7 +55,7 @@
|
||||||
"react-redux": "^8.1.2",
|
"react-redux": "^8.1.2",
|
||||||
"react-router-dom": "^6.14.2",
|
"react-router-dom": "^6.14.2",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-swipeable": "^7.0.1",
|
||||||
"react-tag-autocomplete": "^6.3.0",
|
"react-tag-autocomplete": "^7.0.0",
|
||||||
"reactstrap": "^9.2.0",
|
"reactstrap": "^9.2.0",
|
||||||
"redux-localstorage-simple": "^2.5.1",
|
"redux-localstorage-simple": "^2.5.1",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { faDesktop } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { Checkbox, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
import { Checkbox, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { isEmpty, pipe, replace, trim } from 'ramda';
|
import { isEmpty } from 'ramda';
|
||||||
import type { ChangeEvent, FC } from 'react';
|
import type { ChangeEvent, FC } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||||
|
@ -35,7 +35,6 @@ export interface ShortUrlFormProps<T extends ShlinkCreateShortUrlData | ShlinkEd
|
||||||
onSave: (shortUrlData: T) => Promise<unknown>;
|
onSave: (shortUrlData: T) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
|
||||||
const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
|
const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date);
|
||||||
|
|
||||||
const isCreationData = (data: ShlinkCreateShortUrlData | ShlinkEditShortUrlData): data is ShlinkCreateShortUrlData =>
|
const isCreationData = (data: ShlinkCreateShortUrlData | ShlinkEditShortUrlData): data is ShlinkCreateShortUrlData =>
|
||||||
|
@ -54,7 +53,7 @@ export const ShortUrlForm = (
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
const isCreation = isCreationData(shortUrlData);
|
const isCreation = isCreationData(shortUrlData);
|
||||||
const isBasicMode = mode === 'create-basic';
|
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) => {
|
const setResettableValue = (value: string, initialValue?: any) => {
|
||||||
if (hasValue(value)) {
|
if (hasValue(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|
|
@ -1,70 +1,100 @@
|
||||||
|
import { useElementRef } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
|
import type { OptionRendererProps, ReactTagsAPI, TagRendererProps, TagSuggestion } from 'react-tag-autocomplete';
|
||||||
import ReactTags from 'react-tag-autocomplete';
|
import { ReactTags } from 'react-tag-autocomplete';
|
||||||
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
import type { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||||
import { useSetting } from '../../utils/settings';
|
import { useSetting } from '../../utils/settings';
|
||||||
import type { TagsList } from '../reducers/tagsList';
|
import type { TagsList } from '../reducers/tagsList';
|
||||||
|
import { normalizeTag } from './index';
|
||||||
import { Tag } from './Tag';
|
import { Tag } from './Tag';
|
||||||
import { TagBullet } from './TagBullet';
|
import { TagBullet } from './TagBullet';
|
||||||
|
|
||||||
export interface TagsSelectorProps {
|
export type TagsSelectorProps = {
|
||||||
selectedTags: string[];
|
selectedTags: string[];
|
||||||
onChange: (tags: string[]) => void;
|
onChange: (tags: string[]) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
allowNew?: boolean;
|
allowNew?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface TagsSelectorConnectProps extends TagsSelectorProps {
|
type TagsSelectorConnectProps = TagsSelectorProps & {
|
||||||
listTags: () => void;
|
listTags: () => void;
|
||||||
tagsList: TagsList;
|
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) => (
|
||||||
|
<Tag colorGenerator={colorGenerator} text={tag.label} clearable className="react-tags__tag" onClose={deleteTag} />
|
||||||
|
);
|
||||||
|
const buildOptionRenderer = (colorGenerator: ColorGenerator, api: ReactTagsAPI | null) => (
|
||||||
|
{ option, classNames: classes, ...rest }: OptionRendererProps,
|
||||||
|
) => {
|
||||||
|
const isSelectable = isSelectableOption(option.label);
|
||||||
|
const isNew = isNewOption(option.label);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(classes.option, {
|
||||||
|
[classes.optionIsActive]: isSelectable && option.active,
|
||||||
|
'react-tags__listbox-option--not-selectable': !isSelectable,
|
||||||
|
})}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{!isSelectable ? <i>{option.label}</i> : (
|
||||||
|
<>
|
||||||
|
{!isNew && <TagBullet tag={`${option.label}`} colorGenerator={colorGenerator} />}
|
||||||
|
{!isNew ? option.label : <i>Add "{normalizeTag(api?.input.value ?? '')}"</i>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const TagsSelector = (colorGenerator: ColorGenerator) => (
|
export const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||||
{ selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps,
|
{ selectedTags, onChange, placeholder, listTags, tagsList, allowNew = true }: TagsSelectorConnectProps,
|
||||||
) => {
|
) => {
|
||||||
const shortUrlCreation = useSetting('shortUrlCreation');
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listTags();
|
listTags();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const shortUrlCreation = useSetting('shortUrlCreation');
|
||||||
const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith';
|
const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith';
|
||||||
const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
|
const apiRef = useElementRef<ReactTagsAPI>();
|
||||||
<Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
|
|
||||||
const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
|
|
||||||
<>
|
|
||||||
<TagBullet tag={`${item.name}`} colorGenerator={colorGenerator} />
|
|
||||||
{item.name}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactTags
|
<ReactTags
|
||||||
tags={selectedTags.map(toComponentTag)}
|
ref={apiRef}
|
||||||
tagComponent={ReactTagsTag}
|
selected={selectedTags.map(toTagObject)}
|
||||||
suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toComponentTag)}
|
suggestions={tagsList.tags.filter((tag) => !selectedTags.includes(tag)).map(toTagObject)}
|
||||||
suggestionComponent={ReactTagsSuggestion}
|
renderTag={buildTagRenderer(colorGenerator)}
|
||||||
|
renderOption={buildOptionRenderer(colorGenerator, apiRef.current)}
|
||||||
|
activateFirstOption
|
||||||
allowNew={allowNew}
|
allowNew={allowNew}
|
||||||
addOnBlur
|
newOptionText={NEW_TAG}
|
||||||
|
noOptionsText={NOT_FOUND_TAG}
|
||||||
placeholderText={placeholder ?? 'Add tags to the URL'}
|
placeholderText={placeholder ?? 'Add tags to the URL'}
|
||||||
minQueryLength={1}
|
delimiterKeys={['Enter', 'Tab', ',']}
|
||||||
delimiters={['Enter', 'Tab', ',']}
|
|
||||||
suggestionsTransform={
|
suggestionsTransform={
|
||||||
searchMode === 'includes'
|
(query, suggestions) => {
|
||||||
? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query))
|
const searchTerm = query.toLowerCase().trim();
|
||||||
: undefined
|
return searchTerm.length < 1 ? [] : [...suggestions.filter(
|
||||||
|
({ label }) => (searchMode === 'includes' ? label.includes(searchTerm) : label.startsWith(searchTerm)),
|
||||||
|
)].slice(0, 5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onDelete={(removedTagIndex) => {
|
onDelete={(removedTagIndex) => {
|
||||||
const tagsCopy = [...selectedTags];
|
const tagsCopy = [...selectedTags];
|
||||||
|
|
||||||
tagsCopy.splice(removedTagIndex, 1);
|
tagsCopy.splice(removedTagIndex, 1);
|
||||||
onChange(tagsCopy);
|
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.
|
||||||
// * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once.
|
[...selectedTags, ...newTag.split(',').map(normalizeTag)],
|
||||||
[...new Set([...selectedTags, ...newTag.toLowerCase().split(',')])],
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
3
shlink-web-component/src/tags/helpers/index.ts
Normal file
3
shlink-web-component/src/tags/helpers/index.ts
Normal file
|
@ -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, '-');
|
|
@ -1,5 +1,6 @@
|
||||||
@import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
@import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
|
||||||
|
// Main wrapper
|
||||||
.react-tags {
|
.react-tags {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 5px 0 0 6px;
|
padding: 5px 0 0 6px;
|
||||||
|
@ -26,45 +27,35 @@
|
||||||
background-color: var(--input-color);
|
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%);
|
box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-tags__label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.react-tags__tag {
|
.react-tags__tag {
|
||||||
font-size: 100%;
|
font-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tags__selected {
|
.react-tags__list {
|
||||||
display: inline;
|
display: inline;
|
||||||
vertical-align: 2px;
|
vertical-align: 2px;
|
||||||
|
padding: 0;
|
||||||
|
list-style-type: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tags__selected-tag {
|
.react-tags__list-item {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
box-sizing: border-box;
|
}
|
||||||
margin: 0 6px 6px 0;
|
.react-tags__list-item:not(:last-child) {
|
||||||
padding: 6px 8px;
|
margin-right: 3px;
|
||||||
border: 1px solid var(--input-border-color);
|
|
||||||
border-radius: .25rem;
|
|
||||||
background: #f1f1f1;
|
|
||||||
|
|
||||||
/* match the font styles */
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tags__selected-tag:after {
|
// The block to search
|
||||||
content: '\2715';
|
.react-tags__combobox {
|
||||||
color: #aaaaaa;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__selected-tag:hover,
|
|
||||||
.react-tags__selected-tag:focus {
|
|
||||||
border-color: var(--input-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__search {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
/* match tag layout */
|
/* match tag layout */
|
||||||
|
@ -76,13 +67,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: $smMin) {
|
@media screen and (min-width: $smMin) {
|
||||||
.react-tags__search {
|
.react-tags__combobox {
|
||||||
/* this will become the offsetParent for suggestions */
|
/* this will become the offsetParent for suggestions */
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tags__search-input {
|
.react-tags__combobox-input {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
color: var(--input-text-color);
|
color: var(--input-text-color);
|
||||||
|
@ -98,62 +89,52 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tags__search-input::placeholder {
|
.react-tags__combobox-input::placeholder {
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tags__search-input::-ms-clear {
|
.react-tags__combobox-input::-ms-clear {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tags__suggestions {
|
.react-tags__listbox {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: $smMin) {
|
|
||||||
.react-tags__suggestions {
|
|
||||||
width: 240px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__suggestions ul {
|
|
||||||
margin: 4px -1px;
|
margin: 4px -1px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
box-shadow: 0 2px 6px rgb(0 0 0 / .2);
|
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;
|
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);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tags__suggestions li mark {
|
.react-tags__listbox .react-tags__listbox-option:hover:not(.react-tags__listbox-option--not-selectable) {
|
||||||
text-decoration: underline;
|
|
||||||
background: none;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-tags__suggestions li:hover {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--active-color);
|
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);
|
background-color: var(--active-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-tags__suggestions li.is-disabled {
|
.react-tags__listbox .react-tags__listbox-option.is-disabled {
|
||||||
opacity: .5;
|
opacity: .5;
|
||||||
cursor: auto;
|
cursor: auto;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,22 +2,33 @@ import { screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { TagsSelector as createTagsSelector } from '../../../src/tags/helpers/TagsSelector';
|
import { TagsSelector as createTagsSelector } from '../../../src/tags/helpers/TagsSelector';
|
||||||
import type { TagsList } from '../../../src/tags/reducers/tagsList';
|
import type { TagsList } from '../../../src/tags/reducers/tagsList';
|
||||||
|
import type { TagFilteringMode } from '../../../src/utils/settings';
|
||||||
import { SettingsProvider } from '../../../src/utils/settings';
|
import { SettingsProvider } from '../../../src/utils/settings';
|
||||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||||
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
|
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
|
||||||
|
|
||||||
|
type SetUpOptions = {
|
||||||
|
allowNew?: boolean;
|
||||||
|
allTags?: string[];
|
||||||
|
tagFilteringMode?: TagFilteringMode;
|
||||||
|
};
|
||||||
|
|
||||||
describe('<TagsSelector />', () => {
|
describe('<TagsSelector />', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const TagsSelector = createTagsSelector(colorGeneratorMock);
|
const TagsSelector = createTagsSelector(colorGeneratorMock);
|
||||||
const tags = ['foo', 'bar'];
|
const tags = ['foo', 'bar'];
|
||||||
const tagsList = fromPartial<TagsList>({ tags: [...tags, 'baz'] });
|
const setUp = ({ allowNew = true, allTags, tagFilteringMode }: SetUpOptions = {}) => renderWithEvents(
|
||||||
const setUp = () => renderWithEvents(
|
<SettingsProvider
|
||||||
<SettingsProvider value={fromPartial({})}>
|
value={fromPartial({
|
||||||
|
shortUrlCreation: { tagFilteringMode },
|
||||||
|
})}
|
||||||
|
>
|
||||||
<TagsSelector
|
<TagsSelector
|
||||||
selectedTags={tags}
|
selectedTags={tags}
|
||||||
tagsList={tagsList}
|
tagsList={fromPartial<TagsList>({ tags: allTags ?? [...tags, 'baz'] })}
|
||||||
listTags={vi.fn()}
|
listTags={vi.fn()}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
allowNew={allowNew}
|
||||||
/>
|
/>
|
||||||
</SettingsProvider>,
|
</SettingsProvider>,
|
||||||
);
|
);
|
||||||
|
@ -37,18 +48,36 @@ describe('<TagsSelector />', () => {
|
||||||
it('contains expected suggestions', async () => {
|
it('contains expected suggestions', async () => {
|
||||||
const { container, user } = setUp();
|
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();
|
expect(screen.queryByText('baz')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'ba');
|
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();
|
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([
|
it.each([
|
||||||
['The-New-Tag', [...tags, 'the-new-tag']],
|
['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) => {
|
])('invokes onChange when new tags are added', async (newTag, expectedTags) => {
|
||||||
const { user } = setUp();
|
const { user } = setUp();
|
||||||
|
|
||||||
|
@ -77,4 +106,31 @@ describe('<TagsSelector />', () => {
|
||||||
await user.click(screen.getByLabelText(`Remove ${removedLabel}`));
|
await user.click(screen.getByLabelText(`Remove ${removedLabel}`));
|
||||||
expect(onChange).toHaveBeenCalledWith([expected]);
|
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());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue