Migrated tags helpers to TS

This commit is contained in:
Alejandro Celaya 2020-08-30 20:31:31 +02:00
parent 84fc82b74e
commit 18883caa6d
17 changed files with 279 additions and 262 deletions

43
package-lock.json generated
View file

@ -3384,6 +3384,25 @@
"csstype": "^3.0.2" "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": { "@types/react-copy-to-clipboard": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-4.3.0.tgz", "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-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": { "@types/reactstrap": {
"version": "8.5.1", "version": "8.5.1",
"resolved": "https://registry.npmjs.org/@types/reactstrap/-/reactstrap-8.5.1.tgz", "resolved": "https://registry.npmjs.org/@types/reactstrap/-/reactstrap-8.5.1.tgz",
@ -18146,9 +18183,9 @@
} }
}, },
"react-color": { "react-color": {
"version": "2.17.3", "version": "2.18.1",
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.17.3.tgz", "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.18.1.tgz",
"integrity": "sha512-1dtO8LqAVotPIChlmo6kLtFS1FP89ll8/OiA8EcFRDR+ntcK+0ukJgByuIQHRtzvigf26dV5HklnxDIvhON9VQ==", "integrity": "sha512-X5XpyJS6ncplZs74ak0JJoqPi+33Nzpv5RYWWxn17bslih+X7OlgmfpmGC1fNvdkK7/SGWYf1JJdn7D2n5gSuQ==",
"requires": { "requires": {
"@icons/material": "^0.2.4", "@icons/material": "^0.2.4",
"lodash": "^4.17.11", "lodash": "^4.17.11",

View file

@ -48,7 +48,7 @@
"react": "^16.13.1", "react": "^16.13.1",
"react-autosuggest": "^9.4.3", "react-autosuggest": "^9.4.3",
"react-chartjs-2": "^2.8.0", "react-chartjs-2": "^2.8.0",
"react-color": "^2.17.3", "react-color": "^2.17.4",
"react-copy-to-clipboard": "^5.0.1", "react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "~1.5.0", "react-datepicker": "~1.5.0",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
@ -83,11 +83,14 @@
"@types/qs": "^6.9.4", "@types/qs": "^6.9.4",
"@types/ramda": "^0.27.14", "@types/ramda": "^0.27.14",
"@types/react": "^16.9.46", "@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-copy-to-clipboard": "^4.3.0",
"@types/react-datepicker": "~1.8.0", "@types/react-datepicker": "~1.8.0",
"@types/react-dom": "^16.9.8", "@types/react-dom": "^16.9.8",
"@types/react-redux": "^7.1.9", "@types/react-redux": "^7.1.9",
"@types/react-router-dom": "^5.1.5", "@types/react-router-dom": "^5.1.5",
"@types/react-tagsinput": "^3.19.7",
"@types/reactstrap": "^8.5.1", "@types/reactstrap": "^8.5.1",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"adm-zip": "^0.4.13", "adm-zip": "^0.4.13",

View file

@ -12,6 +12,7 @@ import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import { useToggle } from '../utils/helpers/hooks'; import { useToggle } from '../utils/helpers/hooks';
import { isReachableServer, SelectedServer } from '../servers/data'; import { isReachableServer, SelectedServer } from '../servers/data';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/helpers/date';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { ShortUrlData } from './data'; import { ShortUrlData } from './data';
import { ShortUrlCreation } from './reducers/shortUrlCreation'; import { ShortUrlCreation } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
@ -42,7 +43,7 @@ type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | '
type DateFields = 'validSince' | 'validUntil'; type DateFields = 'validSince' | 'validUntil';
const CreateShortUrl = ( const CreateShortUrl = (
TagsSelector: FC<any>, TagsSelector: FC<TagsSelectorProps>,
CreateShortUrlResult: FC<CreateShortUrlResultProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>,
ForServerVersion: FC<Versions>, ForServerVersion: FC<Versions>,
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => { ) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
@ -103,7 +104,7 @@ const CreateShortUrl = (
<Collapse isOpen={moreOptionsVisible}> <Collapse isOpen={moreOptionsVisible}>
<div className="form-group"> <div className="form-group">
<TagsSelector tags={shortUrlCreation.tags} onChange={changeTags} /> <TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
</div> </div>
<div className="row"> <div className="row">

View file

@ -4,6 +4,7 @@ import { ExternalLink } from 'react-external-link';
import { ShortUrlTags } from '../reducers/shortUrlTags'; import { ShortUrlTags } from '../reducers/shortUrlTags';
import { ShortUrlModalProps } from '../data'; import { ShortUrlModalProps } from '../data';
import { OptionalString } from '../../utils/utils'; import { OptionalString } from '../../utils/utils';
import { TagsSelectorProps } from '../../tags/helpers/TagsSelector';
interface EditTagsModalProps extends ShortUrlModalProps { interface EditTagsModalProps extends ShortUrlModalProps {
shortUrlTags: ShortUrlTags; shortUrlTags: ShortUrlTags;
@ -11,7 +12,7 @@ interface EditTagsModalProps extends ShortUrlModalProps {
resetShortUrlsTags: () => void; 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, { isOpen, toggle, shortUrl, shortUrlTags, editShortUrlTags, resetShortUrlsTags }: EditTagsModalProps,
) => { ) => {
const [ selectedTags, setSelectedTags ] = useState<string[]>(shortUrl.tags || []); const [ selectedTags, setSelectedTags ] = useState<string[]>(shortUrl.tags || []);

10
src/tags/data/index.ts Normal file
View file

@ -0,0 +1,10 @@
export interface TagStats {
shortUrlsCount: number;
visitsCount: number;
}
export interface TagModalProps {
tag: string;
isOpen: boolean;
toggle: () => void;
}

View file

@ -1,18 +1,17 @@
import React from 'react'; import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types'; import { TagDeletion } from '../reducers/tagDelete';
import { tagDeleteType } from '../reducers/tagDelete'; import { TagModalProps } from '../data';
const propTypes = { interface DeleteTagConfirmModalProps extends TagModalProps {
tag: PropTypes.string.isRequired, deleteTag: (tag: string) => Promise<void>;
toggle: PropTypes.func.isRequired, tagDeleted: (tag: string) => void;
isOpen: PropTypes.bool.isRequired, tagDelete: TagDeletion;
deleteTag: PropTypes.func, }
tagDelete: tagDeleteType,
tagDeleted: PropTypes.func,
};
const DeleteTagConfirmModal = ({ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }) => { const DeleteTagConfirmModal = (
{ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }: DeleteTagConfirmModalProps,
) => {
const doDelete = async () => { const doDelete = async () => {
await deleteTag(tag); await deleteTag(tag);
tagDeleted(tag); tagDeleted(tag);
@ -42,6 +41,4 @@ const DeleteTagConfirmModal = ({ tag, toggle, isOpen, deleteTag, tagDelete, tagD
); );
}; };
DeleteTagConfirmModal.propTypes = propTypes;
export default DeleteTagConfirmModal; export default DeleteTagConfirmModal;

View file

@ -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;

View 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;

View file

@ -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}>&times;</span>}
</span>
);
Tag.propTypes = propTypes;
export default Tag;

24
src/tags/helpers/Tag.tsx Normal file
View 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}>&times;</span>}
</span>
);
export default Tag;

View file

@ -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;

View 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;

View file

@ -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;

View 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;

View file

@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types'; 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'; export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
/** @deprecated Use TagDeletion interface */
export const tagDeleteType = PropTypes.shape({
deleting: PropTypes.bool,
error: PropTypes.bool,
});
export interface TagDeletion { export interface TagDeletion {
deleting: boolean; deleting: boolean;
error: boolean; error: boolean;

View file

@ -5,9 +5,10 @@ import { CREATE_VISIT, CreateVisitAction } from '../../visits/reducers/visitCrea
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkTags } from '../../utils/services/types'; import { ShlinkTags } from '../../utils/services/types';
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { TagStats } from '../data';
import { DeleteTagAction, TAG_DELETED } from './tagDelete'; import { DeleteTagAction, TAG_DELETED } from './tagDelete';
import { EditTagAction, TAG_EDITED } from './tagEdit'; import { EditTagAction, TAG_EDITED } from './tagEdit';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START'; export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
@ -28,19 +29,19 @@ export const TagsListType = PropTypes.shape({
error: PropTypes.bool, error: PropTypes.bool,
}); });
type TagsStats = Record<string, { shortUrlsCount: number; visitsCount: number }>; type TagsStatsMap = Record<string, TagStats>;
export interface TagsList { export interface TagsList {
tags: string[]; tags: string[];
filteredTags: string[]; filteredTags: string[];
stats: TagsStats; stats: TagsStatsMap;
loading: boolean; loading: boolean;
error: boolean; error: boolean;
} }
interface ListTagsAction extends Action<string> { interface ListTagsAction extends Action<string> {
tags: string[]; tags: string[];
stats: TagsStats; stats: TagsStatsMap;
} }
interface FilterTagsAction extends Action<string> { interface FilterTagsAction extends Action<string> {
@ -59,7 +60,7 @@ const initialState = {
const renameTag = (oldName: string, newName: string) => (tag: string) => tag === oldName ? newName : tag; const renameTag = (oldName: string, newName: string) => (tag: string) => tag === oldName ? newName : tag;
const rejectTag = (tags: string[], tagToReject: string) => reject((tag) => tag === tagToReject, tags); 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]) { if (!stats[tag]) {
return stats; return stats;
} }
@ -111,7 +112,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
try { try {
const { listTags } = buildShlinkApiClient(getState); const { listTags } = buildShlinkApiClient(getState);
const { tags, stats = [] }: ShlinkTags = await listTags(); 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 }; acc[tag] = { shortUrlsCount, visitsCount };
return acc; return acc;

View file

@ -1,14 +1,15 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Modal, ModalBody, ModalFooter } from 'reactstrap'; import { Modal, ModalBody, ModalFooter } from 'reactstrap';
import DeleteTagConfirmModal from '../../../src/tags/helpers/DeleteTagConfirmModal'; import DeleteTagConfirmModal from '../../../src/tags/helpers/DeleteTagConfirmModal';
import { TagDeletion } from '../../../src/tags/reducers/tagDelete';
describe('<DeleteTagConfirmModal />', () => { describe('<DeleteTagConfirmModal />', () => {
let wrapper; let wrapper: ShallowWrapper;
const tag = 'nodejs'; const tag = 'nodejs';
const deleteTag = jest.fn(); const deleteTag = jest.fn();
const tagDeleted = jest.fn(); const tagDeleted = jest.fn();
const createWrapper = (tagDelete) => { const createWrapper = (tagDelete: TagDeletion) => {
wrapper = shallow( wrapper = shallow(
<DeleteTagConfirmModal <DeleteTagConfirmModal
tag={tag} tag={tag}
@ -23,10 +24,8 @@ describe('<DeleteTagConfirmModal />', () => {
return wrapper; return wrapper;
}; };
afterEach(() => { afterEach(() => wrapper?.unmount());
wrapper && wrapper.unmount(); afterEach(jest.resetAllMocks);
jest.resetAllMocks();
});
it('asks confirmation for provided tag to be deleted', () => { it('asks confirmation for provided tag to be deleted', () => {
wrapper = createWrapper({ error: false, deleting: false }); wrapper = createWrapper({ error: false, deleting: false });
@ -60,7 +59,8 @@ describe('<DeleteTagConfirmModal />', () => {
const footer = wrapper.find(ModalFooter); const footer = wrapper.find(ModalFooter);
const delBtn = footer.find('.btn-danger'); 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).toHaveBeenCalledTimes(1);
expect(deleteTag).toHaveBeenCalledWith(tag); expect(deleteTag).toHaveBeenCalledWith(tag);
expect(tagDeleted).toHaveBeenCalledTimes(1); expect(tagDeleted).toHaveBeenCalledTimes(1);