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.
* [#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.
* [#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

20
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@ 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 { range } from 'ramda';
import { isEmpty, isNil, range } from 'ramda';
const TEN_ROUNDING_NUMBER = 10;
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 hasValue = (value) => !isNil(value) && !isEmpty(value);

View file

@ -25,43 +25,31 @@ describe('<CreateShortUrl />', () => {
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 validUntil = moment('2017-01-06');
const urlInput = wrapper.find('.form-control-lg');
const tagsInput = wrapper.find(TagsSelector);
const customSlugInput = wrapper.find('#customSlug');
const domain = wrapper.find('#domain');
const maxVisitsInput = wrapper.find('#maxVisits');
const dateInputs = wrapper.find(DateInput);
const validSinceInput = dateInputs.at(0);
const validUntilInput = dateInputs.at(1);
wrapper.find('.form-control-lg').simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
wrapper.find('TagsSelector').simulate('change', [ 'tag_foo', 'tag_bar' ]);
wrapper.find('#customSlug').simulate('change', { target: { value: 'my-slug' } });
wrapper.find('#domain').simulate('change', { target: { value: 'example.com' } });
wrapper.find('#maxVisits').simulate('change', { target: { value: '20' } });
wrapper.find('#shortCodeLength').simulate('change', { target: { value: 15 } });
wrapper.find(DateInput).at(0).simulate('change', validSince);
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' } });
tagsInput.simulate('change', [ 'tag_foo', 'tag_bar' ]);
customSlugInput.simulate('change', { target: { value: 'my-slug' } });
domain.simulate('change', { target: { value: 'example.com' } });
maxVisitsInput.simulate('change', { target: { value: '20' } });
validSinceInput.simulate('change', validSince);
validUntilInput.simulate('change', validUntil);
setImmediate(() => {
const form = wrapper.find('form');
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();
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,
shortCodeLength: 15,
});
});
});

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);
});
});
});