mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 01:20:24 +03:00
Migrated tags helpers to TS
This commit is contained in:
parent
84fc82b74e
commit
18883caa6d
17 changed files with 279 additions and 262 deletions
43
package-lock.json
generated
43
package-lock.json
generated
|
@ -3384,6 +3384,25 @@
|
|||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"@types/react-autosuggest": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-autosuggest/-/react-autosuggest-10.0.0.tgz",
|
||||
"integrity": "sha512-lcu3L3158I8DlgicRpyIuYzIc+E70RgXcVI8uJ+nw5JnAlhNht7dE1hJ0Q0x2A5VVBMOi5dcCQACqi5wWfm8ow==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-color": {
|
||||
"version": "2.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-2.17.4.tgz",
|
||||
"integrity": "sha512-pAO3+7uHoESg5QMqjnGjw9F7sALjEZsaU41yGiUZbmHiJMoSXH1UklFJ1bZkwhYskaJgiY+AS6wirl17yBh5GA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*",
|
||||
"@types/reactcss": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-copy-to-clipboard": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-4.3.0.tgz",
|
||||
|
@ -3457,6 +3476,24 @@
|
|||
"@types/react-router": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-tagsinput": {
|
||||
"version": "3.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-tagsinput/-/react-tagsinput-3.19.7.tgz",
|
||||
"integrity": "sha512-yj/3iFBLoan/0vzXMxC9zGhO1uJ89qjQldekf0o3fX4mYdaAPW/VbP921fsyYt6PdHmJ9UMo+kERSMzUAml1xQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/reactcss": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.3.tgz",
|
||||
"integrity": "sha512-d2gQQ0IL6hXLnoRfVYZukQNWHuVsE75DzFTLPUuyyEhJS8G2VvlE+qfQQ91SJjaMqlURRCNIsX7Jcsw6cEuJlA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/reactstrap": {
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/reactstrap/-/reactstrap-8.5.1.tgz",
|
||||
|
@ -18146,9 +18183,9 @@
|
|||
}
|
||||
},
|
||||
"react-color": {
|
||||
"version": "2.17.3",
|
||||
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.17.3.tgz",
|
||||
"integrity": "sha512-1dtO8LqAVotPIChlmo6kLtFS1FP89ll8/OiA8EcFRDR+ntcK+0ukJgByuIQHRtzvigf26dV5HklnxDIvhON9VQ==",
|
||||
"version": "2.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.18.1.tgz",
|
||||
"integrity": "sha512-X5XpyJS6ncplZs74ak0JJoqPi+33Nzpv5RYWWxn17bslih+X7OlgmfpmGC1fNvdkK7/SGWYf1JJdn7D2n5gSuQ==",
|
||||
"requires": {
|
||||
"@icons/material": "^0.2.4",
|
||||
"lodash": "^4.17.11",
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
"react": "^16.13.1",
|
||||
"react-autosuggest": "^9.4.3",
|
||||
"react-chartjs-2": "^2.8.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-color": "^2.17.4",
|
||||
"react-copy-to-clipboard": "^5.0.1",
|
||||
"react-datepicker": "~1.5.0",
|
||||
"react-dom": "^16.13.1",
|
||||
|
@ -83,11 +83,14 @@
|
|||
"@types/qs": "^6.9.4",
|
||||
"@types/ramda": "^0.27.14",
|
||||
"@types/react": "^16.9.46",
|
||||
"@types/react-autosuggest": "^10.0.0",
|
||||
"@types/react-color": "^2.17.4",
|
||||
"@types/react-copy-to-clipboard": "^4.3.0",
|
||||
"@types/react-datepicker": "~1.8.0",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-redux": "^7.1.9",
|
||||
"@types/react-router-dom": "^5.1.5",
|
||||
"@types/react-tagsinput": "^3.19.7",
|
||||
"@types/reactstrap": "^8.5.1",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"adm-zip": "^0.4.13",
|
||||
|
|
|
@ -12,6 +12,7 @@ import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
|||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||
import { formatIsoDate } from '../utils/helpers/date';
|
||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||
import { ShortUrlData } from './data';
|
||||
import { ShortUrlCreation } from './reducers/shortUrlCreation';
|
||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||
|
@ -42,7 +43,7 @@ type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | '
|
|||
type DateFields = 'validSince' | 'validUntil';
|
||||
|
||||
const CreateShortUrl = (
|
||||
TagsSelector: FC<any>,
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||
ForServerVersion: FC<Versions>,
|
||||
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
|
||||
|
@ -103,7 +104,7 @@ const CreateShortUrl = (
|
|||
|
||||
<Collapse isOpen={moreOptionsVisible}>
|
||||
<div className="form-group">
|
||||
<TagsSelector tags={shortUrlCreation.tags} onChange={changeTags} />
|
||||
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
|
|
|
@ -4,6 +4,7 @@ import { ExternalLink } from 'react-external-link';
|
|||
import { ShortUrlTags } from '../reducers/shortUrlTags';
|
||||
import { ShortUrlModalProps } from '../data';
|
||||
import { OptionalString } from '../../utils/utils';
|
||||
import { TagsSelectorProps } from '../../tags/helpers/TagsSelector';
|
||||
|
||||
interface EditTagsModalProps extends ShortUrlModalProps {
|
||||
shortUrlTags: ShortUrlTags;
|
||||
|
@ -11,7 +12,7 @@ interface EditTagsModalProps extends ShortUrlModalProps {
|
|||
resetShortUrlsTags: () => void;
|
||||
}
|
||||
|
||||
const EditTagsModal = (TagsSelector: FC<any>) => ( // TODO Use TagsSelector type when available
|
||||
const EditTagsModal = (TagsSelector: FC<TagsSelectorProps>) => (
|
||||
{ isOpen, toggle, shortUrl, shortUrlTags, editShortUrlTags, resetShortUrlsTags }: EditTagsModalProps,
|
||||
) => {
|
||||
const [ selectedTags, setSelectedTags ] = useState<string[]>(shortUrl.tags || []);
|
||||
|
|
10
src/tags/data/index.ts
Normal file
10
src/tags/data/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export interface TagStats {
|
||||
shortUrlsCount: number;
|
||||
visitsCount: number;
|
||||
}
|
||||
|
||||
export interface TagModalProps {
|
||||
tag: string;
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
}
|
|
@ -1,18 +1,17 @@
|
|||
import React from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { tagDeleteType } from '../reducers/tagDelete';
|
||||
import { TagDeletion } from '../reducers/tagDelete';
|
||||
import { TagModalProps } from '../data';
|
||||
|
||||
const propTypes = {
|
||||
tag: PropTypes.string.isRequired,
|
||||
toggle: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
deleteTag: PropTypes.func,
|
||||
tagDelete: tagDeleteType,
|
||||
tagDeleted: PropTypes.func,
|
||||
};
|
||||
interface DeleteTagConfirmModalProps extends TagModalProps {
|
||||
deleteTag: (tag: string) => Promise<void>;
|
||||
tagDeleted: (tag: string) => void;
|
||||
tagDelete: TagDeletion;
|
||||
}
|
||||
|
||||
const DeleteTagConfirmModal = ({ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }) => {
|
||||
const DeleteTagConfirmModal = (
|
||||
{ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }: DeleteTagConfirmModalProps,
|
||||
) => {
|
||||
const doDelete = async () => {
|
||||
await deleteTag(tag);
|
||||
tagDeleted(tag);
|
||||
|
@ -42,6 +41,4 @@ const DeleteTagConfirmModal = ({ tag, toggle, isOpen, deleteTag, tagDelete, tagD
|
|||
);
|
||||
};
|
||||
|
||||
DeleteTagConfirmModal.propTypes = propTypes;
|
||||
|
||||
export default DeleteTagConfirmModal;
|
|
@ -1,82 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { ChromePicker } from 'react-color';
|
||||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import PropTypes from 'prop-types';
|
||||
import './EditTagModal.scss';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
|
||||
const propTypes = {
|
||||
tag: PropTypes.string,
|
||||
editTag: PropTypes.func,
|
||||
toggle: PropTypes.func,
|
||||
tagEdited: PropTypes.func,
|
||||
isOpen: PropTypes.bool,
|
||||
tagEdit: PropTypes.shape({
|
||||
error: PropTypes.bool,
|
||||
editing: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
|
||||
const EditTagModal = ({ getColorForKey }) => {
|
||||
const EditTagModalComp = ({ tag, editTag, toggle, tagEdited, isOpen, tagEdit }) => {
|
||||
const [ newTagName, setNewTagName ] = useState(tag);
|
||||
const [ color, setColor ] = useState(getColorForKey(tag));
|
||||
const [ showColorPicker, toggleColorPicker ] = useToggle();
|
||||
const saveTag = handleEventPreventingDefault(() => editTag(tag, newTagName, color)
|
||||
.then(() => tagEdited(tag, newTagName, color))
|
||||
.then(toggle)
|
||||
.catch(() => {}));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<form onSubmit={saveTag}>
|
||||
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="input-group">
|
||||
<div className="input-group-prepend" id="colorPickerBtn" onClick={toggleColorPicker}>
|
||||
<div
|
||||
className="input-group-text edit-tag-modal__color-picker-toggle"
|
||||
style={{ backgroundColor: color, borderColor: color }}
|
||||
>
|
||||
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
||||
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
||||
</Popover>
|
||||
<input
|
||||
type="text"
|
||||
value={newTagName}
|
||||
placeholder="Tag"
|
||||
required
|
||||
className="form-control"
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tagEdit.error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while editing the tag :(
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={tagEdit.editing}>
|
||||
{tagEdit.editing ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
EditTagModalComp.propTypes = propTypes;
|
||||
|
||||
return EditTagModalComp;
|
||||
};
|
||||
|
||||
export default EditTagModal;
|
74
src/tags/helpers/EditTagModal.tsx
Normal file
74
src/tags/helpers/EditTagModal.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { ChromePicker } from 'react-color';
|
||||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import { TagModalProps } from '../data';
|
||||
import { TagEdition } from '../reducers/tagEdit';
|
||||
import './EditTagModal.scss';
|
||||
|
||||
interface EditTagModalProps extends TagModalProps {
|
||||
tagEdit: TagEdition;
|
||||
editTag: (oldName: string, newName: string, color: string) => Promise<void>;
|
||||
tagEdited: (oldName: string, newName: string, color: string) => void;
|
||||
}
|
||||
|
||||
const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||
{ tag, editTag, toggle, tagEdited, isOpen, tagEdit }: EditTagModalProps,
|
||||
) => {
|
||||
const [ newTagName, setNewTagName ] = useState(tag);
|
||||
const [ color, setColor ] = useState(getColorForKey(tag));
|
||||
const [ showColorPicker, toggleColorPicker ] = useToggle();
|
||||
const saveTag = handleEventPreventingDefault(async () => editTag(tag, newTagName, color)
|
||||
.then(() => tagEdited(tag, newTagName, color))
|
||||
.then(toggle)
|
||||
.catch(() => {}));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<form onSubmit={saveTag}>
|
||||
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="input-group">
|
||||
<div className="input-group-prepend" id="colorPickerBtn" onClick={toggleColorPicker}>
|
||||
<div
|
||||
className="input-group-text edit-tag-modal__color-picker-toggle"
|
||||
style={{ backgroundColor: color, borderColor: color }}
|
||||
>
|
||||
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
||||
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
||||
</Popover>
|
||||
<input
|
||||
type="text"
|
||||
value={newTagName}
|
||||
placeholder="Tag"
|
||||
required
|
||||
className="form-control"
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tagEdit.error && (
|
||||
<div className="p-2 mt-2 bg-danger text-white text-center">
|
||||
Something went wrong while editing the tag :(
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={tagEdit.editing}>
|
||||
{tagEdit.editing ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTagModal;
|
|
@ -1,35 +0,0 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
|
||||
import './Tag.scss';
|
||||
|
||||
const propTypes = {
|
||||
text: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
clearable: PropTypes.bool,
|
||||
colorGenerator: colorGeneratorType,
|
||||
onClick: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
const Tag = ({
|
||||
text,
|
||||
children,
|
||||
clearable,
|
||||
colorGenerator,
|
||||
onClick,
|
||||
onClose,
|
||||
}) => (
|
||||
<span
|
||||
className="badge tag"
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children || text}
|
||||
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>×</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
Tag.propTypes = propTypes;
|
||||
|
||||
export default Tag;
|
24
src/tags/helpers/Tag.tsx
Normal file
24
src/tags/helpers/Tag.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import React, { FC } from 'react';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import './Tag.scss';
|
||||
|
||||
interface TagProps {
|
||||
colorGenerator: ColorGenerator;
|
||||
text: string;
|
||||
clearable?: boolean;
|
||||
onClick?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const Tag: FC<TagProps> = ({ text, children, clearable, colorGenerator, onClick, onClose }) => (
|
||||
<span
|
||||
className="badge tag"
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable || !onClick ? 'auto' : 'pointer' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children ?? text}
|
||||
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>×</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
export default Tag;
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
import * as PropTypes from 'prop-types';
|
||||
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
|
||||
import './TagBullet.scss';
|
||||
|
||||
const propTypes = {
|
||||
tag: PropTypes.string.isRequired,
|
||||
colorGenerator: colorGeneratorType,
|
||||
};
|
||||
|
||||
const TagBullet = ({ tag, colorGenerator }) => (
|
||||
<div
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
|
||||
className="tag-bullet"
|
||||
/>
|
||||
);
|
||||
|
||||
TagBullet.propTypes = propTypes;
|
||||
|
||||
export default TagBullet;
|
17
src/tags/helpers/TagBullet.tsx
Normal file
17
src/tags/helpers/TagBullet.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import './TagBullet.scss';
|
||||
|
||||
interface TagBulletProps {
|
||||
tag: string;
|
||||
colorGenerator: ColorGenerator;
|
||||
}
|
||||
|
||||
const TagBullet = ({ tag, colorGenerator }: TagBulletProps) => (
|
||||
<div
|
||||
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
|
||||
className="tag-bullet"
|
||||
/>
|
||||
);
|
||||
|
||||
export default TagBullet;
|
|
@ -1,84 +0,0 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import PropTypes from 'prop-types';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import { identity } from 'ramda';
|
||||
import TagBullet from './TagBullet';
|
||||
import './TagsSelector.scss';
|
||||
|
||||
const propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
listTags: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
tagsList: PropTypes.shape({
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
}),
|
||||
};
|
||||
|
||||
const TagsSelector = (colorGenerator) => {
|
||||
const TagsSelectorComp = ({ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }) => {
|
||||
useEffect(() => {
|
||||
listTags();
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line
|
||||
const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => (
|
||||
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
|
||||
{getTagDisplayValue(tag)}
|
||||
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />}
|
||||
</span>
|
||||
);
|
||||
const renderAutocompleteInput = (data) => {
|
||||
const { addTag, ...otherProps } = data;
|
||||
const handleOnChange = (e, { method }) => {
|
||||
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
|
||||
};
|
||||
|
||||
const inputValue = (otherProps.value && otherProps.value.trim().toLowerCase()) || '';
|
||||
const inputLength = inputValue.length;
|
||||
const suggestions = tagsList.tags.filter((state) => state.toLowerCase().slice(0, inputLength) === inputValue);
|
||||
|
||||
return (
|
||||
<Autosuggest
|
||||
ref={otherProps.ref}
|
||||
suggestions={suggestions}
|
||||
inputProps={{ ...otherProps, onChange: handleOnChange }}
|
||||
highlightFirstSuggestion
|
||||
shouldRenderSuggestions={(value) => value && value.trim().length > 0}
|
||||
getSuggestionValue={(suggestion) => suggestion}
|
||||
renderSuggestion={(suggestion) => (
|
||||
<React.Fragment>
|
||||
<TagBullet tag={suggestion} colorGenerator={colorGenerator} />
|
||||
{suggestion}
|
||||
</React.Fragment>
|
||||
)}
|
||||
onSuggestionSelected={(e, { suggestion }) => {
|
||||
addTag(suggestion);
|
||||
}}
|
||||
onSuggestionsClearRequested={identity}
|
||||
onSuggestionsFetchRequested={identity}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TagsInput
|
||||
value={tags}
|
||||
inputProps={{ placeholder }}
|
||||
onlyUnique
|
||||
renderTag={renderTag}
|
||||
renderInput={renderAutocompleteInput}
|
||||
// FIXME Workaround to be able to add tags on Android
|
||||
addOnBlur
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
TagsSelectorComp.propTypes = propTypes;
|
||||
|
||||
return TagsSelectorComp;
|
||||
};
|
||||
|
||||
export default TagsSelector;
|
80
src/tags/helpers/TagsSelector.tsx
Normal file
80
src/tags/helpers/TagsSelector.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import React, { ChangeEvent, useEffect } from 'react';
|
||||
import TagsInput, { RenderInputProps, RenderTagProps } from 'react-tagsinput';
|
||||
import Autosuggest, { ChangeEvent as AutoChangeEvent, SuggestionSelectedEventData } from 'react-autosuggest';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import { TagsList } from '../reducers/tagsList';
|
||||
import TagBullet from './TagBullet';
|
||||
import './TagsSelector.scss';
|
||||
|
||||
export interface TagsSelectorProps {
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface TagsSelectorConnectProps extends TagsSelectorProps {
|
||||
listTags: Function;
|
||||
tagsList: TagsList;
|
||||
}
|
||||
|
||||
const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||
{ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
listTags();
|
||||
}, []);
|
||||
|
||||
const renderTag = (
|
||||
{ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }: RenderTagProps<string>,
|
||||
) => (
|
||||
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
|
||||
{getTagDisplayValue(tag)}
|
||||
{!disabled && <span className={classNameRemove} onClick={() => onRemove(key)} />}
|
||||
</span>
|
||||
);
|
||||
const renderAutocompleteInput = (data: RenderInputProps<string>) => {
|
||||
const { addTag, ...otherProps } = data;
|
||||
const handleOnChange = (e: ChangeEvent<HTMLInputElement>, { method }: AutoChangeEvent) => {
|
||||
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
|
||||
};
|
||||
|
||||
const inputValue = otherProps.value?.trim().toLowerCase() ?? '';
|
||||
const suggestions = tagsList.tags.filter((tag) => tag.startsWith(inputValue));
|
||||
|
||||
return (
|
||||
<Autosuggest
|
||||
ref={otherProps.ref}
|
||||
suggestions={suggestions}
|
||||
inputProps={{ ...otherProps, onChange: handleOnChange }}
|
||||
highlightFirstSuggestion
|
||||
shouldRenderSuggestions={(value: string) => value.trim().length > 0}
|
||||
getSuggestionValue={(suggestion) => suggestion}
|
||||
renderSuggestion={(suggestion) => (
|
||||
<React.Fragment>
|
||||
<TagBullet tag={suggestion} colorGenerator={colorGenerator} />
|
||||
{suggestion}
|
||||
</React.Fragment>
|
||||
)}
|
||||
onSuggestionsFetchRequested={() => {}}
|
||||
onSuggestionSelected={(_, { suggestion }: SuggestionSelectedEventData<string>) => {
|
||||
addTag(suggestion);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TagsInput
|
||||
value={tags}
|
||||
inputProps={{ placeholder }}
|
||||
onlyUnique
|
||||
renderTag={renderTag}
|
||||
renderInput={renderAutocompleteInput}
|
||||
// FIXME Workaround to be able to add tags on Android
|
||||
addOnBlur
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagsSelector;
|
|
@ -1,4 +1,3 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { GetState } from '../../container/types';
|
||||
|
@ -11,12 +10,6 @@ export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
|
|||
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
/** @deprecated Use TagDeletion interface */
|
||||
export const tagDeleteType = PropTypes.shape({
|
||||
deleting: PropTypes.bool,
|
||||
error: PropTypes.bool,
|
||||
});
|
||||
|
||||
export interface TagDeletion {
|
||||
deleting: boolean;
|
||||
error: boolean;
|
||||
|
|
|
@ -5,9 +5,10 @@ import { CREATE_VISIT, CreateVisitAction } from '../../visits/reducers/visitCrea
|
|||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkTags } from '../../utils/services/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
import { TagStats } from '../data';
|
||||
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
||||
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
|
||||
|
@ -28,19 +29,19 @@ export const TagsListType = PropTypes.shape({
|
|||
error: PropTypes.bool,
|
||||
});
|
||||
|
||||
type TagsStats = Record<string, { shortUrlsCount: number; visitsCount: number }>;
|
||||
type TagsStatsMap = Record<string, TagStats>;
|
||||
|
||||
export interface TagsList {
|
||||
tags: string[];
|
||||
filteredTags: string[];
|
||||
stats: TagsStats;
|
||||
stats: TagsStatsMap;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
interface ListTagsAction extends Action<string> {
|
||||
tags: string[];
|
||||
stats: TagsStats;
|
||||
stats: TagsStatsMap;
|
||||
}
|
||||
|
||||
interface FilterTagsAction extends Action<string> {
|
||||
|
@ -59,7 +60,7 @@ const initialState = {
|
|||
|
||||
const renameTag = (oldName: string, newName: string) => (tag: string) => tag === oldName ? newName : tag;
|
||||
const rejectTag = (tags: string[], tagToReject: string) => reject((tag) => tag === tagToReject, tags);
|
||||
const increaseVisitsForTags = (tags: string[], stats: TagsStats) => tags.reduce((stats, tag) => {
|
||||
const increaseVisitsForTags = (tags: string[], stats: TagsStatsMap) => tags.reduce((stats, tag) => {
|
||||
if (!stats[tag]) {
|
||||
return stats;
|
||||
}
|
||||
|
@ -111,7 +112,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
|||
try {
|
||||
const { listTags } = buildShlinkApiClient(getState);
|
||||
const { tags, stats = [] }: ShlinkTags = await listTags();
|
||||
const processedStats = stats.reduce<TagsStats>((acc, { tag, shortUrlsCount, visitsCount }) => {
|
||||
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, shortUrlsCount, visitsCount }) => {
|
||||
acc[tag] = { shortUrlsCount, visitsCount };
|
||||
|
||||
return acc;
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { Modal, ModalBody, ModalFooter } from 'reactstrap';
|
||||
import DeleteTagConfirmModal from '../../../src/tags/helpers/DeleteTagConfirmModal';
|
||||
import { TagDeletion } from '../../../src/tags/reducers/tagDelete';
|
||||
|
||||
describe('<DeleteTagConfirmModal />', () => {
|
||||
let wrapper;
|
||||
let wrapper: ShallowWrapper;
|
||||
const tag = 'nodejs';
|
||||
const deleteTag = jest.fn();
|
||||
const tagDeleted = jest.fn();
|
||||
const createWrapper = (tagDelete) => {
|
||||
const createWrapper = (tagDelete: TagDeletion) => {
|
||||
wrapper = shallow(
|
||||
<DeleteTagConfirmModal
|
||||
tag={tag}
|
||||
|
@ -23,10 +24,8 @@ describe('<DeleteTagConfirmModal />', () => {
|
|||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper && wrapper.unmount();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
afterEach(() => wrapper?.unmount());
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
it('asks confirmation for provided tag to be deleted', () => {
|
||||
wrapper = createWrapper({ error: false, deleting: false });
|
||||
|
@ -60,7 +59,8 @@ describe('<DeleteTagConfirmModal />', () => {
|
|||
const footer = wrapper.find(ModalFooter);
|
||||
const delBtn = footer.find('.btn-danger');
|
||||
|
||||
await delBtn.simulate('click');
|
||||
await delBtn.simulate('click'); // eslint-disable-line @typescript-eslint/await-thenable
|
||||
|
||||
expect(deleteTag).toHaveBeenCalledTimes(1);
|
||||
expect(deleteTag).toHaveBeenCalledWith(tag);
|
||||
expect(tagDeleted).toHaveBeenCalledTimes(1);
|
Loading…
Reference in a new issue