mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #275 from acelaya-forks/feature/remove-class-components
Feature/remove class components
This commit is contained in:
commit
df38cf6ca9
18 changed files with 347 additions and 506 deletions
|
@ -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",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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');
|
||||||
|
|
Loading…
Reference in a new issue