diff --git a/CHANGELOG.md b/CHANGELOG.md index c29c2cf7..bf6ebc8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 489cb65e..45ce16c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index ce2b7b2f..9041a0eb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/servers/helpers/ForServerVersion.js b/src/servers/helpers/ForServerVersion.js index 0c51b22a..0b3c6fba 100644 --- a/src/servers/helpers/ForServerVersion.js +++ b/src/servers/helpers/ForServerVersion.js @@ -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; } diff --git a/src/short-urls/CreateShortUrl.js b/src/short-urls/CreateShortUrl.js index e1719fe3..32544d9e 100644 --- a/src/short-urls/CreateShortUrl.js +++ b/src/short-urls/CreateShortUrl.js @@ -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 = {}) => ( this.setState({ [id]: e.target.value })} + value={shortUrlCreation[id]} + onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })} {...props} /> @@ -57,24 +54,25 @@ const CreateShortUrl = ( const renderDateInput = (id, placeholder, props = {}) => (
this.setState({ [id]: date })} + onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })} {...props} />
); 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 (
@@ -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 })} /> - +
- +
-
+
{renderOptionalInput('customSlug', 'Custom slug')}
-
+
+ {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', + }, + })} +
+
{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 = (
-
+
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
-
- {renderDateInput('validSince', 'Enabled since...', { maxDate: this.state.validUntil })} +
+ {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil })}
-
- {renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })} +
+ {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince })}
@@ -122,8 +129,8 @@ const CreateShortUrl = (
this.setState({ findIfExists })} + checked={shortUrlCreation.findIfExists} + onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })} > Use existing URL if found @@ -133,18 +140,14 @@ const CreateShortUrl = (
- @@ -153,7 +156,11 @@ const CreateShortUrl = ( ); - } + }; + + CreateShortUrlComp.propTypes = propTypes; + + return CreateShortUrlComp; }; export default CreateShortUrl; diff --git a/src/utils/helpers/version.js b/src/utils/helpers/version.js index 92867939..5d62603a 100644 --- a/src/utils/helpers/version.js +++ b/src/utils/helpers/version.js @@ -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; } diff --git a/src/utils/utils.js b/src/utils/utils.js index 0abda883..ee2ee170 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -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); diff --git a/test/short-urls/CreateShortUrl.test.js b/test/short-urls/CreateShortUrl.test.js index aac1172d..fcb1ec04 100644 --- a/test/short-urls/CreateShortUrl.test.js +++ b/test/short-urls/CreateShortUrl.test.js @@ -25,43 +25,31 @@ describe('', () => { 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, }); }); }); diff --git a/test/utils/helpers/version.test.js b/test/utils/helpers/version.test.js new file mode 100644 index 00000000..adcfdee9 --- /dev/null +++ b/test/utils/helpers/version.test.js @@ -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); + }); + }); +});