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