mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Add support for device-specific long URLs when using Shlink 3.5.0 or newer
This commit is contained in:
parent
fa69c21fa2
commit
4c5d0321d2
4 changed files with 116 additions and 28 deletions
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue