Add support for device-specific long URLs when using Shlink 3.5.0 or newer

This commit is contained in:
Alejandro Celaya 2023-03-13 09:05:54 +01:00
parent fa69c21fa2
commit 4c5d0321d2
4 changed files with 116 additions and 28 deletions

View file

@ -1,8 +1,12 @@
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
import { faAppleAlt, faDesktop, faMobileAndroidAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { cond, isEmpty, pipe, replace, T, trim } from 'ramda'; import { isEmpty, pipe, replace, trim } from 'ramda';
import type { FC } from 'react'; import type { ChangeEvent, FC } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, FormGroup, Input, Row } from 'reactstrap'; import { Button, FormGroup, Input, InputGroup, InputGroupText, Row } from 'reactstrap';
import type { InputType } from 'reactstrap/types/lib/Input'; import type { InputType } from 'reactstrap/types/lib/Input';
import type { DomainSelectorProps } from '../domains/DomainSelector'; import type { DomainSelectorProps } from '../domains/DomainSelector';
import type { SelectedServer } from '../servers/data'; import type { SelectedServer } from '../servers/data';
@ -13,9 +17,8 @@ import { DateTimeInput } from '../utils/dates/DateTimeInput';
import { formatIsoDate } from '../utils/helpers/date'; import { formatIsoDate } from '../utils/helpers/date';
import { useFeature } from '../utils/helpers/features'; import { useFeature } from '../utils/helpers/features';
import { SimpleCard } from '../utils/SimpleCard'; import { SimpleCard } from '../utils/SimpleCard';
import type { OptionalString } from '../utils/utils';
import { handleEventPreventingDefault, hasValue } from '../utils/utils'; import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import type { ShortUrlData } from './data'; import type { DeviceLongUrls, ShortUrlData } from './data';
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup'; import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup';
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon'; import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon';
import './ShortUrlForm.scss'; import './ShortUrlForm.scss';
@ -41,38 +44,38 @@ export const ShortUrlForm = (
DomainSelector: FC<DomainSelectorProps>, DomainSelector: FC<DomainSelectorProps>,
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => { ): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
const [shortUrlData, setShortUrlData] = useState(initialState); const [shortUrlData, setShortUrlData] = useState(initialState);
const reset = () => setShortUrlData(initialState);
const supportsDeviceLongUrls = useFeature('deviceLongUrls', selectedServer);
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const isBasicMode = mode === 'create-basic'; const isBasicMode = mode === 'create-basic';
const hadTitleOriginally = hasValue(initialState.title);
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlData(initialState); const setResettableValue = (value: string, initialValue?: any) => {
const resolveNewTitle = (): OptionalString => { if (hasValue(value)) {
const hasNewTitle = hasValue(shortUrlData.title); return value;
const matcher = cond<never, OptionalString>([ }
[() => !hasNewTitle && !hadTitleOriginally, () => undefined],
[() => !hasNewTitle && hadTitleOriginally, () => null],
[T, () => shortUrlData.title],
]);
return matcher(); // If an initial value was provided for this when the input is "emptied", explicitly set it to null so that the
// value gets removed. Otherwise, set undefined so that it gets ignored.
return hasValue(initialValue) ? null : undefined;
}; };
const submit = handleEventPreventingDefault(async () => onSave({ const submit = handleEventPreventingDefault(async () => onSave({
...shortUrlData, ...shortUrlData,
validSince: formatIsoDate(shortUrlData.validSince) ?? null, validSince: formatIsoDate(shortUrlData.validSince) ?? null,
validUntil: formatIsoDate(shortUrlData.validUntil) ?? null, validUntil: formatIsoDate(shortUrlData.validUntil) ?? null,
maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits), maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits),
title: resolveNewTitle(),
}).then(() => !isEdit && reset()).catch(() => {})); }).then(() => !isEdit && reset()).catch(() => {}));
useEffect(() => { useEffect(() => {
setShortUrlData(initialState); setShortUrlData(initialState);
}, [initialState]); }, [initialState]);
// TODO Consider extracting these functions to local components
const renderOptionalInput = ( const renderOptionalInput = (
id: NonDateFields, id: NonDateFields,
placeholder: string, placeholder: string,
type: InputType = 'text', type: InputType = 'text',
props = {}, props: any = {},
fromGroupProps = {}, fromGroupProps = {},
) => ( ) => (
<FormGroup {...fromGroupProps}> <FormGroup {...fromGroupProps}>
@ -81,11 +84,31 @@ export const ShortUrlForm = (
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
value={shortUrlData[id] ?? ''} value={shortUrlData[id] ?? ''}
onChange={(e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value })} onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))}
{...props} {...props}
/> />
</FormGroup> </FormGroup>
); );
const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => (
<InputGroup>
<InputGroupText>
<FontAwesomeIcon icon={icon} fixedWidth />
</InputGroupText>
<Input
id={id}
type="url"
placeholder={placeholder}
value={shortUrlData.deviceLongUrls?.[id] ?? ''}
onChange={(e) => setShortUrlData({
...shortUrlData,
deviceLongUrls: {
...(shortUrlData.deviceLongUrls ?? {}),
[id]: setResettableValue(e.target.value, initialState.deviceLongUrls?.[id]),
},
})}
/>
</InputGroup>
);
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateTimeInputProps> = {}) => ( const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateTimeInputProps> = {}) => (
<DateTimeInput <DateTimeInput
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null} selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
@ -123,14 +146,38 @@ export const ShortUrlForm = (
{isBasicMode && basicComponents} {isBasicMode && basicComponents}
{!isBasicMode && ( {!isBasicMode && (
<> <>
<Row>
<div
className={classNames('mb-3', { 'col-sm-6': supportsDeviceLongUrls, 'col-12': !supportsDeviceLongUrls })}
>
<SimpleCard title="Main options" className="mb-3"> <SimpleCard title="Main options" className="mb-3">
{basicComponents} {basicComponents}
</SimpleCard> </SimpleCard>
</div>
{supportsDeviceLongUrls && (
<div className="col-sm-6 mb-3">
<SimpleCard title="Device-specific long URLs">
<FormGroup>
{renderDeviceLongUrlInput('android', 'Android-specific redirection', faMobileAndroidAlt)}
</FormGroup>
<FormGroup>
{renderDeviceLongUrlInput('ios', 'iOS-specific redirection', faAppleAlt)}
</FormGroup>
{renderDeviceLongUrlInput('desktop', 'Desktop-specific redirection', faDesktop)}
</SimpleCard>
</div>
)}
</Row>
<Row> <Row>
<div className="col-sm-6 mb-3"> <div className="col-sm-6 mb-3">
<SimpleCard title="Customize the short URL"> <SimpleCard title="Customize the short URL">
{renderOptionalInput('title', 'Title')} {renderOptionalInput('title', 'Title', 'text', {
onChange: ({ target }: ChangeEvent<HTMLInputElement>) => setShortUrlData({
...shortUrlData,
title: setResettableValue(target.value, initialState.title),
}),
})}
{!isEdit && ( {!isEdit && (
<> <>
<Row> <Row>

View file

@ -1,8 +1,15 @@
import type { Order } from '../../utils/helpers/ordering'; import type { Order } from '../../utils/helpers/ordering';
import type { Nullable, OptionalString } from '../../utils/utils'; import type { Nullable, OptionalString } from '../../utils/utils';
export interface DeviceLongUrls {
android?: OptionalString;
ios?: OptionalString;
desktop?: OptionalString;
}
export interface EditShortUrlData { export interface EditShortUrlData {
longUrl?: string; longUrl?: string;
deviceLongUrls?: DeviceLongUrls;
tags?: string[]; tags?: string[];
title?: string | null; title?: string | null;
validSince?: Date | string | null; validSince?: Date | string | null;
@ -30,6 +37,7 @@ export interface ShortUrl {
shortCode: string; shortCode: string;
shortUrl: string; shortUrl: string;
longUrl: string; longUrl: string;
deviceLongUrls?: Required<DeviceLongUrls>, // Optional only before Shlink 3.5.0
dateCreated: string; dateCreated: string;
/** @deprecated */ /** @deprecated */
visitsCount: number; // Deprecated since Shlink 3.4.0 visitsCount: number; // Deprecated since Shlink 3.4.0

View file

@ -37,6 +37,7 @@ export const shortUrlDataFromShortUrl = (shortUrl?: ShortUrl, settings?: ShortUr
maxVisits: shortUrl.meta.maxVisits ?? undefined, maxVisits: shortUrl.meta.maxVisits ?? undefined,
crawlable: shortUrl.crawlable, crawlable: shortUrl.crawlable,
forwardQuery: shortUrl.forwardQuery, forwardQuery: shortUrl.forwardQuery,
deviceLongUrls: shortUrl.deviceLongUrls,
validateUrl, validateUrl,
}; };
}; };

View file

@ -31,15 +31,30 @@ describe('<ShortUrlForm />', () => {
await user.type(screen.getByPlaceholderText('Custom slug'), 'my-slug'); await user.type(screen.getByPlaceholderText('Custom slug'), 'my-slug');
}, },
{ customSlug: 'my-slug' }, { customSlug: 'my-slug' },
null,
], ],
[ [
async (user: UserEvent) => { async (user: UserEvent) => {
await user.type(screen.getByPlaceholderText('Short code length'), '15'); await user.type(screen.getByPlaceholderText('Short code length'), '15');
}, },
{ shortCodeLength: '15' }, { shortCodeLength: '15' },
null,
], ],
])('saves short URL with data set in form controls', async (extraFields, extraExpectedValues) => { [
const { user } = setUp(); async (user: UserEvent) => {
await user.type(screen.getByPlaceholderText('Android-specific redirection'), 'https://android.com');
await user.type(screen.getByPlaceholderText('iOS-specific redirection'), 'https://ios.com');
},
{
deviceLongUrls: {
android: 'https://android.com',
ios: 'https://ios.com',
},
},
Mock.of<ReachableServer>({ version: '3.5.0' }),
],
])('saves short URL with data set in form controls', async (extraFields, extraExpectedValues, selectedServer) => {
const { user } = setUp(selectedServer);
const validSince = parseDate('2017-01-01', 'yyyy-MM-dd'); const validSince = parseDate('2017-01-01', 'yyyy-MM-dd');
const validUntil = parseDate('2017-01-06', 'yyyy-MM-dd'); const validUntil = parseDate('2017-01-06', 'yyyy-MM-dd');
@ -81,17 +96,18 @@ describe('<ShortUrlForm />', () => {
[null, true, 'new title'], [null, true, 'new title'],
[undefined, true, 'new title'], [undefined, true, 'new title'],
['', true, 'new title'], ['', true, 'new title'],
[null, false, undefined], ['old title', true, 'new title'],
['', false, undefined], [null, false, null],
['', false, ''],
[undefined, false, undefined],
['old title', false, null], ['old title', false, null],
])('sends expected title based on original and new values', async (originalTitle, withNewTitle, expectedSentTitle) => { ])('sends expected title based on original and new values', async (originalTitle, withNewTitle, expectedSentTitle) => {
const { user } = setUp(Mock.of<ReachableServer>({ version: '2.6.0' }), 'create', originalTitle); const { user } = setUp(Mock.of<ReachableServer>({ version: '2.6.0' }), 'create', originalTitle);
await user.type(screen.getByPlaceholderText('URL to be shortened'), 'https://long-domain.com/foo/bar'); await user.type(screen.getByPlaceholderText('URL to be shortened'), 'https://long-domain.com/foo/bar');
await user.clear(screen.getByPlaceholderText('Title'));
if (withNewTitle) { if (withNewTitle) {
await user.type(screen.getByPlaceholderText('Title'), 'new title'); await user.type(screen.getByPlaceholderText('Title'), 'new title');
} else {
await user.clear(screen.getByPlaceholderText('Title'));
} }
await user.click(screen.getByRole('button', { name: 'Save' })); await user.click(screen.getByRole('button', { name: 'Save' }));
@ -99,4 +115,20 @@ describe('<ShortUrlForm />', () => {
title: expectedSentTitle, title: expectedSentTitle,
})); }));
}); });
it.each([
[Mock.of<ReachableServer>({ version: '3.0.0' }), false],
[Mock.of<ReachableServer>({ version: '3.4.0' }), false],
[Mock.of<ReachableServer>({ version: '3.5.0' }), true],
[Mock.of<ReachableServer>({ version: '3.6.0' }), true],
])('shows device-specific long URLs only for servers supporting it', (selectedServer, fieldsExist) => {
setUp(selectedServer);
const placeholders = ['Android-specific redirection', 'iOS-specific redirection', 'Desktop-specific redirection'];
if (fieldsExist) {
placeholders.forEach((placeholder) => expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument());
} else {
placeholders.forEach((placeholder) => expect(screen.queryByPlaceholderText(placeholder)).not.toBeInTheDocument());
}
});
}); });