From 4c5d0321d253aaa3eaff0d556805c1d4e8a079a1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 13 Mar 2023 09:05:54 +0100 Subject: [PATCH 1/8] Add support for device-specific long URLs when using Shlink 3.5.0 or newer --- src/short-urls/ShortUrlForm.tsx | 91 ++++++++++++++++++++------- src/short-urls/data/index.ts | 8 +++ src/short-urls/helpers/index.ts | 1 + test/short-urls/ShortUrlForm.test.tsx | 44 +++++++++++-- 4 files changed, 116 insertions(+), 28 deletions(-) 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()); + } + }); }); From 006e6b30b7b0ca2972860f25ef6081ad28bfb183 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 13 Mar 2023 09:06:49 +0100 Subject: [PATCH 2/8] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 211c2589..e644166e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added -* *Nothing* +* [#807](https://github.com/shlinkio/shlink-web-client/issues/807) Add support for device-specific long-URLs when creating or editing short URLs. ### Changed * Update to Vite 4.1 From bace2a10e85064b0270e44a5aa24ece29dec63c7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 13 Mar 2023 18:01:21 +0100 Subject: [PATCH 3/8] Create component to display input with an icon --- package-lock.json | 86 +++++++++++++++++++++++---------- package.json | 9 ++-- src/short-urls/ShortUrlForm.tsx | 43 ++++++++--------- src/utils/IconInput.scss | 26 ++++++++++ src/utils/IconInput.tsx | 27 +++++++++++ 5 files changed, 139 insertions(+), 52 deletions(-) create mode 100644 src/utils/IconInput.scss create mode 100644 src/utils/IconInput.tsx diff --git a/package-lock.json b/package-lock.json index 81c020a6..0dd5c8b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,11 @@ "@babel/preset-env": "^7.20.2", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", - "@fortawesome/fontawesome-free": "^6.2.1", - "@fortawesome/fontawesome-svg-core": "^6.2.1", - "@fortawesome/free-regular-svg-icons": "^6.2.1", - "@fortawesome/free-solid-svg-icons": "^6.2.1", + "@fortawesome/fontawesome-free": "^6.3.0", + "@fortawesome/fontawesome-svg-core": "^6.3.0", + "@fortawesome/free-brands-svg-icons": "^6.3.0", + "@fortawesome/free-regular-svg-icons": "^6.3.0", + "@fortawesome/free-solid-svg-icons": "^6.3.0", "@fortawesome/react-fontawesome": "^0.2.0", "@json2csv/plainjs": "^6.1.2", "@reduxjs/toolkit": "^1.9.1", @@ -1937,49 +1938,66 @@ } }, "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.2.1", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz", + "integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg==", "hasInstallScript": true, - "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.2.1", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.3.0.tgz", + "integrity": "sha512-qVtd5i1Cc7cdrqnTWqTObKQHjPWAiRwjUPaXObaeNPcy7+WKxJumGBx66rfSFgK6LNpIasVKkEgW8oyf0tmPLA==", "hasInstallScript": true, - "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.2.1", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz", + "integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==", "hasInstallScript": true, - "license": "MIT", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.2.1" + "@fortawesome/fontawesome-common-types": "6.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.3.0.tgz", + "integrity": "sha512-xI0c+a8xnKItAXCN8rZgCNCJQiVAd2Y7p9e2ND6zN3J3ekneu96qrePieJ7yA7073C1JxxoM3vH1RU7rYsaj8w==", + "hasInstallScript": true, + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.3.0" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.2.1", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.3.0.tgz", + "integrity": "sha512-cZnwiVHZ51SVzWHOaNCIA+u9wevZjCuAGSvSYpNlm6A4H4Vhwh8481Bf/5rwheIC3fFKlgXxLKaw8Xeroz8Ntg==", "hasInstallScript": true, - "license": "(CC-BY-4.0 AND MIT)", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.2.1" + "@fortawesome/fontawesome-common-types": "6.3.0" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.2.1", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz", + "integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==", "hasInstallScript": true, - "license": "(CC-BY-4.0 AND MIT)", "dependencies": { - "@fortawesome/fontawesome-common-types": "6.2.1" + "@fortawesome/fontawesome-common-types": "6.3.0" }, "engines": { "node": ">=6" @@ -13611,27 +13629,45 @@ "dev": true }, "@fortawesome/fontawesome-common-types": { - "version": "6.2.1" + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz", + "integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg==" }, "@fortawesome/fontawesome-free": { - "version": "6.2.1" + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.3.0.tgz", + "integrity": "sha512-qVtd5i1Cc7cdrqnTWqTObKQHjPWAiRwjUPaXObaeNPcy7+WKxJumGBx66rfSFgK6LNpIasVKkEgW8oyf0tmPLA==" }, "@fortawesome/fontawesome-svg-core": { - "version": "6.2.1", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz", + "integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==", "requires": { - "@fortawesome/fontawesome-common-types": "6.2.1" + "@fortawesome/fontawesome-common-types": "6.3.0" + } + }, + "@fortawesome/free-brands-svg-icons": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.3.0.tgz", + "integrity": "sha512-xI0c+a8xnKItAXCN8rZgCNCJQiVAd2Y7p9e2ND6zN3J3ekneu96qrePieJ7yA7073C1JxxoM3vH1RU7rYsaj8w==", + "requires": { + "@fortawesome/fontawesome-common-types": "6.3.0" } }, "@fortawesome/free-regular-svg-icons": { - "version": "6.2.1", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.3.0.tgz", + "integrity": "sha512-cZnwiVHZ51SVzWHOaNCIA+u9wevZjCuAGSvSYpNlm6A4H4Vhwh8481Bf/5rwheIC3fFKlgXxLKaw8Xeroz8Ntg==", "requires": { - "@fortawesome/fontawesome-common-types": "6.2.1" + "@fortawesome/fontawesome-common-types": "6.3.0" } }, "@fortawesome/free-solid-svg-icons": { - "version": "6.2.1", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz", + "integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==", "requires": { - "@fortawesome/fontawesome-common-types": "6.2.1" + "@fortawesome/fontawesome-common-types": "6.3.0" } }, "@fortawesome/react-fontawesome": { diff --git a/package.json b/package.json index 4b2dd124..95f1d9f2 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,11 @@ "@babel/preset-env": "^7.20.2", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", - "@fortawesome/fontawesome-free": "^6.2.1", - "@fortawesome/fontawesome-svg-core": "^6.2.1", - "@fortawesome/free-regular-svg-icons": "^6.2.1", - "@fortawesome/free-solid-svg-icons": "^6.2.1", + "@fortawesome/fontawesome-free": "^6.3.0", + "@fortawesome/fontawesome-svg-core": "^6.3.0", + "@fortawesome/free-brands-svg-icons": "^6.3.0", + "@fortawesome/free-regular-svg-icons": "^6.3.0", + "@fortawesome/free-solid-svg-icons": "^6.3.0", "@fortawesome/react-fontawesome": "^0.2.0", "@json2csv/plainjs": "^6.1.2", "@reduxjs/toolkit": "^1.9.1", diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index a893f9c0..68788064 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -1,12 +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 { faAndroid, faApple } from '@fortawesome/free-brands-svg-icons'; +import { faDesktop } from '@fortawesome/free-solid-svg-icons'; import classNames from 'classnames'; import { parseISO } from 'date-fns'; import { isEmpty, pipe, replace, trim } from 'ramda'; import type { ChangeEvent, FC } from 'react'; import { useEffect, useState } from 'react'; -import { Button, FormGroup, Input, InputGroup, InputGroupText, Row } from 'reactstrap'; +import { Button, FormGroup, Input, Row } from 'reactstrap'; import type { InputType } from 'reactstrap/types/lib/Input'; import type { DomainSelectorProps } from '../domains/DomainSelector'; import type { SelectedServer } from '../servers/data'; @@ -16,6 +16,7 @@ import type { DateTimeInputProps } from '../utils/dates/DateTimeInput'; import { DateTimeInput } from '../utils/dates/DateTimeInput'; import { formatIsoDate } from '../utils/helpers/date'; import { useFeature } from '../utils/helpers/features'; +import { IconInput } from '../utils/IconInput'; import { SimpleCard } from '../utils/SimpleCard'; import { handleEventPreventingDefault, hasValue } from '../utils/utils'; import type { DeviceLongUrls, ShortUrlData } from './data'; @@ -90,24 +91,20 @@ export const ShortUrlForm = ( ); const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => ( - - - - - setShortUrlData({ - ...shortUrlData, - deviceLongUrls: { - ...(shortUrlData.deviceLongUrls ?? {}), - [id]: setResettableValue(e.target.value, initialState.deviceLongUrls?.[id]), - }, - })} - /> - + setShortUrlData({ + ...shortUrlData, + deviceLongUrls: { + ...(shortUrlData.deviceLongUrls ?? {}), + [id]: setResettableValue(e.target.value, initialState.deviceLongUrls?.[id]), + }, + })} + /> ); const renderDateInput = (id: DateFields, placeholder: string, props: Partial = {}) => ( - {renderDeviceLongUrlInput('android', 'Android-specific redirection', faMobileAndroidAlt)} + {renderDeviceLongUrlInput('android', 'Android-specific redirection', faAndroid)} - {renderDeviceLongUrlInput('ios', 'iOS-specific redirection', faAppleAlt)} + {renderDeviceLongUrlInput('ios', 'iOS-specific redirection', faApple)} {renderDeviceLongUrlInput('desktop', 'Desktop-specific redirection', faDesktop)} diff --git a/src/utils/IconInput.scss b/src/utils/IconInput.scss new file mode 100644 index 00000000..3e3e6d04 --- /dev/null +++ b/src/utils/IconInput.scss @@ -0,0 +1,26 @@ +@import './mixins/vertical-align'; +@import './base'; + +.icon-input-container { + position: relative; +} + +.icon-input-container__input { + padding-right: 35px !important; +} + +.icon-input-container__input:not(:disabled) { + background-color: var(--primary-color) !important; +} + +.card .icon-input-container__input:not(:disabled), +.dropdown .icon-input-container__input:not(:disabled) { + background-color: var(--input-color) !important; +} + +.icon-input-container__icon { + @include vertical-align(); + + right: .75rem; + cursor: pointer; +} diff --git a/src/utils/IconInput.tsx b/src/utils/IconInput.tsx new file mode 100644 index 00000000..7afe0416 --- /dev/null +++ b/src/utils/IconInput.tsx @@ -0,0 +1,27 @@ +import type { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import classNames from 'classnames'; +import type { FC } from 'react'; +import { useRef } from 'react'; +import type { InputProps } from 'reactstrap'; +import { Input } from 'reactstrap'; +import './IconInput.scss'; + +type IconInputProps = InputProps & { icon: IconProp }; + +export const IconInput: FC = ({ icon, className, ...rest }) => { + const ref = useRef<{ input: HTMLInputElement }>(); + const classes = classNames('icon-input-container__input', className); + + return ( +
+ + ref.current?.input.focus()} + /> +
+ ); +}; From 2b14c49c803a5c9c7ea1cb893a5f6338d782eb3d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 13 Mar 2023 18:04:09 +0100 Subject: [PATCH 4/8] Update snapshots --- .../__snapshots__/UserInterfaceSettings.test.tsx.snap | 4 ++-- test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/settings/__snapshots__/UserInterfaceSettings.test.tsx.snap b/test/settings/__snapshots__/UserInterfaceSettings.test.tsx.snap index 0e8a383d..d36b6cfa 100644 --- a/test/settings/__snapshots__/UserInterfaceSettings.test.tsx.snap +++ b/test/settings/__snapshots__/UserInterfaceSettings.test.tsx.snap @@ -30,7 +30,7 @@ exports[` shows different icons based on theme 2`] = ` xmlns="http://www.w3.org/2000/svg" > @@ -48,7 +48,7 @@ exports[` shows different icons based on theme 3`] = ` xmlns="http://www.w3.org/2000/svg" > diff --git a/test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap b/test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap index de841e3e..e491ea66 100644 --- a/test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap +++ b/test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap @@ -13,7 +13,7 @@ exports[` wraps expected components 1`] = ` xmlns="http://www.w3.org/2000/svg" > From 3be5126e2de95b1f4e9c0531bcf0235662ed580e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 13 Mar 2023 18:18:35 +0100 Subject: [PATCH 5/8] Add missing ref to IconInput --- src/utils/IconInput.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/IconInput.tsx b/src/utils/IconInput.tsx index 7afe0416..9b0e45d4 100644 --- a/src/utils/IconInput.tsx +++ b/src/utils/IconInput.tsx @@ -2,25 +2,27 @@ import type { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import type { FC } from 'react'; -import { useRef } from 'react'; import type { InputProps } from 'reactstrap'; import { Input } from 'reactstrap'; +import { useElementRef } from './helpers/hooks'; import './IconInput.scss'; -type IconInputProps = InputProps & { icon: IconProp }; +type IconInputProps = InputProps & { + icon: IconProp; +}; export const IconInput: FC = ({ icon, className, ...rest }) => { - const ref = useRef<{ input: HTMLInputElement }>(); + const ref = useElementRef(); const classes = classNames('icon-input-container__input', className); return (
- + ref.current?.input.focus()} + onClick={() => ref.current?.focus()} />
); From 999b21577afaf0e592bc83ac6ef9eb090d89b5b9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 14 Mar 2023 08:50:53 +0100 Subject: [PATCH 6/8] Removed duplicated CSS from DateInput --- src/utils/dates/DateInput.scss | 24 ------------------------ src/utils/dates/DateInput.tsx | 6 +++--- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/src/utils/dates/DateInput.scss b/src/utils/dates/DateInput.scss index f68453dc..d61fd0d2 100644 --- a/src/utils/dates/DateInput.scss +++ b/src/utils/dates/DateInput.scss @@ -1,30 +1,6 @@ @import '../mixins/vertical-align'; @import '../base'; -.date-input-container { - position: relative; -} - -.date-input-container__input { - padding-right: 35px !important; -} - -.date-input-container__input:not(:disabled) { - background-color: var(--primary-color) !important; -} - -.card .date-input-container__input:not(:disabled), -.dropdown .date-input-container__input:not(:disabled) { - background-color: var(--input-color) !important; -} - -.date-input-container__icon { - @include vertical-align(); - - right: .75rem; - cursor: pointer; -} - .react-datepicker__close-icon.react-datepicker__close-icon { @include vertical-align(); diff --git a/src/utils/dates/DateInput.tsx b/src/utils/dates/DateInput.tsx index 07fd96cc..9f32aaa9 100644 --- a/src/utils/dates/DateInput.tsx +++ b/src/utils/dates/DateInput.tsx @@ -16,7 +16,7 @@ export const DateInput = (props: DateInputProps) => { const ref = useRef<{ input: HTMLInputElement }>(); return ( -
+
{ }, ]} dateFormat={dateFormat ?? STANDARD_DATE_FORMAT} - className={classNames('date-input-container__input form-control', className)} + className={classNames('icon-input-container__input form-control', className)} // @ts-expect-error The DatePicker type definition is wrong. It has a ref prop ref={ref} /> {showCalendarIcon && ( ref.current?.input.focus()} /> )} From 3e698b045ab15f454711a3c8fa19c2bfd04c6570 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 14 Mar 2023 09:02:12 +0100 Subject: [PATCH 7/8] Add IconInput test --- test/utils/IconInput.test.tsx | 24 ++++++ .../__snapshots__/IconInput.test.tsx.snap | 85 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 test/utils/IconInput.test.tsx create mode 100644 test/utils/__snapshots__/IconInput.test.tsx.snap diff --git a/test/utils/IconInput.test.tsx b/test/utils/IconInput.test.tsx new file mode 100644 index 00000000..a5d863d0 --- /dev/null +++ b/test/utils/IconInput.test.tsx @@ -0,0 +1,24 @@ +import type { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { faAppleAlt, faCalendar, faTable } from '@fortawesome/free-solid-svg-icons'; +import { screen } from '@testing-library/react'; +import { IconInput } from '../../src/utils/IconInput'; +import { renderWithEvents } from '../__helpers__/setUpTest'; + +describe('', () => { + const setUp = (icon: IconProp, placeholder?: string) => renderWithEvents( + , + ); + + it.each([faCalendar, faAppleAlt, faTable])('displays provided icon', (icon) => { + const { container } = setUp(icon); + expect(container).toMatchSnapshot(); + }); + + it('focuses input on icon click', async () => { + const { user } = setUp(faCalendar, 'foo'); + + expect(screen.getByPlaceholderText('foo')).not.toHaveFocus(); + await user.click(screen.getByRole('img', { hidden: true })); + expect(screen.getByPlaceholderText('foo')).toHaveFocus(); + }); +}); diff --git a/test/utils/__snapshots__/IconInput.test.tsx.snap b/test/utils/__snapshots__/IconInput.test.tsx.snap new file mode 100644 index 00000000..cc72248c --- /dev/null +++ b/test/utils/__snapshots__/IconInput.test.tsx.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` displays provided icon 1`] = ` +
+
+ + +
+
+`; + +exports[` displays provided icon 2`] = ` +
+
+ + +
+
+`; + +exports[` displays provided icon 3`] = ` +
+
+ + +
+
+`; From 16d748800c168250dd647c3d515f7f309d3b7e4e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 14 Mar 2023 09:06:57 +0100 Subject: [PATCH 8/8] Update copy-to-clipboard icons --- src/short-urls/helpers/CreateShortUrlResult.tsx | 2 +- src/utils/CopyToClipboardIcon.tsx | 2 +- test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/short-urls/helpers/CreateShortUrlResult.tsx b/src/short-urls/helpers/CreateShortUrlResult.tsx index d5abd35d..aa60e906 100644 --- a/src/short-urls/helpers/CreateShortUrlResult.tsx +++ b/src/short-urls/helpers/CreateShortUrlResult.tsx @@ -1,4 +1,4 @@ -import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons'; +import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons'; import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useEffect } from 'react'; diff --git a/src/utils/CopyToClipboardIcon.tsx b/src/utils/CopyToClipboardIcon.tsx index c2f159d2..7cde85b0 100644 --- a/src/utils/CopyToClipboardIcon.tsx +++ b/src/utils/CopyToClipboardIcon.tsx @@ -1,4 +1,4 @@ -import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons'; +import { faClone as copyIcon } from '@fortawesome/free-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import type { FC } from 'react'; import CopyToClipboard from 'react-copy-to-clipboard'; diff --git a/test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap b/test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap index e491ea66..27dc438a 100644 --- a/test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap +++ b/test/utils/__snapshots__/CopyToClipboardIcon.test.tsx.snap @@ -4,8 +4,8 @@ exports[` wraps expected components 1`] = `
wraps expected components 1`] = ` xmlns="http://www.w3.org/2000/svg" >