Merge pull request #237 from acelaya-forks/feature/short-code-length

Feature/short code length
This commit is contained in:
Alejandro Celaya 2020-03-29 19:49:09 +02:00 committed by GitHub
commit ab2f311bb7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 143 additions and 118 deletions

View file

@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer. * [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer.
* [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded. * [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded.
* [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited. * [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited.
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short RUL when suing Shlink 2.1 or higher.
#### Changed #### Changed

20
package-lock.json generated
View file

@ -13894,9 +13894,9 @@
} }
}, },
"react": { "react": {
"version": "16.10.2", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react/-/react-16.10.2.tgz", "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz",
"integrity": "sha512-MFVIq0DpIhrHFyqLU0S3+4dIcBhhOvBE8bJ/5kHPVOVaGdo0KuiQzpcjCPsf585WvhypqtrMILyoE2th6dT+Lw==", "integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==",
"requires": { "requires": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
@ -14106,14 +14106,14 @@
} }
}, },
"react-dom": { "react-dom": {
"version": "16.10.2", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.10.2.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz",
"integrity": "sha512-kWGDcH3ItJK4+6Pl9DZB16BXYAZyrYQItU4OMy0jAkv5aNqc+mAKb4TpFtAteI6TJZu+9ZlNhaeNQSVQDHJzkw==", "integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==",
"requires": { "requires": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"scheduler": "^0.16.2" "scheduler": "^0.19.1"
} }
}, },
"react-error-overlay": { "react-error-overlay": {
@ -15279,9 +15279,9 @@
"dev": true "dev": true
}, },
"scheduler": { "scheduler": {
"version": "0.16.2", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.16.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
"integrity": "sha512-BqYVWqwz6s1wZMhjFvLfVR5WXP7ZY32M/wYPo04CcuPM7XZEbV2TBNW7Z0UkguPTl0dWMA59VbNXxK6q+pHItg==", "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
"requires": { "requires": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"object-assign": "^4.1.1" "object-assign": "^4.1.1"

View file

@ -43,13 +43,13 @@
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"qs": "^6.9.0", "qs": "^6.9.0",
"ramda": "^0.26.1", "ramda": "^0.26.1",
"react": "^16.10.2", "react": "^16.13.1",
"react-autosuggest": "^9.4.3", "react-autosuggest": "^9.4.3",
"react-chartjs-2": "^2.8.0", "react-chartjs-2": "^2.8.0",
"react-color": "^2.17.3", "react-color": "^2.17.3",
"react-copy-to-clipboard": "^5.0.1", "react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "~1.5.0", "react-datepicker": "~1.5.0",
"react-dom": "^16.10.2", "react-dom": "^16.13.1",
"react-external-link": "^1.0.0", "react-external-link": "^1.0.0",
"react-leaflet": "^2.4.0", "react-leaflet": "^2.4.0",
"react-moment": "^0.9.5", "react-moment": "^0.9.5",

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { serverType } from '../prop-types'; import { serverType } from '../prop-types';
import { compareVersions } from '../../utils/helpers/version'; import { versionMatch } from '../../utils/helpers/version';
const propTypes = { const propTypes = {
minVersion: PropTypes.string, minVersion: PropTypes.string,
@ -16,10 +16,9 @@ const ForServerVersion = ({ minVersion, maxVersion, selectedServer, children })
} }
const { version } = selectedServer; const { version } = selectedServer;
const matchesMinVersion = !minVersion || compareVersions(version, '>=', minVersion); const matchesVersion = versionMatch(version, { maxVersion, minVersion });
const matchesMaxVersion = !maxVersion || compareVersions(version, '<=', maxVersion);
if (!matchesMinVersion || !matchesMaxVersion) { if (!matchesVersion) {
return null; return null;
} }

View file

@ -1,55 +1,52 @@
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons'; import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isEmpty, isNil, pipe, replace, trim } from 'ramda'; import { isEmpty, isNil, pipe, replace, trim } from 'ramda';
import React from 'react'; import React, { useState } from 'react';
import { Collapse, FormGroup, Input } from 'reactstrap'; import { Collapse, FormGroup, Input } from 'reactstrap';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput'; import DateInput from '../utils/DateInput';
import Checkbox from '../utils/Checkbox'; import Checkbox from '../utils/Checkbox';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import { compareVersions } from '../utils/helpers/version'; import { versionMatch } from '../utils/helpers/version';
import { hasValue } from '../utils/utils';
import { useToggle } from '../utils/helpers/hooks';
import { createShortUrlResultType } from './reducers/shortUrlCreation'; import { createShortUrlResultType } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
const normalizeTag = pipe(trim, replace(/ /g, '-')); const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format(); const formatDate = (date) => isNil(date) ? date : date.format();
const CreateShortUrl = ( const propTypes = {
TagsSelector, createShortUrl: PropTypes.func,
CreateShortUrlResult, shortUrlCreationResult: createShortUrlResultType,
ForServerVersion resetCreateShortUrl: PropTypes.func,
) => class CreateShortUrl extends React.Component { selectedServer: serverType,
static propTypes = { };
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
resetCreateShortUrl: PropTypes.func,
selectedServer: serverType,
};
state = { const CreateShortUrl = (TagsSelector, CreateShortUrlResult, ForServerVersion) => {
longUrl: '', const CreateShortUrlComp = ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }) => {
tags: [], const [ shortUrlCreation, setShortUrlCreation ] = useState({
customSlug: undefined, longUrl: '',
domain: undefined, tags: [],
validSince: undefined, customSlug: undefined,
validUntil: undefined, shortCodeLength: undefined,
maxVisits: undefined, domain: undefined,
findIfExists: false, validSince: undefined,
moreOptionsVisible: false, validUntil: undefined,
}; maxVisits: undefined,
findIfExists: false,
});
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle(false);
render() { const changeTags = (tags) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
const { createShortUrl, shortUrlCreationResult, resetCreateShortUrl } = this.props;
const changeTags = (tags) => this.setState({ tags: tags.map(normalizeTag) });
const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => ( const renderOptionalInput = (id, placeholder, type = 'text', props = {}) => (
<FormGroup> <FormGroup>
<Input <Input
id={id} id={id}
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
value={this.state[id]} value={shortUrlCreation[id]}
onChange={(e) => this.setState({ [id]: e.target.value })} onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
{...props} {...props}
/> />
</FormGroup> </FormGroup>
@ -57,24 +54,25 @@ const CreateShortUrl = (
const renderDateInput = (id, placeholder, props = {}) => ( const renderDateInput = (id, placeholder, props = {}) => (
<div className="form-group"> <div className="form-group">
<DateInput <DateInput
selected={this.state[id]} selected={shortUrlCreation[id]}
placeholderText={placeholder} placeholderText={placeholder}
isClearable isClearable
onChange={(date) => this.setState({ [id]: date })} onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
{...props} {...props}
/> />
</div> </div>
); );
const save = (e) => { const save = (e) => {
e.preventDefault(); e.preventDefault();
createShortUrl(pipe( createShortUrl({
dissoc('moreOptionsVisible'), ...shortUrlCreation,
assoc('validSince', formatDate(this.state.validSince)), validSince: formatDate(shortUrlCreation.validSince),
assoc('validUntil', formatDate(this.state.validUntil)) validUntil: formatDate(shortUrlCreation.validUntil),
)(this.state)); });
}; };
const currentServerVersion = this.props.selectedServer ? this.props.selectedServer.version : ''; const currentServerVersion = selectedServer && selectedServer.version;
const disableDomain = isEmpty(currentServerVersion) || compareVersions(currentServerVersion, '<', '1.19.0-beta.1'); const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
return ( return (
<form onSubmit={save}> <form onSubmit={save}>
@ -84,21 +82,30 @@ const CreateShortUrl = (
type="url" type="url"
placeholder="Insert the URL to be shortened" placeholder="Insert the URL to be shortened"
required required
value={this.state.longUrl} value={shortUrlCreation.longUrl}
onChange={(e) => this.setState({ longUrl: e.target.value })} onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
/> />
</div> </div>
<Collapse isOpen={this.state.moreOptionsVisible}> <Collapse isOpen={moreOptionsVisible}>
<div className="form-group"> <div className="form-group">
<TagsSelector tags={this.state.tags} onChange={changeTags} /> <TagsSelector tags={shortUrlCreation.tags} onChange={changeTags} />
</div> </div>
<div className="row"> <div className="row">
<div className="col-sm-6"> <div className="col-sm-4">
{renderOptionalInput('customSlug', 'Custom slug')} {renderOptionalInput('customSlug', 'Custom slug')}
</div> </div>
<div className="col-sm-6"> <div className="col-sm-4">
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
min: 4,
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
...disableShortCodeLength && {
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
},
})}
</div>
<div className="col-sm-4">
{renderOptionalInput('domain', 'Domain', 'text', { {renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain, disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' }, ...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
@ -107,14 +114,14 @@ const CreateShortUrl = (
</div> </div>
<div className="row"> <div className="row">
<div className="col-sm-6"> <div className="col-sm-4">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div> </div>
<div className="col-sm-3"> <div className="col-sm-4">
{renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })} {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil })}
</div> </div>
<div className="col-sm-3"> <div className="col-sm-4">
{renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })} {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince })}
</div> </div>
</div> </div>
@ -122,8 +129,8 @@ const CreateShortUrl = (
<div className="mb-4 text-right"> <div className="mb-4 text-right">
<Checkbox <Checkbox
className="mr-2" className="mr-2"
checked={this.state.findIfExists} checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => this.setState({ findIfExists })} onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
> >
Use existing URL if found Use existing URL if found
</Checkbox> </Checkbox>
@ -133,18 +140,14 @@ const CreateShortUrl = (
</Collapse> </Collapse>
<div> <div>
<button <button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
type="button" <FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
className="btn btn-outline-secondary"
onClick={() => this.setState(({ moreOptionsVisible }) => ({ moreOptionsVisible: !moreOptionsVisible }))}
>
<FontAwesomeIcon icon={this.state.moreOptionsVisible ? upIcon : downIcon} />
&nbsp; &nbsp;
{this.state.moreOptionsVisible ? 'Less' : 'More'} options {moreOptionsVisible ? 'Less' : 'More'} options
</button> </button>
<button <button
className="btn btn-outline-primary float-right" className="btn btn-outline-primary float-right"
disabled={shortUrlCreationResult.loading || isEmpty(this.state.longUrl)} disabled={shortUrlCreationResult.loading || isEmpty(shortUrlCreation.longUrl)}
> >
{shortUrlCreationResult.loading ? 'Creating...' : 'Create'} {shortUrlCreationResult.loading ? 'Creating...' : 'Create'}
</button> </button>
@ -153,7 +156,11 @@ const CreateShortUrl = (
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} /> <CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
</form> </form>
); );
} };
CreateShortUrlComp.propTypes = propTypes;
return CreateShortUrlComp;
}; };
export default CreateShortUrl; export default CreateShortUrl;

