From 98aa85ca1409fda1fb1cba80e2699518ff015336 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 19 Mar 2021 19:11:27 +0100 Subject: [PATCH 01/13] Created reusable component to have a short URL form --- src/short-urls/CreateShortUrl.scss | 6 - src/short-urls/CreateShortUrl.tsx | 179 ++------------------ src/short-urls/ShortUrlForm.scss | 6 + src/short-urls/ShortUrlForm.tsx | 180 +++++++++++++++++++++ src/short-urls/data/index.ts | 1 + src/short-urls/services/provideServices.ts | 11 +- test/short-urls/CreateShortUrl.test.tsx | 40 +---- test/short-urls/ShortUrlForm.test.tsx | 59 +++++++ 8 files changed, 270 insertions(+), 212 deletions(-) delete mode 100644 src/short-urls/CreateShortUrl.scss create mode 100644 src/short-urls/ShortUrlForm.scss create mode 100644 src/short-urls/ShortUrlForm.tsx create mode 100644 test/short-urls/ShortUrlForm.test.tsx diff --git a/src/short-urls/CreateShortUrl.scss b/src/short-urls/CreateShortUrl.scss deleted file mode 100644 index 81adb310..00000000 --- a/src/short-urls/CreateShortUrl.scss +++ /dev/null @@ -1,6 +0,0 @@ -@import '../utils/base'; - -.create-short-url .form-group:last-child, -.create-short-url p:last-child { - margin-bottom: 0; -} diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx index bf48e394..08aada4d 100644 --- a/src/short-urls/CreateShortUrl.tsx +++ b/src/short-urls/CreateShortUrl.tsx @@ -1,24 +1,11 @@ -import { isEmpty, pipe, replace, trim } from 'ramda'; -import { FC, useMemo, useState } from 'react'; -import { Button, FormGroup, Input } from 'reactstrap'; -import { InputType } from 'reactstrap/lib/Input'; -import * as m from 'moment'; -import DateInput, { DateInputProps } from '../utils/DateInput'; -import Checkbox from '../utils/Checkbox'; -import { Versions } from '../utils/helpers/version'; -import { supportsListingDomains, supportsSettingShortCodeLength } from '../utils/helpers/features'; -import { handleEventPreventingDefault, hasValue } from '../utils/utils'; +import { pipe, replace, trim } from 'ramda'; +import { FC, useMemo } from 'react'; import { SelectedServer } from '../servers/data'; -import { formatIsoDate } from '../utils/helpers/date'; -import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; -import { DomainSelectorProps } from '../domains/DomainSelector'; -import { SimpleCard } from '../utils/SimpleCard'; import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings'; import { ShortUrlData } from './data'; import { ShortUrlCreation } from './reducers/shortUrlCreation'; -import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult'; -import './CreateShortUrl.scss'; +import { ShortUrlFormProps } from './ShortUrlForm'; export interface CreateShortUrlProps { basicMode?: boolean; @@ -38,6 +25,7 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ( longUrl: '', tags: [], customSlug: '', + title: undefined, shortCodeLength: undefined, domain: '', validSince: undefined, @@ -47,15 +35,7 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ( validateUrl: settings?.validateUrls ?? false, }); -type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits'; -type DateFields = 'validSince' | 'validUntil'; - -const CreateShortUrl = ( - TagsSelector: FC, - CreateShortUrlResult: FC, - ForServerVersion: FC, - DomainSelector: FC, -) => ({ +const CreateShortUrl = (ShortUrlForm: FC, CreateShortUrlResult: FC) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, @@ -64,154 +44,21 @@ const CreateShortUrl = ( settings: { shortUrlCreation: shortUrlCreationSettings }, }: CreateShortUrlConnectProps) => { const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [ shortUrlCreationSettings ]); - const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState); - const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) }); - const reset = () => setShortUrlCreation(initialState); - const save = handleEventPreventingDefault(() => { - const shortUrlData = { - ...shortUrlCreation, - validSince: formatIsoDate(shortUrlCreation.validSince) ?? undefined, - validUntil: formatIsoDate(shortUrlCreation.validUntil) ?? undefined, - }; - - createShortUrl(shortUrlData).then(reset).catch(() => {}); - }); - const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => ( - - setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })} - {...props} - /> - - ); - const renderDateInput = (id: DateFields, placeholder: string, props: Partial = {}) => ( -
- setShortUrlCreation({ ...shortUrlCreation, [id]: date })} - {...props} - /> -
- ); - const basicComponents = ( - <> - - setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })} - /> - - - - - - - ); - - const showDomainSelector = supportsListingDomains(selectedServer); - const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer); return ( -
- {basicMode && basicComponents} - {!basicMode && ( - <> - - {basicComponents} - - -
-
- - {renderOptionalInput('customSlug', 'Custom slug', 'text', { - disabled: hasValue(shortUrlCreation.shortCodeLength), - })} - {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', - }, - })} - {!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')} - {showDomainSelector && ( - - setShortUrlCreation({ ...shortUrlCreation, domain })} - /> - - )} - -
- -
- - {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} - {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })} - {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })} - -
-
- - -

- Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all - provided data. -

- -

- setShortUrlCreation({ ...shortUrlCreation, validateUrl })} - > - Validate URL - -

-
-

- setShortUrlCreation({ ...shortUrlCreation, findIfExists })} - > - Use existing URL if found - - -

-
- - )} - -
- -
- + - +
); }; diff --git a/src/short-urls/ShortUrlForm.scss b/src/short-urls/ShortUrlForm.scss new file mode 100644 index 00000000..37f9376d --- /dev/null +++ b/src/short-urls/ShortUrlForm.scss @@ -0,0 +1,6 @@ +@import '../utils/base'; + +.short-url-form .form-group:last-child, +.short-url-form p:last-child { + margin-bottom: 0; +} diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx new file mode 100644 index 00000000..762df157 --- /dev/null +++ b/src/short-urls/ShortUrlForm.tsx @@ -0,0 +1,180 @@ +import { FC, useState } from 'react'; +import { InputType } from 'reactstrap/lib/Input'; +import { Button, FormGroup, Input } from 'reactstrap'; +import { isEmpty } from 'ramda'; +import * as m from 'moment'; +import DateInput, { DateInputProps } from '../utils/DateInput'; +import { supportsListingDomains, supportsSettingShortCodeLength } from '../utils/helpers/features'; +import { SimpleCard } from '../utils/SimpleCard'; +import { handleEventPreventingDefault, hasValue } from '../utils/utils'; +import Checkbox from '../utils/Checkbox'; +import { SelectedServer } from '../servers/data'; +import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; +import { Versions } from '../utils/helpers/version'; +import { DomainSelectorProps } from '../domains/DomainSelector'; +import { formatIsoDate } from '../utils/helpers/date'; +import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; +import { normalizeTag } from './CreateShortUrl'; +import { ShortUrlData } from './data'; +import './ShortUrlForm.scss'; + +type Mode = 'create' | 'create-basic' | 'edit'; +type DateFields = 'validSince' | 'validUntil'; +type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title'; + +export interface ShortUrlFormProps { + mode: Mode; + saving: boolean; + initialState: ShortUrlData; + onSave: (shortUrlData: ShortUrlData) => Promise; + selectedServer: SelectedServer; +} + +export const ShortUrlForm = ( + TagsSelector: FC, + ForServerVersion: FC, + DomainSelector: FC, +): FC => ({ mode, saving, onSave, initialState, selectedServer, children }) => { + const [ shortUrlData, setShortUrlData ] = useState(initialState); + const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); + const reset = () => setShortUrlData(initialState); + const submit = handleEventPreventingDefault(async () => onSave({ + ...shortUrlData, + validSince: formatIsoDate(shortUrlData.validSince) ?? undefined, + validUntil: formatIsoDate(shortUrlData.validUntil) ?? undefined, + }).then(reset).catch(() => {})); + + const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => ( + + setShortUrlData({ ...shortUrlData, [id]: e.target.value })} + {...props} + /> + + ); + const renderDateInput = (id: DateFields, placeholder: string, props: Partial = {}) => ( +
+ setShortUrlData({ ...shortUrlData, [id]: date })} + {...props} + /> +
+ ); + const basicComponents = ( + <> + + setShortUrlData({ ...shortUrlData, longUrl: e.target.value })} + /> + + + + + + + ); + + const showDomainSelector = supportsListingDomains(selectedServer); + const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer); + + return ( +
+ {mode === 'create-basic' && basicComponents} + {mode !== 'create-basic' && ( + <> + + {basicComponents} + + +
+
+ + {renderOptionalInput('customSlug', 'Custom slug', 'text', { + disabled: hasValue(shortUrlData.shortCodeLength), + })} + {renderOptionalInput('shortCodeLength', 'Short code length', 'number', { + min: 4, + disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug), + ...disableShortCodeLength && { + title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length', + }, + })} + {!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')} + {showDomainSelector && ( + + setShortUrlData({ ...shortUrlData, domain })} + /> + + )} + +
+ +
+ + {renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} + {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil as m.Moment | undefined })} + {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince as m.Moment | undefined })} + +
+
+ + +

+ Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all + provided data. +

+ +

+ setShortUrlData({ ...shortUrlData, validateUrl })} + > + Validate URL + +

+
+

+ setShortUrlData({ ...shortUrlData, findIfExists })} + > + Use existing URL if found + + +

