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

View File

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

View File

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

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

View File

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