Skip to content

Commit

Permalink
Merge pull request #813 from acelaya-forks/feature/device-long-urls
Browse files Browse the repository at this point in the history
Feature/device long urls
  • Loading branch information
acelaya committed Mar 14, 2023
2 parents fa69c21 + 16d7488 commit 4674904
Show file tree
Hide file tree
Showing 17 changed files with 353 additions and 91 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Expand Up @@ -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
Expand Down
86 changes: 61 additions & 25 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions package.json
Expand Up @@ -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",
Expand Down
86 changes: 65 additions & 21 deletions src/short-urls/ShortUrlForm.tsx
@@ -1,6 +1,10 @@
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
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 { 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 type { InputType } from 'reactstrap/types/lib/Input';
Expand All @@ -12,10 +16,10 @@ 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 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';
Expand All @@ -41,38 +45,38 @@ export const ShortUrlForm = (
DomainSelector: FC<DomainSelectorProps>,
): FC<ShortUrlFormProps> => ({ 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<never, OptionalString>([
[() => !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 = {},
) => (
<FormGroup {...fromGroupProps}>
Expand All @@ -81,11 +85,27 @@ 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}
/>
</FormGroup>
);
const renderDeviceLongUrlInput = (id: keyof DeviceLongUrls, placeholder: string, icon: IconProp) => (
<IconInput
icon={icon}
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]),
},
})}
/>
);
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateTimeInputProps> = {}) => (
<DateTimeInput
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
Expand Down Expand Up @@ -123,14 +143,38 @@ export const ShortUrlForm = (
{isBasicMode && basicComponents}
{!isBasicMode && (
<>
<SimpleCard title="Main options" className="mb-3">
{basicComponents}
</SimpleCard>
<Row>
<div
className={classNames('mb-3', { 'col-sm-6': supportsDeviceLongUrls, 'col-12': !supportsDeviceLongUrls })}
>
<SimpleCard title="Main options" className="mb-3">
{basicComponents}
</SimpleCard>
</div>
{supportsDeviceLongUrls && (
<div className="col-sm-6 mb-3">
<SimpleCard title="Device-specific long URLs">
<FormGroup>
{renderDeviceLongUrlInput('android', 'Android-specific redirection', faAndroid)}
</FormGroup>
<FormGroup>
{renderDeviceLongUrlInput('ios', 'iOS-specific redirection', faApple)}
</FormGroup>
{renderDeviceLongUrlInput('desktop', 'Desktop-specific redirection', faDesktop)}
</SimpleCard>
</div>
)}
</Row>

<Row>
<div className="col-sm-6 mb-3">
<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 && (
<>
<Row>
Expand Down
8 changes: 8 additions & 0 deletions 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;
Expand Down Expand Up @@ -30,6 +37,7 @@ export interface ShortUrl {
shortCode: string;
shortUrl: string;
longUrl: string;
deviceLongUrls?: Required<DeviceLongUrls>, // Optional only before Shlink 3.5.0
dateCreated: string;
/** @deprecated */
visitsCount: number; // Deprecated since Shlink 3.4.0
Expand Down
2 changes: 1 addition & 1 deletion 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';
Expand Down
1 change: 1 addition & 0 deletions src/short-urls/helpers/index.ts
Expand Up @@ -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,
};
};
Expand Down
2 changes: 1 addition & 1 deletion 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';
Expand Down
26 changes: 26 additions & 0 deletions 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;
}

0 comments on commit 4674904

Please sign in to comment.