Created reusable component to have a short URL form

This commit is contained in:
Alejandro Celaya 2021-03-19 19:11:27 +01:00
parent ea01d22369
commit 98aa85ca14
8 changed files with 270 additions and 212 deletions

View file

@ -1,6 +0,0 @@
@import '../utils/base';
.create-short-url .form-group:last-child,
.create-short-url p:last-child {
margin-bottom: 0;
}

View file

@ -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<TagsSelectorProps>,
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
ForServerVersion: FC<Versions>,
DomainSelector: FC<DomainSelectorProps>,
) => ({
const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>) => ({
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 = {}) => (
<FormGroup>
<Input
id={id}
type={type}
placeholder={placeholder}
value={shortUrlCreation[id]}
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
{...props}
/>
</FormGroup>
);
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
<div className="form-group">
<DateInput
selected={shortUrlCreation[id] as m.Moment | null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
{...props}
/>
</div>
);
const basicComponents = (
<>
<FormGroup>
<Input
bsSize="lg"
type="url"
placeholder="URL to be shortened"
required
value={shortUrlCreation.longUrl}
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
/>
</FormGroup>
<FormGroup>
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
</FormGroup>
</>
);
const showDomainSelector = supportsListingDomains(selectedServer);
const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer);
return (
<form className="create-short-url" onSubmit={save}>
{basicMode && basicComponents}
{!basicMode && (
<>
<SimpleCard title="Basic options" className="mb-3">
{basicComponents}
</SimpleCard>
<div className="row">
<div className="col-sm-6 mb-3">
<SimpleCard title="Customize the short URL">
{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 && (
<FormGroup>
<DomainSelector
value={shortUrlCreation.domain}
onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
/>
</FormGroup>
)}
</SimpleCard>
</div>
<div className="col-sm-6 mb-3">
<SimpleCard title="Limit access to the short URL">
{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 })}
</SimpleCard>
</div>
</div>
<SimpleCard title="Extra validations" className="mb-3">
<p>
Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all
provided data.
</p>
<ForServerVersion minVersion="2.4.0">
<p>
<Checkbox
inline
checked={shortUrlCreation.validateUrl}
onChange={(validateUrl) => setShortUrlCreation({ ...shortUrlCreation, validateUrl })}
>
Validate URL
</Checkbox>
</p>
</ForServerVersion>
<p>
<Checkbox
inline
className="mr-2"
checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</p>
</SimpleCard>
</>
)}
<div className="text-center">
<Button
outline
color="primary"
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
className="btn-xs-block"
>
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
</Button>
</div>
<ShortUrlForm
initialState={initialState}
saving={shortUrlCreationResult.saving}
selectedServer={selectedServer}
mode={basicMode ? 'create-basic' : 'create'}
onSave={createShortUrl}
>
<CreateShortUrlResult
{...shortUrlCreationResult}
resetCreateShortUrl={resetCreateShortUrl}
canBeClosed={basicMode}
/>
</form>
</ShortUrlForm>
);
};

View file

@ -0,0 +1,6 @@
@import '../utils/base';
.short-url-form .form-group:last-child,
.short-url-form p:last-child {
margin-bottom: 0;
}

View file

@ -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<unknown>;
selectedServer: SelectedServer;
}
export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>,
ForServerVersion: FC<Versions>,
DomainSelector: FC<DomainSelectorProps>,
): FC<ShortUrlFormProps> => ({ 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 = {}) => (
<FormGroup>
<Input
id={id}
type={type}
placeholder={placeholder}
value={shortUrlData[id]}
onChange={(e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value })}
{...props}
/>
</FormGroup>
);
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
<div className="form-group">
<DateInput
selected={shortUrlData[id] as m.Moment | null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
{...props}
/>
</div>
);
const basicComponents = (
<>
<FormGroup>
<Input
bsSize="lg"
type="url"
placeholder="URL to be shortened"
required
value={shortUrlData.longUrl}
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
/>
</FormGroup>
<FormGroup>
<TagsSelector tags={shortUrlData.tags ?? []} onChange={changeTags} />
</FormGroup>
</>
);
const showDomainSelector = supportsListingDomains(selectedServer);
const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer);
return (
<form className="short-url-form" onSubmit={submit}>
{mode === 'create-basic' && basicComponents}
{mode !== 'create-basic' && (
<>
<SimpleCard title="Basic options" className="mb-3">
{basicComponents}
</SimpleCard>
<div className="row">
<div className="col-sm-6 mb-3">
<SimpleCard title="Customize the short URL">
{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 && (
<FormGroup>
<DomainSelector
value={shortUrlData.domain}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
/>
</FormGroup>
)}
</SimpleCard>
</div>
<div className="col-sm-6 mb-3">
<SimpleCard title="Limit access to the short URL">
{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 })}
</SimpleCard>
</div>
</div>
<SimpleCard title="Extra validations" className="mb-3">
<p>
Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all
provided data.
</p>
<ForServerVersion minVersion="2.4.0">
<p>
<Checkbox
inline
checked={shortUrlData.validateUrl}
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
>
Validate URL
</Checkbox>
</p>
</ForServerVersion>
<p>
<Checkbox
inline
className="mr-2"
checked={shortUrlData.findIfExists}
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</p>
</SimpleCard>
</>
)}
<div className="text-center">
<Button
outline
color="primary"
disabled={saving || isEmpty(shortUrlData.longUrl)}
className="btn-xs-block"
>
{saving ? 'Creating...' : 'Create'}
</Button>
</div>
{children}
</form>
);
};

View file

@ -5,6 +5,7 @@ export interface ShortUrlData {
longUrl: string;
tags?: string[];
customSlug?: string;
title?: string;
shortCodeLength?: number;
domain?: string;
validSince?: m.Moment | string;

View file

@ -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' ]),

View file

@ -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('<CreateShortUrl />', () => {
let wrapper: ShallowWrapper;
const TagsSelector = () => null;
const ShortUrlForm = () => null;
const CreateShortUrlResult = () => null;
const shortUrlCreation = { validateUrls: true };
const shortUrlCreationResult = Mock.all<ShortUrlCreation>();
const createShortUrl = jest.fn(async () => Promise.resolve());
beforeEach(() => {
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => null, () => null, () => null);
const CreateShortUrl = createShortUrlsCreator(ShortUrlForm, CreateShortUrlResult);
wrapper = shallow(
<CreateShortUrl
@ -31,32 +28,11 @@ describe('<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');
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);
});
});

View file

@ -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('<ShortUrlForm />', () => {
let wrapper: ShallowWrapper;
const TagsSelector = () => null;
const createShortUrl = jest.fn();
beforeEach(() => {
const ShortUrlForm = createShortUrlForm(TagsSelector, () => null, () => null);
wrapper = shallow(
<ShortUrlForm
selectedServer={null}
mode="create"
saving={false}
initialState={Mock.of<ShortUrlData>({ 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,
});
});
});