View file

@ -1,15 +1,21 @@
import { compare } from 'compare-versions'; import { compare } from 'compare-versions';
import { identity, memoizeWith } from 'ramda'; import { identity, memoizeWith } from 'ramda';
import { hasValue } from '../utils';
export const compareVersions = (firstVersion, operator, secondVersion) => compare( export const versionMatch = (versionToMatch, { maxVersion, minVersion }) => {
firstVersion, if (!hasValue(versionToMatch)) {
secondVersion, return false;
operator, }
);
const matchesMinVersion = !minVersion || compare(versionToMatch, minVersion, '>=');
const matchesMaxVersion = !maxVersion || compare(versionToMatch, maxVersion, '<=');
return !!(matchesMaxVersion && matchesMinVersion);
};
const versionIsValidSemVer = memoizeWith(identity, (version) => { const versionIsValidSemVer = memoizeWith(identity, (version) => {
try { try {
return compareVersions(version, '=', version); return compare(version, version, '=');
} catch (e) { } catch (e) {
return false; return false;
} }

View file

@ -2,7 +2,7 @@ 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 { range } from 'ramda'; import { isEmpty, isNil, range } from 'ramda';
const TEN_ROUNDING_NUMBER = 10; const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000; const DEFAULT_TIMEOUT_DELAY = 2000;
@ -45,3 +45,4 @@ export const rangeOf = (size, mappingFn, startAt = 1) => range(startAt, size + 1
export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER; export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;
export const hasValue = (value) => !isNil(value) && !isEmpty(value);

View file

@ -25,43 +25,31 @@ describe('<CreateShortUrl />', () => {
createShortUrl.mockReset(); createShortUrl.mockReset();
}); });
it('saves short URL with data set in form controls', (done) => { it('saves short URL with data set in form controls', () => {
const validSince = moment('2017-01-01'); const validSince = moment('2017-01-01');
const validUntil = moment('2017-01-06'); const validUntil = moment('2017-01-06');
const urlInput = wrapper.find('.form-control-lg'); wrapper.find('.form-control-lg').simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
const tagsInput = wrapper.find(TagsSelector); wrapper.find('TagsSelector').simulate('change', [ 'tag_foo', 'tag_bar' ]);
const customSlugInput = wrapper.find('#customSlug'); wrapper.find('#customSlug').simulate('change', { target: { value: 'my-slug' } });
const domain = wrapper.find('#domain'); wrapper.find('#domain').simulate('change', { target: { value: 'example.com' } });
const maxVisitsInput = wrapper.find('#maxVisits'); wrapper.find('#maxVisits').simulate('change', { target: { value: '20' } });
const dateInputs = wrapper.find(DateInput); wrapper.find('#shortCodeLength').simulate('change', { target: { value: 15 } });
const validSinceInput = dateInputs.at(0); wrapper.find(DateInput).at(0).simulate('change', validSince);
const validUntilInput = dateInputs.at(1); wrapper.find(DateInput).at(1).simulate('change', validUntil);
wrapper.find('form').simulate('submit', { preventDefault: identity });
urlInput.simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } }); expect(createShortUrl).toHaveBeenCalledTimes(1);
tagsInput.simulate('change', [ 'tag_foo', 'tag_bar' ]); expect(createShortUrl).toHaveBeenCalledWith({
customSlugInput.simulate('change', { target: { value: 'my-slug' } }); longUrl: 'https://long-domain.com/foo/bar',
domain.simulate('change', { target: { value: 'example.com' } }); tags: [ 'tag_foo', 'tag_bar' ],
maxVisitsInput.simulate('change', { target: { value: '20' } }); customSlug: 'my-slug',
validSinceInput.simulate('change', validSince); domain: 'example.com',
validUntilInput.simulate('change', validUntil); validSince: validSince.format(),
validUntil: validUntil.format(),
setImmediate(() => { maxVisits: '20',
const form = wrapper.find('form'); findIfExists: false,
shortCodeLength: 15,
form.simulate('submit', { preventDefault: identity });
expect(createShortUrl).toHaveBeenCalledTimes(1);
expect(createShortUrl).toHaveBeenCalledWith({
longUrl: 'https://long-domain.com/foo/bar',
tags: [ 'tag_foo', 'tag_bar' ],
customSlug: 'my-slug',
domain: 'example.com',
validSince: validSince.format(),
validUntil: validUntil.format(),
maxVisits: '20',
findIfExists: false,
});
done();
}); });
}); });
}); });

View file

@ -0,0 +1,23 @@
import { versionMatch } from '../../../src/utils/helpers/version';
describe('version', () => {
describe('versionMatch', () => {
it.each([
[ undefined, {}, false ],
[ null, {}, false ],
[ '', {}, false ],
[[], {}, false ],
[ '2.8.3', {}, true ],
[ '2.8.3', { minVersion: '2.0.0' }, true ],
[ '2.0.0', { minVersion: '2.0.0' }, true ],
[ '1.8.0', { maxVersion: '1.8.0' }, true ],
[ '1.7.1', { maxVersion: '1.8.0' }, true ],
[ '1.7.3', { minVersion: '1.7.0', maxVersion: '1.8.0' }, true ],
[ '1.8.3', { minVersion: '2.0.0' }, false ],
[ '1.8.3', { maxVersion: '1.8.0' }, false ],
[ '1.8.3', { minVersion: '1.7.0', maxVersion: '1.8.0' }, false ],
])('properly matches versions based on what is provided', (version, versionConstraints, expected) => {
expect(versionMatch(version, versionConstraints)).toEqual(expected);
});
});
});