diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a19b08..df4e8429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), #### Added -* *Nothing* +* [#101](https://github.com/shlinkio/shlink-web-client/issues/101) Added checkbox to short URL creation form that allows to determine the value of the `findIfExists` flag introduced in Shlink v1.16.0. #### Changed diff --git a/package.json b/package.json index 29cb437b..bdb53a80 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "react-router-dom": "^4.2.2", "react-swipeable": "^4.3.0", "react-tagsinput": "^3.19.0", - "reactstrap": "^6.0.1", + "reactstrap": "^7.1.0", "redux": "^4.0.0", "redux-actions": "^2.6.5", "redux-thunk": "^2.3.0", diff --git a/src/short-urls/CreateShortUrl.js b/src/short-urls/CreateShortUrl.js index 89838947..38d1d4be 100644 --- a/src/short-urls/CreateShortUrl.js +++ b/src/short-urls/CreateShortUrl.js @@ -5,7 +5,9 @@ import React from 'react'; import { Collapse } from 'reactstrap'; import * as PropTypes from 'prop-types'; import DateInput from '../utils/DateInput'; +import Checkbox from '../utils/Checkbox'; import { createShortUrlResultType } from './reducers/shortUrlCreation'; +import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; const normalizeTag = pipe(trim, replace(/ /g, '-')); const formatDate = (date) => isNil(date) ? date : date.format(); @@ -24,6 +26,7 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort validSince: undefined, validUntil: undefined, maxVisits: undefined, + findIfExists: false, moreOptionsVisible: false, }; @@ -93,22 +96,30 @@ const CreateShortUrl = (TagsSelector, CreateShortUrlResult) => class CreateShort {renderDateInput('validUntil', 'Enabled until...', { minDate: this.state.validSince })} + +
+ this.setState({ findIfExists })} + > + Use existing URL if found + + +
-
diff --git a/src/short-urls/UseExistingIfFoundInfoIcon.js b/src/short-urls/UseExistingIfFoundInfoIcon.js new file mode 100644 index 00000000..7e155ac4 --- /dev/null +++ b/src/short-urls/UseExistingIfFoundInfoIcon.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; +import { Modal, ModalBody, ModalHeader } from 'reactstrap'; +import './UseExistingIfFoundInfoIcon.scss'; +import { useToggle } from '../utils/utils'; + +const renderInfoModal = (isOpen, toggle) => ( + + Info + +

+ When the  + "Use existing URL if found" +  checkbox is checked, the server will return an existing short URL if it matches provided params. +

+

+ These are the checks performed by Shlink in order to determine if an existing short URL should be returned: +

+ +
+ Important: This feature will be ignored while using a Shlink version older than v1.16.0. +
+
+
+); + +const UseExistingIfFoundInfoIcon = () => { + const [ isModalOpen, toggleModal ] = useToggle(false); + + return ( + + + + + {renderInfoModal(isModalOpen, toggleModal)} + + ); +}; + +export default UseExistingIfFoundInfoIcon; diff --git a/src/short-urls/UseExistingIfFoundInfoIcon.scss b/src/short-urls/UseExistingIfFoundInfoIcon.scss new file mode 100644 index 00000000..835e5d2d --- /dev/null +++ b/src/short-urls/UseExistingIfFoundInfoIcon.scss @@ -0,0 +1,7 @@ +.use-existing-if-found-info-icon__modal-quote { + margin-bottom: 0; + padding: 10px 15px; + font-size: 17.5px; + border-left: 5px solid #eee; + background-color: #f9f9f9; +} diff --git a/src/utils/Checkbox.js b/src/utils/Checkbox.js new file mode 100644 index 00000000..0c39e5e4 --- /dev/null +++ b/src/utils/Checkbox.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { v4 as uuid } from 'uuid'; + +const propTypes = { + checked: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + children: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]), + className: PropTypes.string, +}; + +const Checkbox = ({ checked, onChange, className, children }) => { + const id = uuid(); + const onChecked = (e) => onChange(e.target.checked, e); + + return ( + + + + + ); +}; + +Checkbox.propTypes = propTypes; + +export default Checkbox; diff --git a/src/utils/utils.js b/src/utils/utils.js index a49cc604..c727965c 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -3,6 +3,7 @@ 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 { useState } from 'react'; const TEN_ROUNDING_NUMBER = 10; const DEFAULT_TIMEOUT_DELAY = 2000; @@ -44,3 +45,9 @@ export const fixLeafletIcons = () => { export const rangeOf = (size, mappingFn, startAt = 1) => range(startAt, size + 1).map(mappingFn); export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER; + +export const useToggle = (initialValue = false) => { + const [ flag, setFlag ] = useState(initialValue); + + return [ flag, () => setFlag(!flag) ]; +}; diff --git a/test/short-urls/CreateShortUrl.test.js b/test/short-urls/CreateShortUrl.test.js index 507e0f85..55592106 100644 --- a/test/short-urls/CreateShortUrl.test.js +++ b/test/short-urls/CreateShortUrl.test.js @@ -59,6 +59,7 @@ describe('', () => { validSince: validSince.format(), validUntil: validUntil.format(), maxVisits: '20', + findIfExists: false, }, ] ); diff --git a/test/short-urls/UseExistingIfFoundInfoIcon.test.js b/test/short-urls/UseExistingIfFoundInfoIcon.test.js new file mode 100644 index 00000000..12f25a0d --- /dev/null +++ b/test/short-urls/UseExistingIfFoundInfoIcon.test.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Modal } from 'reactstrap'; +import UseExistingIfFoundInfoIcon from '../../src/short-urls/UseExistingIfFoundInfoIcon'; + +describe('', () => { + let wrapped; + + beforeEach(() => { + wrapped = mount(); + }); + + afterEach(() => wrapped.unmount()); + + it('shows modal when icon is clicked', () => { + const icon = wrapped.find(FontAwesomeIcon); + + expect(wrapped.find(Modal).prop('isOpen')).toEqual(false); + icon.simulate('click'); + expect(wrapped.find(Modal).prop('isOpen')).toEqual(true); + }); +}); diff --git a/test/utils/Checkbox.test.js b/test/utils/Checkbox.test.js new file mode 100644 index 00000000..78c8afec --- /dev/null +++ b/test/utils/Checkbox.test.js @@ -0,0 +1,66 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Checkbox from '../../src/utils/Checkbox'; + +describe('', () => { + let wrapped; + + const createComponent = (props = {}) => { + wrapped = mount(); + + return wrapped; + }; + + afterEach(() => wrapped && wrapped.unmount()); + + it('includes extra class names when provided', () => { + const classNames = [ 'foo', 'bar', 'baz' ]; + const checked = false; + const onChange = () => {}; + + expect.assertions(classNames.length); + classNames.forEach((className) => { + const wrapped = createComponent({ className, checked, onChange }); + + expect(wrapped.prop('className')).toContain(className); + }); + }); + + it('marks input as checked if defined', () => { + const checkeds = [ true, false ]; + const onChange = () => {}; + + expect.assertions(checkeds.length); + checkeds.forEach((checked) => { + const wrapped = createComponent({ checked, onChange }); + const input = wrapped.find('input'); + + expect(input.prop('checked')).toEqual(checked); + }); + }); + + it('renders provided children inside the label', () => { + const labels = [ 'foo', 'bar', 'baz' ]; + const checked = false; + const onChange = () => {}; + + expect.assertions(labels.length); + labels.forEach((children) => { + const wrapped = createComponent({ children, checked, onChange }); + const label = wrapped.find('label'); + + expect(label.text()).toEqual(children); + }); + }); + + it('changes checked status on input change', () => { + const onChange = jest.fn(); + const e = { target: { checked: false } }; + const wrapped = createComponent({ checked: true, onChange }); + const input = wrapped.find('input'); + + input.prop('onChange')(e); + + expect(onChange).toHaveBeenCalledWith(false, e); + }); +}); diff --git a/yarn.lock b/yarn.lock index 639969e8..8c51b072 100644 --- a/yarn.lock +++ b/yarn.lock @@ -742,7 +742,7 @@ dependencies: regenerator-runtime "^0.12.0" -"@babel/runtime@^7.3.1": +"@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1": version "7.3.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.4.tgz#73d12ba819e365fcf7fd152aed56d6df97d21c83" integrity sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g== @@ -1221,6 +1221,11 @@ array-filter@^1.0.0: resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-1.0.0.tgz#baf79e62e6ef4c2a4c0b831232daffec251f9d83" integrity sha1-uveeYubvTCpMC4MSMtr/7CUfnYM= +array-filter@~0.0.0: + version "0.0.1" + resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec" + integrity sha1-fajPLiZijtcygDWB/SH2fKzS7uw= + array-find-index@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" @@ -1244,12 +1249,12 @@ array-includes@^3.0.3: define-properties "^1.1.2" es-abstract "^1.7.0" -array-map@^0.0.0: +array-map@^0.0.0, array-map@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662" integrity sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI= -array-reduce@^0.0.0: +array-reduce@^0.0.0, array-reduce@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b" integrity sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys= @@ -7617,11 +7622,6 @@ postcss-reduce-initial@^4.0.2: postcss-reduce-transforms@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.1.tgz#8600d5553bdd3ad640f43bff81eb52f8760d4561" - dependencies: - cssnano-util-get-match "^4.0.0" - has "^1.0.0" - postcss "^7.0.0" - postcss-value-parser "^3.0.0" postcss-replace-overflow-wrap@^3.0.0: version "3.0.0" @@ -8214,10 +8214,12 @@ reactcss@^1.2.0: dependencies: lodash "^4.0.1" -reactstrap@^6.0.1: - version "6.5.0" - resolved "https://registry.yarnpkg.com/reactstrap/-/reactstrap-6.5.0.tgz#ba655e32646e2621829f61faa033e607ec6624e5" +reactstrap@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/reactstrap/-/reactstrap-7.1.0.tgz#fd7125901737a3001c8564c0f8b40e319eec23b2" + integrity sha512-wtc4RkgnGn1TsZ0AxOZ2OqT+b8YmCWZj/tErPujWLepxzlEEhveZGC+uDerdaHVSAzJUP2DTk605iper7hutQQ== dependencies: + "@babel/runtime" "^7.2.0" classnames "^2.2.3" lodash.isfunction "^3.0.9" lodash.isobject "^3.0.2" @@ -8962,11 +8964,6 @@ shebang-regex@^1.0.0: shell-quote@1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767" - dependencies: - array-filter "~0.0.0" - array-map "~0.0.0" - array-reduce "~0.0.0" - jsonify "~0.0.0" shellwords@^0.1.1: version "0.1.1"