diff --git a/package.json b/package.json index 0d5717e1..7267a0b3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/ScrollToTop.js b/src/common/ScrollToTop.js index 28e776b6..7de10d6d 100644 --- a/src/common/ScrollToTop.js +++ b/src/common/ScrollToTop.js @@ -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; diff --git a/src/servers/helpers/ImportServersBtn.js b/src/servers/helpers/ImportServersBtn.js index ec56cb9e..65a47b86 100644 --- a/src/servers/helpers/ImportServersBtn.js +++ b/src/servers/helpers/ImportServersBtn.js @@ -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 - You can create servers by importing a CSV file with columns name, apiKey and url + You can create servers by importing a CSV file with columns name, apiKey and url. - + ); - } + }; + + ImportServersBtnComp.propTypes = propTypes; + + return ImportServersBtnComp; }; export default ImportServersBtn; diff --git a/src/short-urls/helpers/CreateShortUrlResult.js b/src/short-urls/helpers/CreateShortUrlResult.js index 6bebb8f1..f7cd3f5d 100644 --- a/src/short-urls/helpers/CreateShortUrlResult.js +++ b/src/short-urls/helpers/CreateShortUrlResult.js @@ -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 ); } + if (isNil(result)) { return null; } const { shortUrl } = result; - const onCopy = () => stateFlagTimeout(this.setState.bind(this), 'showCopyTooltip'); return ( Great! The short URL is {shortUrl} - + - + Copied! ); - } + }; + + CreateShortUrlResultComp.propTypes = propTypes; + + return CreateShortUrlResultComp; }; export default CreateShortUrlResult; diff --git a/src/short-urls/helpers/DeleteShortUrlModal.js b/src/short-urls/helpers/DeleteShortUrlModal.js index 6121b9a2..a41df3f9 100644 --- a/src/short-urls/helpers/DeleteShortUrlModal.js +++ b/src/short-urls/helpers/DeleteShortUrlModal.js @@ -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 ( + +
+ + Delete short URL + + +

Caution! You are about to delete a short URL.

+

This action cannot be undone. Once you have deleted it, all the visits stats will be lost.

- resetDeleteShortUrl(); - } + 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 && ( +
+ {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.'} +
+ )} + {hasErrorOtherThanThreshold && ( +
+ Something went wrong while deleting the URL :( +
+ )} +
+ + + + +
+
+ ); +}; - return ( - -
- - Delete short URL - - -

Caution! You are about to delete a short URL.

-

This action cannot be undone. Once you have deleted it, all the visits stats will be lost.

+DeleteShortUrlModal.propTypes = propTypes; - this.setState({ inputValue: e.target.value })} - /> - - {hasThresholdError && ( -
- {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.'} -
- )} - {hasErrorOtherThanThreshold && ( -
- Something went wrong while deleting the URL :( -
- )} -
- - - - -
-
- ); - } -} +export default DeleteShortUrlModal; diff --git a/src/short-urls/helpers/EditTagsModal.js b/src/short-urls/helpers/EditTagsModal.js index 8dabb1c1..ed4c5055 100644 --- a/src/short-urls/helpers/EditTagsModal.js +++ b/src/short-urls/helpers/EditTagsModal.js @@ -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 ( - - + + Edit tags for - this.setState({ tags })} /> + setSelectedTags(tags)} /> {shortUrlTags.error && (
Something went wrong while saving the tags :( @@ -54,19 +39,18 @@ const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Compon )} - - + ); - } + }; + + EditTagsModalComp.propTypes = propTypes; + + return EditTagsModalComp; }; export default EditTagsModal; diff --git a/src/short-urls/services/provideServices.js b/src/short-urls/services/provideServices.js index 02a0d5fa..82a70db8 100644 --- a/src/short-urls/services/provideServices.js +++ b/src/short-urls/services/provideServices.js @@ -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( diff --git a/src/tags/helpers/DeleteTagConfirmModal.js b/src/tags/helpers/DeleteTagConfirmModal.js index 460db160..0cb8664e 100644 --- a/src/tags/helpers/DeleteTagConfirmModal.js +++ b/src/tags/helpers/DeleteTagConfirmModal.js @@ -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 ( + + + Delete tag + + + Are you sure you want to delete tag {tag}? + {tagDelete.error && ( +
+ Something went wrong while deleting the tag :( +
+ )} +
+ + + + +
+ ); +}; - tagDeleted(tag); - }; +DeleteTagConfirmModal.propTypes = propTypes; - componentDidMount() { - this.tagWasDeleted = false; - } - - render() { - const { tag, toggle, isOpen, tagDelete } = this.props; - - return ( - - - Delete tag - - - Are you sure you want to delete tag {tag}? - {tagDelete.error && ( -
- Something went wrong while deleting the tag :( -
- )} -
- - - - -
- ); - } -} +export default DeleteTagConfirmModal; diff --git a/src/tags/helpers/EditTagModal.js b/src/tags/helpers/EditTagModal.js index ea6f7bc4..a9cbdb1b 100644 --- a/src/tags/helpers/EditTagModal.js +++ b/src/tags/helpers/EditTagModal.js @@ -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 ( - -
this.saveTag(e)}> + + Edit tag
-
+
- - this.setState({ color: color.hex })} - /> + + setColor(hex)} /> this.setState({ tag: e.target.value })} + onChange={(e) => setNewTagName(e.target.value)} />
@@ -122,7 +75,11 @@ const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Co ); - } + }; + + EditTagModalComp.propTypes = propTypes; + + return EditTagModalComp; }; export default EditTagModal; diff --git a/src/tags/helpers/TagsSelector.js b/src/tags/helpers/TagsSelector.js index 3df8d0fd..23d75127 100644 --- a/src/tags/helpers/TagsSelector.js +++ b/src/tags/helpers/TagsSelector.js @@ -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 }) => ( {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; diff --git a/src/utils/services/provideServices.js b/src/utils/services/provideServices.js index d4068614..5bc62442 100644 --- a/src/utils/services/provideServices.js +++ b/src/utils/services/provideServices.js @@ -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'); }; diff --git a/src/utils/utils.js b/src/utils/utils.js index db527df6..d8758be1 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -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'; diff --git a/src/visits/SortableBarGraph.js b/src/visits/SortableBarGraph.js index a3efaf58..dc64e71f 100644 --- a/src/visits/SortableBarGraph.js +++ b/src/visits/SortableBarGraph.js @@ -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) => + ; - renderPagination(pagesCount) { - const { currentPage } = this.state; - const setCurrentPage = (currentPage) => this.setState({ currentPage }); - - return ; - } - - 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 = () => ( - - {title} + const { currentPageStats, currentPageHighlightedStats, pagination, max } = determineStats( + stats, + highlightedStats && keys(highlightedStats).length > 0 ? highlightedStats : undefined, + sortingItems + ); + const activeCities = keys(currentPageStats); + const computeTitle = () => ( + + {title} +
+ setOrder({ orderField, orderDir }) || setCurrentPage(1)} + /> +
+ {withPagination && keys(stats).length > 50 && (
- this.setState({ orderField, orderDir, currentPage: 1 })} + setItemsPerPage(itemsPerPage) || setCurrentPage(1)} />
- {withPagination && keys(stats).length > 50 && ( -
- this.setState({ itemsPerPage, currentPage: 1 })} - /> -
- )} - {extraHeaderContent && ( -
- {extraHeaderContent(pagination ? activeCities : undefined)} -
- )} -
- ); + )} + {extraHeaderContent && ( +
+ {extraHeaderContent(pagination ? activeCities : undefined)} +
+ )} +
+ ); - return ( - - ); - } -} + return ( + + ); +}; + +SortableBarGraph.propTypes = propTypes; + +export default SortableBarGraph; diff --git a/test/common/ScrollToTop.test.js b/test/common/ScrollToTop.test.js index b49450db..a0264182 100644 --- a/test/common/ScrollToTop.test.js +++ b/test/common/ScrollToTop.test.js @@ -20,9 +20,4 @@ describe('', () => { }); 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); - }); }); diff --git a/test/short-urls/helpers/CreateShortUrlResult.test.js b/test/short-urls/helpers/CreateShortUrlResult.test.js index ece6bbdf..09e41ddf 100644 --- a/test/short-urls/helpers/CreateShortUrlResult.test.js +++ b/test/short-urls/helpers/CreateShortUrlResult.test.js @@ -7,17 +7,17 @@ import createCreateShortUrlResult from '../../../src/short-urls/helpers/CreateSh describe('', () => { 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(); return wrapper; }; afterEach(() => { - stateFlagTimeout.mockReset(); + jest.clearAllMocks(); wrapper && wrapper.unmount(); }); @@ -47,8 +47,8 @@ describe('', () => { 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); }); }); diff --git a/test/short-urls/helpers/EditTagsModal.test.js b/test/short-urls/helpers/EditTagsModal.test.js index f9c6ffb3..bb9866c2 100644 --- a/test/short-urls/helpers/EditTagsModal.test.js +++ b/test/short-urls/helpers/EditTagsModal.test.js @@ -37,17 +37,6 @@ describe('', () => { 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, diff --git a/test/tags/helpers/DeleteTagConfirmModal.test.js b/test/tags/helpers/DeleteTagConfirmModal.test.js index 73d6de29..aa45d921 100644 --- a/test/tags/helpers/DeleteTagConfirmModal.test.js +++ b/test/tags/helpers/DeleteTagConfirmModal.test.js @@ -25,8 +25,7 @@ describe('', () => { afterEach(() => { wrapper && wrapper.unmount(); - deleteTag.mockReset(); - tagDeleted.mockReset(); + jest.resetAllMocks(); }); it('asks confirmation for provided tag to be deleted', () => { @@ -56,14 +55,16 @@ describe('', () => { 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('', () => { 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); - }); }); diff --git a/test/utils/utils.test.js b/test/utils/utils.test.js index 512053fe..66c92618 100644 --- a/test/utils/utils.test.js +++ b/test/utils/utils.test.js @@ -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');