+
+ + )} + +
+ +
+ + {children} +
+ ); +}; diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index 44c280bf..93260f9d 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -5,6 +5,7 @@ export interface ShortUrlData { longUrl: string; tags?: string[]; customSlug?: string; + title?: string; shortCodeLength?: number; domain?: string; validSince?: m.Moment | string; diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index e40fd84f..ba4c8615 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -20,6 +20,7 @@ import { editShortUrl } from '../reducers/shortUrlEdition'; import { ConnectDecorator } from '../../container/types'; import { ShortUrlsTable } from '../ShortUrlsTable'; import QrCodeModal from '../helpers/QrCodeModal'; +import { ShortUrlForm } from '../ShortUrlForm'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components @@ -45,15 +46,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { 'ForServerVersion', ); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout'); + bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'ForServerVersion', 'DomainSelector'); - bottle.serviceFactory( - 'CreateShortUrl', - CreateShortUrl, - 'TagsSelector', - 'CreateShortUrlResult', - 'ForServerVersion', - 'DomainSelector', - ); + bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult'); bottle.decorator( 'CreateShortUrl', connect([ 'shortUrlCreationResult', 'selectedServer', 'settings' ], [ 'createShortUrl', 'resetCreateShortUrl' ]), diff --git a/test/short-urls/CreateShortUrl.test.tsx b/test/short-urls/CreateShortUrl.test.tsx index ca36661f..59cb732e 100644 --- a/test/short-urls/CreateShortUrl.test.tsx +++ b/test/short-urls/CreateShortUrl.test.tsx @@ -1,22 +1,19 @@ import { shallow, ShallowWrapper } from 'enzyme'; -import moment from 'moment'; -import { identity } from 'ramda'; import { Mock } from 'ts-mockery'; -import { Input } from 'reactstrap'; import createShortUrlsCreator from '../../src/short-urls/CreateShortUrl'; -import DateInput from '../../src/utils/DateInput'; import { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation'; import { Settings } from '../../src/settings/reducers/settings'; describe('', () => { let wrapper: ShallowWrapper; - const TagsSelector = () => null; + const ShortUrlForm = () => null; + const CreateShortUrlResult = () => null; const shortUrlCreation = { validateUrls: true }; const shortUrlCreationResult = Mock.all(); const createShortUrl = jest.fn(async () => Promise.resolve()); beforeEach(() => { - const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => null, () => null, () => null); + const CreateShortUrl = createShortUrlsCreator(ShortUrlForm, CreateShortUrlResult); wrapper = shallow( ', () => { afterEach(() => wrapper.unmount()); afterEach(jest.clearAllMocks); - it('saves short URL with data set in form controls', () => { - const validSince = moment('2017-01-01'); - const validUntil = moment('2017-01-06'); + it('renders a ShortUrlForm with a computed initial state', () => { + const form = wrapper.find(ShortUrlForm); + const result = wrapper.find(CreateShortUrlResult); - wrapper.find(Input).first().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 }); - - 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, - validateUrl: true, - }); + expect(form).toHaveLength(1); + expect(result).toHaveLength(1); }); }); diff --git a/test/short-urls/ShortUrlForm.test.tsx b/test/short-urls/ShortUrlForm.test.tsx new file mode 100644 index 00000000..3277d5d0 --- /dev/null +++ b/test/short-urls/ShortUrlForm.test.tsx @@ -0,0 +1,59 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import moment from 'moment'; +import { identity } from 'ramda'; +import { Mock } from 'ts-mockery'; +import { Input } from 'reactstrap'; +import { ShortUrlForm as createShortUrlForm } from '../../src/short-urls/ShortUrlForm'; +import DateInput from '../../src/utils/DateInput'; +import { ShortUrlData } from '../../src/short-urls/data'; + +describe('', () => { + let wrapper: ShallowWrapper; + const TagsSelector = () => null; + const createShortUrl = jest.fn(); + + beforeEach(() => { + const ShortUrlForm = createShortUrlForm(TagsSelector, () => null, () => null); + + wrapper = shallow( + ({ validateUrl: true, findIfExists: false })} + onSave={createShortUrl} + />, + ); + }); + afterEach(() => wrapper.unmount()); + afterEach(jest.clearAllMocks); + + it('saves short URL with data set in form controls', () => { + const validSince = moment('2017-01-01'); + const validUntil = moment('2017-01-06'); + + wrapper.find(Input).first().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 }); + + 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, + validateUrl: true, + }); + }); +}); From 631b46393bb1d33ae2ed101221b5ce7f7105da94 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 20 Mar 2021 11:18:00 +0100 Subject: [PATCH 02/13] Added title to short URL form --- src/short-urls/ShortUrlForm.scss | 6 +- src/short-urls/ShortUrlForm.tsx | 97 +++++++++++++++++++------------- 2 files changed, 64 insertions(+), 39 deletions(-) diff --git a/src/short-urls/ShortUrlForm.scss b/src/short-urls/ShortUrlForm.scss index 37f9376d..afb93736 100644 --- a/src/short-urls/ShortUrlForm.scss +++ b/src/short-urls/ShortUrlForm.scss @@ -1,6 +1,10 @@ @import '../utils/base'; -.short-url-form .form-group:last-child, +.short-url-form .card-body > .form-group:last-child, .short-url-form p:last-child { margin-bottom: 0; } + +.short-url-form .card { + height: 100%; +} diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index 762df157..abdb969e 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -1,10 +1,14 @@ import { FC, useState } from 'react'; import { InputType } from 'reactstrap/lib/Input'; -import { Button, FormGroup, Input } from 'reactstrap'; +import { Button, FormGroup, Input, Row } from 'reactstrap'; import { isEmpty } from 'ramda'; import * as m from 'moment'; import DateInput, { DateInputProps } from '../utils/DateInput'; -import { supportsListingDomains, supportsSettingShortCodeLength } from '../utils/helpers/features'; +import { + supportsListingDomains, + supportsSettingShortCodeLength, + supportsShortUrlTitle, +} from '../utils/helpers/features'; import { SimpleCard } from '../utils/SimpleCard'; import { handleEventPreventingDefault, hasValue } from '../utils/utils'; import Checkbox from '../utils/Checkbox'; @@ -34,7 +38,7 @@ export const ShortUrlForm = ( TagsSelector: FC, ForServerVersion: FC, DomainSelector: FC, -): FC => ({ mode, saving, onSave, initialState, selectedServer, children }) => { +): FC => ({ mode, saving, onSave, initialState, selectedServer, children }) => { // eslint-disable-line complexity const [ shortUrlData, setShortUrlData ] = useState(initialState); const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); const reset = () => setShortUrlData(initialState); @@ -88,6 +92,8 @@ export const ShortUrlForm = ( const showDomainSelector = supportsListingDomains(selectedServer); const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer); + const supportsTitle = supportsShortUrlTitle(selectedServer); + const isEdit = mode === 'edit'; return (
@@ -98,27 +104,38 @@ export const ShortUrlForm = ( {basicComponents} -
+
- {renderOptionalInput('customSlug', 'Custom slug', 'text', { - disabled: hasValue(shortUrlData.shortCodeLength), - })} - {renderOptionalInput('shortCodeLength', 'Short code length', 'number', { - min: 4, - disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug), - ...disableShortCodeLength && { - title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length', - }, - })} - {!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')} - {showDomainSelector && ( - - setShortUrlData({ ...shortUrlData, domain })} - /> - + {supportsTitle && renderOptionalInput('title', 'Title')} + {!isEdit && ( + <> + +
+ {renderOptionalInput('customSlug', 'Custom slug', 'text', { + disabled: hasValue(shortUrlData.shortCodeLength), + })} +
+
+ {renderOptionalInput('shortCodeLength', 'Short code length', 'number', { + min: 4, + disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug), + ...disableShortCodeLength && { + title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length', + }, + })} +
+
+ {!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')} + {showDomainSelector && ( + + setShortUrlData({ ...shortUrlData, domain })} + /> + + )} + )}
@@ -130,13 +147,15 @@ export const ShortUrlForm = ( {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince as m.Moment | undefined })}
- + -

- Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all - provided data. -

+ {!isEdit && ( +

+ Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all + provided data. +

+ )}

-

- setShortUrlData({ ...shortUrlData, findIfExists })} - > - Use existing URL if found - - -

+ {!isEdit && ( +

+ setShortUrlData({ ...shortUrlData, findIfExists })} + > + Use existing URL if found + + +

+ )}
)} From a019bd30dfb527220993ffcfdbd9372205207839 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 20 Mar 2021 16:32:12 +0100 Subject: [PATCH 03/13] Created view to edit short URLs --- src/common/MenuLayout.tsx | 2 + src/common/services/provideServices.ts | 1 + src/short-urls/CreateShortUrl.tsx | 3 - src/short-urls/EditShortUrl.tsx | 76 +++++++++++++++++++ src/short-urls/ShortUrlForm.tsx | 19 +++-- src/short-urls/helpers/ShortUrlDetailLink.tsx | 30 ++++++++ .../helpers/ShortUrlVisitsCount.tsx | 12 ++- src/short-urls/helpers/ShortUrlsRowMenu.tsx | 8 +- src/short-urls/helpers/VisitStatsLink.tsx | 27 ------- src/short-urls/reducers/shortUrlDetail.ts | 13 +++- src/short-urls/services/provideServices.ts | 10 +++ src/visits/services/provideServices.ts | 2 - test/common/MenuLayout.test.tsx | 12 +-- ...k.test.tsx => ShortUrlDetailLink.test.tsx} | 32 ++++++-- .../helpers/ShortUrlsRowMenu.test.tsx | 2 +- .../reducers/shortUrlDetail.test.ts | 2 +- 16 files changed, 190 insertions(+), 61 deletions(-) create mode 100644 src/short-urls/EditShortUrl.tsx create mode 100644 src/short-urls/helpers/ShortUrlDetailLink.tsx delete mode 100644 src/short-urls/helpers/VisitStatsLink.tsx rename test/short-urls/helpers/{VisitStatsLink.test.tsx => ShortUrlDetailLink.test.tsx} (59%) diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index 3743424a..669b2197 100644 --- a/src/common/MenuLayout.tsx +++ b/src/common/MenuLayout.tsx @@ -21,6 +21,7 @@ const MenuLayout = ( OrphanVisits: FC, ServerError: FC, Overview: FC, + EditShortUrl: FC, ) => withSelectedServer(({ location, selectedServer }) => { const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle(); @@ -50,6 +51,7 @@ const MenuLayout = ( + {addTagsVisitsRoute && } {addOrphanVisitsRoute && } diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index c18689b8..eccd43f2 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -37,6 +37,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: 'OrphanVisits', 'ServerError', 'Overview', + 'EditShortUrl', ); bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ])); bottle.decorator('MenuLayout', withRouter); diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx index 08aada4d..13a44654 100644 --- a/src/short-urls/CreateShortUrl.tsx +++ b/src/short-urls/CreateShortUrl.tsx @@ -1,4 +1,3 @@ -import { pipe, replace, trim } from 'ramda'; import { FC, useMemo } from 'react'; import { SelectedServer } from '../servers/data'; import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings'; @@ -19,8 +18,6 @@ interface CreateShortUrlConnectProps extends CreateShortUrlProps { resetCreateShortUrl: () => void; } -export const normalizeTag = pipe(trim, replace(/ /g, '-')); - const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({ longUrl: '', tags: [], diff --git a/src/short-urls/EditShortUrl.tsx b/src/short-urls/EditShortUrl.tsx new file mode 100644 index 00000000..f520ddce --- /dev/null +++ b/src/short-urls/EditShortUrl.tsx @@ -0,0 +1,76 @@ +import { FC, useEffect } from 'react'; +import { RouteComponentProps } from 'react-router'; +import { SelectedServer } from '../servers/data'; +import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings'; +import { OptionalString } from '../utils/utils'; +import { parseQuery } from '../utils/helpers/query'; +import Message from '../utils/Message'; +import { Result } from '../utils/Result'; +import { ShlinkApiError } from '../api/ShlinkApiError'; +import { ShortUrlFormProps } from './ShortUrlForm'; +import { ShortUrlDetail } from './reducers/shortUrlDetail'; +import { ShortUrl, ShortUrlData } from './data'; + +interface EditShortUrlConnectProps extends RouteComponentProps<{ shortCode: string }> { + settings: Settings; + selectedServer: SelectedServer; + shortUrlDetail: ShortUrlDetail; + getShortUrlDetail: (shortCode: string, domain: OptionalString) => void; +} + +const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => { + const validateUrl = settings?.validateUrls ?? false; + + if (!shortUrl) { + return { longUrl: '', validateUrl }; + } + + return { + longUrl: shortUrl.longUrl, + tags: shortUrl.tags, + title: shortUrl.title ?? undefined, + domain: shortUrl.domain ?? undefined, + validSince: shortUrl.meta.validSince ?? undefined, + validUntil: shortUrl.meta.validUntil ?? undefined, + maxVisits: shortUrl.meta.maxVisits ?? undefined, + validateUrl, + }; +}; + +export const EditShortUrl = (ShortUrlForm: FC) => ({ + match: { params }, + location: { search }, + settings: { shortUrlCreation: shortUrlCreationSettings }, + selectedServer, + shortUrlDetail, + getShortUrlDetail, +}: EditShortUrlConnectProps) => { + const { loading, error, errorData, shortUrl } = shortUrlDetail; + const { domain } = parseQuery<{ domain?: string }>(search); + + useEffect(() => { + getShortUrlDetail(params.shortCode, domain); + }, []); + + if (loading) { + return ; + } + + if (error) { + return ( + + + + ); + } + + return ( + Promise.resolve(console.log(shortUrlData))} + /> + ); +}; diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index abdb969e..8030a945 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -1,7 +1,7 @@ -import { FC, useState } from 'react'; +import { FC, useEffect, useState } from 'react'; import { InputType } from 'reactstrap/lib/Input'; import { Button, FormGroup, Input, Row } from 'reactstrap'; -import { isEmpty } from 'ramda'; +import { isEmpty, pipe, replace, trim } from 'ramda'; import * as m from 'moment'; import DateInput, { DateInputProps } from '../utils/DateInput'; import { @@ -18,7 +18,6 @@ import { Versions } from '../utils/helpers/version'; import { DomainSelectorProps } from '../domains/DomainSelector'; import { formatIsoDate } from '../utils/helpers/date'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; -import { normalizeTag } from './CreateShortUrl'; import { ShortUrlData } from './data'; import './ShortUrlForm.scss'; @@ -34,19 +33,26 @@ export interface ShortUrlFormProps { selectedServer: SelectedServer; } +const normalizeTag = pipe(trim, replace(/ /g, '-')); + export const ShortUrlForm = ( TagsSelector: FC, ForServerVersion: FC, DomainSelector: FC, ): FC => ({ mode, saving, onSave, initialState, selectedServer, children }) => { // eslint-disable-line complexity const [ shortUrlData, setShortUrlData ] = useState(initialState); + const isEdit = mode === 'edit'; const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); const reset = () => setShortUrlData(initialState); const submit = handleEventPreventingDefault(async () => onSave({ ...shortUrlData, validSince: formatIsoDate(shortUrlData.validSince) ?? undefined, validUntil: formatIsoDate(shortUrlData.validUntil) ?? undefined, - }).then(reset).catch(() => {})); + }).then(() => !isEdit && reset()).catch(() => {})); + + useEffect(() => { + setShortUrlData(initialState); + }, [ initialState ]); const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => ( @@ -54,7 +60,7 @@ export const ShortUrlForm = ( id={id} type={type} placeholder={placeholder} - value={shortUrlData[id]} + value={shortUrlData[id] ?? ''} onChange={(e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value })} {...props} /> @@ -93,7 +99,6 @@ export const ShortUrlForm = ( const showDomainSelector = supportsListingDomains(selectedServer); const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer); const supportsTitle = supportsShortUrlTitle(selectedServer); - const isEdit = mode === 'edit'; return ( @@ -191,7 +196,7 @@ export const ShortUrlForm = ( disabled={saving || isEmpty(shortUrlData.longUrl)} className="btn-xs-block" > - {saving ? 'Creating...' : 'Create'} + {saving ? 'Saving...' : 'Save'} diff --git a/src/short-urls/helpers/ShortUrlDetailLink.tsx b/src/short-urls/helpers/ShortUrlDetailLink.tsx new file mode 100644 index 00000000..2ba82055 --- /dev/null +++ b/src/short-urls/helpers/ShortUrlDetailLink.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react'; +import { Link } from 'react-router-dom'; +import { isServerWithId, SelectedServer, ServerWithId } from '../../servers/data'; +import { ShortUrl } from '../data'; + +export type LinkSuffix = 'visits' | 'edit'; + +export interface ShortUrlDetailLinkProps { + shortUrl?: ShortUrl | null; + selectedServer?: SelectedServer; + suffix: LinkSuffix; +} + +const buildUrl = ({ id }: ServerWithId, { shortCode, domain }: ShortUrl, suffix: LinkSuffix) => { + const query = domain ? `?domain=${domain}` : ''; + + return `/server/${id}/short-code/${shortCode}/${suffix}${query}`; +}; + +const ShortUrlDetailLink: FC> = ( + { selectedServer, shortUrl, suffix, children, ...rest }, +) => { + if (!selectedServer || !isServerWithId(selectedServer) || !shortUrl) { + return {children}; + } + + return {children}; +}; + +export default ShortUrlDetailLink; diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.tsx b/src/short-urls/helpers/ShortUrlVisitsCount.tsx index 6d4e4afa..7cb2f9a5 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.tsx +++ b/src/short-urls/helpers/ShortUrlVisitsCount.tsx @@ -4,10 +4,14 @@ import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { UncontrolledTooltip } from 'reactstrap'; import classNames from 'classnames'; import { prettify } from '../../utils/helpers/numbers'; -import VisitStatsLink, { VisitStatsLinkProps } from './VisitStatsLink'; +import { ShortUrl } from '../data'; +import { SelectedServer } from '../../servers/data'; +import ShortUrlDetailLink from './ShortUrlDetailLink'; import './ShortUrlVisitsCount.scss'; -interface ShortUrlVisitsCountProps extends VisitStatsLinkProps { +interface ShortUrlVisitsCountProps { + shortUrl?: ShortUrl | null; + selectedServer?: SelectedServer; visitsCount: number; active?: boolean; } @@ -15,13 +19,13 @@ interface ShortUrlVisitsCountProps extends VisitStatsLinkProps { const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps) => { const maxVisits = shortUrl?.meta?.maxVisits; const visitsLink = ( - + {prettify(visitsCount)} - + ); if (!maxVisits) { diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.tsx b/src/short-urls/helpers/ShortUrlsRowMenu.tsx index e867473d..684d56bb 100644 --- a/src/short-urls/helpers/ShortUrlsRowMenu.tsx +++ b/src/short-urls/helpers/ShortUrlsRowMenu.tsx @@ -14,7 +14,7 @@ import { useToggle } from '../../utils/helpers/hooks'; import { ShortUrl, ShortUrlModalProps } from '../data'; import { Versions } from '../../utils/helpers/version'; import { SelectedServer } from '../../servers/data'; -import VisitStatsLink from './VisitStatsLink'; +import ShortUrlDetailLink from './ShortUrlDetailLink'; import './ShortUrlsRowMenu.scss'; export interface ShortUrlsRowMenuProps { @@ -44,10 +44,14 @@ const ShortUrlsRowMenu = (    - + Visit stats + + Edit short URL + + Edit tags diff --git a/src/short-urls/helpers/VisitStatsLink.tsx b/src/short-urls/helpers/VisitStatsLink.tsx deleted file mode 100644 index f80d9964..00000000 --- a/src/short-urls/helpers/VisitStatsLink.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { FC } from 'react'; -import { Link } from 'react-router-dom'; -import { isServerWithId, SelectedServer, ServerWithId } from '../../servers/data'; -import { ShortUrl } from '../data'; - -export interface VisitStatsLinkProps { - shortUrl?: ShortUrl | null; - selectedServer?: SelectedServer; -} - -const buildVisitsUrl = ({ id }: ServerWithId, { shortCode, domain }: ShortUrl) => { - const query = domain ? `?domain=${domain}` : ''; - - return `/server/${id}/short-code/${shortCode}/visits${query}`; -}; - -const VisitStatsLink: FC> = ( - { selectedServer, shortUrl, children, ...rest }, -) => { - if (!selectedServer || !isServerWithId(selectedServer) || !shortUrl) { - return {children}; - } - - return {children}; -}; - -export default VisitStatsLink; diff --git a/src/short-urls/reducers/shortUrlDetail.ts b/src/short-urls/reducers/shortUrlDetail.ts index 42771a92..1b174f1d 100644 --- a/src/short-urls/reducers/shortUrlDetail.ts +++ b/src/short-urls/reducers/shortUrlDetail.ts @@ -5,6 +5,8 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde import { OptionalString } from '../../utils/utils'; import { GetState } from '../../container/types'; import { shortUrlMatches } from '../helpers'; +import { ProblemDetailsError } from '../../api/types'; +import { parseApiError } from '../../api/utils'; /* eslint-disable padding-line-between-statements */ export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; @@ -16,20 +18,25 @@ export interface ShortUrlDetail { shortUrl?: ShortUrl; loading: boolean; error: boolean; + errorData?: ProblemDetailsError; } export interface ShortUrlDetailAction extends Action { shortUrl: ShortUrl; } +export interface ShortUrlDetailFailedAction extends Action { + errorData?: ProblemDetailsError; +} + const initialState: ShortUrlDetail = { loading: false, error: false, }; -export default buildReducer({ +export default buildReducer({ [GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }), - [GET_SHORT_URL_DETAIL_ERROR]: () => ({ loading: false, error: true }), + [GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }), [GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }), }, initialState); @@ -47,6 +54,6 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder) dispatch({ shortUrl, type: GET_SHORT_URL_DETAIL }); } catch (e) { - dispatch({ type: GET_SHORT_URL_DETAIL_ERROR }); + dispatch({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) }); } }; diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index ba4c8615..39820d0c 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -21,6 +21,8 @@ import { ConnectDecorator } from '../../container/types'; import { ShortUrlsTable } from '../ShortUrlsTable'; import QrCodeModal from '../helpers/QrCodeModal'; import { ShortUrlForm } from '../ShortUrlForm'; +import { EditShortUrl } from '../EditShortUrl'; +import { getShortUrlDetail } from '../reducers/shortUrlDetail'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components @@ -54,6 +56,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { connect([ 'shortUrlCreationResult', 'selectedServer', 'settings' ], [ 'createShortUrl', 'resetCreateShortUrl' ]), ); + bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm'); + bottle.decorator( + 'EditShortUrl', + connect([ 'shortUrlDetail', 'selectedServer', 'settings' ], [ 'getShortUrlDetail' ]), + ); + bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal); bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ])); @@ -89,6 +97,8 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient'); bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta); + bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient'); + bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient'); }; diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 2bf91ab9..733cac95 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -1,7 +1,6 @@ import Bottle from 'bottlejs'; import ShortUrlVisits from '../ShortUrlVisits'; import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; -import { getShortUrlDetail } from '../../short-urls/reducers/shortUrlDetail'; import MapModal from '../helpers/MapModal'; import { createNewVisits } from '../reducers/visitCreation'; import TagVisits from '../TagVisits'; @@ -41,7 +40,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Actions bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits); bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); diff --git a/test/common/MenuLayout.test.tsx b/test/common/MenuLayout.test.tsx index 1e7219b5..1177b0e1 100644 --- a/test/common/MenuLayout.test.tsx +++ b/test/common/MenuLayout.test.tsx @@ -11,7 +11,7 @@ import { SemVer } from '../../src/utils/helpers/version'; describe('', () => { const ServerError = jest.fn(); const C = jest.fn(); - const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C); + const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C, C); let wrapper: ShallowWrapper; const createWrapper = (selectedServer: SelectedServer) => { wrapper = shallow( @@ -49,11 +49,11 @@ describe('', () => { }); it.each([ - [ '2.1.0' as SemVer, 6 ], - [ '2.2.0' as SemVer, 7 ], - [ '2.5.0' as SemVer, 7 ], - [ '2.6.0' as SemVer, 8 ], - [ '2.7.0' as SemVer, 8 ], + [ '2.1.0' as SemVer, 7 ], + [ '2.2.0' as SemVer, 8 ], + [ '2.5.0' as SemVer, 8 ], + [ '2.6.0' as SemVer, 9 ], + [ '2.7.0' as SemVer, 9 ], ])('has expected amount of routes based on selected server\'s version', (version, expectedAmountOfRoutes) => { const selectedServer = Mock.of({ version }); const wrapper = createWrapper(selectedServer).dive(); diff --git a/test/short-urls/helpers/VisitStatsLink.test.tsx b/test/short-urls/helpers/ShortUrlDetailLink.test.tsx similarity index 59% rename from test/short-urls/helpers/VisitStatsLink.test.tsx rename to test/short-urls/helpers/ShortUrlDetailLink.test.tsx index 457d3024..9de7902d 100644 --- a/test/short-urls/helpers/VisitStatsLink.test.tsx +++ b/test/short-urls/helpers/ShortUrlDetailLink.test.tsx @@ -1,11 +1,11 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { Link } from 'react-router-dom'; import { Mock } from 'ts-mockery'; -import VisitStatsLink from '../../../src/short-urls/helpers/VisitStatsLink'; +import ShortUrlDetailLink, { LinkSuffix } from '../../../src/short-urls/helpers/ShortUrlDetailLink'; import { NotFoundServer, ReachableServer } from '../../../src/servers/data'; import { ShortUrl } from '../../../src/short-urls/data'; -describe('', () => { +describe('', () => { let wrapper: ShallowWrapper; afterEach(() => wrapper?.unmount()); @@ -19,7 +19,11 @@ describe('', () => { [ null, Mock.all() ], [ undefined, Mock.all() ], ])('only renders a plain span when either server or short URL are not set', (selectedServer, shortUrl) => { - wrapper = shallow(Something); + wrapper = shallow( + + Something + , + ); const link = wrapper.find(Link); expect(link).toHaveLength(0); @@ -30,15 +34,33 @@ describe('', () => { [ Mock.of({ id: '1' }), Mock.of({ shortCode: 'abc123' }), + 'visits' as LinkSuffix, '/server/1/short-code/abc123/visits', ], [ Mock.of({ id: '3' }), Mock.of({ shortCode: 'def456', domain: 'example.com' }), + 'visits' as LinkSuffix, '/server/3/short-code/def456/visits?domain=example.com', ], - ])('renders link with expected query when', (selectedServer, shortUrl, expectedLink) => { - wrapper = shallow(Something); + [ + Mock.of({ id: '1' }), + Mock.of({ shortCode: 'abc123' }), + 'edit' as LinkSuffix, + '/server/1/short-code/abc123/edit', + ], + [ + Mock.of({ id: '3' }), + Mock.of({ shortCode: 'def456', domain: 'example.com' }), + 'edit' as LinkSuffix, + '/server/3/short-code/def456/edit?domain=example.com', + ], + ])('renders link with expected query when', (selectedServer, shortUrl, suffix, expectedLink) => { + wrapper = shallow( + + Something + , + ); const link = wrapper.find(Link); const to = link.prop('to'); diff --git a/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx b/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx index 1c1aac93..c25c7382 100644 --- a/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx +++ b/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx @@ -51,7 +51,7 @@ describe('', () => { const wrapper = createWrapper(); const items = wrapper.find(DropdownItem); - expect(items).toHaveLength(7); + expect(items).toHaveLength(8); expect(items.find('[divider]')).toHaveLength(1); }); diff --git a/test/short-urls/reducers/shortUrlDetail.test.ts b/test/short-urls/reducers/shortUrlDetail.test.ts index a83d1a4d..748d71f0 100644 --- a/test/short-urls/reducers/shortUrlDetail.test.ts +++ b/test/short-urls/reducers/shortUrlDetail.test.ts @@ -51,7 +51,7 @@ describe('shortUrlDetailReducer', () => { const buildGetState = (shortUrlsList?: ShortUrlsList) => () => Mock.of({ shortUrlsList }); it('dispatches start and error when promise is rejected', async () => { - const ShlinkApiClient = buildApiClientMock(Promise.reject()); + const ShlinkApiClient = buildApiClientMock(Promise.reject({})); await getShortUrlDetail(() => ShlinkApiClient)('abc123', '')(dispatchMock, buildGetState()); From eea76d88c36abf90dec8fd2627ee4bbc430bb999 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Mar 2021 09:49:47 +0100 Subject: [PATCH 04/13] Ensured all data can be set when editing a short URL --- src/api/services/ShlinkApiClient.ts | 10 +++++----- src/api/types/index.ts | 4 +++- src/short-urls/EditShortUrl.tsx | 6 ++++-- src/short-urls/ShortUrlForm.tsx | 13 +++++++------ src/short-urls/data/index.ts | 18 +++++++++++------- src/short-urls/helpers/EditShortUrlModal.tsx | 6 +++--- src/short-urls/reducers/shortUrlEdition.ts | 11 +++++++---- src/short-urls/reducers/shortUrlMeta.ts | 4 ++-- src/short-urls/reducers/shortUrlTags.ts | 4 ++-- src/short-urls/services/provideServices.ts | 2 +- test/api/services/ShlinkApiClient.test.ts | 11 ++++------- test/short-urls/ShortUrlForm.test.tsx | 2 +- .../reducers/shortUrlEdition.test.ts | 18 +++++++++--------- test/short-urls/reducers/shortUrlMeta.test.ts | 14 +++++++------- test/short-urls/reducers/shortUrlTags.test.ts | 14 +++++++------- 15 files changed, 73 insertions(+), 64 deletions(-) diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 1c5c34d9..79ae02bb 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -12,7 +12,7 @@ import { ShlinkTagsResponse, ShlinkVisits, ShlinkVisitsParams, - ShlinkShortUrlMeta, + ShlinkShortUrlData, ShlinkDomain, ShlinkDomainsResponse, ShlinkVisitsOverview, @@ -67,7 +67,7 @@ export default class ShlinkApiClient { this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain }) .then(() => {}); - /* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrlMeta instead */ + /* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead */ public readonly updateShortUrlTags = async ( shortCode: string, domain: OptionalString, @@ -76,12 +76,12 @@ export default class ShlinkApiClient { this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags }) .then(({ data }) => data.tags); - public readonly updateShortUrlMeta = async ( + public readonly updateShortUrl = async ( shortCode: string, domain: OptionalString, - meta: ShlinkShortUrlMeta, + data: ShlinkShortUrlData, ): Promise => - this.performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta) + this.performRequest(`/short-urls/${shortCode}`, 'PATCH', { domain }, data) .then(({ data }) => data); public readonly listTags = async (): Promise => diff --git a/src/api/types/index.ts b/src/api/types/index.ts index b8383a74..b9da9fa6 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -57,8 +57,10 @@ export interface ShlinkVisitsParams { endDate?: string; } -export interface ShlinkShortUrlMeta extends ShortUrlMeta { +export interface ShlinkShortUrlData extends ShortUrlMeta { longUrl?: string; + title?: string; + validateUrl?: boolean; tags?: string[]; } diff --git a/src/short-urls/EditShortUrl.tsx b/src/short-urls/EditShortUrl.tsx index f520ddce..7f0f0c31 100644 --- a/src/short-urls/EditShortUrl.tsx +++ b/src/short-urls/EditShortUrl.tsx @@ -9,13 +9,14 @@ import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShortUrlFormProps } from './ShortUrlForm'; import { ShortUrlDetail } from './reducers/shortUrlDetail'; -import { ShortUrl, ShortUrlData } from './data'; +import { EditShortUrlData, ShortUrl, ShortUrlData } from './data'; interface EditShortUrlConnectProps extends RouteComponentProps<{ shortCode: string }> { settings: Settings; selectedServer: SelectedServer; shortUrlDetail: ShortUrlDetail; getShortUrlDetail: (shortCode: string, domain: OptionalString) => void; + editShortUrl: (shortUrl: string, domain: OptionalString, data: EditShortUrlData) => Promise; } const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => { @@ -44,6 +45,7 @@ export const EditShortUrl = (ShortUrlForm: FC) => ({ selectedServer, shortUrlDetail, getShortUrlDetail, + editShortUrl, }: EditShortUrlConnectProps) => { const { loading, error, errorData, shortUrl } = shortUrlDetail; const { domain } = parseQuery<{ domain?: string }>(search); @@ -70,7 +72,7 @@ export const EditShortUrl = (ShortUrlForm: FC) => ({ saving={false} selectedServer={selectedServer} mode="edit" - onSave={async (shortUrlData) => Promise.resolve(console.log(shortUrlData))} + onSave={async (shortUrlData) => shortUrl && editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)} /> ); }; diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index 8030a945..81da5175 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -2,7 +2,7 @@ import { FC, useEffect, useState } from 'react'; import { InputType } from 'reactstrap/lib/Input'; import { Button, FormGroup, Input, Row } from 'reactstrap'; import { isEmpty, pipe, replace, trim } from 'ramda'; -import * as m from 'moment'; +import m from 'moment'; import DateInput, { DateInputProps } from '../utils/DateInput'; import { supportsListingDomains, @@ -46,8 +46,9 @@ export const ShortUrlForm = ( const reset = () => setShortUrlData(initialState); const submit = handleEventPreventingDefault(async () => onSave({ ...shortUrlData, - validSince: formatIsoDate(shortUrlData.validSince) ?? undefined, - validUntil: formatIsoDate(shortUrlData.validUntil) ?? undefined, + validSince: formatIsoDate(shortUrlData.validSince) ?? null, + validUntil: formatIsoDate(shortUrlData.validUntil) ?? null, + maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits), }).then(() => !isEdit && reset()).catch(() => {})); useEffect(() => { @@ -69,7 +70,7 @@ export const ShortUrlForm = ( const renderDateInput = (id: DateFields, placeholder: string, props: Partial = {}) => (
setShortUrlData({ ...shortUrlData, [id]: date })} @@ -148,8 +149,8 @@ export const ShortUrlForm = (
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} - {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil as m.Moment | undefined })} - {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince as m.Moment | undefined })} + {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? m(shortUrlData.validUntil) : undefined })} + {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? m(shortUrlData.validSince) : undefined })}
diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index 93260f9d..c3e5dfea 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -1,18 +1,22 @@ import * as m from 'moment'; import { Nullable, OptionalString } from '../../utils/utils'; -export interface ShortUrlData { - longUrl: string; +export interface EditShortUrlData { + longUrl?: string; tags?: string[]; - customSlug?: string; title?: string; + validSince?: m.Moment | string | null; + validUntil?: m.Moment | string | null; + maxVisits?: number | null; + validateUrl?: boolean; +} + +export interface ShortUrlData extends EditShortUrlData { + longUrl: string; + customSlug?: string; shortCodeLength?: number; domain?: string; - validSince?: m.Moment | string; - validUntil?: m.Moment | string; - maxVisits?: number; findIfExists?: boolean; - validateUrl?: boolean; } export interface ShortUrl { diff --git a/src/short-urls/helpers/EditShortUrlModal.tsx b/src/short-urls/helpers/EditShortUrlModal.tsx index 0a09e27e..8c9c31d0 100644 --- a/src/short-urls/helpers/EditShortUrlModal.tsx +++ b/src/short-urls/helpers/EditShortUrlModal.tsx @@ -3,13 +3,13 @@ import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } import { ExternalLink } from 'react-external-link'; import { ShortUrlEdition } from '../reducers/shortUrlEdition'; import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils'; -import { ShortUrlModalProps } from '../data'; +import { EditShortUrlData, ShortUrlModalProps } from '../data'; import { Result } from '../../utils/Result'; import { ShlinkApiError } from '../../api/ShlinkApiError'; interface EditShortUrlModalProps extends ShortUrlModalProps { shortUrlEdition: ShortUrlEdition; - editShortUrl: (shortUrl: string, domain: OptionalString, longUrl: string) => Promise; + editShortUrl: (shortUrl: string, domain: OptionalString, data: EditShortUrlData) => Promise; } const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }: EditShortUrlModalProps) => { @@ -17,7 +17,7 @@ const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShor const url = shortUrl?.shortUrl ?? ''; const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl); - const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle); + const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, { longUrl }).then(toggle); return ( diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/src/short-urls/reducers/shortUrlEdition.ts index 246b2ae2..1663f403 100644 --- a/src/short-urls/reducers/shortUrlEdition.ts +++ b/src/short-urls/reducers/shortUrlEdition.ts @@ -2,7 +2,7 @@ import { Action, Dispatch } from 'redux'; import { buildReducer } from '../../utils/helpers/redux'; import { GetState } from '../../container/types'; import { OptionalString } from '../../utils/utils'; -import { ShortUrlIdentifier } from '../data'; +import { EditShortUrlData, ShortUrlIdentifier } from '../data'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; @@ -45,13 +45,16 @@ export default buildReducer ( shortCode: string, domain: OptionalString, - longUrl: string, + data: EditShortUrlData, ) => async (dispatch: Dispatch, getState: GetState) => { dispatch({ type: EDIT_SHORT_URL_START }); - const { updateShortUrlMeta } = buildShlinkApiClient(getState); + + // TODO Pass tags to the updateTags function if server version is lower than 2.6 + const { updateShortUrl } = buildShlinkApiClient(getState); try { - await updateShortUrlMeta(shortCode, domain, { longUrl }); + const { longUrl } = await updateShortUrl(shortCode, domain, data as any); // FIXME Parse dates + dispatch({ shortCode, longUrl, domain, type: SHORT_URL_EDITED }); } catch (e) { dispatch({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) }); diff --git a/src/short-urls/reducers/shortUrlMeta.ts b/src/short-urls/reducers/shortUrlMeta.ts index a878058f..7305570e 100644 --- a/src/short-urls/reducers/shortUrlMeta.ts +++ b/src/short-urls/reducers/shortUrlMeta.ts @@ -50,10 +50,10 @@ export const editShortUrlMeta = (buildShlinkApiClient: ShlinkApiClientBuilder) = meta: ShortUrlMeta, ) => async (dispatch: Dispatch, getState: GetState) => { dispatch({ type: EDIT_SHORT_URL_META_START }); - const { updateShortUrlMeta } = buildShlinkApiClient(getState); + const { updateShortUrl } = buildShlinkApiClient(getState); try { - await updateShortUrlMeta(shortCode, domain, meta); + await updateShortUrl(shortCode, domain, meta); dispatch({ shortCode, meta, domain, type: SHORT_URL_META_EDITED }); } catch (e) { dispatch({ type: EDIT_SHORT_URL_META_ERROR, errorData: parseApiError(e) }); diff --git a/src/short-urls/reducers/shortUrlTags.ts b/src/short-urls/reducers/shortUrlTags.ts index cf0bfd8c..2340b571 100644 --- a/src/short-urls/reducers/shortUrlTags.ts +++ b/src/short-urls/reducers/shortUrlTags.ts @@ -54,12 +54,12 @@ export const editShortUrlTags = (buildShlinkApiClient: ShlinkApiClientBuilder) = dispatch({ type: EDIT_SHORT_URL_TAGS_START }); const { selectedServer } = getState(); const tagsInPatch = supportsTagsInPatch(selectedServer); - const { updateShortUrlTags, updateShortUrlMeta } = buildShlinkApiClient(getState); + const { updateShortUrlTags, updateShortUrl } = buildShlinkApiClient(getState); try { const normalizedTags = await ( tagsInPatch - ? updateShortUrlMeta(shortCode, domain, { tags }).then(prop('tags')) + ? updateShortUrl(shortCode, domain, { tags }).then(prop('tags')) : updateShortUrlTags(shortCode, domain, tags) ); diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 39820d0c..b0328c2f 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -59,7 +59,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm'); bottle.decorator( 'EditShortUrl', - connect([ 'shortUrlDetail', 'selectedServer', 'settings' ], [ 'getShortUrlDetail' ]), + connect([ 'shortUrlDetail', 'selectedServer', 'settings' ], [ 'getShortUrlDetail', 'editShortUrl' ]), ); bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal); diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 07c606e8..a4a12d8f 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -48,10 +48,7 @@ describe('ShlinkApiClient', () => { const axiosSpy = createAxiosMock({ data: shortUrl }); const { createShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); - await createShortUrl( - // @ts-expect-error in case maxVisits is null, it needs to be ignored as if it was undefined - { longUrl: 'bar', customSlug: undefined, maxVisits: null }, - ); + await createShortUrl({ longUrl: 'bar', customSlug: undefined, maxVisits: null }); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ data: { longUrl: 'bar' } })); }); @@ -139,7 +136,7 @@ describe('ShlinkApiClient', () => { }); }); - describe('updateShortUrlMeta', () => { + describe('updateShortUrl', () => { it.each(shortCodesWithDomainCombinations)('properly updates short URL meta', async (shortCode, domain) => { const meta = { maxVisits: 50, @@ -147,9 +144,9 @@ describe('ShlinkApiClient', () => { }; const expectedResp = Mock.of(); const axiosSpy = createAxiosMock({ data: expectedResp }); - const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy, '', ''); + const { updateShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); - const result = await updateShortUrlMeta(shortCode, domain, meta); + const result = await updateShortUrl(shortCode, domain, meta); expect(expectedResp).toEqual(result); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/test/short-urls/ShortUrlForm.test.tsx b/test/short-urls/ShortUrlForm.test.tsx index 3277d5d0..c5f13ece 100644 --- a/test/short-urls/ShortUrlForm.test.tsx +++ b/test/short-urls/ShortUrlForm.test.tsx @@ -50,7 +50,7 @@ describe('', () => { domain: 'example.com', validSince: validSince.format(), validUntil: validUntil.format(), - maxVisits: '20', + maxVisits: 20, findIfExists: false, shortCodeLength: 15, validateUrl: true, diff --git a/test/short-urls/reducers/shortUrlEdition.test.ts b/test/short-urls/reducers/shortUrlEdition.test.ts index 18500bab..8e109cf5 100644 --- a/test/short-urls/reducers/shortUrlEdition.test.ts +++ b/test/short-urls/reducers/shortUrlEdition.test.ts @@ -42,19 +42,19 @@ describe('shortUrlEditionReducer', () => { }); describe('editShortUrl', () => { - const updateShortUrlMeta = jest.fn().mockResolvedValue({}); - const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlMeta }); + const updateShortUrl = jest.fn().mockResolvedValue({ longUrl }); + const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl }); const dispatch = jest.fn(); const getState = () => Mock.of(); afterEach(jest.clearAllMocks); it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches long URL on success', async (domain) => { - await editShortUrl(buildShlinkApiClient)(shortCode, domain, longUrl)(dispatch, getState); + await editShortUrl(buildShlinkApiClient)(shortCode, domain, { longUrl })(dispatch, getState); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); - expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, domain, { longUrl }); + expect(updateShortUrl).toHaveBeenCalledTimes(1); + expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, { longUrl }); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_EDITED, longUrl, shortCode, domain }); @@ -63,17 +63,17 @@ describe('shortUrlEditionReducer', () => { it('dispatches error on failure', async () => { const error = new Error(); - updateShortUrlMeta.mockRejectedValue(error); + updateShortUrl.mockRejectedValue(error); try { - await editShortUrl(buildShlinkApiClient)(shortCode, undefined, longUrl)(dispatch, getState); + await editShortUrl(buildShlinkApiClient)(shortCode, undefined, { longUrl })(dispatch, getState); } catch (e) { expect(e).toBe(error); } expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); - expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, { longUrl }); + expect(updateShortUrl).toHaveBeenCalledTimes(1); + expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, { longUrl }); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_ERROR }); diff --git a/test/short-urls/reducers/shortUrlMeta.test.ts b/test/short-urls/reducers/shortUrlMeta.test.ts index 9ab018eb..cb20b85c 100644 --- a/test/short-urls/reducers/shortUrlMeta.test.ts +++ b/test/short-urls/reducers/shortUrlMeta.test.ts @@ -56,8 +56,8 @@ describe('shortUrlMetaReducer', () => { }); describe('editShortUrlMeta', () => { - const updateShortUrlMeta = jest.fn().mockResolvedValue({}); - const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlMeta }); + const updateShortUrl = jest.fn().mockResolvedValue({}); + const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl }); const dispatch = jest.fn(); const getState = () => Mock.all(); @@ -67,8 +67,8 @@ describe('shortUrlMetaReducer', () => { await editShortUrlMeta(buildShlinkApiClient)(shortCode, domain, meta)(dispatch, getState); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); - expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, domain, meta); + expect(updateShortUrl).toHaveBeenCalledTimes(1); + expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, meta); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_META_EDITED, meta, shortCode, domain }); @@ -77,7 +77,7 @@ describe('shortUrlMetaReducer', () => { it('dispatches error on failure', async () => { const error = new Error(); - updateShortUrlMeta.mockRejectedValue(error); + updateShortUrl.mockRejectedValue(error); try { await editShortUrlMeta(buildShlinkApiClient)(shortCode, undefined, meta)(dispatch, getState); @@ -86,8 +86,8 @@ describe('shortUrlMetaReducer', () => { } expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); - expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, meta); + expect(updateShortUrl).toHaveBeenCalledTimes(1); + expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, meta); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_META_ERROR }); diff --git a/test/short-urls/reducers/shortUrlTags.test.ts b/test/short-urls/reducers/shortUrlTags.test.ts index 004093bb..82381da0 100644 --- a/test/short-urls/reducers/shortUrlTags.test.ts +++ b/test/short-urls/reducers/shortUrlTags.test.ts @@ -61,8 +61,8 @@ describe('shortUrlTagsReducer', () => { describe('editShortUrlTags', () => { const updateShortUrlTags = jest.fn(); - const updateShortUrlMeta = jest.fn(); - const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlTags, updateShortUrlMeta }); + const updateShortUrl = jest.fn(); + const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlTags, updateShortUrl }); const dispatch = jest.fn(); const buildGetState = (selectedServer?: SelectedServer) => () => Mock.of({ selectedServer }); @@ -78,7 +78,7 @@ describe('shortUrlTagsReducer', () => { expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(updateShortUrlTags).toHaveBeenCalledTimes(1); expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, domain, tags); - expect(updateShortUrlMeta).not.toHaveBeenCalled(); + expect(updateShortUrl).not.toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START }); expect(dispatch).toHaveBeenNthCalledWith( @@ -87,10 +87,10 @@ describe('shortUrlTagsReducer', () => { ); }); - it('calls updateShortUrlMeta when server is version 2.6.0 or above', async () => { + it('calls updateShortUrl when server is version 2.6.0 or above', async () => { const normalizedTags = [ 'bar', 'foo' ]; - updateShortUrlMeta.mockResolvedValue({ tags: normalizedTags }); + updateShortUrl.mockResolvedValue({ tags: normalizedTags }); await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)( dispatch, @@ -98,8 +98,8 @@ describe('shortUrlTagsReducer', () => { ); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); - expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, { tags }); + expect(updateShortUrl).toHaveBeenCalledTimes(1); + expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, { tags }); expect(updateShortUrlTags).not.toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START }); From d5e20f445de6f3c962745e18e48b0ea631ea046f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Mar 2021 10:19:35 +0100 Subject: [PATCH 05/13] Ensured title is not sent when its value is empty during short URL creation/edition --- src/short-urls/ShortUrlForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index 81da5175..914f6d7b 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -49,6 +49,7 @@ export const ShortUrlForm = ( validSince: formatIsoDate(shortUrlData.validSince) ?? null, validUntil: formatIsoDate(shortUrlData.validUntil) ?? null, maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits), + title: !hasValue(shortUrlData.title) ? undefined : shortUrlData.title, }).then(() => !isEdit && reset()).catch(() => {})); useEffect(() => { From ca670d810da446ed8b771acc6ee4d0a2dbc67288 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Mar 2021 10:27:46 +0100 Subject: [PATCH 06/13] Added error/loading handling to edit short URL --- src/short-urls/EditShortUrl.tsx | 14 ++++++++++++-- src/short-urls/services/provideServices.ts | 8 ++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/short-urls/EditShortUrl.tsx b/src/short-urls/EditShortUrl.tsx index 7f0f0c31..5d2078bf 100644 --- a/src/short-urls/EditShortUrl.tsx +++ b/src/short-urls/EditShortUrl.tsx @@ -10,11 +10,13 @@ import { ShlinkApiError } from '../api/ShlinkApiError'; import { ShortUrlFormProps } from './ShortUrlForm'; import { ShortUrlDetail } from './reducers/shortUrlDetail'; import { EditShortUrlData, ShortUrl, ShortUrlData } from './data'; +import { ShortUrlEdition } from './reducers/shortUrlEdition'; interface EditShortUrlConnectProps extends RouteComponentProps<{ shortCode: string }> { settings: Settings; selectedServer: SelectedServer; shortUrlDetail: ShortUrlDetail; + shortUrlEdition: ShortUrlEdition; getShortUrlDetail: (shortCode: string, domain: OptionalString) => void; editShortUrl: (shortUrl: string, domain: OptionalString, data: EditShortUrlData) => Promise; } @@ -45,9 +47,11 @@ export const EditShortUrl = (ShortUrlForm: FC) => ({ selectedServer, shortUrlDetail, getShortUrlDetail, + shortUrlEdition, editShortUrl, }: EditShortUrlConnectProps) => { const { loading, error, errorData, shortUrl } = shortUrlDetail; + const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition; const { domain } = parseQuery<{ domain?: string }>(search); useEffect(() => { @@ -69,10 +73,16 @@ export const EditShortUrl = (ShortUrlForm: FC) => ({ return ( shortUrl && editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)} - /> + > + {savingError && ( + + + + )} + ); }; diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index b0328c2f..dc2188b7 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -57,10 +57,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { ); bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm'); - bottle.decorator( - 'EditShortUrl', - connect([ 'shortUrlDetail', 'selectedServer', 'settings' ], [ 'getShortUrlDetail', 'editShortUrl' ]), - ); + bottle.decorator('EditShortUrl', connect( + [ 'shortUrlDetail', 'shortUrlEdition', 'selectedServer', 'settings' ], + [ 'getShortUrlDetail', 'editShortUrl' ], + )); bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal); bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ])); From 140353866012275b239fb4715d37b88e5084b843 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Mar 2021 10:41:13 +0100 Subject: [PATCH 07/13] Removed children from ShortUrlForm --- src/short-urls/CreateShortUrl.tsx | 17 +++++++++-------- src/short-urls/EditShortUrl.tsx | 17 +++++++++-------- src/short-urls/ShortUrlForm.tsx | 4 +--- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/short-urls/CreateShortUrl.tsx b/src/short-urls/CreateShortUrl.tsx index 13a44654..cc97427f 100644 --- a/src/short-urls/CreateShortUrl.tsx +++ b/src/short-urls/CreateShortUrl.tsx @@ -43,19 +43,20 @@ const CreateShortUrl = (ShortUrlForm: FC, CreateShortUrlResul const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [ shortUrlCreationSettings ]); return ( - + <> + - + ); }; diff --git a/src/short-urls/EditShortUrl.tsx b/src/short-urls/EditShortUrl.tsx index 5d2078bf..4d83a81e 100644 --- a/src/short-urls/EditShortUrl.tsx +++ b/src/short-urls/EditShortUrl.tsx @@ -71,18 +71,19 @@ export const EditShortUrl = (ShortUrlForm: FC) => ({ } return ( - shortUrl && editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)} - > + <> + shortUrl && editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)} + /> {savingError && ( )} - + ); }; diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index 914f6d7b..1d51e91f 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -39,7 +39,7 @@ export const ShortUrlForm = ( TagsSelector: FC, ForServerVersion: FC, DomainSelector: FC, -): FC => ({ mode, saving, onSave, initialState, selectedServer, children }) => { // eslint-disable-line complexity +): FC => ({ mode, saving, onSave, initialState, selectedServer }) => { // eslint-disable-line complexity const [ shortUrlData, setShortUrlData ] = useState(initialState); const isEdit = mode === 'edit'; const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); @@ -201,8 +201,6 @@ export const ShortUrlForm = ( {saving ? 'Saving...' : 'Save'}
- - {children} ); }; From 3ad0c4d009a16dece040d26475ddaf79759e149d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Mar 2021 10:49:23 +0100 Subject: [PATCH 08/13] Deleted modals that were used to edit short URLs, since now there's a dedicated section --- src/short-urls/helpers/EditMetaModal.tsx | 101 --------------- src/short-urls/helpers/EditShortUrlModal.tsx | 56 --------- src/short-urls/helpers/EditTagsModal.tsx | 53 -------- src/short-urls/helpers/ShortUrlsRowMenu.tsx | 27 ---- src/short-urls/services/provideServices.ts | 23 +--- .../short-urls/helpers/EditMetaModal.test.tsx | 84 ------------- .../helpers/EditShortUrlModal.test.tsx | 80 ------------ .../short-urls/helpers/EditTagsModal.test.tsx | 119 ------------------ .../helpers/ShortUrlsRowMenu.test.tsx | 22 +--- 9 files changed, 4 insertions(+), 561 deletions(-) delete mode 100644 src/short-urls/helpers/EditMetaModal.tsx delete mode 100644 src/short-urls/helpers/EditShortUrlModal.tsx delete mode 100644 src/short-urls/helpers/EditTagsModal.tsx delete mode 100644 test/short-urls/helpers/EditMetaModal.test.tsx delete mode 100644 test/short-urls/helpers/EditShortUrlModal.test.tsx delete mode 100644 test/short-urls/helpers/EditTagsModal.test.tsx diff --git a/src/short-urls/helpers/EditMetaModal.tsx b/src/short-urls/helpers/EditMetaModal.tsx deleted file mode 100644 index 688cf9be..00000000 --- a/src/short-urls/helpers/EditMetaModal.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { ChangeEvent, useState } from 'react'; -import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; -import { ExternalLink } from 'react-external-link'; -import moment from 'moment'; -import { isEmpty, pipe } from 'ramda'; -import { ShortUrlMetaEdition } from '../reducers/shortUrlMeta'; -import DateInput from '../../utils/DateInput'; -import { formatIsoDate } from '../../utils/helpers/date'; -import { ShortUrl, ShortUrlMeta, ShortUrlModalProps } from '../data'; -import { handleEventPreventingDefault, Nullable, OptionalString } from '../../utils/utils'; -import { Result } from '../../utils/Result'; -import { ShlinkApiError } from '../../api/ShlinkApiError'; - -interface EditMetaModalConnectProps extends ShortUrlModalProps { - shortUrlMeta: ShortUrlMetaEdition; - resetShortUrlMeta: () => void; - editShortUrlMeta: (shortCode: string, domain: OptionalString, meta: Nullable) => Promise; -} - -const dateOrNull = (shortUrl: ShortUrl | undefined, dateName: 'validSince' | 'validUntil') => { - const date = shortUrl?.meta?.[dateName]; - - return date ? moment(date) : null; -}; - -const EditMetaModal = ( - { isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }: EditMetaModalConnectProps, -) => { - const { saving, error, errorData } = shortUrlMeta; - const url = shortUrl && (shortUrl.shortUrl || ''); - const [ validSince, setValidSince ] = useState(dateOrNull(shortUrl, 'validSince')); - const [ validUntil, setValidUntil ] = useState(dateOrNull(shortUrl, 'validUntil')); - const [ maxVisits, setMaxVisits ] = useState(shortUrl?.meta?.maxVisits); - - const close = pipe(resetShortUrlMeta, toggle); - const doEdit = async () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, { - maxVisits: maxVisits && !isEmpty(maxVisits) ? maxVisits : null, - validSince: validSince && formatIsoDate(validSince), - validUntil: validUntil && formatIsoDate(validUntil), - }).then(close); - - return ( - - - Edit metadata for - -

Using these metadata properties, you can limit when and how many times your short URL can be visited.

-

If any of the params is not met, the URL will behave as if it was an invalid short URL.

-
-
-
- - - - - - - - - ) => setMaxVisits(Number(e.target.value))} - /> - - - {error && ( - - - - )} - - - - - -
-
- ); -}; - -export default EditMetaModal; diff --git a/src/short-urls/helpers/EditShortUrlModal.tsx b/src/short-urls/helpers/EditShortUrlModal.tsx deleted file mode 100644 index 8c9c31d0..00000000 --- a/src/short-urls/helpers/EditShortUrlModal.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useState } from 'react'; -import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap'; -import { ExternalLink } from 'react-external-link'; -import { ShortUrlEdition } from '../reducers/shortUrlEdition'; -import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils'; -import { EditShortUrlData, ShortUrlModalProps } from '../data'; -import { Result } from '../../utils/Result'; -import { ShlinkApiError } from '../../api/ShlinkApiError'; - -interface EditShortUrlModalProps extends ShortUrlModalProps { - shortUrlEdition: ShortUrlEdition; - editShortUrl: (shortUrl: string, domain: OptionalString, data: EditShortUrlData) => Promise; -} - -const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }: EditShortUrlModalProps) => { - const { saving, error, errorData } = shortUrlEdition; - const url = shortUrl?.shortUrl ?? ''; - const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl); - - const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, { longUrl }).then(toggle); - - return ( - - - Edit long URL for - -
- - - setLongUrl(e.target.value)} - /> - - {error && ( - - - - )} - - - - - -
-
- ); -}; - -export default EditShortUrlModal; diff --git a/src/short-urls/helpers/EditTagsModal.tsx b/src/short-urls/helpers/EditTagsModal.tsx deleted file mode 100644 index a390499c..00000000 --- a/src/short-urls/helpers/EditTagsModal.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { FC, useEffect, useState } from 'react'; -import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; -import { ExternalLink } from 'react-external-link'; -import { ShortUrlTags } from '../reducers/shortUrlTags'; -import { ShortUrlModalProps } from '../data'; -import { OptionalString } from '../../utils/utils'; -import { TagsSelectorProps } from '../../tags/helpers/TagsSelector'; -import { Result } from '../../utils/Result'; -import { ShlinkApiError } from '../../api/ShlinkApiError'; - -interface EditTagsModalProps extends ShortUrlModalProps { - shortUrlTags: ShortUrlTags; - editShortUrlTags: (shortCode: string, domain: OptionalString, tags: string[]) => Promise; - resetShortUrlsTags: () => void; -} - -const EditTagsModal = (TagsSelector: FC) => ( - { isOpen, toggle, shortUrl, shortUrlTags, editShortUrlTags, resetShortUrlsTags }: EditTagsModalProps, -) => { - const [ selectedTags, setSelectedTags ] = useState(shortUrl.tags || []); - - useEffect(() => resetShortUrlsTags, []); - - const { saving, error, errorData } = shortUrlTags; - const url = shortUrl?.shortUrl ?? ''; - const saveTags = async () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags) - .then(toggle) - .catch(() => {}); - - return ( - - - Edit tags for - - - - {error && ( - - - - )} - - - - - - - ); -}; - -export default EditTagsModal; diff --git a/src/short-urls/helpers/ShortUrlsRowMenu.tsx b/src/short-urls/helpers/ShortUrlsRowMenu.tsx index 684d56bb..d97d5d4d 100644 --- a/src/short-urls/helpers/ShortUrlsRowMenu.tsx +++ b/src/short-urls/helpers/ShortUrlsRowMenu.tsx @@ -1,18 +1,15 @@ import { - faTags as tagsIcon, faChartPie as pieChartIcon, faEllipsisV as menuIcon, faQrcode as qrIcon, faMinusCircle as deleteIcon, faEdit as editIcon, - faLink as linkIcon, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FC } from 'react'; import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; import { useToggle } from '../../utils/helpers/hooks'; import { ShortUrl, ShortUrlModalProps } from '../data'; -import { Versions } from '../../utils/helpers/version'; import { SelectedServer } from '../../servers/data'; import ShortUrlDetailLink from './ShortUrlDetailLink'; import './ShortUrlsRowMenu.scss'; @@ -25,18 +22,11 @@ type ShortUrlModal = FC; const ShortUrlsRowMenu = ( DeleteShortUrlModal: ShortUrlModal, - EditTagsModal: ShortUrlModal, - EditMetaModal: ShortUrlModal, - EditShortUrlModal: ShortUrlModal, QrCodeModal: ShortUrlModal, - ForServerVersion: FC, ) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => { const [ isOpen, toggle ] = useToggle(); const [ isQrModalOpen, toggleQrCode ] = useToggle(); - const [ isTagsModalOpen, toggleTags ] = useToggle(); - const [ isMetaModalOpen, toggleMeta ] = useToggle(); const [ isDeleteModalOpen, toggleDelete ] = useToggle(); - const [ isEditModalOpen, toggleEdit ] = useToggle(); return ( @@ -52,23 +42,6 @@ const ShortUrlsRowMenu = ( Edit short URL
- - Edit tags - - - - - Edit metadata - - - - - - Edit long URL - - - - QR code diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index dc2188b7..927ae87b 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -6,9 +6,6 @@ import ShortUrlsRow from '../helpers/ShortUrlsRow'; import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu'; import CreateShortUrl from '../CreateShortUrl'; import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal'; -import EditTagsModal from '../helpers/EditTagsModal'; -import EditMetaModal from '../helpers/EditMetaModal'; -import EditShortUrlModal from '../helpers/EditShortUrlModal'; import CreateShortUrlResult from '../helpers/CreateShortUrlResult'; import { listShortUrls } from '../reducers/shortUrlsList'; import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation'; @@ -37,16 +34,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow'); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout'); - bottle.serviceFactory( - 'ShortUrlsRowMenu', - ShortUrlsRowMenu, - 'DeleteShortUrlModal', - 'EditTagsModal', - 'EditMetaModal', - 'EditShortUrlModal', - 'QrCodeModal', - 'ForServerVersion', - ); + bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal'); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout'); bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'ForServerVersion', 'DomainSelector'); @@ -65,15 +53,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal); bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ])); - bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector'); - bottle.decorator('EditTagsModal', connect([ 'shortUrlTags' ], [ 'editShortUrlTags', 'resetShortUrlsTags' ])); - - bottle.serviceFactory('EditMetaModal', () => EditMetaModal); - bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ])); - - bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal); - bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ])); - bottle.serviceFactory('QrCodeModal', () => QrCodeModal); bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); diff --git a/test/short-urls/helpers/EditMetaModal.test.tsx b/test/short-urls/helpers/EditMetaModal.test.tsx deleted file mode 100644 index b5553125..00000000 --- a/test/short-urls/helpers/EditMetaModal.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { shallow, ShallowWrapper } from 'enzyme'; -import { FormGroup } from 'reactstrap'; -import { Mock } from 'ts-mockery'; -import EditMetaModal from '../../../src/short-urls/helpers/EditMetaModal'; -import { ShortUrl } from '../../../src/short-urls/data'; -import { ShortUrlMetaEdition } from '../../../src/short-urls/reducers/shortUrlMeta'; -import { Result } from '../../../src/utils/Result'; - -describe('', () => { - let wrapper: ShallowWrapper; - const editShortUrlMeta = jest.fn(async () => Promise.resolve()); - const resetShortUrlMeta = jest.fn(); - const toggle = jest.fn(); - const createWrapper = (shortUrlMeta: Partial) => { - wrapper = shallow( - ()} - shortUrlMeta={Mock.of(shortUrlMeta)} - toggle={toggle} - editShortUrlMeta={editShortUrlMeta} - resetShortUrlMeta={resetShortUrlMeta} - />, - ); - - return wrapper; - }; - - afterEach(() => wrapper?.unmount()); - afterEach(jest.clearAllMocks); - - it('properly renders form with components', () => { - const wrapper = createWrapper({ saving: false, error: false }); - const error = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error'); - const form = wrapper.find('form'); - const formGroup = form.find(FormGroup); - - expect(form).toHaveLength(1); - expect(formGroup).toHaveLength(3); - expect(error).toHaveLength(0); - }); - - it.each([ - [ true, 'Saving...' ], - [ false, 'Save' ], - ])('renders submit button on expected state', (saving, expectedText) => { - const wrapper = createWrapper({ saving, error: false }); - const button = wrapper.find('[type="submit"]'); - - expect(button.prop('disabled')).toEqual(saving); - expect(button.text()).toContain(expectedText); - }); - - it('renders error message on error', () => { - const wrapper = createWrapper({ saving: false, error: true }); - const error = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error'); - - expect(error).toHaveLength(1); - }); - - it('saves meta when form is submit', () => { - const preventDefault = jest.fn(); - const wrapper = createWrapper({ saving: false, error: false }); - const form = wrapper.find('form'); - - form.simulate('submit', { preventDefault }); - - expect(preventDefault).toHaveBeenCalled(); - expect(editShortUrlMeta).toHaveBeenCalled(); - }); - - it.each([ - [ '.btn-link', 'onClick' ], - [ 'Modal', 'toggle' ], - [ 'ModalHeader', 'toggle' ], - ])('resets meta when modal is toggled in any way', (componentToFind, propToCall) => { - const wrapper = createWrapper({ saving: false, error: false }); - const component = wrapper.find(componentToFind); - - (component.prop(propToCall) as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion - - expect(resetShortUrlMeta).toHaveBeenCalled(); - }); -}); diff --git a/test/short-urls/helpers/EditShortUrlModal.test.tsx b/test/short-urls/helpers/EditShortUrlModal.test.tsx deleted file mode 100644 index 2ab14c4b..00000000 --- a/test/short-urls/helpers/EditShortUrlModal.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { shallow, ShallowWrapper } from 'enzyme'; -import { FormGroup } from 'reactstrap'; -import { Mock } from 'ts-mockery'; -import EditShortUrlModal from '../../../src/short-urls/helpers/EditShortUrlModal'; -import { ShortUrl } from '../../../src/short-urls/data'; -import { ShortUrlEdition } from '../../../src/short-urls/reducers/shortUrlEdition'; -import { Result } from '../../../src/utils/Result'; - -describe('', () => { - let wrapper: ShallowWrapper; - const editShortUrl = jest.fn(async () => Promise.resolve()); - const toggle = jest.fn(); - const createWrapper = (shortUrl: Partial, shortUrlEdition: Partial) => { - wrapper = shallow( - (shortUrl)} - shortUrlEdition={Mock.of(shortUrlEdition)} - toggle={toggle} - editShortUrl={editShortUrl} - />, - ); - - return wrapper; - }; - - afterEach(() => wrapper?.unmount()); - afterEach(jest.clearAllMocks); - - it.each([ - [ false, 0 ], - [ true, 1 ], - ])('properly renders form with expected components', (error, expectedErrorLength) => { - const wrapper = createWrapper({}, { saving: false, error }); - const errorElement = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error'); - const form = wrapper.find('form'); - const formGroup = form.find(FormGroup); - - expect(form).toHaveLength(1); - expect(formGroup).toHaveLength(1); - expect(errorElement).toHaveLength(expectedErrorLength); - }); - - it.each([ - [ true, 'Saving...', 'something', true ], - [ true, 'Saving...', undefined, true ], - [ false, 'Save', 'something', false ], - [ false, 'Save', undefined, true ], - ])('renders submit button on expected state', (saving, expectedText, longUrl, expectedDisabled) => { - const wrapper = createWrapper({ longUrl }, { saving, error: false }); - const button = wrapper.find('[color="primary"]'); - - expect(button.prop('disabled')).toEqual(expectedDisabled); - expect(button.html()).toContain(expectedText); - }); - - it('saves data when form is submit', () => { - const preventDefault = jest.fn(); - const wrapper = createWrapper({}, { saving: false, error: false }); - const form = wrapper.find('form'); - - form.simulate('submit', { preventDefault }); - - expect(preventDefault).toHaveBeenCalled(); - expect(editShortUrl).toHaveBeenCalled(); - }); - - it.each([ - [ '[color="link"]', 'onClick' ], - [ 'Modal', 'toggle' ], - [ 'ModalHeader', 'toggle' ], - ])('toggles modal with different mechanisms', (componentToFind, propToCall) => { - const wrapper = createWrapper({}, { saving: false, error: false }); - const component = wrapper.find(componentToFind); - - (component.prop(propToCall) as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion - - expect(toggle).toHaveBeenCalled(); - }); -}); diff --git a/test/short-urls/helpers/EditTagsModal.test.tsx b/test/short-urls/helpers/EditTagsModal.test.tsx deleted file mode 100644 index d145331c..00000000 --- a/test/short-urls/helpers/EditTagsModal.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { shallow, ShallowWrapper } from 'enzyme'; -import { Mock } from 'ts-mockery'; -import { Modal } from 'reactstrap'; -import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal'; -import { ShortUrl } from '../../../src/short-urls/data'; -import { ShortUrlTags } from '../../../src/short-urls/reducers/shortUrlTags'; -import { OptionalString } from '../../../src/utils/utils'; - -describe('', () => { - let wrapper: ShallowWrapper; - const shortCode = 'abc123'; - const TagsSelector = () => null; - const editShortUrlTags = jest.fn(async () => Promise.resolve()); - const resetShortUrlsTags = jest.fn(); - const toggle = jest.fn(); - const createWrapper = (shortUrlTags: ShortUrlTags, domain?: OptionalString) => { - const EditTagsModal = createEditTagsModal(TagsSelector); - - wrapper = shallow( - ({ - tags: [], - shortCode, - domain, - longUrl: 'https://long-domain.com/foo/bar', - })} - shortUrlTags={shortUrlTags} - toggle={toggle} - editShortUrlTags={editShortUrlTags} - resetShortUrlsTags={resetShortUrlsTags} - />, - ); - - return wrapper; - }; - - afterEach(() => wrapper?.unmount()); - afterEach(jest.clearAllMocks); - - it('renders tags selector and save button when loaded', () => { - const wrapper = createWrapper({ - shortCode, - tags: [], - saving: false, - error: false, - }); - const saveBtn = wrapper.find('.btn-primary'); - - expect(wrapper.find(TagsSelector)).toHaveLength(1); - expect(saveBtn.prop('disabled')).toBe(false); - expect(saveBtn.text()).toEqual('Save tags'); - }); - - it('disables save button when saving is in progress', () => { - const wrapper = createWrapper({ - shortCode, - tags: [], - saving: true, - error: false, - }); - const saveBtn = wrapper.find('.btn-primary'); - - expect(saveBtn.prop('disabled')).toBe(true); - expect(saveBtn.text()).toEqual('Saving tags...'); - }); - - it.each([ - [ undefined ], - [ null ], - [ 'example.com' ], - // @ts-expect-error Type declaration is not correct, which makes "done" function not being properly detected - ])('saves tags when save button is clicked', (domain: OptionalString, done: jest.DoneCallback) => { - const wrapper = createWrapper({ - shortCode, - tags: [], - saving: true, - error: false, - }, domain); - const saveBtn = wrapper.find('.btn-primary'); - - saveBtn.simulate('click'); - - expect(editShortUrlTags).toHaveBeenCalledTimes(1); - expect(editShortUrlTags).toHaveBeenCalledWith(shortCode, domain, []); - - // Wrap this expect in a setImmediate since it is called as a result of an inner promise - setImmediate(() => { - expect(toggle).toHaveBeenCalledTimes(1); - done(); - }); - }); - - it('does not notify tags have been edited when window is closed without saving', () => { - const wrapper = createWrapper({ - shortCode, - tags: [], - saving: false, - error: false, - }); - const modal = wrapper.find(Modal); - - modal.simulate('closed'); - expect(editShortUrlTags).not.toHaveBeenCalled(); - }); - - it('toggles modal when cancel button is clicked', () => { - const wrapper = createWrapper({ - shortCode, - tags: [], - saving: true, - error: false, - }); - const cancelBtn = wrapper.find('.btn-link'); - - cancelBtn.simulate('click'); - expect(toggle).toHaveBeenCalledTimes(1); - }); -}); diff --git a/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx b/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx index c25c7382..08270a26 100644 --- a/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx +++ b/test/short-urls/helpers/ShortUrlsRowMenu.test.tsx @@ -8,9 +8,6 @@ import { ShortUrl } from '../../../src/short-urls/data'; describe('', () => { let wrapper: ShallowWrapper; const DeleteShortUrlModal = () => null; - const EditTagsModal = () => null; - const EditMetaModal = () => null; - const EditShortUrlModal = () => null; const QrCodeModal = () => null; const selectedServer = Mock.of({ id: 'abc123' }); const shortUrl = Mock.of({ @@ -18,14 +15,7 @@ describe('', () => { shortUrl: 'https://doma.in/abc123', }); const createWrapper = () => { - const ShortUrlsRowMenu = createShortUrlsRowMenu( - DeleteShortUrlModal, - EditTagsModal, - EditMetaModal, - EditShortUrlModal, - QrCodeModal, - () => null, - ); + const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, QrCodeModal); wrapper = shallow(); @@ -37,21 +27,17 @@ describe('', () => { it('renders modal windows', () => { const wrapper = createWrapper(); const deleteShortUrlModal = wrapper.find(DeleteShortUrlModal); - const editTagsModal = wrapper.find(EditTagsModal); const qrCodeModal = wrapper.find(QrCodeModal); - const editModal = wrapper.find(EditShortUrlModal); expect(deleteShortUrlModal).toHaveLength(1); - expect(editTagsModal).toHaveLength(1); expect(qrCodeModal).toHaveLength(1); - expect(editModal).toHaveLength(1); }); it('renders correct amount of menu items', () => { const wrapper = createWrapper(); const items = wrapper.find(DropdownItem); - expect(items).toHaveLength(8); + expect(items).toHaveLength(5); expect(items.find('[divider]')).toHaveLength(1); }); @@ -65,9 +51,7 @@ describe('', () => { }; it('DeleteShortUrlModal', () => assert(DeleteShortUrlModal)); - it('EditTagsModal', () => assert(EditTagsModal)); it('QrCodeModal', () => assert(QrCodeModal)); - it('EditShortUrlModal', () => assert(EditShortUrlModal)); - it('EditShortUrlModal', () => assert(ButtonDropdown)); + it('ShortUrlRowMenu', () => assert(ButtonDropdown)); }); }); From d703e5e1822fd820b69ab9fd91dbde99c904a752 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Mar 2021 13:56:44 +0100 Subject: [PATCH 09/13] Deleted reducers for short URL tags and short URL meta --- src/container/types.ts | 4 - src/reducers/index.ts | 4 - src/short-urls/reducers/shortUrlEdition.ts | 26 ++-- src/short-urls/reducers/shortUrlMeta.ts | 65 --------- src/short-urls/reducers/shortUrlTags.ts | 74 ---------- src/short-urls/reducers/shortUrlsList.ts | 22 --- src/short-urls/services/provideServices.ts | 8 -- .../reducers/shortUrlEdition.test.ts | 46 ++++-- test/short-urls/reducers/shortUrlMeta.test.ts | 100 ------------- test/short-urls/reducers/shortUrlTags.test.ts | 131 ------------------ .../short-urls/reducers/shortUrlsList.test.ts | 90 ------------ 11 files changed, 46 insertions(+), 524 deletions(-) delete mode 100644 src/short-urls/reducers/shortUrlMeta.ts delete mode 100644 src/short-urls/reducers/shortUrlTags.ts delete mode 100644 test/short-urls/reducers/shortUrlMeta.test.ts delete mode 100644 test/short-urls/reducers/shortUrlTags.test.ts diff --git a/src/container/types.ts b/src/container/types.ts index 3b4fff27..95b234ac 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -1,12 +1,10 @@ import { MercureInfo } from '../mercure/reducers/mercureInfo'; import { SelectedServer, ServersMap } from '../servers/data'; import { Settings } from '../settings/reducers/settings'; -import { ShortUrlMetaEdition } from '../short-urls/reducers/shortUrlMeta'; import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation'; import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion'; import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition'; import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams'; -import { ShortUrlTags } from '../short-urls/reducers/shortUrlTags'; import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList'; import { TagDeletion } from '../tags/reducers/tagDelete'; import { TagEdition } from '../tags/reducers/tagEdit'; @@ -25,8 +23,6 @@ export interface ShlinkState { shortUrlsListParams: ShortUrlsListParams; shortUrlCreationResult: ShortUrlCreation; shortUrlDeletion: ShortUrlDeletion; - shortUrlTags: ShortUrlTags; - shortUrlMeta: ShortUrlMetaEdition; shortUrlEdition: ShortUrlEdition; shortUrlVisits: ShortUrlVisits; tagVisits: TagVisits; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 29311fa7..d764f54d 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -5,8 +5,6 @@ import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList'; import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams'; import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation'; import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; -import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags'; -import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; @@ -28,8 +26,6 @@ export default combineReducers({ shortUrlsListParams: shortUrlsListParamsReducer, shortUrlCreationResult: shortUrlCreationReducer, shortUrlDeletion: shortUrlDeletionReducer, - shortUrlTags: shortUrlTagsReducer, - shortUrlMeta: shortUrlMetaReducer, shortUrlEdition: shortUrlEditionReducer, shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, diff --git a/src/short-urls/reducers/shortUrlEdition.ts b/src/short-urls/reducers/shortUrlEdition.ts index 1663f403..50537fb1 100644 --- a/src/short-urls/reducers/shortUrlEdition.ts +++ b/src/short-urls/reducers/shortUrlEdition.ts @@ -2,10 +2,11 @@ import { Action, Dispatch } from 'redux'; import { buildReducer } from '../../utils/helpers/redux'; import { GetState } from '../../container/types'; import { OptionalString } from '../../utils/utils'; -import { EditShortUrlData, ShortUrlIdentifier } from '../data'; +import { EditShortUrlData, ShortUrl } from '../data'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ProblemDetailsError } from '../../api/types'; import { parseApiError } from '../../api/utils'; +import { supportsTagsInPatch } from '../../utils/helpers/features'; /* eslint-disable padding-line-between-statements */ export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START'; @@ -14,15 +15,14 @@ export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED'; /* eslint-enable padding-line-between-statements */ export interface ShortUrlEdition { - shortCode: string | null; - longUrl: string | null; + shortUrl?: ShortUrl; saving: boolean; error: boolean; errorData?: ProblemDetailsError; } -export interface ShortUrlEditedAction extends Action, ShortUrlIdentifier { - longUrl: string; +export interface ShortUrlEditedAction extends Action { + shortUrl: ShortUrl; } export interface ShortUrlEditionFailedAction extends Action { @@ -30,8 +30,6 @@ export interface ShortUrlEditionFailedAction extends Action { } const initialState: ShortUrlEdition = { - shortCode: null, - longUrl: null, saving: false, error: false, }; @@ -39,7 +37,7 @@ const initialState: ShortUrlEdition = { export default buildReducer({ [EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }), [EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }), - [SHORT_URL_EDITED]: (_, { shortCode, longUrl }) => ({ shortCode, longUrl, saving: false, error: false }), + [SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }), }, initialState); export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( @@ -49,13 +47,17 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( ) => async (dispatch: Dispatch, getState: GetState) => { dispatch({ type: EDIT_SHORT_URL_START }); - // TODO Pass tags to the updateTags function if server version is lower than 2.6 - const { updateShortUrl } = buildShlinkApiClient(getState); + const { selectedServer } = getState(); + const sendTagsSeparately = !supportsTagsInPatch(selectedServer); + const { updateShortUrl, updateShortUrlTags } = buildShlinkApiClient(getState); try { - const { longUrl } = await updateShortUrl(shortCode, domain, data as any); // FIXME Parse dates + const [ shortUrl ] = await Promise.all([ + updateShortUrl(shortCode, domain, data as any), // FIXME Parse dates + sendTagsSeparately && data.tags ? updateShortUrlTags(shortCode, domain, data.tags) : undefined, + ]); - dispatch({ shortCode, longUrl, domain, type: SHORT_URL_EDITED }); + dispatch({ shortUrl, type: SHORT_URL_EDITED }); } catch (e) { dispatch({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) }); diff --git a/src/short-urls/reducers/shortUrlMeta.ts b/src/short-urls/reducers/shortUrlMeta.ts deleted file mode 100644 index 7305570e..00000000 --- a/src/short-urls/reducers/shortUrlMeta.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Dispatch, Action } from 'redux'; -import { ShortUrlIdentifier, ShortUrlMeta } from '../data'; -import { GetState } from '../../container/types'; -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; -import { OptionalString } from '../../utils/utils'; -import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { ProblemDetailsError } from '../../api/types'; -import { parseApiError } from '../../api/utils'; - -/* eslint-disable padding-line-between-statements */ -export const EDIT_SHORT_URL_META_START = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_START'; -export const EDIT_SHORT_URL_META_ERROR = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_ERROR'; -export const SHORT_URL_META_EDITED = 'shlink/shortUrlMeta/SHORT_URL_META_EDITED'; -export const RESET_EDIT_SHORT_URL_META = 'shlink/shortUrlMeta/RESET_EDIT_SHORT_URL_META'; -/* eslint-enable padding-line-between-statements */ - -export interface ShortUrlMetaEdition { - shortCode: string | null; - meta: ShortUrlMeta; - saving: boolean; - error: boolean; - errorData?: ProblemDetailsError; -} - -export interface ShortUrlMetaEditedAction extends Action, ShortUrlIdentifier { - meta: ShortUrlMeta; -} - -export interface ShortUrlMetaEditionFailedAction extends Action { - errorData?: ProblemDetailsError; -} - -const initialState: ShortUrlMetaEdition = { - shortCode: null, - meta: {}, - saving: false, - error: false, -}; - -export default buildReducer({ - [EDIT_SHORT_URL_META_START]: (state) => ({ ...state, saving: true, error: false }), - [EDIT_SHORT_URL_META_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }), - [SHORT_URL_META_EDITED]: (_, { shortCode, meta }) => ({ shortCode, meta, saving: false, error: false }), - [RESET_EDIT_SHORT_URL_META]: () => initialState, -}, initialState); - -export const editShortUrlMeta = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - shortCode: string, - domain: OptionalString, - meta: ShortUrlMeta, -) => async (dispatch: Dispatch, getState: GetState) => { - dispatch({ type: EDIT_SHORT_URL_META_START }); - const { updateShortUrl } = buildShlinkApiClient(getState); - - try { - await updateShortUrl(shortCode, domain, meta); - dispatch({ shortCode, meta, domain, type: SHORT_URL_META_EDITED }); - } catch (e) { - dispatch({ type: EDIT_SHORT_URL_META_ERROR, errorData: parseApiError(e) }); - - throw e; - } -}; - -export const resetShortUrlMeta = buildActionCreator(RESET_EDIT_SHORT_URL_META); diff --git a/src/short-urls/reducers/shortUrlTags.ts b/src/short-urls/reducers/shortUrlTags.ts deleted file mode 100644 index 2340b571..00000000 --- a/src/short-urls/reducers/shortUrlTags.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Action, Dispatch } from 'redux'; -import { prop } from 'ramda'; -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; -import { GetState } from '../../container/types'; -import { OptionalString } from '../../utils/utils'; -import { ShortUrlIdentifier } from '../data'; -import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { ProblemDetailsError } from '../../api/types'; -import { parseApiError } from '../../api/utils'; -import { supportsTagsInPatch } from '../../utils/helpers/features'; - -/* eslint-disable padding-line-between-statements */ -export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START'; -export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR'; -export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED'; -export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS'; -/* eslint-enable padding-line-between-statements */ - -export interface ShortUrlTags { - shortCode: string | null; - tags: string[]; - saving: boolean; - error: boolean; - errorData?: ProblemDetailsError; -} - -export interface EditShortUrlTagsAction extends Action, ShortUrlIdentifier { - tags: string[]; -} - -export interface EditShortUrlTagsFailedAction extends Action { - errorData?: ProblemDetailsError; -} - -const initialState: ShortUrlTags = { - shortCode: null, - tags: [], - saving: false, - error: false, -}; - -export default buildReducer({ - [EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }), - [EDIT_SHORT_URL_TAGS_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }), - [SHORT_URL_TAGS_EDITED]: (_, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }), - [RESET_EDIT_SHORT_URL_TAGS]: () => initialState, -}, initialState); - -export const editShortUrlTags = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - shortCode: string, - domain: OptionalString, - tags: string[], -) => async (dispatch: Dispatch, getState: GetState) => { - dispatch({ type: EDIT_SHORT_URL_TAGS_START }); - const { selectedServer } = getState(); - const tagsInPatch = supportsTagsInPatch(selectedServer); - const { updateShortUrlTags, updateShortUrl } = buildShlinkApiClient(getState); - - try { - const normalizedTags = await ( - tagsInPatch - ? updateShortUrl(shortCode, domain, { tags }).then(prop('tags')) - : updateShortUrlTags(shortCode, domain, tags) - ); - - dispatch({ tags: normalizedTags, shortCode, domain, type: SHORT_URL_TAGS_EDITED }); - } catch (e) { - dispatch({ type: EDIT_SHORT_URL_TAGS_ERROR, errorData: parseApiError(e) }); - - throw e; - } -}; - -export const resetShortUrlsTags = buildActionCreator(RESET_EDIT_SHORT_URL_TAGS); diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index 1ff640f0..6ae5765f 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -2,15 +2,11 @@ import { assoc, assocPath, init, last, pipe, reject } from 'ramda'; import { Action, Dispatch } from 'redux'; import { shortUrlMatches } from '../helpers'; import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation'; -import { ShortUrl, ShortUrlIdentifier } from '../data'; import { buildReducer } from '../../utils/helpers/redux'; import { GetState } from '../../container/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkShortUrlsResponse } from '../../api/types'; -import { EditShortUrlTagsAction, SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion'; -import { SHORT_URL_META_EDITED, ShortUrlMetaEditedAction } from './shortUrlMeta'; -import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition'; import { ShortUrlsListParams } from './shortUrlsListParams'; import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation'; @@ -33,9 +29,6 @@ export interface ListShortUrlsAction extends Action { export type ListShortUrlsCombinedAction = ( ListShortUrlsAction - & EditShortUrlTagsAction - & ShortUrlEditedAction - & ShortUrlMetaEditedAction & CreateVisitsAction & CreateShortUrlAction & DeleteShortUrlAction @@ -46,18 +39,6 @@ const initialState: ShortUrlsList = { error: false, }; -const setPropFromActionOnMatchingShortUrl = (prop: keyof T) => ( - state: ShortUrlsList, - { shortCode, domain, [prop]: propValue }: T, -): ShortUrlsList => !state.shortUrls ? state : assocPath( - [ 'shortUrls', 'data' ], - state.shortUrls.data.map( - (shortUrl: ShortUrl) => - shortUrlMatches(shortUrl, shortCode, domain) ? { ...shortUrl, [prop]: propValue } : shortUrl, - ), - state, -); - export default buildReducer({ [LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }), [LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }), @@ -74,9 +55,6 @@ export default buildReducer({ state, ), ), - [SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'), - [SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'), - [SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'), [CREATE_VISITS]: (state, { createdVisits }) => assocPath( [ 'shortUrls', 'data' ], state.shortUrls?.data?.map( diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 927ae87b..9caa6d36 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -10,8 +10,6 @@ import CreateShortUrlResult from '../helpers/CreateShortUrlResult'; import { listShortUrls } from '../reducers/shortUrlsList'; import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation'; import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion'; -import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags'; -import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta'; import { resetShortUrlParams } from '../reducers/shortUrlsListParams'; import { editShortUrl } from '../reducers/shortUrlEdition'; import { ConnectDecorator } from '../../container/types'; @@ -61,9 +59,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ])); // Actions - bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient'); - bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags); - bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams); @@ -73,9 +68,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl); - bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient'); - bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta); - bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient'); bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient'); diff --git a/test/short-urls/reducers/shortUrlEdition.test.ts b/test/short-urls/reducers/shortUrlEdition.test.ts index 8e109cf5..5775f5b8 100644 --- a/test/short-urls/reducers/shortUrlEdition.test.ts +++ b/test/short-urls/reducers/shortUrlEdition.test.ts @@ -7,16 +7,17 @@ import reducer, { ShortUrlEditedAction, } from '../../../src/short-urls/reducers/shortUrlEdition'; import { ShlinkState } from '../../../src/container/types'; +import { ShortUrl } from '../../../src/short-urls/data'; +import { ReachableServer, SelectedServer } from '../../../src/servers/data'; describe('shortUrlEditionReducer', () => { const longUrl = 'https://shlink.io'; const shortCode = 'abc123'; + const shortUrl = Mock.of({ longUrl, shortCode }); describe('reducer', () => { it('returns loading on EDIT_SHORT_URL_START', () => { expect(reducer(undefined, Mock.of({ type: EDIT_SHORT_URL_START }))).toEqual({ - longUrl: null, - shortCode: null, saving: true, error: false, }); @@ -24,17 +25,14 @@ describe('shortUrlEditionReducer', () => { it('returns error on EDIT_SHORT_URL_ERROR', () => { expect(reducer(undefined, Mock.of({ type: EDIT_SHORT_URL_ERROR }))).toEqual({ - longUrl: null, - shortCode: null, saving: false, error: true, }); }); it('returns provided tags and shortCode on SHORT_URL_EDITED', () => { - expect(reducer(undefined, { type: SHORT_URL_EDITED, longUrl, shortCode, domain: null })).toEqual({ - longUrl, - shortCode, + expect(reducer(undefined, { type: SHORT_URL_EDITED, shortUrl })).toEqual({ + shortUrl, saving: false, error: false, }); @@ -42,31 +40,51 @@ describe('shortUrlEditionReducer', () => { }); describe('editShortUrl', () => { - const updateShortUrl = jest.fn().mockResolvedValue({ longUrl }); - const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl }); + const updateShortUrl = jest.fn().mockResolvedValue(shortUrl); + const updateShortUrlTags = jest.fn().mockResolvedValue([]); + const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl, updateShortUrlTags }); const dispatch = jest.fn(); - const getState = () => Mock.of(); + const createGetState = (selectedServer: SelectedServer = null) => () => Mock.of({ selectedServer }); afterEach(jest.clearAllMocks); - it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches long URL on success', async (domain) => { - await editShortUrl(buildShlinkApiClient)(shortCode, domain, { longUrl })(dispatch, getState); + it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches short URL on success', async (domain) => { + await editShortUrl(buildShlinkApiClient)(shortCode, domain, { longUrl })(dispatch, createGetState()); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(updateShortUrl).toHaveBeenCalledTimes(1); expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, { longUrl }); expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_EDITED, longUrl, shortCode, domain }); + expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_EDITED, shortUrl }); }); + it.each([ + [ null, { tags: [ 'foo', 'bar' ] }, 1 ], + [ null, {}, 0 ], + [ Mock.of({ version: '2.6.0' }), {}, 0 ], + [ Mock.of({ version: '2.6.0' }), { tags: [ 'foo', 'bar' ] }, 0 ], + [ Mock.of({ version: '2.5.0' }), {}, 0 ], + [ Mock.of({ version: '2.5.0' }), { tags: [ 'foo', 'bar' ] }, 1 ], + ])( + 'sends tags separately when appropriate, based on selected server and the payload', + async (server, payload, expectedTagsCalls) => { + const getState = createGetState(server); + + await editShortUrl(buildShlinkApiClient)(shortCode, null, payload)(dispatch, getState); + + expect(updateShortUrl).toHaveBeenCalled(); + expect(updateShortUrlTags).toHaveBeenCalledTimes(expectedTagsCalls); + }, + ); + it('dispatches error on failure', async () => { const error = new Error(); updateShortUrl.mockRejectedValue(error); try { - await editShortUrl(buildShlinkApiClient)(shortCode, undefined, { longUrl })(dispatch, getState); + await editShortUrl(buildShlinkApiClient)(shortCode, undefined, { longUrl })(dispatch, createGetState()); } catch (e) { expect(e).toBe(error); } diff --git a/test/short-urls/reducers/shortUrlMeta.test.ts b/test/short-urls/reducers/shortUrlMeta.test.ts deleted file mode 100644 index cb20b85c..00000000 --- a/test/short-urls/reducers/shortUrlMeta.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import moment from 'moment'; -import { Mock } from 'ts-mockery'; -import reducer, { - EDIT_SHORT_URL_META_START, - EDIT_SHORT_URL_META_ERROR, - SHORT_URL_META_EDITED, - RESET_EDIT_SHORT_URL_META, - editShortUrlMeta, - resetShortUrlMeta, -} from '../../../src/short-urls/reducers/shortUrlMeta'; -import { ShlinkState } from '../../../src/container/types'; - -describe('shortUrlMetaReducer', () => { - const meta = { - maxVisits: 50, - startDate: moment('2020-01-01').format(), - }; - const shortCode = 'abc123'; - - describe('reducer', () => { - it('returns loading on EDIT_SHORT_URL_META_START', () => { - expect(reducer(undefined, { type: EDIT_SHORT_URL_META_START } as any)).toEqual({ - meta: {}, - shortCode: null, - saving: true, - error: false, - }); - }); - - it('returns error on EDIT_SHORT_URL_META_ERROR', () => { - expect(reducer(undefined, { type: EDIT_SHORT_URL_META_ERROR } as any)).toEqual({ - meta: {}, - shortCode: null, - saving: false, - error: true, - }); - }); - - it('returns provided tags and shortCode on SHORT_URL_META_EDITED', () => { - expect(reducer(undefined, { type: SHORT_URL_META_EDITED, meta, shortCode } as any)).toEqual({ - meta, - shortCode, - saving: false, - error: false, - }); - }); - - it('goes back to initial state on RESET_EDIT_SHORT_URL_META', () => { - expect(reducer(undefined, { type: RESET_EDIT_SHORT_URL_META } as any)).toEqual({ - meta: {}, - shortCode: null, - saving: false, - error: false, - }); - }); - }); - - describe('editShortUrlMeta', () => { - const updateShortUrl = jest.fn().mockResolvedValue({}); - const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl }); - const dispatch = jest.fn(); - const getState = () => Mock.all(); - - afterEach(jest.clearAllMocks); - - it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches metadata on success', async (domain) => { - await editShortUrlMeta(buildShlinkApiClient)(shortCode, domain, meta)(dispatch, getState); - - expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrl).toHaveBeenCalledTimes(1); - expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, meta); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_META_EDITED, meta, shortCode, domain }); - }); - - it('dispatches error on failure', async () => { - const error = new Error(); - - updateShortUrl.mockRejectedValue(error); - - try { - await editShortUrlMeta(buildShlinkApiClient)(shortCode, undefined, meta)(dispatch, getState); - } catch (e) { - expect(e).toBe(error); - } - - expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrl).toHaveBeenCalledTimes(1); - expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, meta); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_META_ERROR }); - }); - }); - - describe('resetShortUrlMeta', () => { - it('creates expected action', () => expect(resetShortUrlMeta()).toEqual({ type: RESET_EDIT_SHORT_URL_META })); - }); -}); diff --git a/test/short-urls/reducers/shortUrlTags.test.ts b/test/short-urls/reducers/shortUrlTags.test.ts deleted file mode 100644 index 82381da0..00000000 --- a/test/short-urls/reducers/shortUrlTags.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Mock } from 'ts-mockery'; -import reducer, { - EDIT_SHORT_URL_TAGS_ERROR, - EDIT_SHORT_URL_TAGS_START, - RESET_EDIT_SHORT_URL_TAGS, - resetShortUrlsTags, - SHORT_URL_TAGS_EDITED, - editShortUrlTags, - EditShortUrlTagsAction, -} from '../../../src/short-urls/reducers/shortUrlTags'; -import { ShlinkState } from '../../../src/container/types'; -import { ReachableServer, SelectedServer } from '../../../src/servers/data'; - -describe('shortUrlTagsReducer', () => { - const tags = [ 'foo', 'bar', 'baz' ]; - const shortCode = 'abc123'; - - describe('reducer', () => { - const action = (type: string) => Mock.of({ type }); - - it('returns loading on EDIT_SHORT_URL_TAGS_START', () => { - expect(reducer(undefined, action(EDIT_SHORT_URL_TAGS_START))).toEqual({ - tags: [], - shortCode: null, - saving: true, - error: false, - }); - }); - - it('returns error on EDIT_SHORT_URL_TAGS_ERROR', () => { - expect(reducer(undefined, action(EDIT_SHORT_URL_TAGS_ERROR))).toEqual({ - tags: [], - shortCode: null, - saving: false, - error: true, - }); - }); - - it('returns provided tags and shortCode on SHORT_URL_TAGS_EDITED', () => { - expect(reducer(undefined, { type: SHORT_URL_TAGS_EDITED, tags, shortCode, domain: null })).toEqual({ - tags, - shortCode, - saving: false, - error: false, - }); - }); - - it('goes back to initial state on RESET_EDIT_SHORT_URL_TAGS', () => { - expect(reducer(undefined, action(RESET_EDIT_SHORT_URL_TAGS))).toEqual({ - tags: [], - shortCode: null, - saving: false, - error: false, - }); - }); - }); - - describe('resetShortUrlsTags', () => { - it('creates expected action', () => expect(resetShortUrlsTags()).toEqual({ type: RESET_EDIT_SHORT_URL_TAGS })); - }); - - describe('editShortUrlTags', () => { - const updateShortUrlTags = jest.fn(); - const updateShortUrl = jest.fn(); - const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlTags, updateShortUrl }); - const dispatch = jest.fn(); - const buildGetState = (selectedServer?: SelectedServer) => () => Mock.of({ selectedServer }); - - afterEach(jest.clearAllMocks); - - it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches normalized tags on success', async (domain) => { - const normalizedTags = [ 'bar', 'foo' ]; - - updateShortUrlTags.mockResolvedValue(normalizedTags); - - await editShortUrlTags(buildShlinkApiClient)(shortCode, domain, tags)(dispatch, buildGetState()); - - expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrlTags).toHaveBeenCalledTimes(1); - expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, domain, tags); - expect(updateShortUrl).not.toHaveBeenCalled(); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START }); - expect(dispatch).toHaveBeenNthCalledWith( - 2, - { type: SHORT_URL_TAGS_EDITED, tags: normalizedTags, shortCode, domain }, - ); - }); - - it('calls updateShortUrl when server is version 2.6.0 or above', async () => { - const normalizedTags = [ 'bar', 'foo' ]; - - updateShortUrl.mockResolvedValue({ tags: normalizedTags }); - - await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)( - dispatch, - buildGetState(Mock.of({ printableVersion: '', version: '2.6.0' })), - ); - - expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrl).toHaveBeenCalledTimes(1); - expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, { tags }); - expect(updateShortUrlTags).not.toHaveBeenCalled(); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START }); - expect(dispatch).toHaveBeenNthCalledWith( - 2, - { type: SHORT_URL_TAGS_EDITED, tags: normalizedTags, shortCode }, - ); - }); - - it('dispatches error on failure', async () => { - const error = new Error(); - - updateShortUrlTags.mockRejectedValue(error); - - try { - await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)(dispatch, buildGetState()); - } catch (e) { - expect(e).toBe(error); - } - - expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); - expect(updateShortUrlTags).toHaveBeenCalledTimes(1); - expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, undefined, tags); - expect(dispatch).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START }); - expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_TAGS_ERROR }); - }); - }); -}); diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts index 2266129c..761cf0b1 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/test/short-urls/reducers/shortUrlsList.test.ts @@ -5,15 +5,12 @@ import reducer, { LIST_SHORT_URLS_START, listShortUrls, } from '../../../src/short-urls/reducers/shortUrlsList'; -import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags'; import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion'; -import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta'; import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { ShortUrl } from '../../../src/short-urls/data'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types'; import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation'; -import { SHORT_URL_EDITED } from '../../../src/short-urls/reducers/shortUrlEdition'; describe('shortUrlsListReducer', () => { describe('reducer', () => { @@ -36,66 +33,6 @@ describe('shortUrlsListReducer', () => { error: true, })); - it('updates tags on matching URL on SHORT_URL_TAGS_EDITED', () => { - const shortCode = 'abc123'; - const tags = [ 'foo', 'bar', 'baz' ]; - const state = { - shortUrls: Mock.of({ - data: [ - Mock.of({ shortCode, tags: [] }), - Mock.of({ shortCode, tags: [], domain: 'example.com' }), - Mock.of({ shortCode: 'foo', tags: [] }), - ], - }), - loading: false, - error: false, - }; - - expect(reducer(state, { type: SHORT_URL_TAGS_EDITED, shortCode, tags, domain: null } as any)).toEqual({ - shortUrls: { - data: [ - { shortCode, tags }, - { shortCode, tags: [], domain: 'example.com' }, - { shortCode: 'foo', tags: [] }, - ], - }, - loading: false, - error: false, - }); - }); - - it('updates meta on matching URL on SHORT_URL_META_EDITED', () => { - const shortCode = 'abc123'; - const domain = 'example.com'; - const meta = { - maxVisits: 5, - validSince: '2020-05-05', - }; - const state = { - shortUrls: Mock.of({ - data: [ - Mock.of({ shortCode, meta: { maxVisits: 10 }, domain }), - Mock.of({ shortCode, meta: { maxVisits: 50 } }), - Mock.of({ shortCode: 'foo', meta: {} }), - ], - }), - loading: false, - error: false, - }; - - expect(reducer(state, { type: SHORT_URL_META_EDITED, shortCode, meta, domain } as any)).toEqual({ - shortUrls: { - data: [ - { shortCode, meta, domain: 'example.com' }, - { shortCode, meta: { maxVisits: 50 } }, - { shortCode: 'foo', meta: {} }, - ], - }, - loading: false, - error: false, - }); - }); - it('removes matching URL and reduces total on SHORT_URL_DELETED', () => { const shortCode = 'abc123'; const state = { @@ -123,33 +60,6 @@ describe('shortUrlsListReducer', () => { }); }); - it('updates edited short URL on SHORT_URL_EDITED', () => { - const shortCode = 'abc123'; - const state = { - shortUrls: Mock.of({ - data: [ - Mock.of({ shortCode, longUrl: 'old' }), - Mock.of({ shortCode, domain: 'example.com', longUrl: 'foo' }), - Mock.of({ shortCode: 'foo', longUrl: 'bar' }), - ], - }), - loading: false, - error: false, - }; - - expect(reducer(state, { type: SHORT_URL_EDITED, shortCode, longUrl: 'newValue' } as any)).toEqual({ - shortUrls: { - data: [ - { shortCode, longUrl: 'newValue' }, - { shortCode, longUrl: 'foo', domain: 'example.com' }, - { shortCode: 'foo', longUrl: 'bar' }, - ], - }, - loading: false, - error: false, - }); - }); - const createNewShortUrlVisit = (visitsCount: number) => ({ shortUrl: { shortCode: 'abc123', visitsCount }, }); From 10c9f7dabd6759aff16d60f7ff6dc553a72ae109 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Mar 2021 17:56:46 +0100 Subject: [PATCH 10/13] Added header to EditShortUrl and created EditSHortUrl test --- src/short-urls/EditShortUrl.tsx | 18 +++++ test/short-urls/EditShortUrl.test.tsx | 105 ++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 test/short-urls/EditShortUrl.test.tsx diff --git a/src/short-urls/EditShortUrl.tsx b/src/short-urls/EditShortUrl.tsx index 4d83a81e..ff21254e 100644 --- a/src/short-urls/EditShortUrl.tsx +++ b/src/short-urls/EditShortUrl.tsx @@ -1,5 +1,9 @@ import { FC, useEffect } from 'react'; import { RouteComponentProps } from 'react-router'; +import { Button, Card } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; +import { ExternalLink } from 'react-external-link'; import { SelectedServer } from '../servers/data'; import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings'; import { OptionalString } from '../utils/utils'; @@ -41,6 +45,7 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting }; export const EditShortUrl = (ShortUrlForm: FC) => ({ + history: { goBack }, match: { params }, location: { search }, settings: { shortUrlCreation: shortUrlCreationSettings }, @@ -70,8 +75,21 @@ export const EditShortUrl = (ShortUrlForm: FC) => ({ ); } + const title = Edit ; + return ( <> +
+ +

+ + {title} + +

+
+
', () => { + let wrapper: ShallowWrapper; + const ShortUrlForm = () => null; + const goBack = jest.fn(); + const getShortUrlDetail = jest.fn(); + const editShortUrl = jest.fn(); + const shortUrlCreation = { validateUrls: true }; + const createWrapper = (detail: Partial = {}, edition: Partial = {}) => { + const EditSHortUrl = createEditShortUrl(ShortUrlForm); + + wrapper = shallow( + ({ shortUrlCreation })} + selectedServer={null} + shortUrlDetail={Mock.of(detail)} + shortUrlEdition={Mock.of(edition)} + getShortUrlDetail={getShortUrlDetail} + editShortUrl={editShortUrl} + history={Mock.of({ goBack })} + location={Mock.all()} + match={Mock.of>({ + params: { shortCode: 'the_base_url' }, + })} + />, + ); + + return wrapper; + }; + + beforeEach(jest.clearAllMocks); + afterEach(() => wrapper?.unmount()); + + it('renders loading message while loading detail', () => { + const wrapper = createWrapper({ loading: true }); + + expect(wrapper.prop('loading')).toEqual(true); + }); + + it('renders error when loading detail fails', () => { + const wrapper = createWrapper({ error: true }); + const form = wrapper.find(ShortUrlForm); + const apiError = wrapper.find(ShlinkApiError); + + expect(form).toHaveLength(0); + expect(apiError).toHaveLength(1); + expect(apiError.prop('fallbackMessage')).toEqual('An error occurred while loading short URL detail :('); + }); + + it.each([ + [ undefined, { longUrl: '', validateUrl: true }, true ], + [ + Mock.of({ meta: {} }), + { + longUrl: undefined, + tags: undefined, + title: undefined, + domain: undefined, + validSince: undefined, + validUntil: undefined, + maxVisits: undefined, + validateUrl: true, + }, + false, + ], + ])('renders form when detail properly loads', (shortUrl, expectedInitialState, saving) => { + const wrapper = createWrapper({ shortUrl }, { saving }); + const form = wrapper.find(ShortUrlForm); + const apiError = wrapper.find(ShlinkApiError); + + expect(form).toHaveLength(1); + expect(apiError).toHaveLength(0); + expect(form.prop('initialState')).toEqual(expectedInitialState); + expect(form.prop('saving')).toEqual(saving); + expect(editShortUrl).not.toHaveBeenCalled(); + + form.simulate('save', {}); + + if (shortUrl) { + expect(editShortUrl).toHaveBeenCalledWith(shortUrl.shortCode, shortUrl.domain, {}); + } else { + expect(editShortUrl).not.toHaveBeenCalled(); + } + }); + + it('shows error when saving data has failed', () => { + const wrapper = createWrapper({}, { error: true }); + const form = wrapper.find(ShortUrlForm); + const apiError = wrapper.find(ShlinkApiError); + + expect(form).toHaveLength(1); + expect(apiError).toHaveLength(1); + expect(apiError.prop('fallbackMessage')).toEqual('An error occurred while updating short URL :('); + }); +}); From 6628a4059e21ccf192617073edcdc7a4eac7660d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Mar 2021 17:58:17 +0100 Subject: [PATCH 11/13] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c00a28dd..a924e1c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#383](https://github.com/shlinkio/shlink-web-client/issues/383) Added title to short URLs list, displayed when consuming Shlink >=2.6.0. * [#368](https://github.com/shlinkio/shlink-web-client/issues/368) Added new settings to define the default interval for visits pages. * [#349](https://github.com/shlinkio/shlink-web-client/issues/349) Added support to export visits to CSV. +* [#397](https://github.com/shlinkio/shlink-web-client/issues/397) New section to edit all data for short URLs, including title when using Shlink v2.6 or newer. + + This new section replaces the old modals to edit short URL meta, short URL tags and the long URL. Everything is now together in the same section. ### Changed * [#382](https://github.com/shlinkio/shlink-web-client/issues/382) Ensured short URL tags are edited through the `PATCH /short-urls/{shortCode}` endpoint when using Shlink 2.6.0 or higher. From 56aab349dbb14c363b71e80892636b17dec3182f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Mar 2021 18:39:55 +0100 Subject: [PATCH 12/13] Updated ShortUrlForm to ensure it does not render empty cards --- src/short-urls/ShortUrlForm.tsx | 152 +++++++++++---------- src/short-urls/services/provideServices.ts | 2 +- src/utils/helpers/features.ts | 2 + test/short-urls/ShortUrlForm.test.tsx | 40 +++++- 4 files changed, 118 insertions(+), 78 deletions(-) diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index 1d51e91f..51714bbc 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -3,25 +3,27 @@ import { InputType } from 'reactstrap/lib/Input'; import { Button, FormGroup, Input, Row } from 'reactstrap'; import { isEmpty, pipe, replace, trim } from 'ramda'; import m from 'moment'; +import classNames from 'classnames'; import DateInput, { DateInputProps } from '../utils/DateInput'; import { supportsListingDomains, supportsSettingShortCodeLength, supportsShortUrlTitle, + supportsValidateUrl, } from '../utils/helpers/features'; import { SimpleCard } from '../utils/SimpleCard'; import { handleEventPreventingDefault, hasValue } from '../utils/utils'; import Checkbox from '../utils/Checkbox'; import { SelectedServer } from '../servers/data'; import { TagsSelectorProps } from '../tags/helpers/TagsSelector'; -import { Versions } from '../utils/helpers/version'; import { DomainSelectorProps } from '../domains/DomainSelector'; import { formatIsoDate } from '../utils/helpers/date'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; import { ShortUrlData } from './data'; import './ShortUrlForm.scss'; -type Mode = 'create' | 'create-basic' | 'edit'; +export type Mode = 'create' | 'create-basic' | 'edit'; + type DateFields = 'validSince' | 'validUntil'; type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title'; @@ -37,7 +39,6 @@ const normalizeTag = pipe(trim, replace(/ /g, '-')); export const ShortUrlForm = ( TagsSelector: FC, - ForServerVersion: FC, DomainSelector: FC, ): FC => ({ mode, saving, onSave, initialState, selectedServer }) => { // eslint-disable-line complexity const [ shortUrlData, setShortUrlData ] = useState(initialState); @@ -101,6 +102,13 @@ export const ShortUrlForm = ( const showDomainSelector = supportsListingDomains(selectedServer); const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer); const supportsTitle = supportsShortUrlTitle(selectedServer); + const showCustomizeCard = supportsTitle || !isEdit; + const limitAccessCardClasses = classNames('mb-3', { + 'col-sm-6': showCustomizeCard, + 'col-sm-12': !showCustomizeCard, + }); + const showValidateUrl = supportsValidateUrl(selectedServer); + const showExtraValidationsCard = showValidateUrl || !isEdit; return (
@@ -112,42 +120,44 @@ export const ShortUrlForm = ( -
- - {supportsTitle && renderOptionalInput('title', 'Title')} - {!isEdit && ( - <> - -
- {renderOptionalInput('customSlug', 'Custom slug', 'text', { - disabled: hasValue(shortUrlData.shortCodeLength), - })} -
-
- {renderOptionalInput('shortCodeLength', 'Short code length', 'number', { - min: 4, - disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug), - ...disableShortCodeLength && { - title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length', - }, - })} -
-
- {!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')} - {showDomainSelector && ( - - setShortUrlData({ ...shortUrlData, domain })} - /> - - )} - - )} -
-
+ {showCustomizeCard && ( +
+ + {supportsTitle && renderOptionalInput('title', 'Title')} + {!isEdit && ( + <> + +
+ {renderOptionalInput('customSlug', 'Custom slug', 'text', { + disabled: hasValue(shortUrlData.shortCodeLength), + })} +
+
+ {renderOptionalInput('shortCodeLength', 'Short code length', 'number', { + min: 4, + disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug), + ...disableShortCodeLength && { + title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length', + }, + })} +
+
+ {!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')} + {showDomainSelector && ( + + setShortUrlData({ ...shortUrlData, domain })} + /> + + )} + + )} +
+
+ )} -
+
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? m(shortUrlData.validUntil) : undefined })} @@ -156,38 +166,40 @@ export const ShortUrlForm = (
- - {!isEdit && ( -

- Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all - provided data. -

- )} - -

- setShortUrlData({ ...shortUrlData, validateUrl })} - > - Validate URL - -

-
- {!isEdit && ( -

- setShortUrlData({ ...shortUrlData, findIfExists })} - > - Use existing URL if found - - -

- )} -
+ {showExtraValidationsCard && ( + + {!isEdit && ( +

+ Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all + provided data. +

+ )} + {showValidateUrl && ( +

+ setShortUrlData({ ...shortUrlData, validateUrl })} + > + Validate URL + +

+ )} + {!isEdit && ( +

+ setShortUrlData({ ...shortUrlData, findIfExists })} + > + Use existing URL if found + + +

+ )} +
+ )} )} diff --git a/src/short-urls/services/provideServices.ts b/src/short-urls/services/provideServices.ts index 9caa6d36..49dee640 100644 --- a/src/short-urls/services/provideServices.ts +++ b/src/short-urls/services/provideServices.ts @@ -34,7 +34,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout'); bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal'); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout'); - bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'ForServerVersion', 'DomainSelector'); + bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector'); bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult'); bottle.decorator( diff --git a/src/utils/helpers/features.ts b/src/utils/helpers/features.ts index 44279433..2caecc75 100644 --- a/src/utils/helpers/features.ts +++ b/src/utils/helpers/features.ts @@ -12,6 +12,8 @@ export const supportsListingDomains = serverMatchesVersions({ minVersion: '2.4.0 export const supportsQrCodeSvgFormat = supportsListingDomains; +export const supportsValidateUrl = supportsListingDomains; + export const supportsQrCodeSizeInQuery = serverMatchesVersions({ minVersion: '2.5.0' }); export const supportsShortUrlTitle = serverMatchesVersions({ minVersion: '2.6.0' }); diff --git a/test/short-urls/ShortUrlForm.test.tsx b/test/short-urls/ShortUrlForm.test.tsx index c5f13ece..b5b9dc6d 100644 --- a/test/short-urls/ShortUrlForm.test.tsx +++ b/test/short-urls/ShortUrlForm.test.tsx @@ -3,32 +3,37 @@ import moment from 'moment'; import { identity } from 'ramda'; import { Mock } from 'ts-mockery'; import { Input } from 'reactstrap'; -import { ShortUrlForm as createShortUrlForm } from '../../src/short-urls/ShortUrlForm'; +import { ShortUrlForm as createShortUrlForm, Mode } from '../../src/short-urls/ShortUrlForm'; import DateInput from '../../src/utils/DateInput'; import { ShortUrlData } from '../../src/short-urls/data'; +import { ReachableServer, SelectedServer } from '../../src/servers/data'; +import { SimpleCard } from '../../src/utils/SimpleCard'; describe('', () => { let wrapper: ShallowWrapper; const TagsSelector = () => null; const createShortUrl = jest.fn(); - - beforeEach(() => { - const ShortUrlForm = createShortUrlForm(TagsSelector, () => null, () => null); + const createWrapper = (selectedServer: SelectedServer = null, mode: Mode = 'create') => { + const ShortUrlForm = createShortUrlForm(TagsSelector, () => null); wrapper = shallow( ({ validateUrl: true, findIfExists: false })} onSave={createShortUrl} />, ); - }); + + return wrapper; + }; + afterEach(() => wrapper.unmount()); afterEach(jest.clearAllMocks); it('saves short URL with data set in form controls', () => { + const wrapper = createWrapper(); const validSince = moment('2017-01-01'); const validUntil = moment('2017-01-06'); @@ -56,4 +61,25 @@ describe('', () => { validateUrl: true, }); }); + + it.each([ + [ null, 'create' as Mode, 4 ], + [ null, 'create-basic' as Mode, 0 ], + [ Mock.of({ version: '2.6.0' }), 'create' as Mode, 4 ], + [ Mock.of({ version: '2.5.0' }), 'create' as Mode, 4 ], + [ Mock.of({ version: '2.4.0' }), 'create' as Mode, 4 ], + [ Mock.of({ version: '2.3.0' }), 'create' as Mode, 4 ], + [ Mock.of({ version: '2.6.0' }), 'edit' as Mode, 4 ], + [ Mock.of({ version: '2.5.0' }), 'edit' as Mode, 3 ], + [ Mock.of({ version: '2.4.0' }), 'edit' as Mode, 3 ], + [ Mock.of({ version: '2.3.0' }), 'edit' as Mode, 2 ], + ])( + 'renders expected amount of cards based on server capabilities and mode', + (selectedServer, mode, expectedAmountOfCards) => { + const wrapper = createWrapper(selectedServer, mode); + const cards = wrapper.find(SimpleCard); + + expect(cards).toHaveLength(expectedAmountOfCards); + }, + ); }); From 8c7a91c7b87ef1be1cde4258293e575922a17690 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Mar 2021 18:56:24 +0100 Subject: [PATCH 13/13] Memoized initial state for editing short URL, to ensure the form values are not reset while saving --- src/short-urls/EditShortUrl.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/short-urls/EditShortUrl.tsx b/src/short-urls/EditShortUrl.tsx index ff21254e..3f3f3e49 100644 --- a/src/short-urls/EditShortUrl.tsx +++ b/src/short-urls/EditShortUrl.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect } from 'react'; +import { FC, useEffect, useMemo } from 'react'; import { RouteComponentProps } from 'react-router'; import { Button, Card } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -58,6 +58,10 @@ export const EditShortUrl = (ShortUrlForm: FC) => ({ const { loading, error, errorData, shortUrl } = shortUrlDetail; const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition; const { domain } = parseQuery<{ domain?: string }>(search); + const initialState = useMemo( + () => getInitialState(shortUrl, shortUrlCreationSettings), + [ shortUrl, shortUrlCreationSettings ], + ); useEffect(() => { getShortUrlDetail(params.shortCode, domain); @@ -91,7 +95,7 @@ export const EditShortUrl = (ShortUrlForm: FC) => ({