Merge pull request #275 from acelaya-forks/feature/remove-class-components

Feature/remove class components
This commit is contained in:
Alejandro Celaya 2020-05-31 11:51:08 +02:00 committed by GitHub
commit df38cf6ca9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 347 additions and 506 deletions

View file

@ -18,7 +18,8 @@
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
"mutate": "./node_modules/.bin/stryker run",
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES",
"check": "npm run test & npm run lint & wait"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.11.2",

View file

@ -1,23 +1,23 @@
import React from 'react';
import { useEffect } from 'react';
import PropTypes from 'prop-types';
const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component {
static propTypes = {
location: PropTypes.object,
children: PropTypes.node,
const propTypes = {
location: PropTypes.object,
children: PropTypes.node,
};
const ScrollToTop = () => {
const ScrollToTopComp = ({ location, children }) => {
useEffect(() => {
scrollTo(0, 0);
}, [ location ]);
return children;
};
componentDidUpdate({ location: prevLocation }) {
const { location } = this.props;
ScrollToTopComp.propTypes = propTypes;
if (location !== prevLocation) {
scrollTo(0, 0);
}
}
render() {
return this.props.children;
}
return ScrollToTopComp;
};
export default ScrollToTop;

View file

@ -1,25 +1,17 @@
import React from 'react';
import React, { useRef } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import PropTypes from 'prop-types';
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
static defaultProps = {
onImport: () => ({}),
};
static propTypes = {
onImport: PropTypes.func,
createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
};
const propTypes = {
onImport: PropTypes.func,
createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
};
constructor(props) {
super(props);
this.fileRef = props.fileRef || React.createRef();
}
render() {
const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props;
// FIXME Replace with typescript: (ServersImporter)
const ImportServersBtn = ({ importServersFromFile }) => {
const ImportServersBtnComp = ({ createServers, fileRef, onImport = () => {} }) => {
const ref = fileRef || useRef();
const onChange = ({ target }) =>
importServersFromFile(target.files[0])
.then(createServers)
@ -35,24 +27,22 @@ const ImportServersBtn = (serversImporter) => class ImportServersBtn extends Rea
type="button"
className="btn btn-outline-secondary mr-2"
id="importBtn"
onClick={() => this.fileRef.current.click()}
onClick={() => ref.current.click()}
>
Import from file
</button>
<UncontrolledTooltip placement="top" target="importBtn">
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
</UncontrolledTooltip>
<input
type="file"
accept="text/csv"
className="create-server__csv-select"
ref={this.fileRef}
onChange={onChange}
/>
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
</React.Fragment>
);
}
};
ImportServersBtnComp.propTypes = propTypes;
return ImportServersBtnComp;
};
export default ImportServersBtn;

View file

@ -1,28 +1,26 @@
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda';
import React from 'react';
import React, { useEffect } from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Card, CardBody, Tooltip } from 'reactstrap';
import PropTypes from 'prop-types';
import { createShortUrlResultType } from '../reducers/shortUrlCreation';
import './CreateShortUrlResult.scss';
const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult extends React.Component {
static propTypes = {
resetCreateShortUrl: PropTypes.func,
error: PropTypes.bool,
result: createShortUrlResultType,
};
const propTypes = {
resetCreateShortUrl: PropTypes.func,
error: PropTypes.bool,
result: createShortUrlResultType,
};
state = { showCopyTooltip: false };
const CreateShortUrlResult = (useStateFlagTimeout) => {
const CreateShortUrlResultComp = ({ error, result, resetCreateShortUrl }) => {
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
componentDidMount() {
this.props.resetCreateShortUrl();
}
render() {
const { error, result } = this.props;
useEffect(() => {
resetCreateShortUrl();
}, []);
if (error) {
return (
@ -31,19 +29,19 @@ const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult ex
</Card>
);
}
if (isNil(result)) {
return null;
}
const { shortUrl } = result;
const onCopy = () => stateFlagTimeout(this.setState.bind(this), 'showCopyTooltip');
return (
<Card inverse className="bg-main mt-3">
<CardBody>
<b>Great!</b> The short URL is <b>{shortUrl}</b>
<CopyToClipboard text={shortUrl} onCopy={onCopy}>
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
<button
className="btn btn-light btn-sm create-short-url-result__copy-btn"
id="copyBtn"
@ -53,13 +51,17 @@ const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult ex
</button>
</CopyToClipboard>
<Tooltip placement="left" isOpen={this.state.showCopyTooltip} target="copyBtn">
<Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
Copied!
</Tooltip>
</CardBody>
</Card>
);
}
};
CreateShortUrlResultComp.propTypes = propTypes;
return CreateShortUrlResultComp;
};
export default CreateShortUrlResult;

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { identity, pipe } from 'ramda';
@ -7,21 +7,28 @@ import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
export default class DeleteShortUrlModal extends React.Component {
static propTypes = {
shortUrl: shortUrlType,
toggle: PropTypes.func,
isOpen: PropTypes.bool,
shortUrlDeletion: shortUrlDeletionType,
deleteShortUrl: PropTypes.func,
resetDeleteShortUrl: PropTypes.func,
};
const propTypes = {
shortUrl: shortUrlType,
toggle: PropTypes.func,
isOpen: PropTypes.bool,
shortUrlDeletion: shortUrlDeletionType,
deleteShortUrl: PropTypes.func,
resetDeleteShortUrl: PropTypes.func,
};
state = { inputValue: '' };
handleDeleteUrl = (e) => {
const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }) => {
const [ inputValue, setInputValue ] = useState('');
useEffect(() => resetDeleteShortUrl, []);
const { error, errorData } = shortUrlDeletion;
const errorCode = error && (errorData.type || errorData.error);
const hasThresholdError = errorCode === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
const close = pipe(resetDeleteShortUrl, toggle);
const handleDeleteUrl = (e) => {
e.preventDefault();
const { deleteShortUrl, shortUrl, toggle } = this.props;
const { shortCode, domain } = shortUrl;
deleteShortUrl(shortCode, domain)
@ -29,62 +36,51 @@ export default class DeleteShortUrlModal extends React.Component {
.catch(identity);
};
componentWillUnmount() {
const { resetDeleteShortUrl } = this.props;
return (
<Modal isOpen={isOpen} toggle={close} centered>
<form onSubmit={handleDeleteUrl}>
<ModalHeader toggle={close}>
<span className="text-danger">Delete short URL</span>
</ModalHeader>
<ModalBody>
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
resetDeleteShortUrl();
}
<input
type="text"
className="form-control"
placeholder="Insert the short code of the URL"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
render() {
const { shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl } = this.props;
const { error, errorData } = shortUrlDeletion;
const errorCode = error && (errorData.type || errorData.error);
const hasThresholdError = errorCode === THRESHOLD_REACHED;
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED;
const close = pipe(resetDeleteShortUrl, toggle);
{hasThresholdError && (
<div className="p-2 mt-2 bg-warning text-center">
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
</div>
)}
{hasErrorOtherThanThreshold && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the URL :(
</div>
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
<button
type="submit"
className="btn btn-danger"
disabled={inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
>
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
</button>
</ModalFooter>
</form>
</Modal>
);
};
return (
<Modal isOpen={isOpen} toggle={close} centered>
<form onSubmit={this.handleDeleteUrl}>
<ModalHeader toggle={close}>
<span className="text-danger">Delete short URL</span>
</ModalHeader>
<ModalBody>
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
DeleteShortUrlModal.propTypes = propTypes;
<input
type="text"
className="form-control"
placeholder="Insert the short code of the URL"
value={this.state.inputValue}
onChange={(e) => this.setState({ inputValue: e.target.value })}
/>
{hasThresholdError && (
<div className="p-2 mt-2 bg-warning text-center">
{errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
{!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
</div>
)}
{hasErrorOtherThanThreshold && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the URL :(
</div>
)}
</ModalBody>
<ModalFooter>
<button type="button" className="btn btn-link" onClick={close}>Cancel</button>
<button
type="submit"
className="btn btn-danger"
disabled={this.state.inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
>
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
</button>
</ModalFooter>
</form>
</Modal>
);
}
}
export default DeleteShortUrlModal;

View file

@ -1,52 +1,37 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { ExternalLink } from 'react-external-link';
import { pipe } from 'ramda';
import { shortUrlTagsType } from '../reducers/shortUrlTags';
import { shortUrlType } from '../reducers/shortUrlsList';
const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlTags: shortUrlTagsType,
editShortUrlTags: PropTypes.func,
resetShortUrlsTags: PropTypes.func,
};
const propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
shortUrl: shortUrlType.isRequired,
shortUrlTags: shortUrlTagsType,
editShortUrlTags: PropTypes.func,
resetShortUrlsTags: PropTypes.func,
};
saveTags = () => {
const { editShortUrlTags, shortUrl, toggle } = this.props;
const EditTagsModal = (TagsSelector) => {
const EditTagsModalComp = ({ isOpen, toggle, shortUrl, shortUrlTags, editShortUrlTags, resetShortUrlsTags }) => {
const [ selectedTags, setSelectedTags ] = useState(shortUrl.tags || []);
editShortUrlTags(shortUrl.shortCode, shortUrl.domain, this.state.tags)
useEffect(() => resetShortUrlsTags, []);
const url = shortUrl && (shortUrl.shortUrl || '');
const saveTags = () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
.then(toggle)
.catch(() => {});
};
componentDidMount() {
const { resetShortUrlsTags } = this.props;
resetShortUrlsTags();
}
constructor(props) {
super(props);
this.state = { tags: props.shortUrl.tags };
}
render() {
const { isOpen, toggle, shortUrl, shortUrlTags, resetShortUrlsTags } = this.props;
const url = shortUrl && (shortUrl.shortUrl || '');
const close = pipe(resetShortUrlsTags, toggle);
return (
<Modal isOpen={isOpen} toggle={close} centered>
<ModalHeader toggle={close}>
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}>
Edit tags for <ExternalLink href={url} />
</ModalHeader>
<ModalBody>
<TagsSelector tags={this.state.tags} onChange={(tags) => this.setState({ tags })} />
<TagsSelector tags={selectedTags} onChange={(tags) => setSelectedTags(tags)} />
{shortUrlTags.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while saving the tags :(
@ -54,19 +39,18 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={close}>Cancel</button>
<button
className="btn btn-primary"
type="button"
disabled={shortUrlTags.saving}
onClick={() => this.saveTags()}
>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button className="btn btn-primary" type="button" disabled={shortUrlTags.saving} onClick={saveTags}>
{shortUrlTags.saving ? 'Saving tags...' : 'Save tags'}
</button>
</ModalFooter>
</Modal>
);
}
};
EditTagsModalComp.propTypes = propTypes;
return EditTagsModalComp;
};
export default EditTagsModal;

View file

@ -46,7 +46,7 @@ const provideServices = (bottle, connect) => {
'EditShortUrlModal',
'ForServerVersion'
);
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'stateFlagTimeout');
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector', 'CreateShortUrlResult', 'ForServerVersion');
bottle.decorator(

View file

@ -3,64 +3,45 @@ import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { tagDeleteType } from '../reducers/tagDelete';
export default class DeleteTagConfirmModal extends React.Component {
static propTypes = {
tag: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
deleteTag: PropTypes.func,
tagDelete: tagDeleteType,
tagDeleted: PropTypes.func,
};
doDelete = async () => {
const { tag, toggle, deleteTag } = this.props;
const propTypes = {
tag: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
deleteTag: PropTypes.func,
tagDelete: tagDeleteType,
tagDeleted: PropTypes.func,
};
const DeleteTagConfirmModal = ({ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }) => {
const doDelete = async () => {
await deleteTag(tag);
this.tagWasDeleted = true;
tagDeleted(tag);
toggle();
};
handleOnClosed = () => {
if (!this.tagWasDeleted) {
return;
}
const { tagDeleted, tag } = this.props;
return (
<Modal toggle={toggle} isOpen={isOpen} centered>
<ModalHeader toggle={toggle}>
<span className="text-danger">Delete tag</span>
</ModalHeader>
<ModalBody>
Are you sure you want to delete tag <b>{tag}</b>?
{tagDelete.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the tag :(
</div>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button className="btn btn-danger" disabled={tagDelete.deleting} onClick={doDelete}>
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
</button>
</ModalFooter>
</Modal>
);
};
tagDeleted(tag);
};
DeleteTagConfirmModal.propTypes = propTypes;
componentDidMount() {
this.tagWasDeleted = false;
}
render() {
const { tag, toggle, isOpen, tagDelete } = this.props;
return (
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={this.handleOnClosed}>
<ModalHeader toggle={toggle}>
<span className="text-danger">Delete tag</span>
</ModalHeader>
<ModalBody>
Are you sure you want to delete tag <b>{tag}</b>?
{tagDelete.error && (
<div className="p-2 mt-2 bg-danger text-white text-center">
Something went wrong while deleting the tag :(
</div>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button
className="btn btn-danger"
disabled={tagDelete.deleting}
onClick={() => this.doDelete()}
>
{tagDelete.deleting ? 'Deleting tag...' : 'Delete tag'}
</button>
</ModalFooter>
</Modal>
);
}
}
export default DeleteTagConfirmModal;

View file

@ -1,109 +1,62 @@
import React from 'react';
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';
const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Component {
static 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 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,
}),
};
saveTag = (e) => {
e.preventDefault();
const { tag: oldName, editTag, toggle } = this.props;
const { tag: newName, color } = this.state;
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 = (e) => {
e.preventDefault();
editTag(oldName, newName, color)
.then(() => {
this.tagWasEdited = true;
toggle();
})
.catch(() => {});
};
handleOnClosed = () => {
if (!this.tagWasEdited) {
return;
}
const { tag: oldName, tagEdited } = this.props;
const { tag: newName, color } = this.state;
tagEdited(oldName, newName, color);
};
constructor(props) {
super(props);
const { tag } = props;
this.state = {
showColorPicker: false,
tag,
color: getColorForKey(tag),
editTag(tag, newTagName, color)
.then(() => tagEdited(tag, newTagName, color))
.then(toggle)
.catch(() => {});
};
}
componentDidMount() {
this.tagWasEdited = false;
}
render() {
const { isOpen, toggle, tagEdit } = this.props;
const { tag, color } = this.state;
const toggleColorPicker = () =>
this.setState(({ showColorPicker }) => ({ showColorPicker: !showColorPicker }));
return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={this.handleOnClosed}>
<form onSubmit={(e) => this.saveTag(e)}>
<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-prepend" id="colorPickerBtn" onClick={toggleColorPicker}>
<div
className="input-group-text edit-tag-modal__color-picker-toggle"
style={{
backgroundColor: color,
borderColor: color,
}}
style={{ backgroundColor: color, borderColor: color }}
>
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
</div>
</div>
<Popover
isOpen={this.state.showColorPicker}
toggle={toggleColorPicker}
target="colorPickerBtn"
placement="right"
>
<ChromePicker
color={color}
disableAlpha
onChange={(color) => this.setState({ color: color.hex })}
/>
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
</Popover>
<input
type="text"
value={tag}
value={newTagName}
placeholder="Tag"
required
className="form-control"
onChange={(e) => this.setState({ tag: e.target.value })}
onChange={(e) => setNewTagName(e.target.value)}
/>
</div>
@ -122,7 +75,11 @@ const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Co
</form>
</Modal>
);
}
};
EditTagModalComp.propTypes = propTypes;
return EditTagModalComp;
};
export default EditTagModal;

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import TagsInput from 'react-tagsinput';
import PropTypes from 'prop-types';
import Autosuggest from 'react-autosuggest';
@ -6,28 +6,23 @@ import { identity } from 'ramda';
import TagBullet from './TagBullet';
import './TagsSelector.scss';
const TagsSelector = (colorGenerator) => class TagsSelector extends React.Component {
static 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),
}),
};
static defaultProps = {
placeholder: 'Add tags to the URL',
};
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),
}),
};
componentDidMount() {
const { listTags } = this.props;
const TagsSelector = (colorGenerator) => {
const TagsSelectorComp = ({ tags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }) => {
useEffect(() => {
listTags();
}, []);
listTags();
}
render() {
const { tags, onChange, placeholder, tagsList } = this.props;
// eslint-disable-next-line
const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => (
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
{getTagDisplayValue(tag)}
@ -40,7 +35,6 @@ const TagsSelector = (colorGenerator) => class TagsSelector extends React.Compon
method === 'enter' ? e.preventDefault() : otherProps.onChange(e);
};
// eslint-disable-next-line no-extra-parens
const inputValue = (otherProps.value && otherProps.value.trim().toLowerCase()) || '';
const inputLength = inputValue.length;
const suggestions = tagsList.tags.filter((state) => state.toLowerCase().slice(0, inputLength) === inputValue);
@ -75,13 +69,16 @@ const TagsSelector = (colorGenerator) => class TagsSelector extends React.Compon
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

@ -1,5 +1,4 @@
import axios from 'axios';
import { stateFlagTimeout } from '../utils';
import { useStateFlagTimeout } from '../helpers/hooks';
import Storage from './Storage';
import ColorGenerator from './ColorGenerator';
@ -15,7 +14,6 @@ const provideServices = (bottle) => {
bottle.constant('setTimeout', global.setTimeout);
bottle.constant('clearTimeout', global.clearTimeout);
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout');
};

View file

@ -4,18 +4,6 @@ import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { isEmpty, isNil, range } from 'ramda';
const DEFAULT_TIMEOUT_DELAY = 2000;
export const stateFlagTimeout = (setTimeout) => (
setState,
flagName,
initialValue = true,
delay = DEFAULT_TIMEOUT_DELAY
) => {
setState({ [flagName]: initialValue });
setTimeout(() => setState({ [flagName]: !initialValue }), delay);
};
export const determineOrderDir = (clickedField, currentOrderField, currentOrderDir) => {
if (currentOrderField !== clickedField) {
return 'ASC';

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
import SortingDropdown from '../utils/SortingDropdown';
@ -8,73 +8,78 @@ import { roundTen } from '../utils/helpers/numbers';
import SimplePaginator from '../common/SimplePaginator';
import GraphCard from './GraphCard';
const { max } = Math;
const propTypes = {
stats: PropTypes.object.isRequired,
highlightedStats: PropTypes.object,
highlightedLabel: PropTypes.string,
title: PropTypes.string.isRequired,
sortingItems: PropTypes.object.isRequired,
extraHeaderContent: PropTypes.func,
withPagination: PropTypes.bool,
onClick: PropTypes.func,
};
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
const pickKeyFromPair = ([ key ]) => key;
const pickValueFromPair = ([ , value ]) => value;
export default class SortableBarGraph extends React.Component {
static propTypes = {
stats: PropTypes.object.isRequired,
highlightedStats: PropTypes.object,
highlightedLabel: PropTypes.string,
title: PropTypes.string.isRequired,
sortingItems: PropTypes.object.isRequired,
extraHeaderContent: PropTypes.func,
withPagination: PropTypes.bool,
onClick: PropTypes.func,
};
state = {
const SortableBarGraph = ({
stats,
highlightedStats,
title,
sortingItems,
extraHeaderContent,
withPagination = true,
...rest
}) => {
const [ order, setOrder ] = useState({
orderField: undefined,
orderDir: undefined,
currentPage: 1,
itemsPerPage: 50,
};
});
const [ currentPage, setCurrentPage ] = useState(1);
const [ itemsPerPage, setItemsPerPage ] = useState(50);
getSortedPairsForStats(stats, sortingItems) {
const getSortedPairsForStats = (stats, sortingItems) => {
const pairs = toPairs(stats);
const sortedPairs = !this.state.orderField ? pairs : sortBy(
const sortedPairs = !order.orderField ? pairs : sortBy(
pipe(
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1),
prop(order.orderField === head(keys(sortingItems)) ? 0 : 1),
toLowerIfString
),
pairs
);
return !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
}
determineStats(stats, highlightedStats, sortingItems) {
const sortedPairs = this.getSortedPairsForStats(stats, sortingItems);
return !order.orderDir || order.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
};
const determineStats = (stats, highlightedStats, sortingItems) => {
const sortedPairs = getSortedPairsForStats(stats, sortingItems);
const sortedKeys = sortedPairs.map(pickKeyFromPair);
// The highlighted stats have to be ordered based on the regular stats, not on its own values
const sortedHighlightedPairs = highlightedStats && toPairs(
{ ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...highlightedStats }
);
if (sortedPairs.length <= this.state.itemsPerPage) {
if (sortedPairs.length <= itemsPerPage) {
return {
currentPageStats: fromPairs(sortedPairs),
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
};
}
const pages = splitEvery(this.state.itemsPerPage, sortedPairs);
const highlightedPages = sortedHighlightedPairs && splitEvery(this.state.itemsPerPage, sortedHighlightedPairs);
const pages = splitEvery(itemsPerPage, sortedPairs);
const highlightedPages = sortedHighlightedPairs && splitEvery(itemsPerPage, sortedHighlightedPairs);
return {
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)),
currentPageHighlightedStats: highlightedPages && fromPairs(this.determineCurrentPagePairs(highlightedPages)),
pagination: this.renderPagination(pages.length),
max: roundTen(max(...sortedPairs.map(pickValueFromPair))),
currentPageStats: fromPairs(determineCurrentPagePairs(pages)),
currentPageHighlightedStats: highlightedPages && fromPairs(determineCurrentPagePairs(highlightedPages)),
pagination: renderPagination(pages.length),
max: roundTen(Math.max(...sortedPairs.map(pickValueFromPair))),
};
}
};
const determineCurrentPagePairs = (pages) => {
const page = pages[currentPage - 1];
determineCurrentPagePairs(pages) {
const page = pages[this.state.currentPage - 1];
if (this.state.currentPage < pages.length) {
if (currentPage < pages.length) {
return page;
}
@ -82,72 +87,60 @@ export default class SortableBarGraph extends React.Component {
// Using the "hidden" key, the chart will just replace the label by an empty string
return [ ...page, ...rangeOf(firstPageLength - page.length, (i) => [ `hidden_${i}`, 0 ]) ];
}
};
const renderPagination = (pagesCount) =>
<SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
renderPagination(pagesCount) {
const { currentPage } = this.state;
const setCurrentPage = (currentPage) => this.setState({ currentPage });
return <SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
}
render() {
const {
stats,
highlightedStats,
sortingItems,
title,
extraHeaderContent,
withPagination = true,
...rest
} = this.props;
const { currentPageStats, currentPageHighlightedStats, pagination, max } = this.determineStats(
stats,
highlightedStats && keys(highlightedStats).length > 0 ? highlightedStats : undefined,
sortingItems
);
const activeCities = keys(currentPageStats);
const computeTitle = () => (
<React.Fragment>
{title}
const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats(
stats,
highlightedStats && keys(highlightedStats).length > 0 ? highlightedStats : undefined,
sortingItems
);
const activeCities = keys(currentPageStats);
const computeTitle = () => (
<React.Fragment>
{title}
<div className="float-right">
<SortingDropdown
isButton={false}
right
items={sortingItems}
orderField={order.orderField}
orderDir={order.orderDir}
onChange={(orderField, orderDir) => setOrder({ orderField, orderDir }) || setCurrentPage(1)}
/>
</div>
{withPagination && keys(stats).length > 50 && (
<div className="float-right">
<SortingDropdown
isButton={false}
right
items={sortingItems}
orderField={this.state.orderField}
orderDir={this.state.orderDir}
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir, currentPage: 1 })}
<PaginationDropdown
toggleClassName="btn-sm p-0 mr-3"
ranges={[ 50, 100, 200, 500 ]}
value={itemsPerPage}
setValue={(itemsPerPage) => setItemsPerPage(itemsPerPage) || setCurrentPage(1)}
/>
</div>
{withPagination && keys(stats).length > 50 && (
<div className="float-right">
<PaginationDropdown
toggleClassName="btn-sm p-0 mr-3"
ranges={[ 50, 100, 200, 500 ]}
value={this.state.itemsPerPage}
setValue={(itemsPerPage) => this.setState({ itemsPerPage, currentPage: 1 })}
/>
</div>
)}
{extraHeaderContent && (
<div className="float-right">
{extraHeaderContent(pagination ? activeCities : undefined)}
</div>
)}
</React.Fragment>
);
)}
{extraHeaderContent && (
<div className="float-right">
{extraHeaderContent(pagination ? activeCities : undefined)}
</div>
)}
</React.Fragment>
);
return (
<GraphCard
isBarChart
title={computeTitle}
stats={currentPageStats}
highlightedStats={currentPageHighlightedStats}
footer={pagination}
max={max}
{...rest}
/>
);
}
}
return (
<GraphCard
isBarChart
title={computeTitle}
stats={currentPageStats}
highlightedStats={currentPageHighlightedStats}
footer={pagination}
max={max}
{...rest}
/>
);
};
SortableBarGraph.propTypes = propTypes;
export default SortableBarGraph;

View file

@ -20,9 +20,4 @@ describe('<ScrollToTop />', () => {
});
it('just renders children', () => expect(wrapper.text()).toEqual('Foobar'));
it('scrolls to top when location changes', () => {
wrapper.instance().componentDidUpdate({ location: { href: 'bar' } });
expect(window.scrollTo).toHaveBeenCalledTimes(1);
});
});

View file

@ -7,17 +7,17 @@ import createCreateShortUrlResult from '../../../src/short-urls/helpers/CreateSh
describe('<CreateShortUrlResult />', () => {
let wrapper;
const stateFlagTimeout = jest.fn();
const copyToClipboard = jest.fn();
const useStateFlagTimeout = jest.fn(() => [ false, copyToClipboard ]);
const CreateShortUrlResult = createCreateShortUrlResult(useStateFlagTimeout);
const createWrapper = (result, error = false) => {
const CreateShortUrlResult = createCreateShortUrlResult(stateFlagTimeout);
wrapper = shallow(<CreateShortUrlResult resetCreateShortUrl={identity} result={result} error={error} />);
return wrapper;
};
afterEach(() => {
stateFlagTimeout.mockReset();
jest.clearAllMocks();
wrapper && wrapper.unmount();
});
@ -47,8 +47,8 @@ describe('<CreateShortUrlResult />', () => {
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
const copyBtn = wrapper.find(CopyToClipboard);
expect(stateFlagTimeout).not.toHaveBeenCalled();
expect(copyToClipboard).not.toHaveBeenCalled();
copyBtn.simulate('copy');
expect(stateFlagTimeout).toHaveBeenCalledTimes(1);
expect(copyToClipboard).toHaveBeenCalledTimes(1);
});
});

View file

@ -37,17 +37,6 @@ describe('<EditTagsModal />', () => {
jest.clearAllMocks();
});
it('resets tags when component is mounted', () => {
createWrapper({
shortCode,
tags: [],
saving: false,
error: false,
});
expect(resetShortUrlsTags).toHaveBeenCalledTimes(1);
});
it('renders tags selector and save button when loaded', () => {
const wrapper = createWrapper({
shortCode,

View file

@ -25,8 +25,7 @@ describe('<DeleteTagConfirmModal />', () => {
afterEach(() => {
wrapper && wrapper.unmount();
deleteTag.mockReset();
tagDeleted.mockReset();
jest.resetAllMocks();
});
it('asks confirmation for provided tag to be deleted', () => {
@ -56,14 +55,16 @@ describe('<DeleteTagConfirmModal />', () => {
expect(delBtn.text()).toEqual('Deleting tag...');
});
it('deletes tag modal when btn is clicked', () => {
it('deletes tag modal when btn is clicked', async () => {
wrapper = createWrapper({ error: false, deleting: true });
const footer = wrapper.find(ModalFooter);
const delBtn = footer.find('.btn-danger');
delBtn.simulate('click');
await delBtn.simulate('click');
expect(deleteTag).toHaveBeenCalledTimes(1);
expect(deleteTag).toHaveBeenCalledWith(tag);
expect(tagDeleted).toHaveBeenCalledTimes(1);
expect(tagDeleted).toHaveBeenCalledWith(tag);
});
it('does no further actions when modal is closed without deleting tag', () => {
@ -71,16 +72,7 @@ describe('<DeleteTagConfirmModal />', () => {
const modal = wrapper.find(Modal);
modal.simulate('closed');
expect(deleteTag).not.toHaveBeenCalled();
expect(tagDeleted).not.toHaveBeenCalled();
});
it('notifies tag to be deleted when modal is closed after deleting tag', () => {
wrapper = createWrapper({ error: false, deleting: false });
const modal = wrapper.find(Modal);
wrapper.instance().tagWasDeleted = true;
modal.simulate('closed');
expect(tagDeleted).toHaveBeenCalledTimes(1);
expect(tagDeleted).toHaveBeenCalledWith(tag);
});
});

View file

@ -2,31 +2,9 @@ import L from 'leaflet';
import marker2x from 'leaflet/dist/images/marker-icon-2x.png';
import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import {
stateFlagTimeout as stateFlagTimeoutFactory,
determineOrderDir,
fixLeafletIcons,
rangeOf,
} from '../../src/utils/utils';
import { determineOrderDir, fixLeafletIcons, rangeOf } from '../../src/utils/utils';
describe('utils', () => {
describe('stateFlagTimeout', () => {
it('sets state and initializes timeout with provided delay', () => {
const setTimeout = jest.fn((callback) => callback());
const setState = jest.fn();
const stateFlagTimeout = stateFlagTimeoutFactory(setTimeout);
const delay = 5000;
stateFlagTimeout(setState, 'foo', false, delay);
expect(setState).toHaveBeenCalledTimes(2);
expect(setState).toHaveBeenNthCalledWith(1, { foo: false });
expect(setState).toHaveBeenNthCalledWith(2, { foo: true });
expect(setTimeout).toHaveBeenCalledTimes(1);
expect(setTimeout).toHaveBeenCalledWith(expect.anything(), delay);
});
});
describe('determineOrderDir', () => {
it('returns ASC when current order field and selected field are different', () => {
expect(determineOrderDir('foo', 'bar')).toEqual('ASC');