mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +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"
|
"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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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
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 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;
|
|
@ -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 { 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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
Loading…
Reference in a new issue