mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
Merge pull request #404 from acelaya-forks/feature/edit-title
Feature/edit title
This commit is contained in:
commit
205e3ffb90
43 changed files with 737 additions and 1370 deletions
|
@ -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.
|
||||||
|
|
|
@ -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> =>
|
||||||
|
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
@import '../utils/base';
|
|
||||||
|
|
||||||
.create-short-url .form-group:last-child,
|
|
||||||
.create-short-url p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
|
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
111
src/short-urls/EditShortUrl.tsx
Normal file
111
src/short-urls/EditShortUrl.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
10
src/short-urls/ShortUrlForm.scss
Normal file
10
src/short-urls/ShortUrlForm.scss
Normal 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%;
|
||||||
|
}
|
218
src/short-urls/ShortUrlForm.tsx
Normal file
218
src/short-urls/ShortUrlForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
30
src/short-urls/helpers/ShortUrlDetailLink.tsx
Normal file
30
src/short-urls/helpers/ShortUrlDetailLink.tsx
Normal 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;
|
|
@ -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) {
|
||||||
|
|
|
@ -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 = (
|
||||||
<FontAwesomeIcon icon={menuIcon} />
|
<FontAwesomeIcon icon={menuIcon} />
|
||||||
</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
|
||||||
|
|
|
@ -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;
|
|
|
@ -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) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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) });
|
||||||
|
|
||||||
|
|
|
@ -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);
|
|
|
@ -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);
|
|
|
@ -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(
|
||||||
|
|
|
@ -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');
|
||||||
};
|
};
|
||||||
|
|
|
@ -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' });
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
105
test/short-urls/EditShortUrl.test.tsx
Normal file
105
test/short-urls/EditShortUrl.test.tsx
Normal 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 :(');
|
||||||
|
});
|
||||||
|
});
|
85
test/short-urls/ShortUrlForm.test.tsx
Normal file
85
test/short-urls/ShortUrlForm.test.tsx
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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');
|
||||||
|
|
|
@ -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));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
|
@ -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 }));
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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 },
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue