Merge pull request #404 from acelaya-forks/feature/edit-title

Feature/edit title
This commit is contained in:
Alejandro Celaya 2021-03-27 19:01:02 +01:00 committed by GitHub
commit 205e3ffb90
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 737 additions and 1370 deletions

View file

@ -14,6 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* [#383](https://github.com/shlinkio/shlink-web-client/issues/383) Added title to short URLs list, displayed when consuming Shlink >=2.6.0. * [#383](https://github.com/shlinkio/shlink-web-client/issues/383) Added title to short URLs list, displayed when consuming Shlink >=2.6.0.
* [#368](https://github.com/shlinkio/shlink-web-client/issues/368) Added new settings to define the default interval for visits pages. * [#368](https://github.com/shlinkio/shlink-web-client/issues/368) Added new settings to define the default interval for visits pages.
* [#349](https://github.com/shlinkio/shlink-web-client/issues/349) Added support to export visits to CSV. * [#349](https://github.com/shlinkio/shlink-web-client/issues/349) Added support to export visits to CSV.
* [#397](https://github.com/shlinkio/shlink-web-client/issues/397) New section to edit all data for short URLs, including title when using Shlink v2.6 or newer.
This new section replaces the old modals to edit short URL meta, short URL tags and the long URL. Everything is now together in the same section.
### Changed ### Changed
* [#382](https://github.com/shlinkio/shlink-web-client/issues/382) Ensured short URL tags are edited through the `PATCH /short-urls/{shortCode}` endpoint when using Shlink 2.6.0 or higher. * [#382](https://github.com/shlinkio/shlink-web-client/issues/382) Ensured short URL tags are edited through the `PATCH /short-urls/{shortCode}` endpoint when using Shlink 2.6.0 or higher.

View file

@ -12,7 +12,7 @@ import {
ShlinkTagsResponse, ShlinkTagsResponse,
ShlinkVisits, ShlinkVisits,
ShlinkVisitsParams, ShlinkVisitsParams,
ShlinkShortUrlMeta, ShlinkShortUrlData,
ShlinkDomain, ShlinkDomain,
ShlinkDomainsResponse, ShlinkDomainsResponse,
ShlinkVisitsOverview, ShlinkVisitsOverview,
@ -67,7 +67,7 @@ export default class ShlinkApiClient {
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain }) this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
.then(() => {}); .then(() => {});
/* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrlMeta instead */ /* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead */
public readonly updateShortUrlTags = async ( public readonly updateShortUrlTags = async (
shortCode: string, shortCode: string,
domain: OptionalString, domain: OptionalString,
@ -76,12 +76,12 @@ export default class ShlinkApiClient {
this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags }) this.performRequest<{ tags: string[] }>(`/short-urls/${shortCode}/tags`, 'PUT', { domain }, { tags })
.then(({ data }) => data.tags); .then(({ data }) => data.tags);
public readonly updateShortUrlMeta = async ( public readonly updateShortUrl = async (
shortCode: string, shortCode: string,
domain: OptionalString, domain: OptionalString,
meta: ShlinkShortUrlMeta, data: ShlinkShortUrlData,
): Promise<ShortUrl> => ): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, meta) this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, data)
.then(({ data }) => data); .then(({ data }) => data);
public readonly listTags = async (): Promise<ShlinkTags> => public readonly listTags = async (): Promise<ShlinkTags> =>

View file

@ -57,8 +57,10 @@ export interface ShlinkVisitsParams {
endDate?: string; endDate?: string;
} }
export interface ShlinkShortUrlMeta extends ShortUrlMeta { export interface ShlinkShortUrlData extends ShortUrlMeta {
longUrl?: string; longUrl?: string;
title?: string;
validateUrl?: boolean;
tags?: string[]; tags?: string[];
} }

View file

@ -21,6 +21,7 @@ const MenuLayout = (
OrphanVisits: FC, OrphanVisits: FC,
ServerError: FC, ServerError: FC,
Overview: FC, Overview: FC,
EditShortUrl: FC,
) => withSelectedServer(({ location, selectedServer }) => { ) => withSelectedServer(({ location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle(); const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
@ -50,6 +51,7 @@ const MenuLayout = (
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} /> <Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} /> <Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} /> <Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />} {addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />} {addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} /> <Route exact path="/server/:serverId/manage-tags" component={TagsList} />

View file

@ -37,6 +37,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'OrphanVisits', 'OrphanVisits',
'ServerError', 'ServerError',
'Overview', 'Overview',
'EditShortUrl',
); );
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ])); bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter); bottle.decorator('MenuLayout', withRouter);

View file

@ -1,12 +1,10 @@
import { MercureInfo } from '../mercure/reducers/mercureInfo'; import { MercureInfo } from '../mercure/reducers/mercureInfo';
import { SelectedServer, ServersMap } from '../servers/data'; import { SelectedServer, ServersMap } from '../servers/data';
import { Settings } from '../settings/reducers/settings'; import { Settings } from '../settings/reducers/settings';
import { ShortUrlMetaEdition } from '../short-urls/reducers/shortUrlMeta';
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation'; import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion'; import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition'; import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams'; import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
import { ShortUrlTags } from '../short-urls/reducers/shortUrlTags';
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList'; import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
import { TagDeletion } from '../tags/reducers/tagDelete'; import { TagDeletion } from '../tags/reducers/tagDelete';
import { TagEdition } from '../tags/reducers/tagEdit'; import { TagEdition } from '../tags/reducers/tagEdit';
@ -25,8 +23,6 @@ export interface ShlinkState {
shortUrlsListParams: ShortUrlsListParams; shortUrlsListParams: ShortUrlsListParams;
shortUrlCreationResult: ShortUrlCreation; shortUrlCreationResult: ShortUrlCreation;
shortUrlDeletion: ShortUrlDeletion; shortUrlDeletion: ShortUrlDeletion;
shortUrlTags: ShortUrlTags;
shortUrlMeta: ShortUrlMetaEdition;
shortUrlEdition: ShortUrlEdition; shortUrlEdition: ShortUrlEdition;
shortUrlVisits: ShortUrlVisits; shortUrlVisits: ShortUrlVisits;
tagVisits: TagVisits; tagVisits: TagVisits;

View file

@ -5,8 +5,6 @@ import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams'; import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation'; import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion'; import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits';
@ -28,8 +26,6 @@ export default combineReducers<ShlinkState>({
shortUrlsListParams: shortUrlsListParamsReducer, shortUrlsListParams: shortUrlsListParamsReducer,
shortUrlCreationResult: shortUrlCreationReducer, shortUrlCreationResult: shortUrlCreationReducer,
shortUrlDeletion: shortUrlDeletionReducer, shortUrlDeletion: shortUrlDeletionReducer,
shortUrlTags: shortUrlTagsReducer,
shortUrlMeta: shortUrlMetaReducer,
shortUrlEdition: shortUrlEditionReducer, shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer, shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer, tagVisits: tagVisitsReducer,

View file

@ -1,6 +0,0 @@
@import '../utils/base';
.create-short-url .form-group:last-child,
.create-short-url p:last-child {
margin-bottom: 0;
}

View file

@ -1,24 +1,10 @@
import { isEmpty, pipe, replace, trim } from 'ramda'; import { FC, useMemo } from 'react';
import { FC, useMemo, useState } from 'react';
import { Button, FormGroup, Input } from 'reactstrap';
import { InputType } from 'reactstrap/lib/Input';
import * as m from 'moment';
import DateInput, { DateInputProps } from '../utils/DateInput';
import Checkbox from '../utils/Checkbox';
import { Versions } from '../utils/helpers/version';
import { supportsListingDomains, supportsSettingShortCodeLength } from '../utils/helpers/features';
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import { SelectedServer } from '../servers/data'; import { SelectedServer } from '../servers/data';
import { formatIsoDate } from '../utils/helpers/date';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { DomainSelectorProps } from '../domains/DomainSelector';
import { SimpleCard } from '../utils/SimpleCard';
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings'; import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
import { ShortUrlData } from './data'; import { ShortUrlData } from './data';
import { ShortUrlCreation } from './reducers/shortUrlCreation'; import { ShortUrlCreation } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult'; import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
import './CreateShortUrl.scss'; import { ShortUrlFormProps } from './ShortUrlForm';
export interface CreateShortUrlProps { export interface CreateShortUrlProps {
basicMode?: boolean; basicMode?: boolean;
@ -32,12 +18,11 @@ interface CreateShortUrlConnectProps extends CreateShortUrlProps {
resetCreateShortUrl: () => void; resetCreateShortUrl: () => void;
} }
export const normalizeTag = pipe(trim, replace(/ /g, '-'));
const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => ({
longUrl: '', longUrl: '',
tags: [], tags: [],
customSlug: '', customSlug: '',
title: undefined,
shortCodeLength: undefined, shortCodeLength: undefined,
domain: '', domain: '',
validSince: undefined, validSince: undefined,
@ -47,15 +32,7 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => (
validateUrl: settings?.validateUrls ?? false, validateUrl: settings?.validateUrls ?? false,
}); });
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits'; const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>) => ({
type DateFields = 'validSince' | 'validUntil';
const CreateShortUrl = (
TagsSelector: FC<TagsSelectorProps>,
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
ForServerVersion: FC<Versions>,
DomainSelector: FC<DomainSelectorProps>,
) => ({
createShortUrl, createShortUrl,
shortUrlCreationResult, shortUrlCreationResult,
resetCreateShortUrl, resetCreateShortUrl,
@ -64,154 +41,22 @@ const CreateShortUrl = (
settings: { shortUrlCreation: shortUrlCreationSettings }, settings: { shortUrlCreation: shortUrlCreationSettings },
}: CreateShortUrlConnectProps) => { }: CreateShortUrlConnectProps) => {
const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [ shortUrlCreationSettings ]); const initialState = useMemo(() => getInitialState(shortUrlCreationSettings), [ shortUrlCreationSettings ]);
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlCreation(initialState);
const save = handleEventPreventingDefault(() => {
const shortUrlData = {
...shortUrlCreation,
validSince: formatIsoDate(shortUrlCreation.validSince) ?? undefined,
validUntil: formatIsoDate(shortUrlCreation.validUntil) ?? undefined,
};
createShortUrl(shortUrlData).then(reset).catch(() => {});
});
const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => (
<FormGroup>
<Input
id={id}
type={type}
placeholder={placeholder}
value={shortUrlCreation[id]}
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, [id]: e.target.value })}
{...props}
/>
</FormGroup>
);
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
<div className="form-group">
<DateInput
selected={shortUrlCreation[id] as m.Moment | null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlCreation({ ...shortUrlCreation, [id]: date })}
{...props}
/>
</div>
);
const basicComponents = (
<>
<FormGroup>
<Input
bsSize="lg"
type="url"
placeholder="URL to be shortened"
required
value={shortUrlCreation.longUrl}
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
/>
</FormGroup>
<FormGroup>
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
</FormGroup>
</>
);
const showDomainSelector = supportsListingDomains(selectedServer);
const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer);
return ( return (
<form className="create-short-url" onSubmit={save}>
{basicMode && basicComponents}
{!basicMode && (
<> <>
<SimpleCard title="Basic options" className="mb-3"> <ShortUrlForm
{basicComponents} initialState={initialState}
</SimpleCard> saving={shortUrlCreationResult.saving}
selectedServer={selectedServer}
<div className="row"> mode={basicMode ? 'create-basic' : 'create'}
<div className="col-sm-6 mb-3"> onSave={createShortUrl}
<SimpleCard title="Customize the short URL">
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
disabled: hasValue(shortUrlCreation.shortCodeLength),
})}
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
min: 4,
disabled: disableShortCodeLength || hasValue(shortUrlCreation.customSlug),
...disableShortCodeLength && {
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
},
})}
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')}
{showDomainSelector && (
<FormGroup>
<DomainSelector
value={shortUrlCreation.domain}
onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
/> />
</FormGroup>
)}
</SimpleCard>
</div>
<div className="col-sm-6 mb-3">
<SimpleCard title="Limit access to the short URL">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
</SimpleCard>
</div>
</div>
<SimpleCard title="Extra validations" className="mb-3">
<p>
Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all
provided data.
</p>
<ForServerVersion minVersion="2.4.0">
<p>
<Checkbox
inline
checked={shortUrlCreation.validateUrl}
onChange={(validateUrl) => setShortUrlCreation({ ...shortUrlCreation, validateUrl })}
>
Validate URL
</Checkbox>
</p>
</ForServerVersion>
<p>
<Checkbox
inline
className="mr-2"
checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</p>
</SimpleCard>
</>
)}
<div className="text-center">
<Button
outline
color="primary"
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
className="btn-xs-block"
>
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
</Button>
</div>
<CreateShortUrlResult <CreateShortUrlResult
{...shortUrlCreationResult} {...shortUrlCreationResult}
resetCreateShortUrl={resetCreateShortUrl} resetCreateShortUrl={resetCreateShortUrl}
canBeClosed={basicMode} canBeClosed={basicMode}
/> />
</form> </>
); );
}; };

View file

@ -0,0 +1,111 @@
import { FC, useEffect, useMemo } from 'react';
import { RouteComponentProps } from 'react-router';
import { Button, Card } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link';
import { SelectedServer } from '../servers/data';
import { Settings, ShortUrlCreationSettings } from '../settings/reducers/settings';
import { OptionalString } from '../utils/utils';
import { parseQuery } from '../utils/helpers/query';
import Message from '../utils/Message';
import { Result } from '../utils/Result';
import { ShlinkApiError } from '../api/ShlinkApiError';
import { ShortUrlFormProps } from './ShortUrlForm';
import { ShortUrlDetail } from './reducers/shortUrlDetail';
import { EditShortUrlData, ShortUrl, ShortUrlData } from './data';
import { ShortUrlEdition } from './reducers/shortUrlEdition';
interface EditShortUrlConnectProps extends RouteComponentProps<{ shortCode: string }> {
settings: Settings;
selectedServer: SelectedServer;
shortUrlDetail: ShortUrlDetail;
shortUrlEdition: ShortUrlEdition;
getShortUrlDetail: (shortCode: string, domain: OptionalString) => void;
editShortUrl: (shortUrl: string, domain: OptionalString, data: EditShortUrlData) => Promise<void>;
}
const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSettings): ShortUrlData => {
const validateUrl = settings?.validateUrls ?? false;
if (!shortUrl) {
return { longUrl: '', validateUrl };
}
return {
longUrl: shortUrl.longUrl,
tags: shortUrl.tags,
title: shortUrl.title ?? undefined,
domain: shortUrl.domain ?? undefined,
validSince: shortUrl.meta.validSince ?? undefined,
validUntil: shortUrl.meta.validUntil ?? undefined,
maxVisits: shortUrl.meta.maxVisits ?? undefined,
validateUrl,
};
};
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
history: { goBack },
match: { params },
location: { search },
settings: { shortUrlCreation: shortUrlCreationSettings },
selectedServer,
shortUrlDetail,
getShortUrlDetail,
shortUrlEdition,
editShortUrl,
}: EditShortUrlConnectProps) => {
const { loading, error, errorData, shortUrl } = shortUrlDetail;
const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition;
const { domain } = parseQuery<{ domain?: string }>(search);
const initialState = useMemo(
() => getInitialState(shortUrl, shortUrlCreationSettings),
[ shortUrl, shortUrlCreationSettings ],
);
useEffect(() => {
getShortUrlDetail(params.shortCode, domain);
}, []);
if (loading) {
return <Message loading />;
}
if (error) {
return (
<Result type="error">
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while loading short URL detail :(" />
</Result>
);
}
const title = <small>Edit <ExternalLink href={shortUrl?.shortUrl ?? ''} /></small>;
return (
<>
<header className="mb-3">
<Card body>
<h2 className="d-sm-flex justify-content-between align-items-center mb-0">
<Button color="link" size="lg" className="p-0 mr-3" onClick={goBack}>
<FontAwesomeIcon icon={faArrowLeft} />
</Button>
<span className="text-center">{title}</span>
<span />
</h2>
</Card>
</header>
<ShortUrlForm
initialState={initialState}
saving={saving}
selectedServer={selectedServer}
mode="edit"
onSave={async (shortUrlData) => shortUrl && editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)}
/>
{savingError && (
<Result type="error" className="mt-3">
<ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" />
</Result>
)}
</>
);
};

View file

@ -0,0 +1,10 @@
@import '../utils/base';
.short-url-form .card-body > .form-group:last-child,
.short-url-form p:last-child {
margin-bottom: 0;
}
.short-url-form .card {
height: 100%;
}

View file

@ -0,0 +1,218 @@
import { FC, useEffect, useState } from 'react';
import { InputType } from 'reactstrap/lib/Input';
import { Button, FormGroup, Input, Row } from 'reactstrap';
import { isEmpty, pipe, replace, trim } from 'ramda';
import m from 'moment';
import classNames from 'classnames';
import DateInput, { DateInputProps } from '../utils/DateInput';
import {
supportsListingDomains,
supportsSettingShortCodeLength,
supportsShortUrlTitle,
supportsValidateUrl,
} from '../utils/helpers/features';
import { SimpleCard } from '../utils/SimpleCard';
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import Checkbox from '../utils/Checkbox';
import { SelectedServer } from '../servers/data';
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
import { DomainSelectorProps } from '../domains/DomainSelector';
import { formatIsoDate } from '../utils/helpers/date';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
import { ShortUrlData } from './data';
import './ShortUrlForm.scss';
export type Mode = 'create' | 'create-basic' | 'edit';
type DateFields = 'validSince' | 'validUntil';
type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title';
export interface ShortUrlFormProps {
mode: Mode;
saving: boolean;
initialState: ShortUrlData;
onSave: (shortUrlData: ShortUrlData) => Promise<unknown>;
selectedServer: SelectedServer;
}
const normalizeTag = pipe(trim, replace(/ /g, '-'));
export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>,
DomainSelector: FC<DomainSelectorProps>,
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => { // eslint-disable-line complexity
const [ shortUrlData, setShortUrlData ] = useState(initialState);
const isEdit = mode === 'edit';
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlData(initialState);
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: !hasValue(shortUrlData.title) ? undefined : shortUrlData.title,
}).then(() => !isEdit && reset()).catch(() => {}));
useEffect(() => {
setShortUrlData(initialState);
}, [ initialState ]);
const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => (
<FormGroup>
<Input
id={id}
type={type}
placeholder={placeholder}
value={shortUrlData[id] ?? ''}
onChange={(e) => setShortUrlData({ ...shortUrlData, [id]: e.target.value })}
{...props}
/>
</FormGroup>
);
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
<div className="form-group">
<DateInput
selected={shortUrlData[id] ? m(shortUrlData[id]) : null}
placeholderText={placeholder}
isClearable
onChange={(date) => setShortUrlData({ ...shortUrlData, [id]: date })}
{...props}
/>
</div>
);
const basicComponents = (
<>
<FormGroup>
<Input
bsSize="lg"
type="url"
placeholder="URL to be shortened"
required
value={shortUrlData.longUrl}
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
/>
</FormGroup>
<FormGroup>
<TagsSelector tags={shortUrlData.tags ?? []} onChange={changeTags} />
</FormGroup>
</>
);
const showDomainSelector = supportsListingDomains(selectedServer);
const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer);
const supportsTitle = supportsShortUrlTitle(selectedServer);
const showCustomizeCard = supportsTitle || !isEdit;
const limitAccessCardClasses = classNames('mb-3', {
'col-sm-6': showCustomizeCard,
'col-sm-12': !showCustomizeCard,
});
const showValidateUrl = supportsValidateUrl(selectedServer);
const showExtraValidationsCard = showValidateUrl || !isEdit;
return (
<form className="short-url-form" onSubmit={submit}>
{mode === 'create-basic' && basicComponents}
{mode !== 'create-basic' && (
<>
<SimpleCard title="Basic options" className="mb-3">
{basicComponents}
</SimpleCard>
<Row>
{showCustomizeCard && (
<div className="col-sm-6 mb-3">
<SimpleCard title="Customize the short URL">
{supportsTitle && renderOptionalInput('title', 'Title')}
{!isEdit && (
<>
<Row>
<div className="col-lg-6">
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
disabled: hasValue(shortUrlData.shortCodeLength),
})}
</div>
<div className="col-lg-6">
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
min: 4,
disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug),
...disableShortCodeLength && {
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
},
})}
</div>
</Row>
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')}
{showDomainSelector && (
<FormGroup>
<DomainSelector
value={shortUrlData.domain}
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
/>
</FormGroup>
)}
</>
)}
</SimpleCard>
</div>
)}
<div className={limitAccessCardClasses}>
<SimpleCard title="Limit access to the short URL">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? m(shortUrlData.validUntil) : undefined })}
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? m(shortUrlData.validSince) : undefined })}
</SimpleCard>
</div>
</Row>
{showExtraValidationsCard && (
<SimpleCard title="Extra validations" className="mb-3">
{!isEdit && (
<p>
Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all
provided data.
</p>
)}
{showValidateUrl && (
<p>
<Checkbox
inline
checked={shortUrlData.validateUrl}
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
>
Validate URL
</Checkbox>
</p>
)}
{!isEdit && (
<p>
<Checkbox
inline
className="mr-2"
checked={shortUrlData.findIfExists}
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</p>
)}
</SimpleCard>
)}
</>
)}
<div className="text-center">
<Button
outline
color="primary"
disabled={saving || isEmpty(shortUrlData.longUrl)}
className="btn-xs-block"
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</form>
);
};

View file

@ -1,17 +1,22 @@
import * as m from 'moment'; import * as m from 'moment';
import { Nullable, OptionalString } from '../../utils/utils'; import { Nullable, OptionalString } from '../../utils/utils';
export interface ShortUrlData { export interface EditShortUrlData {
longUrl: string; longUrl?: string;
tags?: string[]; tags?: string[];
title?: string;
validSince?: m.Moment | string | null;
validUntil?: m.Moment | string | null;
maxVisits?: number | null;
validateUrl?: boolean;
}
export interface ShortUrlData extends EditShortUrlData {
longUrl: string;
customSlug?: string; customSlug?: string;
shortCodeLength?: number; shortCodeLength?: number;
domain?: string; domain?: string;
validSince?: m.Moment | string;
validUntil?: m.Moment | string;
maxVisits?: number;
findIfExists?: boolean; findIfExists?: boolean;
validateUrl?: boolean;
} }
export interface ShortUrl { export interface ShortUrl {

View file

@ -1,101 +0,0 @@
import { ChangeEvent, useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, UncontrolledTooltip } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { ExternalLink } from 'react-external-link';
import moment from 'moment';
import { isEmpty, pipe } from 'ramda';
import { ShortUrlMetaEdition } from '../reducers/shortUrlMeta';
import DateInput from '../../utils/DateInput';
import { formatIsoDate } from '../../utils/helpers/date';
import { ShortUrl, ShortUrlMeta, ShortUrlModalProps } from '../data';
import { handleEventPreventingDefault, Nullable, OptionalString } from '../../utils/utils';
import { Result } from '../../utils/Result';
import { ShlinkApiError } from '../../api/ShlinkApiError';
interface EditMetaModalConnectProps extends ShortUrlModalProps {
shortUrlMeta: ShortUrlMetaEdition;
resetShortUrlMeta: () => void;
editShortUrlMeta: (shortCode: string, domain: OptionalString, meta: Nullable<ShortUrlMeta>) => Promise<void>;
}
const dateOrNull = (shortUrl: ShortUrl | undefined, dateName: 'validSince' | 'validUntil') => {
const date = shortUrl?.meta?.[dateName];
return date ? moment(date) : null;
};
const EditMetaModal = (
{ isOpen, toggle, shortUrl, shortUrlMeta, editShortUrlMeta, resetShortUrlMeta }: EditMetaModalConnectProps,
) => {
const { saving, error, errorData } = shortUrlMeta;
const url = shortUrl && (shortUrl.shortUrl || '');
const [ validSince, setValidSince ] = useState(dateOrNull(shortUrl, 'validSince'));
const [ validUntil, setValidUntil ] = useState(dateOrNull(shortUrl, 'validUntil'));
const [ maxVisits, setMaxVisits ] = useState(shortUrl?.meta?.maxVisits);
const close = pipe(resetShortUrlMeta, toggle);
const doEdit = async () => editShortUrlMeta(shortUrl.shortCode, shortUrl.domain, {
maxVisits: maxVisits && !isEmpty(maxVisits) ? maxVisits : null,
validSince: validSince && formatIsoDate(validSince),
validUntil: validUntil && formatIsoDate(validUntil),
}).then(close);
return (
<Modal isOpen={isOpen} toggle={close} centered>
<ModalHeader toggle={close}>
<FontAwesomeIcon icon={infoIcon} id="metaTitleInfo" /> Edit metadata for <ExternalLink href={url} />
<UncontrolledTooltip target="metaTitleInfo" placement="bottom">
<p>Using these metadata properties, you can limit when and how many times your short URL can be visited.</p>
<p>If any of the params is not met, the URL will behave as if it was an invalid short URL.</p>
</UncontrolledTooltip>
</ModalHeader>
<form onSubmit={handleEventPreventingDefault(doEdit)}>
<ModalBody>
<FormGroup>
<DateInput
placeholderText="Enabled since..."
selected={validSince}
maxDate={validUntil}
isClearable
onChange={setValidSince}
/>
</FormGroup>
<FormGroup>
<DateInput
placeholderText="Enabled until..."
selected={validUntil}
minDate={validSince}
isClearable
onChange={setValidUntil as any}
/>
</FormGroup>
<FormGroup className="mb-0">
<Input
type="number"
placeholder="Maximum number of visits allowed"
min={1}
value={maxVisits ?? ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => setMaxVisits(Number(e.target.value))}
/>
</FormGroup>
{error && (
<Result type="error" small className="mt-2">
<ShlinkApiError
errorData={errorData}
fallbackMessage="Something went wrong while saving the metadata :("
/>
</Result>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" type="button" onClick={close}>Cancel</button>
<button className="btn btn-primary" type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
</ModalFooter>
</form>
</Modal>
);
};
export default EditMetaModal;

View file

@ -1,56 +0,0 @@
import { useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader, FormGroup, Input, Button } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { ShortUrlEdition } from '../reducers/shortUrlEdition';
import { handleEventPreventingDefault, hasValue, OptionalString } from '../../utils/utils';
import { ShortUrlModalProps } from '../data';
import { Result } from '../../utils/Result';
import { ShlinkApiError } from '../../api/ShlinkApiError';
interface EditShortUrlModalProps extends ShortUrlModalProps {
shortUrlEdition: ShortUrlEdition;
editShortUrl: (shortUrl: string, domain: OptionalString, longUrl: string) => Promise<void>;
}
const EditShortUrlModal = ({ isOpen, toggle, shortUrl, shortUrlEdition, editShortUrl }: EditShortUrlModalProps) => {
const { saving, error, errorData } = shortUrlEdition;
const url = shortUrl?.shortUrl ?? '';
const [ longUrl, setLongUrl ] = useState(shortUrl.longUrl);
const doEdit = async () => editShortUrl(shortUrl.shortCode, shortUrl.domain, longUrl).then(toggle);
return (
<Modal isOpen={isOpen} toggle={toggle} centered size="lg">
<ModalHeader toggle={toggle}>
Edit long URL for <ExternalLink href={url} />
</ModalHeader>
<form onSubmit={handleEventPreventingDefault(doEdit)}>
<ModalBody>
<FormGroup className="mb-0">
<Input
type="url"
required
placeholder="Long URL"
value={longUrl}
onChange={(e) => setLongUrl(e.target.value)}
/>
</FormGroup>
{error && (
<Result type="error" small className="mt-2">
<ShlinkApiError
errorData={errorData}
fallbackMessage="Something went wrong while saving the long URL :("
/>
</Result>
)}
</ModalBody>
<ModalFooter>
<Button color="link" onClick={toggle}>Cancel</Button>
<Button color="primary" disabled={saving || !hasValue(longUrl)}>{saving ? 'Saving...' : 'Save'}</Button>
</ModalFooter>
</form>
</Modal>
);
};
export default EditShortUrlModal;

View file

@ -1,53 +0,0 @@
import { FC, useEffect, useState } from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { ExternalLink } from 'react-external-link';
import { ShortUrlTags } from '../reducers/shortUrlTags';
import { ShortUrlModalProps } from '../data';
import { OptionalString } from '../../utils/utils';
import { TagsSelectorProps } from '../../tags/helpers/TagsSelector';
import { Result } from '../../utils/Result';
import { ShlinkApiError } from '../../api/ShlinkApiError';
interface EditTagsModalProps extends ShortUrlModalProps {
shortUrlTags: ShortUrlTags;
editShortUrlTags: (shortCode: string, domain: OptionalString, tags: string[]) => Promise<void>;
resetShortUrlsTags: () => void;
}
const EditTagsModal = (TagsSelector: FC<TagsSelectorProps>) => (
{ isOpen, toggle, shortUrl, shortUrlTags, editShortUrlTags, resetShortUrlsTags }: EditTagsModalProps,
) => {
const [ selectedTags, setSelectedTags ] = useState<string[]>(shortUrl.tags || []);
useEffect(() => resetShortUrlsTags, []);
const { saving, error, errorData } = shortUrlTags;
const url = shortUrl?.shortUrl ?? '';
const saveTags = async () => editShortUrlTags(shortUrl.shortCode, shortUrl.domain, selectedTags)
.then(toggle)
.catch(() => {});
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}>
Edit tags for <ExternalLink href={url} />
</ModalHeader>
<ModalBody>
<TagsSelector tags={selectedTags} onChange={setSelectedTags} />
{error && (
<Result type="error" small className="mt-2">
<ShlinkApiError errorData={errorData} fallbackMessage="Something went wrong while saving the tags :(" />
</Result>
)}
</ModalBody>
<ModalFooter>
<button className="btn btn-link" onClick={toggle}>Cancel</button>
<button className="btn btn-primary" type="button" disabled={saving} onClick={saveTags}>
{saving ? 'Saving tags...' : 'Save tags'}
</button>
</ModalFooter>
</Modal>
);
};
export default EditTagsModal;

View file

@ -0,0 +1,30 @@
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { isServerWithId, SelectedServer, ServerWithId } from '../../servers/data';
import { ShortUrl } from '../data';
export type LinkSuffix = 'visits' | 'edit';
export interface ShortUrlDetailLinkProps {
shortUrl?: ShortUrl | null;
selectedServer?: SelectedServer;
suffix: LinkSuffix;
}
const buildUrl = ({ id }: ServerWithId, { shortCode, domain }: ShortUrl, suffix: LinkSuffix) => {
const query = domain ? `?domain=${domain}` : '';
return `/server/${id}/short-code/${shortCode}/${suffix}${query}`;
};
const ShortUrlDetailLink: FC<ShortUrlDetailLinkProps & Record<string | number, any>> = (
{ selectedServer, shortUrl, suffix, children, ...rest },
) => {
if (!selectedServer || !isServerWithId(selectedServer) || !shortUrl) {
return <span {...rest}>{children}</span>;
}
return <Link to={buildUrl(selectedServer, shortUrl, suffix)} {...rest}>{children}</Link>;
};
export default ShortUrlDetailLink;

View file

@ -4,10 +4,14 @@ import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import classNames from 'classnames'; import classNames from 'classnames';
import { prettify } from '../../utils/helpers/numbers'; import { prettify } from '../../utils/helpers/numbers';
import VisitStatsLink, { VisitStatsLinkProps } from './VisitStatsLink'; import { ShortUrl } from '../data';
import { SelectedServer } from '../../servers/data';
import ShortUrlDetailLink from './ShortUrlDetailLink';
import './ShortUrlVisitsCount.scss'; import './ShortUrlVisitsCount.scss';
interface ShortUrlVisitsCountProps extends VisitStatsLinkProps { interface ShortUrlVisitsCountProps {
shortUrl?: ShortUrl | null;
selectedServer?: SelectedServer;
visitsCount: number; visitsCount: number;
active?: boolean; active?: boolean;
} }
@ -15,13 +19,13 @@ interface ShortUrlVisitsCountProps extends VisitStatsLinkProps {
const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps) => { const ShortUrlVisitsCount = ({ visitsCount, shortUrl, selectedServer, active = false }: ShortUrlVisitsCountProps) => {
const maxVisits = shortUrl?.meta?.maxVisits; const maxVisits = shortUrl?.meta?.maxVisits;
const visitsLink = ( const visitsLink = (
<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}> <ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
<strong <strong
className={classNames('short-url-visits-count__amount', { 'short-url-visits-count__amount--big': active })} className={classNames('short-url-visits-count__amount', { 'short-url-visits-count__amount--big': active })}
> >
{prettify(visitsCount)} {prettify(visitsCount)}
</strong> </strong>
</VisitStatsLink> </ShortUrlDetailLink>
); );
if (!maxVisits) { if (!maxVisits) {

View file

@ -1,20 +1,17 @@
import { import {
faTags as tagsIcon,
faChartPie as pieChartIcon, faChartPie as pieChartIcon,
faEllipsisV as menuIcon, faEllipsisV as menuIcon,
faQrcode as qrIcon, faQrcode as qrIcon,
faMinusCircle as deleteIcon, faMinusCircle as deleteIcon,
faEdit as editIcon, faEdit as editIcon,
faLink as linkIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react'; import { FC } from 'react';
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
import { useToggle } from '../../utils/helpers/hooks'; import { useToggle } from '../../utils/helpers/hooks';
import { ShortUrl, ShortUrlModalProps } from '../data'; import { ShortUrl, ShortUrlModalProps } from '../data';
import { Versions } from '../../utils/helpers/version';
import { SelectedServer } from '../../servers/data'; import { SelectedServer } from '../../servers/data';
import VisitStatsLink from './VisitStatsLink'; import ShortUrlDetailLink from './ShortUrlDetailLink';
import './ShortUrlsRowMenu.scss'; import './ShortUrlsRowMenu.scss';
export interface ShortUrlsRowMenuProps { export interface ShortUrlsRowMenuProps {
@ -25,18 +22,11 @@ type ShortUrlModal = FC<ShortUrlModalProps>;
const ShortUrlsRowMenu = ( const ShortUrlsRowMenu = (
DeleteShortUrlModal: ShortUrlModal, DeleteShortUrlModal: ShortUrlModal,
EditTagsModal: ShortUrlModal,
EditMetaModal: ShortUrlModal,
EditShortUrlModal: ShortUrlModal,
QrCodeModal: ShortUrlModal, QrCodeModal: ShortUrlModal,
ForServerVersion: FC<Versions>,
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => { ) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
const [ isOpen, toggle ] = useToggle(); const [ isOpen, toggle ] = useToggle();
const [ isQrModalOpen, toggleQrCode ] = useToggle(); const [ isQrModalOpen, toggleQrCode ] = useToggle();
const [ isTagsModalOpen, toggleTags ] = useToggle();
const [ isMetaModalOpen, toggleMeta ] = useToggle();
const [ isDeleteModalOpen, toggleDelete ] = useToggle(); const [ isDeleteModalOpen, toggleDelete ] = useToggle();
const [ isEditModalOpen, toggleEdit ] = useToggle();
return ( return (
<ButtonDropdown toggle={toggle} isOpen={isOpen}> <ButtonDropdown toggle={toggle} isOpen={isOpen}>
@ -44,26 +34,13 @@ const ShortUrlsRowMenu = (
&nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp; &nbsp;<FontAwesomeIcon icon={menuIcon} />&nbsp;
</DropdownToggle> </DropdownToggle>
<DropdownMenu right> <DropdownMenu right>
<DropdownItem tag={VisitStatsLink} selectedServer={selectedServer} shortUrl={shortUrl}> <DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats <FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
</DropdownItem> </DropdownItem>
<DropdownItem onClick={toggleTags}> <DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
<FontAwesomeIcon icon={tagsIcon} fixedWidth /> Edit tags <FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
</DropdownItem> </DropdownItem>
<EditTagsModal shortUrl={shortUrl} isOpen={isTagsModalOpen} toggle={toggleTags} />
<DropdownItem onClick={toggleMeta}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit metadata
</DropdownItem>
<EditMetaModal shortUrl={shortUrl} isOpen={isMetaModalOpen} toggle={toggleMeta} />
<ForServerVersion minVersion="2.1.0">
<DropdownItem onClick={toggleEdit}>
<FontAwesomeIcon icon={linkIcon} fixedWidth /> Edit long URL
</DropdownItem>
<EditShortUrlModal shortUrl={shortUrl} isOpen={isEditModalOpen} toggle={toggleEdit} />
</ForServerVersion>
<DropdownItem onClick={toggleQrCode}> <DropdownItem onClick={toggleQrCode}>
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code <FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code

View file

@ -1,27 +0,0 @@
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { isServerWithId, SelectedServer, ServerWithId } from '../../servers/data';
import { ShortUrl } from '../data';
export interface VisitStatsLinkProps {
shortUrl?: ShortUrl | null;
selectedServer?: SelectedServer;
}
const buildVisitsUrl = ({ id }: ServerWithId, { shortCode, domain }: ShortUrl) => {
const query = domain ? `?domain=${domain}` : '';
return `/server/${id}/short-code/${shortCode}/visits${query}`;
};
const VisitStatsLink: FC<VisitStatsLinkProps & Record<string | number, any>> = (
{ selectedServer, shortUrl, children, ...rest },
) => {
if (!selectedServer || !isServerWithId(selectedServer) || !shortUrl) {
return <span {...rest}>{children}</span>;
}
return <Link to={buildVisitsUrl(selectedServer, shortUrl)} {...rest}>{children}</Link>;
};
export default VisitStatsLink;

View file

@ -5,6 +5,8 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
import { OptionalString } from '../../utils/utils'; import { OptionalString } from '../../utils/utils';
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { shortUrlMatches } from '../helpers'; import { shortUrlMatches } from '../helpers';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START'; export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
@ -16,20 +18,25 @@ export interface ShortUrlDetail {
shortUrl?: ShortUrl; shortUrl?: ShortUrl;
loading: boolean; loading: boolean;
error: boolean; error: boolean;
errorData?: ProblemDetailsError;
} }
export interface ShortUrlDetailAction extends Action<string> { export interface ShortUrlDetailAction extends Action<string> {
shortUrl: ShortUrl; shortUrl: ShortUrl;
} }
export interface ShortUrlDetailFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlDetail = { const initialState: ShortUrlDetail = {
loading: false, loading: false,
error: false, error: false,
}; };
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction>({ export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ShortUrlDetailFailedAction>({
[GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }), [GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }),
[GET_SHORT_URL_DETAIL_ERROR]: () => ({ loading: false, error: true }), [GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }), [GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
}, initialState); }, initialState);
@ -47,6 +54,6 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL }); dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
} catch (e) { } catch (e) {
dispatch({ type: GET_SHORT_URL_DETAIL_ERROR }); dispatch<ShortUrlDetailFailedAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
} }
}; };

View file

@ -2,10 +2,11 @@ import { Action, Dispatch } from 'redux';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { OptionalString } from '../../utils/utils'; import { OptionalString } from '../../utils/utils';
import { ShortUrlIdentifier } from '../data'; import { EditShortUrlData, ShortUrl } from '../data';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types'; import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { supportsTagsInPatch } from '../../utils/helpers/features';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START'; export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
@ -14,15 +15,14 @@ export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export interface ShortUrlEdition { export interface ShortUrlEdition {
shortCode: string | null; shortUrl?: ShortUrl;
longUrl: string | null;
saving: boolean; saving: boolean;
error: boolean; error: boolean;
errorData?: ProblemDetailsError; errorData?: ProblemDetailsError;
} }
export interface ShortUrlEditedAction extends Action<string>, ShortUrlIdentifier { export interface ShortUrlEditedAction extends Action<string> {
longUrl: string; shortUrl: ShortUrl;
} }
export interface ShortUrlEditionFailedAction extends Action<string> { export interface ShortUrlEditionFailedAction extends Action<string> {
@ -30,8 +30,6 @@ export interface ShortUrlEditionFailedAction extends Action<string> {
} }
const initialState: ShortUrlEdition = { const initialState: ShortUrlEdition = {
shortCode: null,
longUrl: null,
saving: false, saving: false,
error: false, error: false,
}; };
@ -39,20 +37,27 @@ const initialState: ShortUrlEdition = {
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ShortUrlEditionFailedAction>({ export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ShortUrlEditionFailedAction>({
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }), [EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }), [EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
[SHORT_URL_EDITED]: (_, { shortCode, longUrl }) => ({ shortCode, longUrl, saving: false, error: false }), [SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }),
}, initialState); }, initialState);
export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
shortCode: string, shortCode: string,
domain: OptionalString, domain: OptionalString,
longUrl: string, data: EditShortUrlData,
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_SHORT_URL_START }); dispatch({ type: EDIT_SHORT_URL_START });
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
const { selectedServer } = getState();
const sendTagsSeparately = !supportsTagsInPatch(selectedServer);
const { updateShortUrl, updateShortUrlTags } = buildShlinkApiClient(getState);
try { try {
await updateShortUrlMeta(shortCode, domain, { longUrl }); const [ shortUrl ] = await Promise.all([
dispatch<ShortUrlEditedAction>({ shortCode, longUrl, domain, type: SHORT_URL_EDITED }); updateShortUrl(shortCode, domain, data as any), // FIXME Parse dates
sendTagsSeparately && data.tags ? updateShortUrlTags(shortCode, domain, data.tags) : undefined,
]);
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
} catch (e) { } catch (e) {
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) }); dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });

View file

@ -1,65 +0,0 @@
import { Dispatch, Action } from 'redux';
import { ShortUrlIdentifier, ShortUrlMeta } from '../data';
import { GetState } from '../../container/types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { OptionalString } from '../../utils/utils';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_META_START = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_START';
export const EDIT_SHORT_URL_META_ERROR = 'shlink/shortUrlMeta/EDIT_SHORT_URL_META_ERROR';
export const SHORT_URL_META_EDITED = 'shlink/shortUrlMeta/SHORT_URL_META_EDITED';
export const RESET_EDIT_SHORT_URL_META = 'shlink/shortUrlMeta/RESET_EDIT_SHORT_URL_META';
/* eslint-enable padding-line-between-statements */
export interface ShortUrlMetaEdition {
shortCode: string | null;
meta: ShortUrlMeta;
saving: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface ShortUrlMetaEditedAction extends Action<string>, ShortUrlIdentifier {
meta: ShortUrlMeta;
}
export interface ShortUrlMetaEditionFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlMetaEdition = {
shortCode: null,
meta: {},
saving: false,
error: false,
};
export default buildReducer<ShortUrlMetaEdition, ShortUrlMetaEditedAction & ShortUrlMetaEditionFailedAction>({
[EDIT_SHORT_URL_META_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_META_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
[SHORT_URL_META_EDITED]: (_, { shortCode, meta }) => ({ shortCode, meta, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_META]: () => initialState,
}, initialState);
export const editShortUrlMeta = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
shortCode: string,
domain: OptionalString,
meta: ShortUrlMeta,
) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_SHORT_URL_META_START });
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, domain, meta);
dispatch<ShortUrlMetaEditedAction>({ shortCode, meta, domain, type: SHORT_URL_META_EDITED });
} catch (e) {
dispatch<ShortUrlMetaEditionFailedAction>({ type: EDIT_SHORT_URL_META_ERROR, errorData: parseApiError(e) });
throw e;
}
};
export const resetShortUrlMeta = buildActionCreator(RESET_EDIT_SHORT_URL_META);

View file

@ -1,74 +0,0 @@
import { Action, Dispatch } from 'redux';
import { prop } from 'ramda';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types';
import { OptionalString } from '../../utils/utils';
import { ShortUrlIdentifier } from '../data';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ProblemDetailsError } from '../../api/types';
import { parseApiError } from '../../api/utils';
import { supportsTagsInPatch } from '../../utils/helpers/features';
/* eslint-disable padding-line-between-statements */
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
export const EDIT_SHORT_URL_TAGS_ERROR = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_ERROR';
export const SHORT_URL_TAGS_EDITED = 'shlink/shortUrlTags/SHORT_URL_TAGS_EDITED';
export const RESET_EDIT_SHORT_URL_TAGS = 'shlink/shortUrlTags/RESET_EDIT_SHORT_URL_TAGS';
/* eslint-enable padding-line-between-statements */
export interface ShortUrlTags {
shortCode: string | null;
tags: string[];
saving: boolean;
error: boolean;
errorData?: ProblemDetailsError;
}
export interface EditShortUrlTagsAction extends Action<string>, ShortUrlIdentifier {
tags: string[];
}
export interface EditShortUrlTagsFailedAction extends Action<string> {
errorData?: ProblemDetailsError;
}
const initialState: ShortUrlTags = {
shortCode: null,
tags: [],
saving: false,
error: false,
};
export default buildReducer<ShortUrlTags, EditShortUrlTagsAction & EditShortUrlTagsFailedAction>({
[EDIT_SHORT_URL_TAGS_START]: (state) => ({ ...state, saving: true, error: false }),
[EDIT_SHORT_URL_TAGS_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
[SHORT_URL_TAGS_EDITED]: (_, { shortCode, tags }) => ({ shortCode, tags, saving: false, error: false }),
[RESET_EDIT_SHORT_URL_TAGS]: () => initialState,
}, initialState);
export const editShortUrlTags = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
shortCode: string,
domain: OptionalString,
tags: string[],
) => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { selectedServer } = getState();
const tagsInPatch = supportsTagsInPatch(selectedServer);
const { updateShortUrlTags, updateShortUrlMeta } = buildShlinkApiClient(getState);
try {
const normalizedTags = await (
tagsInPatch
? updateShortUrlMeta(shortCode, domain, { tags }).then(prop('tags'))
: updateShortUrlTags(shortCode, domain, tags)
);
dispatch<EditShortUrlTagsAction>({ tags: normalizedTags, shortCode, domain, type: SHORT_URL_TAGS_EDITED });
} catch (e) {
dispatch<EditShortUrlTagsFailedAction>({ type: EDIT_SHORT_URL_TAGS_ERROR, errorData: parseApiError(e) });
throw e;
}
};
export const resetShortUrlsTags = buildActionCreator(RESET_EDIT_SHORT_URL_TAGS);

View file

@ -2,15 +2,11 @@ import { assoc, assocPath, init, last, pipe, reject } from 'ramda';
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { shortUrlMatches } from '../helpers'; import { shortUrlMatches } from '../helpers';
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
import { ShortUrl, ShortUrlIdentifier } from '../data';
import { buildReducer } from '../../utils/helpers/redux'; import { buildReducer } from '../../utils/helpers/redux';
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { ShlinkShortUrlsResponse } from '../../api/types'; import { ShlinkShortUrlsResponse } from '../../api/types';
import { EditShortUrlTagsAction, SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion'; import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
import { SHORT_URL_META_EDITED, ShortUrlMetaEditedAction } from './shortUrlMeta';
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
import { ShortUrlsListParams } from './shortUrlsListParams'; import { ShortUrlsListParams } from './shortUrlsListParams';
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation'; import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
@ -33,9 +29,6 @@ export interface ListShortUrlsAction extends Action<string> {
export type ListShortUrlsCombinedAction = ( export type ListShortUrlsCombinedAction = (
ListShortUrlsAction ListShortUrlsAction
& EditShortUrlTagsAction
& ShortUrlEditedAction
& ShortUrlMetaEditedAction
& CreateVisitsAction & CreateVisitsAction
& CreateShortUrlAction & CreateShortUrlAction
& DeleteShortUrlAction & DeleteShortUrlAction
@ -46,18 +39,6 @@ const initialState: ShortUrlsList = {
error: false, error: false,
}; };
const setPropFromActionOnMatchingShortUrl = <T extends ShortUrlIdentifier>(prop: keyof T) => (
state: ShortUrlsList,
{ shortCode, domain, [prop]: propValue }: T,
): ShortUrlsList => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ],
state.shortUrls.data.map(
(shortUrl: ShortUrl) =>
shortUrlMatches(shortUrl, shortCode, domain) ? { ...shortUrl, [prop]: propValue } : shortUrl,
),
state,
);
export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }), [LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }), [LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
@ -74,9 +55,6 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
state, state,
), ),
), ),
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl<EditShortUrlTagsAction>('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlMetaEditedAction>('meta'),
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlEditedAction>('longUrl'),
[CREATE_VISITS]: (state, { createdVisits }) => assocPath( [CREATE_VISITS]: (state, { createdVisits }) => assocPath(
[ 'shortUrls', 'data' ], [ 'shortUrls', 'data' ],
state.shortUrls?.data?.map( state.shortUrls?.data?.map(

View file

@ -6,20 +6,18 @@ import ShortUrlsRow from '../helpers/ShortUrlsRow';
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu'; import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
import CreateShortUrl from '../CreateShortUrl'; import CreateShortUrl from '../CreateShortUrl';
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal'; import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
import EditTagsModal from '../helpers/EditTagsModal';
import EditMetaModal from '../helpers/EditMetaModal';
import EditShortUrlModal from '../helpers/EditShortUrlModal';
import CreateShortUrlResult from '../helpers/CreateShortUrlResult'; import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
import { listShortUrls } from '../reducers/shortUrlsList'; import { listShortUrls } from '../reducers/shortUrlsList';
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation'; import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion'; import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams'; import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
import { editShortUrl } from '../reducers/shortUrlEdition'; import { editShortUrl } from '../reducers/shortUrlEdition';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { ShortUrlsTable } from '../ShortUrlsTable'; import { ShortUrlsTable } from '../ShortUrlsTable';
import QrCodeModal from '../helpers/QrCodeModal'; import QrCodeModal from '../helpers/QrCodeModal';
import { ShortUrlForm } from '../ShortUrlForm';
import { EditShortUrl } from '../EditShortUrl';
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components // Components
@ -34,43 +32,25 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow'); bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout'); bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
bottle.serviceFactory( bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'QrCodeModal');
'ShortUrlsRowMenu',
ShortUrlsRowMenu,
'DeleteShortUrlModal',
'EditTagsModal',
'EditMetaModal',
'EditShortUrlModal',
'QrCodeModal',
'ForServerVersion',
);
bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout'); bottle.serviceFactory('CreateShortUrlResult', CreateShortUrlResult, 'useStateFlagTimeout');
bottle.serviceFactory('ShortUrlForm', ShortUrlForm, 'TagsSelector', 'DomainSelector');
bottle.serviceFactory( bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult');
'CreateShortUrl',
CreateShortUrl,
'TagsSelector',
'CreateShortUrlResult',
'ForServerVersion',
'DomainSelector',
);
bottle.decorator( bottle.decorator(
'CreateShortUrl', 'CreateShortUrl',
connect([ 'shortUrlCreationResult', 'selectedServer', 'settings' ], [ 'createShortUrl', 'resetCreateShortUrl' ]), connect([ 'shortUrlCreationResult', 'selectedServer', 'settings' ], [ 'createShortUrl', 'resetCreateShortUrl' ]),
); );
bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm');
bottle.decorator('EditShortUrl', connect(
[ 'shortUrlDetail', 'shortUrlEdition', 'selectedServer', 'settings' ],
[ 'getShortUrlDetail', 'editShortUrl' ],
));
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal); bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ])); bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector');
bottle.decorator('EditTagsModal', connect([ 'shortUrlTags' ], [ 'editShortUrlTags', 'resetShortUrlsTags' ]));
bottle.serviceFactory('EditMetaModal', () => EditMetaModal);
bottle.decorator('EditMetaModal', connect([ 'shortUrlMeta' ], [ 'editShortUrlMeta', 'resetShortUrlMeta' ]));
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
bottle.serviceFactory('QrCodeModal', () => QrCodeModal); bottle.serviceFactory('QrCodeModal', () => QrCodeModal);
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ])); bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
@ -79,9 +59,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ])); bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
// Actions // Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient'); bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams); bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
@ -91,8 +68,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl); bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
bottle.serviceFactory('editShortUrlMeta', editShortUrlMeta, 'buildShlinkApiClient'); bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlMeta', () => resetShortUrlMeta);
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient'); bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient');
}; };

View file

@ -12,6 +12,8 @@ export const supportsListingDomains = serverMatchesVersions({ minVersion: '2.4.0
export const supportsQrCodeSvgFormat = supportsListingDomains; export const supportsQrCodeSvgFormat = supportsListingDomains;
export const supportsValidateUrl = supportsListingDomains;
export const supportsQrCodeSizeInQuery = serverMatchesVersions({ minVersion: '2.5.0' }); export const supportsQrCodeSizeInQuery = serverMatchesVersions({ minVersion: '2.5.0' });
export const supportsShortUrlTitle = serverMatchesVersions({ minVersion: '2.6.0' }); export const supportsShortUrlTitle = serverMatchesVersions({ minVersion: '2.6.0' });

View file

@ -1,7 +1,6 @@
import Bottle from 'bottlejs'; import Bottle from 'bottlejs';
import ShortUrlVisits from '../ShortUrlVisits'; import ShortUrlVisits from '../ShortUrlVisits';
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits';
import { getShortUrlDetail } from '../../short-urls/reducers/shortUrlDetail';
import MapModal from '../helpers/MapModal'; import MapModal from '../helpers/MapModal';
import { createNewVisits } from '../reducers/visitCreation'; import { createNewVisits } from '../reducers/visitCreation';
import TagVisits from '../TagVisits'; import TagVisits from '../TagVisits';
@ -41,7 +40,6 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Actions // Actions
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits); bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient');

View file

@ -48,10 +48,7 @@ describe('ShlinkApiClient', () => {
const axiosSpy = createAxiosMock({ data: shortUrl }); const axiosSpy = createAxiosMock({ data: shortUrl });
const { createShortUrl } = new ShlinkApiClient(axiosSpy, '', ''); const { createShortUrl } = new ShlinkApiClient(axiosSpy, '', '');
await createShortUrl( await createShortUrl({ longUrl: 'bar', customSlug: undefined, maxVisits: null });
// @ts-expect-error in case maxVisits is null, it needs to be ignored as if it was undefined
{ longUrl: 'bar', customSlug: undefined, maxVisits: null },
);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ data: { longUrl: 'bar' } })); expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ data: { longUrl: 'bar' } }));
}); });
@ -139,7 +136,7 @@ describe('ShlinkApiClient', () => {
}); });
}); });
describe('updateShortUrlMeta', () => { describe('updateShortUrl', () => {
it.each(shortCodesWithDomainCombinations)('properly updates short URL meta', async (shortCode, domain) => { it.each(shortCodesWithDomainCombinations)('properly updates short URL meta', async (shortCode, domain) => {
const meta = { const meta = {
maxVisits: 50, maxVisits: 50,
@ -147,9 +144,9 @@ describe('ShlinkApiClient', () => {
}; };
const expectedResp = Mock.of<ShortUrl>(); const expectedResp = Mock.of<ShortUrl>();
const axiosSpy = createAxiosMock({ data: expectedResp }); const axiosSpy = createAxiosMock({ data: expectedResp });
const { updateShortUrlMeta } = new ShlinkApiClient(axiosSpy, '', ''); const { updateShortUrl } = new ShlinkApiClient(axiosSpy, '', '');
const result = await updateShortUrlMeta(shortCode, domain, meta); const result = await updateShortUrl(shortCode, domain, meta);
expect(expectedResp).toEqual(result); expect(expectedResp).toEqual(result);
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({ expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({

View file

@ -11,7 +11,7 @@ import { SemVer } from '../../src/utils/helpers/version';
describe('<MenuLayout />', () => { describe('<MenuLayout />', () => {
const ServerError = jest.fn(); const ServerError = jest.fn();
const C = jest.fn(); const C = jest.fn();
const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C); const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C, C);
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const createWrapper = (selectedServer: SelectedServer) => { const createWrapper = (selectedServer: SelectedServer) => {
wrapper = shallow( wrapper = shallow(
@ -49,11 +49,11 @@ describe('<MenuLayout />', () => {
}); });
it.each([ it.each([
[ '2.1.0' as SemVer, 6 ], [ '2.1.0' as SemVer, 7 ],
[ '2.2.0' as SemVer, 7 ], [ '2.2.0' as SemVer, 8 ],
[ '2.5.0' as SemVer, 7 ], [ '2.5.0' as SemVer, 8 ],
[ '2.6.0' as SemVer, 8 ], [ '2.6.0' as SemVer, 9 ],
[ '2.7.0' as SemVer, 8 ], [ '2.7.0' as SemVer, 9 ],
])('has expected amount of routes based on selected server\'s version', (version, expectedAmountOfRoutes) => { ])('has expected amount of routes based on selected server\'s version', (version, expectedAmountOfRoutes) => {
const selectedServer = Mock.of<ReachableServer>({ version }); const selectedServer = Mock.of<ReachableServer>({ version });
const wrapper = createWrapper(selectedServer).dive(); const wrapper = createWrapper(selectedServer).dive();

View file

@ -1,22 +1,19 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import moment from 'moment';
import { identity } from 'ramda';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { Input } from 'reactstrap';
import createShortUrlsCreator from '../../src/short-urls/CreateShortUrl'; import createShortUrlsCreator from '../../src/short-urls/CreateShortUrl';
import DateInput from '../../src/utils/DateInput';
import { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation'; import { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation';
import { Settings } from '../../src/settings/reducers/settings'; import { Settings } from '../../src/settings/reducers/settings';
describe('<CreateShortUrl />', () => { describe('<CreateShortUrl />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const TagsSelector = () => null; const ShortUrlForm = () => null;
const CreateShortUrlResult = () => null;
const shortUrlCreation = { validateUrls: true }; const shortUrlCreation = { validateUrls: true };
const shortUrlCreationResult = Mock.all<ShortUrlCreation>(); const shortUrlCreationResult = Mock.all<ShortUrlCreation>();
const createShortUrl = jest.fn(async () => Promise.resolve()); const createShortUrl = jest.fn(async () => Promise.resolve());
beforeEach(() => { beforeEach(() => {
const CreateShortUrl = createShortUrlsCreator(TagsSelector, () => null, () => null, () => null); const CreateShortUrl = createShortUrlsCreator(ShortUrlForm, CreateShortUrlResult);
wrapper = shallow( wrapper = shallow(
<CreateShortUrl <CreateShortUrl
@ -31,32 +28,11 @@ describe('<CreateShortUrl />', () => {
afterEach(() => wrapper.unmount()); afterEach(() => wrapper.unmount());
afterEach(jest.clearAllMocks); afterEach(jest.clearAllMocks);
it('saves short URL with data set in form controls', () => { it('renders a ShortUrlForm with a computed initial state', () => {
const validSince = moment('2017-01-01'); const form = wrapper.find(ShortUrlForm);
const validUntil = moment('2017-01-06'); const result = wrapper.find(CreateShortUrlResult);
wrapper.find(Input).first().simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } }); expect(form).toHaveLength(1);
wrapper.find('TagsSelector').simulate('change', [ 'tag_foo', 'tag_bar' ]); expect(result).toHaveLength(1);
wrapper.find('#customSlug').simulate('change', { target: { value: 'my-slug' } });
wrapper.find('#domain').simulate('change', { target: { value: 'example.com' } });
wrapper.find('#maxVisits').simulate('change', { target: { value: '20' } });
wrapper.find('#shortCodeLength').simulate('change', { target: { value: 15 } });
wrapper.find(DateInput).at(0).simulate('change', validSince);
wrapper.find(DateInput).at(1).simulate('change', validUntil);
wrapper.find('form').simulate('submit', { preventDefault: identity });
expect(createShortUrl).toHaveBeenCalledTimes(1);
expect(createShortUrl).toHaveBeenCalledWith({
longUrl: 'https://long-domain.com/foo/bar',
tags: [ 'tag_foo', 'tag_bar' ],
customSlug: 'my-slug',
domain: 'example.com',
validSince: validSince.format(),
validUntil: validUntil.format(),
maxVisits: '20',
findIfExists: false,
shortCodeLength: 15,
validateUrl: true,
});
}); });
}); });

View file

@ -0,0 +1,105 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery';
import { History, Location } from 'history';
import { match } from 'react-router'; // eslint-disable-line @typescript-eslint/no-unused-vars
import { EditShortUrl as createEditShortUrl } from '../../src/short-urls/EditShortUrl';
import { Settings } from '../../src/settings/reducers/settings';
import { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail';
import { ShortUrlEdition } from '../../src/short-urls/reducers/shortUrlEdition';
import { ShlinkApiError } from '../../src/api/ShlinkApiError';
import { ShortUrl } from '../../src/short-urls/data';
describe('<EditShortUrl />', () => {
let wrapper: ShallowWrapper;
const ShortUrlForm = () => null;
const goBack = jest.fn();
const getShortUrlDetail = jest.fn();
const editShortUrl = jest.fn();
const shortUrlCreation = { validateUrls: true };
const createWrapper = (detail: Partial<ShortUrlDetail> = {}, edition: Partial<ShortUrlEdition> = {}) => {
const EditSHortUrl = createEditShortUrl(ShortUrlForm);
wrapper = shallow(
<EditSHortUrl
settings={Mock.of<Settings>({ shortUrlCreation })}
selectedServer={null}
shortUrlDetail={Mock.of<ShortUrlDetail>(detail)}
shortUrlEdition={Mock.of<ShortUrlEdition>(edition)}
getShortUrlDetail={getShortUrlDetail}
editShortUrl={editShortUrl}
history={Mock.of<History>({ goBack })}
location={Mock.all<Location>()}
match={Mock.of<match<{ shortCode: string }>>({
params: { shortCode: 'the_base_url' },
})}
/>,
);
return wrapper;
};
beforeEach(jest.clearAllMocks);
afterEach(() => wrapper?.unmount());
it('renders loading message while loading detail', () => {
const wrapper = createWrapper({ loading: true });
expect(wrapper.prop('loading')).toEqual(true);
});
it('renders error when loading detail fails', () => {
const wrapper = createWrapper({ error: true });
const form = wrapper.find(ShortUrlForm);
const apiError = wrapper.find(ShlinkApiError);
expect(form).toHaveLength(0);
expect(apiError).toHaveLength(1);
expect(apiError.prop('fallbackMessage')).toEqual('An error occurred while loading short URL detail :(');
});
it.each([
[ undefined, { longUrl: '', validateUrl: true }, true ],
[
Mock.of<ShortUrl>({ meta: {} }),
{
longUrl: undefined,
tags: undefined,
title: undefined,
domain: undefined,
validSince: undefined,
validUntil: undefined,
maxVisits: undefined,
validateUrl: true,
},
false,
],
])('renders form when detail properly loads', (shortUrl, expectedInitialState, saving) => {
const wrapper = createWrapper({ shortUrl }, { saving });
const form = wrapper.find(ShortUrlForm);
const apiError = wrapper.find(ShlinkApiError);
expect(form).toHaveLength(1);
expect(apiError).toHaveLength(0);
expect(form.prop('initialState')).toEqual(expectedInitialState);
expect(form.prop('saving')).toEqual(saving);
expect(editShortUrl).not.toHaveBeenCalled();
form.simulate('save', {});
if (shortUrl) {
expect(editShortUrl).toHaveBeenCalledWith(shortUrl.shortCode, shortUrl.domain, {});
} else {
expect(editShortUrl).not.toHaveBeenCalled();
}
});
it('shows error when saving data has failed', () => {
const wrapper = createWrapper({}, { error: true });
const form = wrapper.find(ShortUrlForm);
const apiError = wrapper.find(ShlinkApiError);
expect(form).toHaveLength(1);
expect(apiError).toHaveLength(1);
expect(apiError.prop('fallbackMessage')).toEqual('An error occurred while updating short URL :(');
});
});

View file

@ -0,0 +1,85 @@
import { shallow, ShallowWrapper } from 'enzyme';
import moment from 'moment';
import { identity } from 'ramda';
import { Mock } from 'ts-mockery';
import { Input } from 'reactstrap';
import { ShortUrlForm as createShortUrlForm, Mode } from '../../src/short-urls/ShortUrlForm';
import DateInput from '../../src/utils/DateInput';
import { ShortUrlData } from '../../src/short-urls/data';
import { ReachableServer, SelectedServer } from '../../src/servers/data';
import { SimpleCard } from '../../src/utils/SimpleCard';
describe('<ShortUrlForm />', () => {
let wrapper: ShallowWrapper;
const TagsSelector = () => null;
const createShortUrl = jest.fn();
const createWrapper = (selectedServer: SelectedServer = null, mode: Mode = 'create') => {
const ShortUrlForm = createShortUrlForm(TagsSelector, () => null);
wrapper = shallow(
<ShortUrlForm
selectedServer={selectedServer}
mode={mode}
saving={false}
initialState={Mock.of<ShortUrlData>({ validateUrl: true, findIfExists: false })}
onSave={createShortUrl}
/>,
);
return wrapper;
};
afterEach(() => wrapper.unmount());
afterEach(jest.clearAllMocks);
it('saves short URL with data set in form controls', () => {
const wrapper = createWrapper();
const validSince = moment('2017-01-01');
const validUntil = moment('2017-01-06');
wrapper.find(Input).first().simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
wrapper.find('TagsSelector').simulate('change', [ 'tag_foo', 'tag_bar' ]);
wrapper.find('#customSlug').simulate('change', { target: { value: 'my-slug' } });
wrapper.find('#domain').simulate('change', { target: { value: 'example.com' } });
wrapper.find('#maxVisits').simulate('change', { target: { value: '20' } });
wrapper.find('#shortCodeLength').simulate('change', { target: { value: 15 } });
wrapper.find(DateInput).at(0).simulate('change', validSince);
wrapper.find(DateInput).at(1).simulate('change', validUntil);
wrapper.find('form').simulate('submit', { preventDefault: identity });
expect(createShortUrl).toHaveBeenCalledTimes(1);
expect(createShortUrl).toHaveBeenCalledWith({
longUrl: 'https://long-domain.com/foo/bar',
tags: [ 'tag_foo', 'tag_bar' ],
customSlug: 'my-slug',
domain: 'example.com',
validSince: validSince.format(),
validUntil: validUntil.format(),
maxVisits: 20,
findIfExists: false,
shortCodeLength: 15,
validateUrl: true,
});
});
it.each([
[ null, 'create' as Mode, 4 ],
[ null, 'create-basic' as Mode, 0 ],
[ Mock.of<ReachableServer>({ version: '2.6.0' }), 'create' as Mode, 4 ],
[ Mock.of<ReachableServer>({ version: '2.5.0' }), 'create' as Mode, 4 ],
[ Mock.of<ReachableServer>({ version: '2.4.0' }), 'create' as Mode, 4 ],
[ Mock.of<ReachableServer>({ version: '2.3.0' }), 'create' as Mode, 4 ],
[ Mock.of<ReachableServer>({ version: '2.6.0' }), 'edit' as Mode, 4 ],
[ Mock.of<ReachableServer>({ version: '2.5.0' }), 'edit' as Mode, 3 ],
[ Mock.of<ReachableServer>({ version: '2.4.0' }), 'edit' as Mode, 3 ],
[ Mock.of<ReachableServer>({ version: '2.3.0' }), 'edit' as Mode, 2 ],
])(
'renders expected amount of cards based on server capabilities and mode',
(selectedServer, mode, expectedAmountOfCards) => {
const wrapper = createWrapper(selectedServer, mode);
const cards = wrapper.find(SimpleCard);
expect(cards).toHaveLength(expectedAmountOfCards);
},
);
});

View file

@ -1,84 +0,0 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { FormGroup } from 'reactstrap';
import { Mock } from 'ts-mockery';
import EditMetaModal from '../../../src/short-urls/helpers/EditMetaModal';
import { ShortUrl } from '../../../src/short-urls/data';
import { ShortUrlMetaEdition } from '../../../src/short-urls/reducers/shortUrlMeta';
import { Result } from '../../../src/utils/Result';
describe('<EditMetaModal />', () => {
let wrapper: ShallowWrapper;
const editShortUrlMeta = jest.fn(async () => Promise.resolve());
const resetShortUrlMeta = jest.fn();
const toggle = jest.fn();
const createWrapper = (shortUrlMeta: Partial<ShortUrlMetaEdition>) => {
wrapper = shallow(
<EditMetaModal
isOpen={true}
shortUrl={Mock.all<ShortUrl>()}
shortUrlMeta={Mock.of<ShortUrlMetaEdition>(shortUrlMeta)}
toggle={toggle}
editShortUrlMeta={editShortUrlMeta}
resetShortUrlMeta={resetShortUrlMeta}
/>,
);
return wrapper;
};
afterEach(() => wrapper?.unmount());
afterEach(jest.clearAllMocks);
it('properly renders form with components', () => {
const wrapper = createWrapper({ saving: false, error: false });
const error = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
const form = wrapper.find('form');
const formGroup = form.find(FormGroup);
expect(form).toHaveLength(1);
expect(formGroup).toHaveLength(3);
expect(error).toHaveLength(0);
});
it.each([
[ true, 'Saving...' ],
[ false, 'Save' ],
])('renders submit button on expected state', (saving, expectedText) => {
const wrapper = createWrapper({ saving, error: false });
const button = wrapper.find('[type="submit"]');
expect(button.prop('disabled')).toEqual(saving);
expect(button.text()).toContain(expectedText);
});
it('renders error message on error', () => {
const wrapper = createWrapper({ saving: false, error: true });
const error = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
expect(error).toHaveLength(1);
});
it('saves meta when form is submit', () => {
const preventDefault = jest.fn();
const wrapper = createWrapper({ saving: false, error: false });
const form = wrapper.find('form');
form.simulate('submit', { preventDefault });
expect(preventDefault).toHaveBeenCalled();
expect(editShortUrlMeta).toHaveBeenCalled();
});
it.each([
[ '.btn-link', 'onClick' ],
[ 'Modal', 'toggle' ],
[ 'ModalHeader', 'toggle' ],
])('resets meta when modal is toggled in any way', (componentToFind, propToCall) => {
const wrapper = createWrapper({ saving: false, error: false });
const component = wrapper.find(componentToFind);
(component.prop(propToCall) as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
expect(resetShortUrlMeta).toHaveBeenCalled();
});
});

View file

@ -1,80 +0,0 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { FormGroup } from 'reactstrap';
import { Mock } from 'ts-mockery';
import EditShortUrlModal from '../../../src/short-urls/helpers/EditShortUrlModal';
import { ShortUrl } from '../../../src/short-urls/data';
import { ShortUrlEdition } from '../../../src/short-urls/reducers/shortUrlEdition';
import { Result } from '../../../src/utils/Result';
describe('<EditShortUrlModal />', () => {
let wrapper: ShallowWrapper;
const editShortUrl = jest.fn(async () => Promise.resolve());
const toggle = jest.fn();
const createWrapper = (shortUrl: Partial<ShortUrl>, shortUrlEdition: Partial<ShortUrlEdition>) => {
wrapper = shallow(
<EditShortUrlModal
isOpen={true}
shortUrl={Mock.of<ShortUrl>(shortUrl)}
shortUrlEdition={Mock.of<ShortUrlEdition>(shortUrlEdition)}
toggle={toggle}
editShortUrl={editShortUrl}
/>,
);
return wrapper;
};
afterEach(() => wrapper?.unmount());
afterEach(jest.clearAllMocks);
it.each([
[ false, 0 ],
[ true, 1 ],
])('properly renders form with expected components', (error, expectedErrorLength) => {
const wrapper = createWrapper({}, { saving: false, error });
const errorElement = wrapper.find(Result).filterWhere((result) => result.prop('type') === 'error');
const form = wrapper.find('form');
const formGroup = form.find(FormGroup);
expect(form).toHaveLength(1);
expect(formGroup).toHaveLength(1);
expect(errorElement).toHaveLength(expectedErrorLength);
});
it.each([
[ true, 'Saving...', 'something', true ],
[ true, 'Saving...', undefined, true ],
[ false, 'Save', 'something', false ],
[ false, 'Save', undefined, true ],
])('renders submit button on expected state', (saving, expectedText, longUrl, expectedDisabled) => {
const wrapper = createWrapper({ longUrl }, { saving, error: false });
const button = wrapper.find('[color="primary"]');
expect(button.prop('disabled')).toEqual(expectedDisabled);
expect(button.html()).toContain(expectedText);
});
it('saves data when form is submit', () => {
const preventDefault = jest.fn();
const wrapper = createWrapper({}, { saving: false, error: false });
const form = wrapper.find('form');
form.simulate('submit', { preventDefault });
expect(preventDefault).toHaveBeenCalled();
expect(editShortUrl).toHaveBeenCalled();
});
it.each([
[ '[color="link"]', 'onClick' ],
[ 'Modal', 'toggle' ],
[ 'ModalHeader', 'toggle' ],
])('toggles modal with different mechanisms', (componentToFind, propToCall) => {
const wrapper = createWrapper({}, { saving: false, error: false });
const component = wrapper.find(componentToFind);
(component.prop(propToCall) as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
expect(toggle).toHaveBeenCalled();
});
});

View file

@ -1,119 +0,0 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery';
import { Modal } from 'reactstrap';
import createEditTagsModal from '../../../src/short-urls/helpers/EditTagsModal';
import { ShortUrl } from '../../../src/short-urls/data';
import { ShortUrlTags } from '../../../src/short-urls/reducers/shortUrlTags';
import { OptionalString } from '../../../src/utils/utils';
describe('<EditTagsModal />', () => {
let wrapper: ShallowWrapper;
const shortCode = 'abc123';
const TagsSelector = () => null;
const editShortUrlTags = jest.fn(async () => Promise.resolve());
const resetShortUrlsTags = jest.fn();
const toggle = jest.fn();
const createWrapper = (shortUrlTags: ShortUrlTags, domain?: OptionalString) => {
const EditTagsModal = createEditTagsModal(TagsSelector);
wrapper = shallow(
<EditTagsModal
isOpen={true}
shortUrl={Mock.of<ShortUrl>({
tags: [],
shortCode,
domain,
longUrl: 'https://long-domain.com/foo/bar',
})}
shortUrlTags={shortUrlTags}
toggle={toggle}
editShortUrlTags={editShortUrlTags}
resetShortUrlsTags={resetShortUrlsTags}
/>,
);
return wrapper;
};
afterEach(() => wrapper?.unmount());
afterEach(jest.clearAllMocks);
it('renders tags selector and save button when loaded', () => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: false,
error: false,
});
const saveBtn = wrapper.find('.btn-primary');
expect(wrapper.find(TagsSelector)).toHaveLength(1);
expect(saveBtn.prop('disabled')).toBe(false);
expect(saveBtn.text()).toEqual('Save tags');
});
it('disables save button when saving is in progress', () => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: true,
error: false,
});
const saveBtn = wrapper.find('.btn-primary');
expect(saveBtn.prop('disabled')).toBe(true);
expect(saveBtn.text()).toEqual('Saving tags...');
});
it.each([
[ undefined ],
[ null ],
[ 'example.com' ],
// @ts-expect-error Type declaration is not correct, which makes "done" function not being properly detected
])('saves tags when save button is clicked', (domain: OptionalString, done: jest.DoneCallback) => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: true,
error: false,
}, domain);
const saveBtn = wrapper.find('.btn-primary');
saveBtn.simulate('click');
expect(editShortUrlTags).toHaveBeenCalledTimes(1);
expect(editShortUrlTags).toHaveBeenCalledWith(shortCode, domain, []);
// Wrap this expect in a setImmediate since it is called as a result of an inner promise
setImmediate(() => {
expect(toggle).toHaveBeenCalledTimes(1);
done();
});
});
it('does not notify tags have been edited when window is closed without saving', () => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: false,
error: false,
});
const modal = wrapper.find(Modal);
modal.simulate('closed');
expect(editShortUrlTags).not.toHaveBeenCalled();
});
it('toggles modal when cancel button is clicked', () => {
const wrapper = createWrapper({
shortCode,
tags: [],
saving: true,
error: false,
});
const cancelBtn = wrapper.find('.btn-link');
cancelBtn.simulate('click');
expect(toggle).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,11 +1,11 @@
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import VisitStatsLink from '../../../src/short-urls/helpers/VisitStatsLink'; import ShortUrlDetailLink, { LinkSuffix } from '../../../src/short-urls/helpers/ShortUrlDetailLink';
import { NotFoundServer, ReachableServer } from '../../../src/servers/data'; import { NotFoundServer, ReachableServer } from '../../../src/servers/data';
import { ShortUrl } from '../../../src/short-urls/data'; import { ShortUrl } from '../../../src/short-urls/data';
describe('<VisitStatsLink />', () => { describe('<ShortUrlDetailLink />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
afterEach(() => wrapper?.unmount()); afterEach(() => wrapper?.unmount());
@ -19,7 +19,11 @@ describe('<VisitStatsLink />', () => {
[ null, Mock.all<ShortUrl>() ], [ null, Mock.all<ShortUrl>() ],
[ undefined, Mock.all<ShortUrl>() ], [ undefined, Mock.all<ShortUrl>() ],
])('only renders a plain span when either server or short URL are not set', (selectedServer, shortUrl) => { ])('only renders a plain span when either server or short URL are not set', (selectedServer, shortUrl) => {
wrapper = shallow(<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>Something</VisitStatsLink>); wrapper = shallow(
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
Something
</ShortUrlDetailLink>,
);
const link = wrapper.find(Link); const link = wrapper.find(Link);
expect(link).toHaveLength(0); expect(link).toHaveLength(0);
@ -30,15 +34,33 @@ describe('<VisitStatsLink />', () => {
[ [
Mock.of<ReachableServer>({ id: '1' }), Mock.of<ReachableServer>({ id: '1' }),
Mock.of<ShortUrl>({ shortCode: 'abc123' }), Mock.of<ShortUrl>({ shortCode: 'abc123' }),
'visits' as LinkSuffix,
'/server/1/short-code/abc123/visits', '/server/1/short-code/abc123/visits',
], ],
[ [
Mock.of<ReachableServer>({ id: '3' }), Mock.of<ReachableServer>({ id: '3' }),
Mock.of<ShortUrl>({ shortCode: 'def456', domain: 'example.com' }), Mock.of<ShortUrl>({ shortCode: 'def456', domain: 'example.com' }),
'visits' as LinkSuffix,
'/server/3/short-code/def456/visits?domain=example.com', '/server/3/short-code/def456/visits?domain=example.com',
], ],
])('renders link with expected query when', (selectedServer, shortUrl, expectedLink) => { [
wrapper = shallow(<VisitStatsLink selectedServer={selectedServer} shortUrl={shortUrl}>Something</VisitStatsLink>); Mock.of<ReachableServer>({ id: '1' }),
Mock.of<ShortUrl>({ shortCode: 'abc123' }),
'edit' as LinkSuffix,
'/server/1/short-code/abc123/edit',
],
[
Mock.of<ReachableServer>({ id: '3' }),
Mock.of<ShortUrl>({ shortCode: 'def456', domain: 'example.com' }),
'edit' as LinkSuffix,
'/server/3/short-code/def456/edit?domain=example.com',
],
])('renders link with expected query when', (selectedServer, shortUrl, suffix, expectedLink) => {
wrapper = shallow(
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix={suffix}>
Something
</ShortUrlDetailLink>,
);
const link = wrapper.find(Link); const link = wrapper.find(Link);
const to = link.prop('to'); const to = link.prop('to');

View file

@ -8,9 +8,6 @@ import { ShortUrl } from '../../../src/short-urls/data';
describe('<ShortUrlsRowMenu />', () => { describe('<ShortUrlsRowMenu />', () => {
let wrapper: ShallowWrapper; let wrapper: ShallowWrapper;
const DeleteShortUrlModal = () => null; const DeleteShortUrlModal = () => null;
const EditTagsModal = () => null;
const EditMetaModal = () => null;
const EditShortUrlModal = () => null;
const QrCodeModal = () => null; const QrCodeModal = () => null;
const selectedServer = Mock.of<ReachableServer>({ id: 'abc123' }); const selectedServer = Mock.of<ReachableServer>({ id: 'abc123' });
const shortUrl = Mock.of<ShortUrl>({ const shortUrl = Mock.of<ShortUrl>({
@ -18,14 +15,7 @@ describe('<ShortUrlsRowMenu />', () => {
shortUrl: 'https://doma.in/abc123', shortUrl: 'https://doma.in/abc123',
}); });
const createWrapper = () => { const createWrapper = () => {
const ShortUrlsRowMenu = createShortUrlsRowMenu( const ShortUrlsRowMenu = createShortUrlsRowMenu(DeleteShortUrlModal, QrCodeModal);
DeleteShortUrlModal,
EditTagsModal,
EditMetaModal,
EditShortUrlModal,
QrCodeModal,
() => null,
);
wrapper = shallow(<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />); wrapper = shallow(<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />);
@ -37,21 +27,17 @@ describe('<ShortUrlsRowMenu />', () => {
it('renders modal windows', () => { it('renders modal windows', () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const deleteShortUrlModal = wrapper.find(DeleteShortUrlModal); const deleteShortUrlModal = wrapper.find(DeleteShortUrlModal);
const editTagsModal = wrapper.find(EditTagsModal);
const qrCodeModal = wrapper.find(QrCodeModal); const qrCodeModal = wrapper.find(QrCodeModal);
const editModal = wrapper.find(EditShortUrlModal);
expect(deleteShortUrlModal).toHaveLength(1); expect(deleteShortUrlModal).toHaveLength(1);
expect(editTagsModal).toHaveLength(1);
expect(qrCodeModal).toHaveLength(1); expect(qrCodeModal).toHaveLength(1);
expect(editModal).toHaveLength(1);
}); });
it('renders correct amount of menu items', () => { it('renders correct amount of menu items', () => {
const wrapper = createWrapper(); const wrapper = createWrapper();
const items = wrapper.find(DropdownItem); const items = wrapper.find(DropdownItem);
expect(items).toHaveLength(7); expect(items).toHaveLength(5);
expect(items.find('[divider]')).toHaveLength(1); expect(items.find('[divider]')).toHaveLength(1);
}); });
@ -65,9 +51,7 @@ describe('<ShortUrlsRowMenu />', () => {
}; };
it('DeleteShortUrlModal', () => assert(DeleteShortUrlModal)); it('DeleteShortUrlModal', () => assert(DeleteShortUrlModal));
it('EditTagsModal', () => assert(EditTagsModal));
it('QrCodeModal', () => assert(QrCodeModal)); it('QrCodeModal', () => assert(QrCodeModal));
it('EditShortUrlModal', () => assert(EditShortUrlModal)); it('ShortUrlRowMenu', () => assert(ButtonDropdown));
it('EditShortUrlModal', () => assert(ButtonDropdown));
}); });
}); });

View file

@ -51,7 +51,7 @@ describe('shortUrlDetailReducer', () => {
const buildGetState = (shortUrlsList?: ShortUrlsList) => () => Mock.of<ShlinkState>({ shortUrlsList }); const buildGetState = (shortUrlsList?: ShortUrlsList) => () => Mock.of<ShlinkState>({ shortUrlsList });
it('dispatches start and error when promise is rejected', async () => { it('dispatches start and error when promise is rejected', async () => {
const ShlinkApiClient = buildApiClientMock(Promise.reject()); const ShlinkApiClient = buildApiClientMock(Promise.reject({}));
await getShortUrlDetail(() => ShlinkApiClient)('abc123', '')(dispatchMock, buildGetState()); await getShortUrlDetail(() => ShlinkApiClient)('abc123', '')(dispatchMock, buildGetState());

View file

@ -7,16 +7,17 @@ import reducer, {
ShortUrlEditedAction, ShortUrlEditedAction,
} from '../../../src/short-urls/reducers/shortUrlEdition'; } from '../../../src/short-urls/reducers/shortUrlEdition';
import { ShlinkState } from '../../../src/container/types'; import { ShlinkState } from '../../../src/container/types';
import { ShortUrl } from '../../../src/short-urls/data';
import { ReachableServer, SelectedServer } from '../../../src/servers/data';
describe('shortUrlEditionReducer', () => { describe('shortUrlEditionReducer', () => {
const longUrl = 'https://shlink.io'; const longUrl = 'https://shlink.io';
const shortCode = 'abc123'; const shortCode = 'abc123';
const shortUrl = Mock.of<ShortUrl>({ longUrl, shortCode });
describe('reducer', () => { describe('reducer', () => {
it('returns loading on EDIT_SHORT_URL_START', () => { it('returns loading on EDIT_SHORT_URL_START', () => {
expect(reducer(undefined, Mock.of<ShortUrlEditedAction>({ type: EDIT_SHORT_URL_START }))).toEqual({ expect(reducer(undefined, Mock.of<ShortUrlEditedAction>({ type: EDIT_SHORT_URL_START }))).toEqual({
longUrl: null,
shortCode: null,
saving: true, saving: true,
error: false, error: false,
}); });
@ -24,17 +25,14 @@ describe('shortUrlEditionReducer', () => {
it('returns error on EDIT_SHORT_URL_ERROR', () => { it('returns error on EDIT_SHORT_URL_ERROR', () => {
expect(reducer(undefined, Mock.of<ShortUrlEditedAction>({ type: EDIT_SHORT_URL_ERROR }))).toEqual({ expect(reducer(undefined, Mock.of<ShortUrlEditedAction>({ type: EDIT_SHORT_URL_ERROR }))).toEqual({
longUrl: null,
shortCode: null,
saving: false, saving: false,
error: true, error: true,
}); });
}); });
it('returns provided tags and shortCode on SHORT_URL_EDITED', () => { it('returns provided tags and shortCode on SHORT_URL_EDITED', () => {
expect(reducer(undefined, { type: SHORT_URL_EDITED, longUrl, shortCode, domain: null })).toEqual({ expect(reducer(undefined, { type: SHORT_URL_EDITED, shortUrl })).toEqual({
longUrl, shortUrl,
shortCode,
saving: false, saving: false,
error: false, error: false,
}); });
@ -42,38 +40,58 @@ describe('shortUrlEditionReducer', () => {
}); });
describe('editShortUrl', () => { describe('editShortUrl', () => {
const updateShortUrlMeta = jest.fn().mockResolvedValue({}); const updateShortUrl = jest.fn().mockResolvedValue(shortUrl);
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlMeta }); const updateShortUrlTags = jest.fn().mockResolvedValue([]);
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrl, updateShortUrlTags });
const dispatch = jest.fn(); const dispatch = jest.fn();
const getState = () => Mock.of<ShlinkState>(); const createGetState = (selectedServer: SelectedServer = null) => () => Mock.of<ShlinkState>({ selectedServer });
afterEach(jest.clearAllMocks); afterEach(jest.clearAllMocks);
it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches long URL on success', async (domain) => { it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches short URL on success', async (domain) => {
await editShortUrl(buildShlinkApiClient)(shortCode, domain, longUrl)(dispatch, getState); await editShortUrl(buildShlinkApiClient)(shortCode, domain, { longUrl })(dispatch, createGetState());
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); expect(updateShortUrl).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, domain, { longUrl }); expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, { longUrl });
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START }); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_EDITED, longUrl, shortCode, domain }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_EDITED, shortUrl });
}); });
it.each([
[ null, { tags: [ 'foo', 'bar' ] }, 1 ],
[ null, {}, 0 ],
[ Mock.of<ReachableServer>({ version: '2.6.0' }), {}, 0 ],
[ Mock.of<ReachableServer>({ version: '2.6.0' }), { tags: [ 'foo', 'bar' ] }, 0 ],
[ Mock.of<ReachableServer>({ version: '2.5.0' }), {}, 0 ],
[ Mock.of<ReachableServer>({ version: '2.5.0' }), { tags: [ 'foo', 'bar' ] }, 1 ],
])(
'sends tags separately when appropriate, based on selected server and the payload',
async (server, payload, expectedTagsCalls) => {
const getState = createGetState(server);
await editShortUrl(buildShlinkApiClient)(shortCode, null, payload)(dispatch, getState);
expect(updateShortUrl).toHaveBeenCalled();
expect(updateShortUrlTags).toHaveBeenCalledTimes(expectedTagsCalls);
},
);
it('dispatches error on failure', async () => { it('dispatches error on failure', async () => {
const error = new Error(); const error = new Error();
updateShortUrlMeta.mockRejectedValue(error); updateShortUrl.mockRejectedValue(error);
try { try {
await editShortUrl(buildShlinkApiClient)(shortCode, undefined, longUrl)(dispatch, getState); await editShortUrl(buildShlinkApiClient)(shortCode, undefined, { longUrl })(dispatch, createGetState());
} catch (e) { } catch (e) {
expect(e).toBe(error); expect(e).toBe(error);
} }
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1); expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1); expect(updateShortUrl).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, { longUrl }); expect(updateShortUrl).toHaveBeenCalledWith(shortCode, undefined, { longUrl });
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START }); expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_ERROR }); expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_ERROR });

View file

@ -1,100 +0,0 @@
import moment from 'moment';
import { Mock } from 'ts-mockery';
import reducer, {
EDIT_SHORT_URL_META_START,
EDIT_SHORT_URL_META_ERROR,
SHORT_URL_META_EDITED,
RESET_EDIT_SHORT_URL_META,
editShortUrlMeta,
resetShortUrlMeta,
} from '../../../src/short-urls/reducers/shortUrlMeta';
import { ShlinkState } from '../../../src/container/types';
describe('shortUrlMetaReducer', () => {
const meta = {
maxVisits: 50,
startDate: moment('2020-01-01').format(),
};
const shortCode = 'abc123';
describe('reducer', () => {
it('returns loading on EDIT_SHORT_URL_META_START', () => {
expect(reducer(undefined, { type: EDIT_SHORT_URL_META_START } as any)).toEqual({
meta: {},
shortCode: null,
saving: true,
error: false,
});
});
it('returns error on EDIT_SHORT_URL_META_ERROR', () => {
expect(reducer(undefined, { type: EDIT_SHORT_URL_META_ERROR } as any)).toEqual({
meta: {},
shortCode: null,
saving: false,
error: true,
});
});
it('returns provided tags and shortCode on SHORT_URL_META_EDITED', () => {
expect(reducer(undefined, { type: SHORT_URL_META_EDITED, meta, shortCode } as any)).toEqual({
meta,
shortCode,
saving: false,
error: false,
});
});
it('goes back to initial state on RESET_EDIT_SHORT_URL_META', () => {
expect(reducer(undefined, { type: RESET_EDIT_SHORT_URL_META } as any)).toEqual({
meta: {},
shortCode: null,
saving: false,
error: false,
});
});
});
describe('editShortUrlMeta', () => {
const updateShortUrlMeta = jest.fn().mockResolvedValue({});
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlMeta });
const dispatch = jest.fn();
const getState = () => Mock.all<ShlinkState>();
afterEach(jest.clearAllMocks);
it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches metadata on success', async (domain) => {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, domain, meta)(dispatch, getState);
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, domain, meta);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SHORT_URL_META_EDITED, meta, shortCode, domain });
});
it('dispatches error on failure', async () => {
const error = new Error();
updateShortUrlMeta.mockRejectedValue(error);
try {
await editShortUrlMeta(buildShlinkApiClient)(shortCode, undefined, meta)(dispatch, getState);
} catch (e) {
expect(e).toBe(error);
}
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, meta);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_META_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_META_ERROR });
});
});
describe('resetShortUrlMeta', () => {
it('creates expected action', () => expect(resetShortUrlMeta()).toEqual({ type: RESET_EDIT_SHORT_URL_META }));
});
});

View file

@ -1,131 +0,0 @@
import { Mock } from 'ts-mockery';
import reducer, {
EDIT_SHORT_URL_TAGS_ERROR,
EDIT_SHORT_URL_TAGS_START,
RESET_EDIT_SHORT_URL_TAGS,
resetShortUrlsTags,
SHORT_URL_TAGS_EDITED,
editShortUrlTags,
EditShortUrlTagsAction,
} from '../../../src/short-urls/reducers/shortUrlTags';
import { ShlinkState } from '../../../src/container/types';
import { ReachableServer, SelectedServer } from '../../../src/servers/data';
describe('shortUrlTagsReducer', () => {
const tags = [ 'foo', 'bar', 'baz' ];
const shortCode = 'abc123';
describe('reducer', () => {
const action = (type: string) => Mock.of<EditShortUrlTagsAction>({ type });
it('returns loading on EDIT_SHORT_URL_TAGS_START', () => {
expect(reducer(undefined, action(EDIT_SHORT_URL_TAGS_START))).toEqual({
tags: [],
shortCode: null,
saving: true,
error: false,
});
});
it('returns error on EDIT_SHORT_URL_TAGS_ERROR', () => {
expect(reducer(undefined, action(EDIT_SHORT_URL_TAGS_ERROR))).toEqual({
tags: [],
shortCode: null,
saving: false,
error: true,
});
});
it('returns provided tags and shortCode on SHORT_URL_TAGS_EDITED', () => {
expect(reducer(undefined, { type: SHORT_URL_TAGS_EDITED, tags, shortCode, domain: null })).toEqual({
tags,
shortCode,
saving: false,
error: false,
});
});
it('goes back to initial state on RESET_EDIT_SHORT_URL_TAGS', () => {
expect(reducer(undefined, action(RESET_EDIT_SHORT_URL_TAGS))).toEqual({
tags: [],
shortCode: null,
saving: false,
error: false,
});
});
});
describe('resetShortUrlsTags', () => {
it('creates expected action', () => expect(resetShortUrlsTags()).toEqual({ type: RESET_EDIT_SHORT_URL_TAGS }));
});
describe('editShortUrlTags', () => {
const updateShortUrlTags = jest.fn();
const updateShortUrlMeta = jest.fn();
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlTags, updateShortUrlMeta });
const dispatch = jest.fn();
const buildGetState = (selectedServer?: SelectedServer) => () => Mock.of<ShlinkState>({ selectedServer });
afterEach(jest.clearAllMocks);
it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches normalized tags on success', async (domain) => {
const normalizedTags = [ 'bar', 'foo' ];
updateShortUrlTags.mockResolvedValue(normalizedTags);
await editShortUrlTags(buildShlinkApiClient)(shortCode, domain, tags)(dispatch, buildGetState());
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, domain, tags);
expect(updateShortUrlMeta).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });
expect(dispatch).toHaveBeenNthCalledWith(
2,
{ type: SHORT_URL_TAGS_EDITED, tags: normalizedTags, shortCode, domain },
);
});
it('calls updateShortUrlMeta when server is version 2.6.0 or above', async () => {
const normalizedTags = [ 'bar', 'foo' ];
updateShortUrlMeta.mockResolvedValue({ tags: normalizedTags });
await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)(
dispatch,
buildGetState(Mock.of<ReachableServer>({ printableVersion: '', version: '2.6.0' })),
);
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledTimes(1);
expect(updateShortUrlMeta).toHaveBeenCalledWith(shortCode, undefined, { tags });
expect(updateShortUrlTags).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });
expect(dispatch).toHaveBeenNthCalledWith(
2,
{ type: SHORT_URL_TAGS_EDITED, tags: normalizedTags, shortCode },
);
});
it('dispatches error on failure', async () => {
const error = new Error();
updateShortUrlTags.mockRejectedValue(error);
try {
await editShortUrlTags(buildShlinkApiClient)(shortCode, undefined, tags)(dispatch, buildGetState());
} catch (e) {
expect(e).toBe(error);
}
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledTimes(1);
expect(updateShortUrlTags).toHaveBeenCalledWith(shortCode, undefined, tags);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: EDIT_SHORT_URL_TAGS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: EDIT_SHORT_URL_TAGS_ERROR });
});
});
});

View file

@ -5,15 +5,12 @@ import reducer, {
LIST_SHORT_URLS_START, LIST_SHORT_URLS_START,
listShortUrls, listShortUrls,
} from '../../../src/short-urls/reducers/shortUrlsList'; } from '../../../src/short-urls/reducers/shortUrlsList';
import { SHORT_URL_TAGS_EDITED } from '../../../src/short-urls/reducers/shortUrlTags';
import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion'; import { SHORT_URL_DELETED } from '../../../src/short-urls/reducers/shortUrlDeletion';
import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrlMeta';
import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
import { ShortUrl } from '../../../src/short-urls/data'; import { ShortUrl } from '../../../src/short-urls/data';
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient'; import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types'; import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/api/types';
import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation'; import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation';
import { SHORT_URL_EDITED } from '../../../src/short-urls/reducers/shortUrlEdition';
describe('shortUrlsListReducer', () => { describe('shortUrlsListReducer', () => {
describe('reducer', () => { describe('reducer', () => {
@ -36,66 +33,6 @@ describe('shortUrlsListReducer', () => {
error: true, error: true,
})); }));
it('updates tags on matching URL on SHORT_URL_TAGS_EDITED', () => {
const shortCode = 'abc123';
const tags = [ 'foo', 'bar', 'baz' ];
const state = {
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
data: [
Mock.of<ShortUrl>({ shortCode, tags: [] }),
Mock.of<ShortUrl>({ shortCode, tags: [], domain: 'example.com' }),
Mock.of<ShortUrl>({ shortCode: 'foo', tags: [] }),
],
}),
loading: false,
error: false,
};
expect(reducer(state, { type: SHORT_URL_TAGS_EDITED, shortCode, tags, domain: null } as any)).toEqual({
shortUrls: {
data: [
{ shortCode, tags },
{ shortCode, tags: [], domain: 'example.com' },
{ shortCode: 'foo', tags: [] },
],
},
loading: false,
error: false,
});
});
it('updates meta on matching URL on SHORT_URL_META_EDITED', () => {
const shortCode = 'abc123';
const domain = 'example.com';
const meta = {
maxVisits: 5,
validSince: '2020-05-05',
};
const state = {
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
data: [
Mock.of<ShortUrl>({ shortCode, meta: { maxVisits: 10 }, domain }),
Mock.of<ShortUrl>({ shortCode, meta: { maxVisits: 50 } }),
Mock.of<ShortUrl>({ shortCode: 'foo', meta: {} }),
],
}),
loading: false,
error: false,
};
expect(reducer(state, { type: SHORT_URL_META_EDITED, shortCode, meta, domain } as any)).toEqual({
shortUrls: {
data: [
{ shortCode, meta, domain: 'example.com' },
{ shortCode, meta: { maxVisits: 50 } },
{ shortCode: 'foo', meta: {} },
],
},
loading: false,
error: false,
});
});
it('removes matching URL and reduces total on SHORT_URL_DELETED', () => { it('removes matching URL and reduces total on SHORT_URL_DELETED', () => {
const shortCode = 'abc123'; const shortCode = 'abc123';
const state = { const state = {
@ -123,33 +60,6 @@ describe('shortUrlsListReducer', () => {
}); });
}); });
it('updates edited short URL on SHORT_URL_EDITED', () => {
const shortCode = 'abc123';
const state = {
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
data: [
Mock.of<ShortUrl>({ shortCode, longUrl: 'old' }),
Mock.of<ShortUrl>({ shortCode, domain: 'example.com', longUrl: 'foo' }),
Mock.of<ShortUrl>({ shortCode: 'foo', longUrl: 'bar' }),
],
}),
loading: false,
error: false,
};
expect(reducer(state, { type: SHORT_URL_EDITED, shortCode, longUrl: 'newValue' } as any)).toEqual({
shortUrls: {
data: [
{ shortCode, longUrl: 'newValue' },
{ shortCode, longUrl: 'foo', domain: 'example.com' },
{ shortCode: 'foo', longUrl: 'bar' },
],
},
loading: false,
error: false,
});
});
const createNewShortUrlVisit = (visitsCount: number) => ({ const createNewShortUrlVisit = (visitsCount: number) => ({
shortUrl: { shortCode: 'abc123', visitsCount }, shortUrl: { shortCode: 'abc123', visitsCount },
}); });