diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index eb2e2c23..a893f9c0 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -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 { cond, isEmpty, pipe, replace, T, trim } from 'ramda'; -import type { FC } from 'react'; +import { isEmpty, pipe, replace, trim } from 'ramda'; +import type { ChangeEvent, FC } 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 { DomainSelectorProps } from '../domains/DomainSelector'; import type { SelectedServer } from '../servers/data'; @@ -13,9 +17,8 @@ import { DateTimeInput } from '../utils/dates/DateTimeInput'; import { formatIsoDate } from '../utils/helpers/date'; import { useFeature } from '../utils/helpers/features'; import { SimpleCard } from '../utils/SimpleCard'; -import type { OptionalString } 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 { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon'; import './ShortUrlForm.scss'; @@ -41,38 +44,38 @@ export const ShortUrlForm = ( DomainSelector: FC, ): FC => ({ mode, saving, onSave, initialState, selectedServer }) => { const [shortUrlData, setShortUrlData] = useState(initialState); + const reset = () => setShortUrlData(initialState); + const supportsDeviceLongUrls = useFeature('deviceLongUrls', selectedServer); + const isEdit = mode === 'edit'; const isBasicMode = mode === 'create-basic'; - const hadTitleOriginally = hasValue(initialState.title); const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); - const reset = () => setShortUrlData(initialState); - const resolveNewTitle = (): OptionalString => { - const hasNewTitle = hasValue(shortUrlData.title); - const matcher = cond([ - [() => !hasNewTitle && !hadTitleOriginally, () => undefined], - [() => !hasNewTitle && hadTitleOriginally, () => null], - [T, () => shortUrlData.title], - ]); + const setResettableValue = (value: string, initialValue?: any) => { + if (hasValue(value)) { + return value; + } - 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({ ...shortUrlData, validSince: formatIsoDate(shortUrlData.validSince) ?? null, validUntil: formatIsoDate(shortUrlData.validUntil) ?? null, maxVisits: !hasValue(shortUrlData.maxVisits) ? null : Number(shortUrlData.maxVisits), - title: resolveNewTitle(), }).then(() => !isEdit && reset()).catch(() => {})); useEffect(() => { setShortUrlData(initialState); }, [initialState]); + // TODO Consider extracting these functions to local components const renderOptionalInput = ( id: NonDateFields, placeholder: string, type: InputType = 'text', - props = {}, + props: any = {}, fromGroupProps = {}, ) => ( @@ -81,11 +84,31 @@ export const ShortUrlForm = ( type={type} placeholder={placeholder} value={shortUrlData[id] ?? ''} - onChange={(e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value })} + onChange={props.onChange ?? ((e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value }))} {...props} /> ); + const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => ( + + + + + setShortUrlData({ + ...shortUrlData, + deviceLongUrls: { + ...(shortUrlData.deviceLongUrls ?? {}), + [id]: setResettableValue(e.target.value, initialState.deviceLongUrls?.[id]), + }, + })} + /> + + ); const renderDateInput = (id: DateFields, placeholder: string, props: Partial = {}) => ( - - {basicComponents} - + +
+ + {basicComponents} + +
+ {supportsDeviceLongUrls && ( +
+ + + {renderDeviceLongUrlInput('android', 'Android-specific redirection', faMobileAndroidAlt)} + + + {renderDeviceLongUrlInput('ios', 'iOS-specific redirection', faAppleAlt)} + + {renderDeviceLongUrlInput('desktop', 'Desktop-specific redirection', faDesktop)} + +
+ )} +
- {renderOptionalInput('title', 'Title')} + {renderOptionalInput('title', 'Title', 'text', { + onChange: ({ target }: ChangeEvent) => setShortUrlData({ + ...shortUrlData, + title: setResettableValue(target.value, initialState.title), + }), + })} {!isEdit && ( <> diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index 78a59318..f1a5f9f8 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -1,8 +1,15 @@ import type { Order } from '../../utils/helpers/ordering'; import type { Nullable, OptionalString } from '../../utils/utils'; +export interface DeviceLongUrls { + android?: OptionalString; + ios?: OptionalString; + desktop?: OptionalString; +} + export interface EditShortUrlData { longUrl?: string; + deviceLongUrls?: DeviceLongUrls; tags?: string[]; title?: string | null; validSince?: Date | string | null; @@ -30,6 +37,7 @@ export interface ShortUrl { shortCode: string; shortUrl: string; longUrl: string; + deviceLongUrls?: Required, // Optional only before Shlink 3.5.0 dateCreated: string; /** @deprecated */ visitsCount: number; // Deprecated since Shlink 3.4.0 diff --git a/src/short-urls/helpers/index.ts b/src/short-urls/helpers/index.ts index a5042608..771db963 100644 --- a/src/short-urls/helpers/index.ts +++ b/src/short-urls/helpers/index.ts @@ -37,6 +37,7 @@ export const shortUrlDataFromShortUrl = (shortUrl?: ShortUrl, settings?: ShortUr maxVisits: shortUrl.meta.maxVisits ?? undefined, crawlable: shortUrl.crawlable, forwardQuery: shortUrl.forwardQuery, + deviceLongUrls: shortUrl.deviceLongUrls, validateUrl, }; }; diff --git a/test/short-urls/ShortUrlForm.test.tsx b/test/short-urls/ShortUrlForm.test.tsx index 03be97e2..da31944d 100644 --- a/test/short-urls/ShortUrlForm.test.tsx +++ b/test/short-urls/ShortUrlForm.test.tsx @@ -31,15 +31,30 @@ describe('', () => { await user.type(screen.getByPlaceholderText('Custom slug'), 'my-slug'); }, { customSlug: 'my-slug' }, + null, ], [ async (user: UserEvent) => { await user.type(screen.getByPlaceholderText('Short code length'), '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({ 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 validUntil = parseDate('2017-01-06', 'yyyy-MM-dd'); @@ -81,17 +96,18 @@ describe('', () => { [null, true, 'new title'], [undefined, true, 'new title'], ['', true, 'new title'], - [null, false, undefined], - ['', false, undefined], + ['old title', true, 'new title'], + [null, false, null], + ['', false, ''], + [undefined, false, undefined], ['old title', false, null], ])('sends expected title based on original and new values', async (originalTitle, withNewTitle, expectedSentTitle) => { const { user } = setUp(Mock.of({ version: '2.6.0' }), 'create', originalTitle); await user.type(screen.getByPlaceholderText('URL to be shortened'), 'https://long-domain.com/foo/bar'); + await user.clear(screen.getByPlaceholderText('Title')); if (withNewTitle) { await user.type(screen.getByPlaceholderText('Title'), 'new title'); - } else { - await user.clear(screen.getByPlaceholderText('Title')); } await user.click(screen.getByRole('button', { name: 'Save' })); @@ -99,4 +115,20 @@ describe('', () => { title: expectedSentTitle, })); }); + + it.each([ + [Mock.of({ version: '3.0.0' }), false], + [Mock.of({ version: '3.4.0' }), false], + [Mock.of({ version: '3.5.0' }), true], + [Mock.of({ 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()); + } + }); });