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: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", "test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
"mutate": "./node_modules/.bin/stryker run", "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": { "dependencies": {
"@fortawesome/fontawesome-free": "^5.11.2", "@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'; import PropTypes from 'prop-types';
const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component { const propTypes = {
static propTypes = { location: PropTypes.object,
location: PropTypes.object, children: PropTypes.node,
children: PropTypes.node, };
const ScrollToTop = () => {
const ScrollToTopComp = ({ location, children }) => {
useEffect(() => {
scrollTo(0, 0);
}, [ location ]);
return children;
}; };
componentDidUpdate({ location: prevLocation }) { ScrollToTopComp.propTypes = propTypes;
const { location } = this.props;
if (location !== prevLocation) { return ScrollToTopComp;
scrollTo(0, 0);
}
}
render() {
return this.props.children;
}
}; };
export default ScrollToTop; export default ScrollToTop;

View file

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

View file

@ -1,28 +1,26 @@
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons'; import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda'; import { isNil } from 'ramda';
import React from 'react'; import React, { useEffect } from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Card, CardBody, Tooltip } from 'reactstrap'; import { Card, CardBody, Tooltip } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { createShortUrlResultType } from '../reducers/shortUrlCreation'; import { createShortUrlResultType } from '../reducers/shortUrlCreation';
import './CreateShortUrlResult.scss'; import './CreateShortUrlResult.scss';
const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult extends React.Component { const propTypes = {
static propTypes = { resetCreateShortUrl: PropTypes.func,
resetCreateShortUrl: PropTypes.func, error: PropTypes.bool,
error: PropTypes.bool, result: createShortUrlResultType,
result: createShortUrlResultType, };
};
state = { showCopyTooltip: false }; const CreateShortUrlResult = (useStateFlagTimeout) => {
const CreateShortUrlResultComp = ({ error, result, resetCreateShortUrl }) => {
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
componentDidMount() { useEffect(() => {
this.props.resetCreateShortUrl(); resetCreateShortUrl();
} }, []);
render() {
const { error, result } = this.props;
if (error) { if (error) {
return ( return (
@ -31,19 +29,19 @@ const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult ex
</Card> </Card>
); );
} }
if (isNil(result)) { if (isNil(result)) {
return null; return null;
} }
const { shortUrl } = result; const { shortUrl } = result;
const onCopy = () => stateFlagTimeout(this.setState.bind(this), 'showCopyTooltip');
return ( return (
<Card inverse className="bg-main mt-3"> <Card inverse className="bg-main mt-3">
<CardBody> <CardBody>
<b>Great!</b> The short URL is <b>{shortUrl}</b> <b>Great!</b> The short URL is <b>{shortUrl}</b>
<CopyToClipboard text={shortUrl} onCopy={onCopy}> <CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
<button <button
className="btn btn-light btn-sm create-short-url-result__copy-btn" className="btn btn-light btn-sm create-short-url-result__copy-btn"
id="copyBtn" id="copyBtn"
@ -53,13 +51,17 @@ const CreateShortUrlResult = (stateFlagTimeout) => class CreateShortUrlResult ex
</button> </button>
</CopyToClipboard> </CopyToClipboard>
<Tooltip placement="left" isOpen={this.state.showCopyTooltip} target="copyBtn"> <Tooltip placement="left" isOpen={showCopyTooltip} target="copyBtn">
Copied! Copied!
</Tooltip> </Tooltip>
</CardBody> </CardBody>
</Card> </Card>
); );
} };
CreateShortUrlResultComp.propTypes = propTypes;
return CreateShortUrlResultComp;
}; };
export default CreateShortUrlResult; 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 { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { identity, pipe } from 'ramda'; import { identity, pipe } from 'ramda';
@ -7,21 +7,28 @@ import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION'; const THRESHOLD_REACHED = 'INVALID_SHORTCODE_DELETION';
export default class DeleteShortUrlModal extends React.Component { const propTypes = {
static propTypes = { shortUrl: shortUrlType,
shortUrl: shortUrlType, toggle: PropTypes.func,
toggle: PropTypes.func, isOpen: PropTypes.bool,
isOpen: PropTypes.bool, shortUrlDeletion: shortUrlDeletionType,
shortUrlDeletion: shortUrlDeletionType, deleteShortUrl: PropTypes.func,
deleteShortUrl: PropTypes.func, resetDeleteShortUrl: PropTypes.func,
resetDeleteShortUrl: PropTypes.func, };
};
state = { inputValue: '' }; const DeleteShortUrlModal = ({ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }) => {
handleDeleteUrl = (e) => { 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(); e.preventDefault();
const { deleteShortUrl, shortUrl, toggle } = this.props;
const { shortCode, domain } = shortUrl; const { shortCode, domain } = shortUrl;
deleteShortUrl(shortCode, domain) deleteShortUrl(shortCode, domain)
@ -29,62 +36,51 @@ export default class DeleteShortUrlModal extends React.Component {
.catch(identity); .catch(identity);
}; };
componentWillUnmount() { return (
const { resetDeleteShortUrl } = this.props; <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() { {hasThresholdError && (
const { shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl } = this.props; <div className="p-2 mt-2 bg-warning text-center">
const { error, errorData } = shortUrlDeletion; {errorData.threshold && `This short URL has received more than ${errorData.threshold} visits, and therefore, it cannot be deleted.`}
const errorCode = error && (errorData.type || errorData.error); {!errorData.threshold && 'This short URL has received too many visits, and therefore, it cannot be deleted.'}
const hasThresholdError = errorCode === THRESHOLD_REACHED; </div>
const hasErrorOtherThanThreshold = error && errorCode !== THRESHOLD_REACHED; )}
const close = pipe(resetDeleteShortUrl, toggle); {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 ( DeleteShortUrlModal.propTypes = propTypes;
<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>
<input export default DeleteShortUrlModal;
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>
);
}
}

View file

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

View file

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

View file

@ -3,64 +3,45 @@ import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { tagDeleteType } from '../reducers/tagDelete'; import { tagDeleteType } from '../reducers/tagDelete';
export default class DeleteTagConfirmModal extends React.Component { const propTypes = {
static propTypes = { tag: PropTypes.string.isRequired,
tag: PropTypes.string.isRequired, toggle: PropTypes.func.isRequired,
toggle: PropTypes.func.isRequired, isOpen: PropTypes.bool.isRequired,
isOpen: PropTypes.bool.isRequired, deleteTag: PropTypes.func,
deleteTag: PropTypes.func, tagDelete: tagDeleteType,
tagDelete: tagDeleteType, tagDeleted: PropTypes.func,
tagDeleted: PropTypes.func, };
};
doDelete = async () => {
const { tag, toggle, deleteTag } = this.props;
const DeleteTagConfirmModal = ({ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }) => {
const doDelete = async () => {
await deleteTag(tag); await deleteTag(tag);
this.tagWasDeleted = true; tagDeleted(tag);
toggle(); 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() { export default DeleteTagConfirmModal;
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>
);
}
}

View file

@ -1,109 +1,62 @@
import React from 'react'; import React, { useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
import { ChromePicker } from 'react-color'; import { ChromePicker } from 'react-color';
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons'; import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './EditTagModal.scss'; import './EditTagModal.scss';
import { useToggle } from '../../utils/helpers/hooks';
const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Component { const propTypes = {
static propTypes = { tag: PropTypes.string,
tag: PropTypes.string, editTag: PropTypes.func,
editTag: PropTypes.func, toggle: PropTypes.func,
toggle: PropTypes.func, tagEdited: PropTypes.func,
tagEdited: PropTypes.func, isOpen: PropTypes.bool,
isOpen: PropTypes.bool, tagEdit: PropTypes.shape({
tagEdit: PropTypes.shape({ error: PropTypes.bool,
error: PropTypes.bool, editing: PropTypes.bool,
editing: PropTypes.bool, }),
}), };
};
saveTag = (e) => { const EditTagModal = ({ getColorForKey }) => {
e.preventDefault(); const EditTagModalComp = ({ tag, editTag, toggle, tagEdited, isOpen, tagEdit }) => {
const { tag: oldName, editTag, toggle } = this.props; const [ newTagName, setNewTagName ] = useState(tag);
const { tag: newName, color } = this.state; const [ color, setColor ] = useState(getColorForKey(tag));
const [ showColorPicker, toggleColorPicker ] = useToggle();
const saveTag = (e) => {
e.preventDefault();
editTag(oldName, newName, color) editTag(tag, newTagName, color)
.then(() => { .then(() => tagEdited(tag, newTagName, color))
this.tagWasEdited = true; .then(toggle)
toggle(); .catch(() => {});
})
.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),
}; };
}
componentDidMount() {
this.tagWasEdited = false;
}
render() {
const { isOpen, toggle, tagEdit } = this.props;
const { tag, color } = this.state;
const toggleColorPicker = () =>
this.setState(({ showColorPicker }) => ({ showColorPicker: !showColorPicker }));
return ( return (
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={this.handleOnClosed}> <Modal isOpen={isOpen} toggle={toggle} centered>
<form onSubmit={(e) => this.saveTag(e)}> <form onSubmit={saveTag}>
<ModalHeader toggle={toggle}>Edit tag</ModalHeader> <ModalHeader toggle={toggle}>Edit tag</ModalHeader>
<ModalBody> <ModalBody>
<div className="input-group"> <div className="input-group">
<div <div className="input-group-prepend" id="colorPickerBtn" onClick={toggleColorPicker}>
className="input-group-prepend"
id="colorPickerBtn"
onClick={toggleColorPicker}
>
<div <div
className="input-group-text edit-tag-modal__color-picker-toggle" className="input-group-text edit-tag-modal__color-picker-toggle"
style={{ style={{ backgroundColor: color, borderColor: color }}
backgroundColor: color,
borderColor: color,
}}
> >
<FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" /> <FontAwesomeIcon icon={colorIcon} className="edit-tag-modal__color-icon" />
</div> </div>
</div> </div>
<Popover <Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
isOpen={this.state.showColorPicker} <ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
toggle={toggleColorPicker}
target="colorPickerBtn"
placement="right"
>
<ChromePicker
color={color}
disableAlpha
onChange={(color) => this.setState({ color: color.hex })}
/>
</Popover> </Popover>
<input <input
type="text" type="text"
value={tag} value={newTagName}
placeholder="Tag" placeholder="Tag"
required required
className="form-control" className="form-control"
onChange={(e) => this.setState({ tag: e.target.value })} onChange={(e) => setNewTagName(e.target.value)}
/> />
</div> </div>
@ -122,7 +75,11 @@ const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Co
</form> </form>
</Modal> </Modal>
); );
} };
EditTagModalComp.propTypes = propTypes;
return EditTagModalComp;
}; };
export default EditTagModal; export default EditTagModal;

View file

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

View file

@ -1,5 +1,4 @@
import axios from 'axios'; import axios from 'axios';
import { stateFlagTimeout } from '../utils';
import { useStateFlagTimeout } from '../helpers/hooks'; import { useStateFlagTimeout } from '../helpers/hooks';
import Storage from './Storage'; import Storage from './Storage';
import ColorGenerator from './ColorGenerator'; import ColorGenerator from './ColorGenerator';
@ -15,7 +14,6 @@ const provideServices = (bottle) => {
bottle.constant('setTimeout', global.setTimeout); bottle.constant('setTimeout', global.setTimeout);
bottle.constant('clearTimeout', global.clearTimeout); bottle.constant('clearTimeout', global.clearTimeout);
bottle.serviceFactory('stateFlagTimeout', stateFlagTimeout, 'setTimeout');
bottle.serviceFactory('useStateFlagTimeout', useStateFlagTimeout, 'setTimeout', 'clearTimeout'); 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 markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { isEmpty, isNil, range } from 'ramda'; 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) => { export const determineOrderDir = (clickedField, currentOrderField, currentOrderDir) => {
if (currentOrderField !== clickedField) { if (currentOrderField !== clickedField) {
return 'ASC'; return 'ASC';

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda'; import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
import SortingDropdown from '../utils/SortingDropdown'; import SortingDropdown from '../utils/SortingDropdown';
@ -8,73 +8,78 @@ import { roundTen } from '../utils/helpers/numbers';
import SimplePaginator from '../common/SimplePaginator'; import SimplePaginator from '../common/SimplePaginator';
import GraphCard from './GraphCard'; 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 toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
const pickKeyFromPair = ([ key ]) => key; const pickKeyFromPair = ([ key ]) => key;
const pickValueFromPair = ([ , value ]) => value; const pickValueFromPair = ([ , value ]) => value;
export default class SortableBarGraph extends React.Component { const SortableBarGraph = ({
static propTypes = { stats,
stats: PropTypes.object.isRequired, highlightedStats,
highlightedStats: PropTypes.object, title,
highlightedLabel: PropTypes.string, sortingItems,
title: PropTypes.string.isRequired, extraHeaderContent,
sortingItems: PropTypes.object.isRequired, withPagination = true,
extraHeaderContent: PropTypes.func, ...rest
withPagination: PropTypes.bool, }) => {
onClick: PropTypes.func, const [ order, setOrder ] = useState({
};
state = {
orderField: undefined, orderField: undefined,
orderDir: 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 pairs = toPairs(stats);
const sortedPairs = !this.state.orderField ? pairs : sortBy( const sortedPairs = !order.orderField ? pairs : sortBy(
pipe( pipe(
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1), prop(order.orderField === head(keys(sortingItems)) ? 0 : 1),
toLowerIfString toLowerIfString
), ),
pairs pairs
); );
return !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs); return !order.orderDir || order.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
} };
const determineStats = (stats, highlightedStats, sortingItems) => {
determineStats(stats, highlightedStats, sortingItems) { const sortedPairs = getSortedPairsForStats(stats, sortingItems);
const sortedPairs = this.getSortedPairsForStats(stats, sortingItems);
const sortedKeys = sortedPairs.map(pickKeyFromPair); const sortedKeys = sortedPairs.map(pickKeyFromPair);
// The highlighted stats have to be ordered based on the regular stats, not on its own values // The highlighted stats have to be ordered based on the regular stats, not on its own values
const sortedHighlightedPairs = highlightedStats && toPairs( const sortedHighlightedPairs = highlightedStats && toPairs(
{ ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...highlightedStats } { ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...highlightedStats }
); );
if (sortedPairs.length <= this.state.itemsPerPage) { if (sortedPairs.length <= itemsPerPage) {
return { return {
currentPageStats: fromPairs(sortedPairs), currentPageStats: fromPairs(sortedPairs),
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs), currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
}; };
} }
const pages = splitEvery(this.state.itemsPerPage, sortedPairs); const pages = splitEvery(itemsPerPage, sortedPairs);
const highlightedPages = sortedHighlightedPairs && splitEvery(this.state.itemsPerPage, sortedHighlightedPairs); const highlightedPages = sortedHighlightedPairs && splitEvery(itemsPerPage, sortedHighlightedPairs);
return { return {
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)), currentPageStats: fromPairs(determineCurrentPagePairs(pages)),
currentPageHighlightedStats: highlightedPages && fromPairs(this.determineCurrentPagePairs(highlightedPages)), currentPageHighlightedStats: highlightedPages && fromPairs(determineCurrentPagePairs(highlightedPages)),
pagination: this.renderPagination(pages.length), pagination: renderPagination(pages.length),
max: roundTen(max(...sortedPairs.map(pickValueFromPair))), max: roundTen(Math.max(...sortedPairs.map(pickValueFromPair))),
}; };
} };
const determineCurrentPagePairs = (pages) => {
const page = pages[currentPage - 1];
determineCurrentPagePairs(pages) { if (currentPage < pages.length) {
const page = pages[this.state.currentPage - 1];
if (this.state.currentPage < pages.length) {
return page; 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 // 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 ]) ]; return [ ...page, ...rangeOf(firstPageLength - page.length, (i) => [ `hidden_${i}`, 0 ]) ];
} };
const renderPagination = (pagesCount) =>
<SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />;
renderPagination(pagesCount) { const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats(
const { currentPage } = this.state; stats,
const setCurrentPage = (currentPage) => this.setState({ currentPage }); highlightedStats && keys(highlightedStats).length > 0 ? highlightedStats : undefined,
sortingItems
return <SimplePaginator currentPage={currentPage} pagesCount={pagesCount} setCurrentPage={setCurrentPage} />; );
} const activeCities = keys(currentPageStats);
const computeTitle = () => (
render() { <React.Fragment>
const { {title}
stats, <div className="float-right">
highlightedStats, <SortingDropdown
sortingItems, isButton={false}
title, right
extraHeaderContent, items={sortingItems}
withPagination = true, orderField={order.orderField}
...rest orderDir={order.orderDir}
} = this.props; onChange={(orderField, orderDir) => setOrder({ orderField, orderDir }) || setCurrentPage(1)}
const { currentPageStats, currentPageHighlightedStats, pagination, max } = this.determineStats( />
stats, </div>
highlightedStats && keys(highlightedStats).length > 0 ? highlightedStats : undefined, {withPagination && keys(stats).length > 50 && (
sortingItems
);
const activeCities = keys(currentPageStats);
const computeTitle = () => (
<React.Fragment>
{title}
<div className="float-right"> <div className="float-right">
<SortingDropdown <PaginationDropdown
isButton={false} toggleClassName="btn-sm p-0 mr-3"
right ranges={[ 50, 100, 200, 500 ]}
items={sortingItems} value={itemsPerPage}
orderField={this.state.orderField} setValue={(itemsPerPage) => setItemsPerPage(itemsPerPage) || setCurrentPage(1)}
orderDir={this.state.orderDir}
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir, currentPage: 1 })}
/> />
</div> </div>
{withPagination && keys(stats).length > 50 && ( )}
<div className="float-right"> {extraHeaderContent && (
<PaginationDropdown <div className="float-right">
toggleClassName="btn-sm p-0 mr-3" {extraHeaderContent(pagination ? activeCities : undefined)}
ranges={[ 50, 100, 200, 500 ]} </div>
value={this.state.itemsPerPage} )}
setValue={(itemsPerPage) => this.setState({ itemsPerPage, currentPage: 1 })} </React.Fragment>
/> );
</div>
)}
{extraHeaderContent && (
<div className="float-right">
{extraHeaderContent(pagination ? activeCities : undefined)}
</div>
)}
</React.Fragment>
);
return ( return (
<GraphCard <GraphCard
isBarChart isBarChart
title={computeTitle} title={computeTitle}
stats={currentPageStats} stats={currentPageStats}
highlightedStats={currentPageHighlightedStats} highlightedStats={currentPageHighlightedStats}
footer={pagination} footer={pagination}
max={max} max={max}
{...rest} {...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('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 />', () => { describe('<CreateShortUrlResult />', () => {
let wrapper; 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 createWrapper = (result, error = false) => {
const CreateShortUrlResult = createCreateShortUrlResult(stateFlagTimeout);
wrapper = shallow(<CreateShortUrlResult resetCreateShortUrl={identity} result={result} error={error} />); wrapper = shallow(<CreateShortUrlResult resetCreateShortUrl={identity} result={result} error={error} />);
return wrapper; return wrapper;
}; };
afterEach(() => { afterEach(() => {
stateFlagTimeout.mockReset(); jest.clearAllMocks();
wrapper && wrapper.unmount(); wrapper && wrapper.unmount();
}); });
@ -47,8 +47,8 @@ describe('<CreateShortUrlResult />', () => {
const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' }); const wrapper = createWrapper({ shortUrl: 'https://doma.in/abc123' });
const copyBtn = wrapper.find(CopyToClipboard); const copyBtn = wrapper.find(CopyToClipboard);
expect(stateFlagTimeout).not.toHaveBeenCalled(); expect(copyToClipboard).not.toHaveBeenCalled();
copyBtn.simulate('copy'); copyBtn.simulate('copy');
expect(stateFlagTimeout).toHaveBeenCalledTimes(1); expect(copyToClipboard).toHaveBeenCalledTimes(1);
}); });
}); });

View file

@ -37,17 +37,6 @@ describe('<EditTagsModal />', () => {
jest.clearAllMocks(); 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', () => { it('renders tags selector and save button when loaded', () => {
const wrapper = createWrapper({ const wrapper = createWrapper({
shortCode, shortCode,

View file

@ -25,8 +25,7 @@ describe('<DeleteTagConfirmModal />', () => {
afterEach(() => { afterEach(() => {
wrapper && wrapper.unmount(); wrapper && wrapper.unmount();
deleteTag.mockReset(); jest.resetAllMocks();
tagDeleted.mockReset();
}); });
it('asks confirmation for provided tag to be deleted', () => { it('asks confirmation for provided tag to be deleted', () => {
@ -56,14 +55,16 @@ describe('<DeleteTagConfirmModal />', () => {
expect(delBtn.text()).toEqual('Deleting tag...'); 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 }); wrapper = createWrapper({ error: false, deleting: true });
const footer = wrapper.find(ModalFooter); const footer = wrapper.find(ModalFooter);
const delBtn = footer.find('.btn-danger'); const delBtn = footer.find('.btn-danger');
delBtn.simulate('click'); await delBtn.simulate('click');
expect(deleteTag).toHaveBeenCalledTimes(1); expect(deleteTag).toHaveBeenCalledTimes(1);
expect(deleteTag).toHaveBeenCalledWith(tag); expect(deleteTag).toHaveBeenCalledWith(tag);
expect(tagDeleted).toHaveBeenCalledTimes(1);
expect(tagDeleted).toHaveBeenCalledWith(tag);
}); });
it('does no further actions when modal is closed without deleting tag', () => { it('does no further actions when modal is closed without deleting tag', () => {
@ -71,16 +72,7 @@ describe('<DeleteTagConfirmModal />', () => {
const modal = wrapper.find(Modal); const modal = wrapper.find(Modal);
modal.simulate('closed'); modal.simulate('closed');
expect(deleteTag).not.toHaveBeenCalled();
expect(tagDeleted).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 marker2x from 'leaflet/dist/images/marker-icon-2x.png';
import marker from 'leaflet/dist/images/marker-icon.png'; import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { import { determineOrderDir, fixLeafletIcons, rangeOf } from '../../src/utils/utils';
stateFlagTimeout as stateFlagTimeoutFactory,
determineOrderDir,
fixLeafletIcons,
rangeOf,
} from '../../src/utils/utils';
describe('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', () => { describe('determineOrderDir', () => {
it('returns ASC when current order field and selected field are different', () => { it('returns ASC when current order field and selected field are different', () => {
expect(determineOrderDir('foo', 'bar')).toEqual('ASC'); expect(determineOrderDir('foo', 'bar')).toEqual('ASC');