Merge pull request #343 from acelaya-forks/feature/overview-page

Feature/overview page
This commit is contained in:
Alejandro Celaya 2020-12-08 19:44:25 +01:00 committed by GitHub
commit bf1b59c0d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 912 additions and 336 deletions

View file

@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
### Added
* [#340](https://github.com/shlinkio/shlink-web-client/issues/340) Added new "overview" page, showing basic information of the active server.
As a side effect, it also introduces improvements in the "create short URL" page, grouping components by context and explaining what they are for.
* [#309](https://github.com/shlinkio/shlink-web-client/issues/309) Added new domain selector component in create URL form which allows selecting from previously used domains or set a new one.
* [#315](https://github.com/shlinkio/shlink-web-client/issues/315) Now you can tell if you want to validate the long URL when using Shlink >=2.4.

View file

@ -3,6 +3,7 @@ import {
faLink as createIcon,
faTags as tagsIcon,
faPen as editIcon,
faHome as overviewIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
@ -48,6 +49,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
return (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<AsideMenuItem to={buildPath('/overview')}>
<FontAwesomeIcon icon={overviewIcon} />
<span className="aside-menu__item-text">Overview</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>

View file

@ -1,5 +1,5 @@
import { FC, useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Redirect, Route, Switch } from 'react-router-dom';
import { useSwipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@ -20,6 +20,7 @@ const MenuLayout = (
ShortUrlVisits: FC,
TagVisits: FC,
ServerError: FC,
Overview: FC,
) => withSelectedServer(({ location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
@ -60,6 +61,8 @@ const MenuLayout = (
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
<div className="menu-layout__container">
<Switch>
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
<Route exact path="/server/:serverId/overview" component={Overview} />
<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/short-code/:shortCode/visits" component={ShortUrlVisits} />

View file

@ -4,7 +4,7 @@
border-radius: .25rem;
overflow: hidden;
min-height: 2.6rem;
padding: 6px 0 0 6px;
padding: .5rem 0 0 1rem;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
}
@ -40,7 +40,9 @@
background: transparent;
border: 0;
outline: none;
padding: 3px 5px;
padding: 1px 0;
width: 100%;
margin-bottom: 6px;
font-size: 1.25rem;
color: #495057;
}

View file

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

View file

@ -15,6 +15,7 @@ import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
import { TagVisits } from '../visits/reducers/tagVisits';
import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
export interface ShlinkState {
servers: ServersMap;
@ -35,6 +36,7 @@ export interface ShlinkState {
mercureInfo: MercureInfo;
settings: Settings;
domainsList: DomainsList;
visitsOverview: VisitsOverview;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;

View file

@ -1,4 +1,6 @@
@import './utils/base';
@import 'node_modules/bootstrap/scss/bootstrap.scss';
@import './common/react-tagsinput.scss';
html,
body,
@ -52,6 +54,10 @@ body,
white-space: nowrap;
}
.pointer {
cursor: pointer;
}
.text-ellipsis {
text-overflow: ellipsis;
overflow: hidden;

View file

@ -5,10 +5,8 @@ import { homepage } from '../package.json';
import container from './container';
import store from './container/store';
import { fixLeafletIcons } from './utils/helpers/leaflet';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'react-datepicker/dist/react-datepicker.css';
import 'leaflet/dist/leaflet.css';
import './common/react-tagsinput.scss';
import './index.scss';
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS

View file

@ -17,6 +17,7 @@ import tagEditReducer from '../tags/reducers/tagEdit';
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
import settingsReducer from '../settings/reducers/settings';
import domainsListReducer from '../domains/reducers/domainsList';
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
import { ShlinkState } from '../container/types';
export default combineReducers<ShlinkState>({
@ -38,4 +39,5 @@ export default combineReducers<ShlinkState>({
mercureInfo: mercureInfoReducer,
settings: settingsReducer,
domainsList: domainsListReducer,
visitsOverview: visitsOverviewReducer,
});

View file

@ -39,7 +39,7 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
const id = uuid();
createServer({ ...serverData, id });
push(`/server/${id}/list-short-urls/1`);
push(`/server/${id}`);
};
return (

View file

@ -26,7 +26,7 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: De
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p>
<i>
No data will be deleted, only the access to this server will be removed from this host.
No data will be deleted, only the access to this server will be removed from this device.
You can create it again at any moment.
</i>
</p>

View file

@ -18,7 +18,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData);
push(`/server/${selectedServer.id}/list-short-urls/1`);
push(`/server/${selectedServer.id}`);
};
return (

View file

@ -0,0 +1,8 @@
.overview__card.overview__card {
text-align: center;
}
.overview__card-title {
text-transform: uppercase;
color: #6c757d;
}

94
src/servers/Overview.tsx Normal file
View file

@ -0,0 +1,94 @@
import { FC, useEffect } from 'react';
import { Card, CardBody, CardHeader, CardText, CardTitle } from 'reactstrap';
import { Link } from 'react-router-dom';
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
import { prettify } from '../utils/helpers/numbers';
import { TagsList } from '../tags/reducers/tagsList';
import { ShortUrlsTableProps } from '../short-urls/ShortUrlsTable';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { isServerWithId, SelectedServer } from './data';
import './Overview.scss';
interface OverviewConnectProps {
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShortUrlsListParams) => void;
listTags: Function;
tagsList: TagsList;
selectedServer: SelectedServer;
visitsOverview: VisitsOverview;
loadVisitsOverview: Function;
}
export const Overview = (
ShortUrlsTable: FC<ShortUrlsTableProps>,
CreateShortUrl: FC<CreateShortUrlProps>,
) => boundToMercureHub(({
shortUrlsList,
listShortUrls,
listTags,
tagsList,
selectedServer,
loadVisitsOverview,
visitsOverview,
}: OverviewConnectProps) => {
const { loading, shortUrls } = shortUrlsList;
const { loading: loadingTags } = tagsList;
const { loading: loadingVisits, visitsCount } = visitsOverview;
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
useEffect(() => {
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
listTags();
loadVisitsOverview();
}, []);
return (
<>
<div className="row mb-3">
<div className="col-sm-4">
<Card className="overview__card mb-2" body color="light">
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
</Card>
</div>
<div className="col-sm-4">
<Card className="overview__card mb-2" body color="light">
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
<CardText tag="h2">
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
</CardText>
</Card>
</div>
<div className="col-sm-4">
<Card className="overview__card mb-2" body color="light">
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
</Card>
</div>
</div>
<Card className="mb-4">
<CardHeader>
<span className="d-sm-none">Create a short URL</span>
<h5 className="d-none d-sm-inline">Create a short URL</h5>
<Link className="float-right" to={`/server/${serverId}/create-short-url`}>Advanced options &raquo;</Link>
</CardHeader>
<CardBody>
<CreateShortUrl basicMode />
</CardBody>
</Card>
<Card>
<CardHeader>
<span className="d-sm-none">Recently created URLs</span>
<h5 className="d-none d-sm-inline">Recently created URLs</h5>
<Link className="float-right" to={`/server/${serverId}/list-short-urls/1`}>See all &raquo;</Link>
</CardHeader>
<CardBody>
<ShortUrlsTable shortUrlsList={shortUrlsList} selectedServer={selectedServer} className="mb-0" />
</CardBody>
</Card>
</>
);
}, () => 'https://shlink.io/new-visit');

View file

@ -23,7 +23,7 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
<DropdownItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
to={`/server/${id}`}
active={isServerWithId(selectedServer) && selectedServer.id === id}
>
{name}

View file

@ -11,7 +11,7 @@ interface ServersListGroup {
}
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
{name}
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
</ListGroupItem>

View file

@ -13,6 +13,7 @@ import ForServerVersion from '../helpers/ForServerVersion';
import { ServerError } from '../helpers/ServerError';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import { Overview } from '../Overview';
import ServersImporter from './ServersImporter';
import ServersExporter from './ServersExporter';
@ -43,6 +44,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
bottle.serviceFactory('Overview', Overview, 'ShortUrlsTable', 'CreateShortUrl');
bottle.decorator('Overview', connect(
[ 'shortUrlsList', 'tagsList', 'selectedServer', 'mercureInfo', 'visitsOverview' ],
[ 'listShortUrls', 'listTags', 'createNewVisits', 'loadMercureInfo', 'loadVisitsOverview' ],
));
// Services
bottle.constant('csvjson', csvjson);
bottle.constant('fileReaderFactory', () => new FileReader());

View file

@ -1,6 +1,7 @@
import { Card, CardBody, CardHeader, FormGroup, Input } from 'reactstrap';
import { FormGroup, Input } from 'reactstrap';
import classNames from 'classnames';
import ToggleSwitch from '../utils/ToggleSwitch';
import { SimpleCard } from '../utils/SimpleCard';
import { Settings } from './reducers/settings';
interface RealTimeUpdatesProps {
@ -14,39 +15,36 @@ const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
const RealTimeUpdates = (
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
) => (
<Card>
<CardHeader>Real-time updates</CardHeader>
<CardBody>
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
Real-time updates frequency (in minutes):
</label>
<Input
type="number"
min={0}
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
/>
{realTimeUpdates.enabled && (
<small className="form-text text-muted">
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</small>
)}
</FormGroup>
</CardBody>
</Card>
<SimpleCard title="Real-time updates">
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
Real-time updates frequency (in minutes):
</label>
<Input
type="number"
min={0}
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
/>
{realTimeUpdates.enabled && (
<small className="form-text text-muted">
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</small>
)}
</FormGroup>
</SimpleCard>
);
export default RealTimeUpdates;

View file

@ -0,0 +1,13 @@
@import '../utils/base';
.create-short-url__save-btn {
@media (max-width: $xsMax) {
width: 100%;
display: block;
}
}
.create-short-url .form-group:last-child,
.create-short-url p:last-child {
margin-bottom: 0;
}

View file

@ -1,33 +1,36 @@
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe, replace, trim } from 'ramda';
import { FC, useState } from 'react';
import { Collapse, FormGroup, Input } from 'reactstrap';
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 { versionMatch, Versions } from '../utils/helpers/version';
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
import { useToggle } from '../utils/helpers/hooks';
import { isReachableServer, 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 { ShortUrlData } from './data';
import { ShortUrlCreation } from './reducers/shortUrlCreation';
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
import './CreateShortUrl.scss';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
export interface CreateShortUrlProps {
basicMode?: boolean;
}
interface CreateShortUrlProps {
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
shortUrlCreationResult: ShortUrlCreation;
selectedServer: SelectedServer;
createShortUrl: Function;
createShortUrl: (data: ShortUrlData) => Promise<void>;
resetCreateShortUrl: () => void;
}
export const normalizeTag = pipe(trim, replace(/ /g, '-'));
const initialState: ShortUrlData = {
longUrl: '',
tags: [],
@ -49,17 +52,22 @@ const CreateShortUrl = (
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
ForServerVersion: FC<Versions>,
DomainSelector: FC<DomainSelectorProps>,
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
) => ({
createShortUrl,
shortUrlCreationResult,
resetCreateShortUrl,
selectedServer,
basicMode = false,
}: CreateShortUrlConnectProps) => {
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
const reset = () => setShortUrlCreation(initialState);
const save = handleEventPreventingDefault(() => {
const shortUrlData = {
...shortUrlCreation,
validSince: formatIsoDate(shortUrlCreation.validSince),
validUntil: formatIsoDate(shortUrlCreation.validUntil),
validSince: formatIsoDate(shortUrlCreation.validSince) ?? undefined,
validUntil: formatIsoDate(shortUrlCreation.validUntil) ?? undefined,
};
createShortUrl(shortUrlData).then(reset).catch(() => {});
@ -87,6 +95,24 @@ const CreateShortUrl = (
/>
</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 currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
@ -94,109 +120,100 @@ const CreateShortUrl = (
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
return (
<form onSubmit={save}>
<div className="form-group">
<input
className="form-control form-control-lg"
type="url"
placeholder="Insert the URL to be shortened"
required
value={shortUrlCreation.longUrl}
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
/>
</div>
<form className="create-short-url" onSubmit={save}>
{basicMode && basicComponents}
{!basicMode && (
<>
<SimpleCard title="Basic options" className="mb-3">
{basicComponents}
</SimpleCard>
<Collapse isOpen={moreOptionsVisible}>
<div className="form-group">
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
</div>
<div className="row">
<div className="col-sm-6 mb-3">
<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', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
{showDomainSelector && (
<FormGroup>
<DomainSelector
value={shortUrlCreation.domain}
onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
/>
</FormGroup>
)}
</SimpleCard>
</div>
<div className="row">
<div className="col-sm-4">
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
disabled: hasValue(shortUrlCreation.shortCodeLength),
})}
<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>
<div className="col-sm-4">
{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',
},
})}
</div>
<div className="col-sm-4">
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text', {
disabled: disableDomain,
...disableDomain && { title: 'Shlink 1.19.0 or higher is required to be able to provide the domain' },
})}
{showDomainSelector && (
<FormGroup>
<DomainSelector
value={shortUrlCreation.domain}
onChange={(domain?: string) => setShortUrlCreation({ ...shortUrlCreation, domain })}
/>
</FormGroup>
)}
</div>
</div>
<div className="row">
<div className="col-sm-4">
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
</div>
<div className="col-sm-4">
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
</div>
<div className="col-sm-4">
{renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlCreation.validSince as m.Moment | undefined })}
</div>
</div>
<ForServerVersion minVersion="1.16.0">
<div className="mb-4 row">
<div className="col-sm-6 text-center text-sm-left mb-2 mb-sm-0">
<ForServerVersion minVersion="1.16.0">
<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
checked={shortUrlCreation.validateUrl}
onChange={(validateUrl) => setShortUrlCreation({ ...shortUrlCreation, validateUrl })}
className="mr-2"
checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
>
Validate URL
Use existing URL if found
</Checkbox>
</ForServerVersion>
</div>
<div className="col-sm-6 text-center text-sm-right">
<Checkbox
inline
className="mr-2"
checked={shortUrlCreation.findIfExists}
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
>
Use existing URL if found
</Checkbox>
<UseExistingIfFoundInfoIcon />
</div>
</div>
</ForServerVersion>
</Collapse>
<UseExistingIfFoundInfoIcon />
</p>
</SimpleCard>
</ForServerVersion>
</>
)}
<div>
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
&nbsp;
{moreOptionsVisible ? 'Less' : 'More'} options
</button>
<button
className="btn btn-outline-primary float-right"
<div className="text-center">
<Button
outline
color="primary"
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
className="create-short-url__save-btn"
>
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
</button>
</Button>
</div>
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
<CreateShortUrlResult
{...shortUrlCreationResult}
resetCreateShortUrl={resetCreateShortUrl}
canBeClosed={basicMode}
/>
</form>
);
};

View file

@ -1,16 +1,11 @@
import { FC, useEffect, useState } from 'react';
import { ShlinkShortUrlsResponse } from '../utils/services/types';
import Paginator from './Paginator';
import { ShortUrlsListProps, WithList } from './ShortUrlsList';
import { ShortUrlsListProps } from './ShortUrlsList';
interface ShortUrlsProps extends ShortUrlsListProps {
shortUrlsList?: ShlinkShortUrlsResponse;
}
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps & WithList>) => (props: ShortUrlsProps) => {
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps>) => (props: ShortUrlsListProps) => {
const { match, shortUrlsList } = props;
const { page = '1', serverId = '' } = match?.params ?? {};
const { data = [], pagination } = shortUrlsList ?? {};
const { pagination } = shortUrlsList?.shortUrls ?? {};
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
// Using a key on a component makes react to create a new instance every time the key changes
@ -23,7 +18,7 @@ const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps & WithLis
<>
<div className="form-group"><SearchBar /></div>
<div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<ShortUrlsList {...props} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
</>

View file

@ -1,19 +1,3 @@
@import '../utils/base';
.short-urls-list__header {
@media (max-width: $smMax) {
display: none;
}
}
.short-urls-list__header--with-action {
cursor: pointer;
}
.short-urls-list__header-icon {
margin-right: 5px;
}
.short-urls-list__header-cell--with-action {
cursor: pointer;
}

View file

@ -1,6 +1,6 @@
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { head, isEmpty, keys, values } from 'ramda';
import { head, keys, values } from 'ramda';
import { FC, useEffect, useState } from 'react';
import qs from 'qs';
import { RouteComponentProps } from 'react-router';
@ -9,9 +9,8 @@ import { determineOrderDir, OrderDir } from '../utils/utils';
import { SelectedServer } from '../servers/data';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
import { ShortUrl } from './data';
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
import { ShortUrlsTableProps } from './ShortUrlsTable';
import './ShortUrlsList.scss';
interface RouteParams {
@ -19,28 +18,23 @@ interface RouteParams {
serverId: string;
}
export interface WithList {
shortUrlsList: ShortUrl[];
}
export interface ShortUrlsListProps extends ShortUrlsListState, RouteComponentProps<RouteParams> {
export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
selectedServer: SelectedServer;
shortUrlsList: ShortUrlsListState;
listShortUrls: (params: ShortUrlsListParams) => void;
shortUrlsListParams: ShortUrlsListParams;
resetShortUrlParams: () => void;
}
const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub(({
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
listShortUrls,
resetShortUrlParams,
shortUrlsListParams,
match,
location,
loading,
error,
shortUrlsList,
selectedServer,
}: ShortUrlsListProps & WithList) => {
}: ShortUrlsListProps) => {
const { orderBy } = shortUrlsListParams;
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
@ -69,39 +63,12 @@ const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub
/>
);
};
const renderShortUrls = () => {
if (error) {
return (
<tr>
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td>
</tr>
);
}
if (loading) {
return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
}
if (!loading && isEmpty(shortUrlsList)) {
return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
}
return shortUrlsList.map((shortUrl) => (
<ShortUrlsRow
key={shortUrl.shortUrl}
shortUrl={shortUrl}
selectedServer={selectedServer}
refreshList={refreshList}
shortUrlsListParams={shortUrlsListParams}
/>
));
};
useEffect(() => {
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
const tags = query.tag ? [ query.tag as string ] : shortUrlsListParams.tags;
refreshList({ page: match.params.page, tags });
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
return resetShortUrlParams;
}, []);
@ -116,44 +83,14 @@ const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub
onChange={handleOrderBy}
/>
</div>
<table className="table table-striped table-hover">
<thead className="short-urls-list__header">
<tr>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={orderByColumn('dateCreated')}
>
{renderOrderIcon('dateCreated')}
Created at
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={orderByColumn('shortCode')}
>
{renderOrderIcon('shortCode')}
Short URL
</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={orderByColumn('longUrl')}
>
{renderOrderIcon('longUrl')}
Long URL
</th>
<th className="short-urls-list__header-cell">Tags</th>
<th
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
onClick={orderByColumn('visits')}
>
<span className="indivisible">{renderOrderIcon('visits')} Visits</span>
</th>
<th className="short-urls-list__header-cell">&nbsp;</th>
</tr>
</thead>
<tbody>
{renderShortUrls()}
</tbody>
</table>
<ShortUrlsTable
orderByColumn={orderByColumn}
renderOrderIcon={renderOrderIcon}
selectedServer={selectedServer}
refreshList={refreshList}
shortUrlsListParams={shortUrlsListParams}
shortUrlsList={shortUrlsList}
/>
</>
);
}, () => 'https://shlink.io/new-visit');

View file

@ -0,0 +1,11 @@
@import '../utils/base';
.short-urls-table__header {
@media (max-width: $smMax) {
display: none;
}
}
.short-urls-table__header-cell--with-action {
cursor: pointer;
}

View file

@ -0,0 +1,91 @@
import { FC, ReactNode } from 'react';
import { isEmpty } from 'ramda';
import classNames from 'classnames';
import { SelectedServer } from '../servers/data';
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
import { OrderableFields, ShortUrlsListParams } from './reducers/shortUrlsListParams';
import './ShortUrlsTable.scss';
export interface ShortUrlsTableProps {
orderByColumn?: (column: OrderableFields) => () => void;
renderOrderIcon?: (column: OrderableFields) => ReactNode;
shortUrlsList: ShortUrlsListState;
selectedServer: SelectedServer;
refreshList?: Function;
shortUrlsListParams?: ShortUrlsListParams;
className?: string;
}
export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
orderByColumn,
renderOrderIcon,
shortUrlsList,
refreshList,
shortUrlsListParams,
selectedServer,
className,
}: ShortUrlsTableProps) => {
const { error, loading, shortUrls } = shortUrlsList;
const orderableColumnsClasses = classNames('short-urls-table__header-cell', {
'short-urls-table__header-cell--with-action': !!orderByColumn,
});
const tableClasses = classNames('table table-striped table-hover', className);
const renderShortUrls = () => {
if (error) {
return (
<tr>
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td>
</tr>
);
}
if (loading) {
return <tr><td colSpan={6} className="text-center">Loading...</td></tr>;
}
if (!loading && isEmpty(shortUrlsList)) {
return <tr><td colSpan={6} className="text-center">No results found</td></tr>;
}
return shortUrls?.data.map((shortUrl) => (
<ShortUrlsRow
key={shortUrl.shortUrl}
shortUrl={shortUrl}
selectedServer={selectedServer}
refreshList={refreshList}
shortUrlsListParams={shortUrlsListParams}
/>
));
};
return (
<table className={tableClasses}>
<thead className="short-urls-table__header">
<tr>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
{renderOrderIcon?.('dateCreated')}
Created at
</th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
{renderOrderIcon?.('shortCode')}
Short URL
</th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
{renderOrderIcon?.('longUrl')}
Long URL
</th>
<th className="short-urls-table__header-cell">Tags</th>
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
<span className="indivisible">{renderOrderIcon?.('visits')} Visits</span>
</th>
<th className="short-urls-table__header-cell">&nbsp;</th>
</tr>
</thead>
<tbody>
{renderShortUrls()}
</tbody>
</table>
);
};

View file

@ -1,4 +1,5 @@
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isNil } from 'ramda';
import { useEffect } from 'react';
@ -10,10 +11,11 @@ import './CreateShortUrlResult.scss';
export interface CreateShortUrlResultProps extends ShortUrlCreation {
resetCreateShortUrl: () => void;
canBeClosed?: boolean;
}
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
{ error, result, resetCreateShortUrl }: CreateShortUrlResultProps,
{ error, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
) => {
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
@ -38,6 +40,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
return (
<Card inverse className="bg-main mt-3">
<CardBody>
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
<b>Great!</b> The short URL is <b>{shortUrl}</b>
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>

View file

@ -16,8 +16,8 @@ import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
import './ShortUrlsRow.scss';
export interface ShortUrlsRowProps {
refreshList: Function;
shortUrlsListParams: ShortUrlsListParams;
refreshList?: Function;
shortUrlsListParams?: ShortUrlsListParams;
selectedServer: SelectedServer;
shortUrl: ShortUrl;
}
@ -36,14 +36,14 @@ const ShortUrlsRow = (
return <i className="indivisible"><small>No tags</small></i>;
}
const selectedTags = shortUrlsListParams.tags ?? [];
const selectedTags = shortUrlsListParams?.tags ?? [];
return tags.map((tag) => (
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
onClick={() => refreshList?.({ tags: [ ...selectedTags, tag ] })}
/>
));
};

View file

@ -18,7 +18,7 @@ export interface ShortUrlDeletion {
errorData?: ProblemDetailsError;
}
interface DeleteShortUrlAction extends Action<string> {
export interface DeleteShortUrlAction extends Action<string> {
shortCode: string;
domain?: string | null;
}

View file

@ -1,4 +1,4 @@
import { assoc, assocPath, last, reject } from 'ramda';
import { assoc, assocPath, init, last, pipe, reject } from 'ramda';
import { Action, Dispatch } from 'redux';
import { shortUrlMatches } from '../helpers';
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
@ -8,10 +8,11 @@ import { GetState } from '../../container/types';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { ShlinkShortUrlsResponse } from '../../utils/services/types';
import { EditShortUrlTagsAction, SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { 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 { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
/* eslint-disable padding-line-between-statements */
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
@ -31,7 +32,13 @@ export interface ListShortUrlsAction extends Action<string> {
}
export type ListShortUrlsCombinedAction = (
ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitsAction
ListShortUrlsAction
& EditShortUrlTagsAction
& ShortUrlEditedAction
& ShortUrlMetaEditedAction
& CreateVisitsAction
& CreateShortUrlAction
& DeleteShortUrlAction
);
const initialState: ShortUrlsList = {
@ -55,10 +62,17 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
[LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
[SHORT_URL_DELETED]: (state, { shortCode, domain }) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ],
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
state,
[SHORT_URL_DELETED]: pipe(
(state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ],
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
state,
),
(state) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'pagination', 'totalItems' ],
state.shortUrls.pagination.totalItems - 1,
state,
),
),
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl<EditShortUrlTagsAction>('tags'),
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlMetaEditedAction>('meta'),
@ -77,6 +91,20 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
),
state,
),
[CREATE_SHORT_URL]: pipe(
// The only place where the list and the creation form coexist is the overview page.
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL and remove the last one.
(state: ShortUrlsList, { result }: CreateShortUrlAction) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'data' ],
[ result, ...init(state.shortUrls.data) ],
state,
),
(state: ShortUrlsList) => !state.shortUrls ? state : assocPath(
[ 'shortUrls', 'pagination', 'totalItems' ],
state.shortUrls.pagination.totalItems + 1,
state,
),
),
}, initialState);
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (

View file

@ -15,6 +15,7 @@ export type OrderableFields = keyof typeof SORTABLE_FIELDS;
export interface ShortUrlsListParams {
page?: string;
itemsPerPage?: number;
tags?: string[];
searchTerm?: string;
startDate?: string;

View file

@ -1,5 +1,3 @@
import { connect as reduxConnect } from 'react-redux';
import { assoc } from 'ramda';
import Bottle from 'bottlejs';
import ShortUrls from '../ShortUrls';
import SearchBar from '../SearchBar';
@ -19,27 +17,22 @@ import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
import { editShortUrl } from '../reducers/shortUrlEdition';
import { ConnectDecorator, ShlinkState } from '../../container/types';
import { ConnectDecorator } from '../../container/types';
import { ShortUrlsTable } from '../ShortUrlsTable';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
bottle.decorator('ShortUrls', reduxConnect(
(state: ShlinkState) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList),
));
bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ]));
// Services
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable');
bottle.decorator('ShortUrlsList', connect(
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
));
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
bottle.serviceFactory(
'ShortUrlsRowMenu',
ShortUrlsRowMenu,
@ -76,6 +69,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
// Services
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
// Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);

13
src/utils/SimpleCard.tsx Normal file
View file

@ -0,0 +1,13 @@
import { CardProps } from 'reactstrap/lib/Card';
import { Card, CardBody, CardHeader } from 'reactstrap';
interface SimpleCardProps extends CardProps {
title?: string;
}
export const SimpleCard = ({ title, children, ...rest }: SimpleCardProps) => (
<Card {...rest}>
{title && <CardHeader>{title}</CardHeader>}
<CardBody>{children}</CardBody>
</Card>
);

View file

@ -19,3 +19,8 @@ $mediumGrey: #dee2e6;
$headerHeight: 57px;
$footer-height: 2.3rem;
$footer-margin: .8rem;
// Bootstrap overwrites
//$theme-colors: (
// 'primary': $mainColor
//);

View file

@ -15,6 +15,7 @@ import {
ShlinkShortUrlMeta,
ShlinkDomain,
ShlinkDomainsResponse,
ShlinkVisitsOverview,
} from './types';
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
@ -50,6 +51,10 @@ export default class ShlinkApiClient {
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
.then(({ data }) => data.visits);
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
.then(({ data }) => data.visits);
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
.then(({ data }) => data);

View file

@ -36,6 +36,7 @@ export interface ShlinkTagsResponse {
export interface ShlinkPaginator {
currentPage: number;
pagesCount: number;
totalItems: number;
}
export interface ShlinkVisits {
@ -43,6 +44,10 @@ export interface ShlinkVisits {
pagination?: ShlinkPaginator; // Is only optional in old Shlink versions
}
export interface ShlinkVisitsOverview {
visitsCount: number;
}
export interface ShlinkVisitsParams {
domain?: OptionalString;
page?: number;

View file

@ -0,0 +1,52 @@
import { Action, Dispatch } from 'redux';
import { ShlinkVisitsOverview } from '../../utils/services/types';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types';
import { buildReducer } from '../../utils/helpers/redux';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
/* eslint-disable padding-line-between-statements */
export const GET_OVERVIEW_START = 'shlink/visitsOverview/GET_OVERVIEW_START';
export const GET_OVERVIEW_ERROR = 'shlink/visitsOverview/GET_OVERVIEW_ERROR';
export const GET_OVERVIEW = 'shlink/visitsOverview/GET_OVERVIEW';
/* eslint-enable padding-line-between-statements */
export interface VisitsOverview {
visitsCount: number;
loading: boolean;
error: boolean;
}
export type GetVisitsOverviewAction = ShlinkVisitsOverview & Action<string>;
const initialState: VisitsOverview = {
visitsCount: 0,
loading: false,
error: false,
};
export default buildReducer<VisitsOverview, GetVisitsOverviewAction & CreateVisitsAction>({
[GET_OVERVIEW_START]: () => ({ ...initialState, loading: true }),
[GET_OVERVIEW_ERROR]: () => ({ ...initialState, error: true }),
[GET_OVERVIEW]: (_, { visitsCount }) => ({ ...initialState, visitsCount }),
[CREATE_VISITS]: ({ visitsCount, ...rest }, { createdVisits }) => ({
...rest,
visitsCount: visitsCount + createdVisits.length,
}),
}, initialState);
export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
dispatch: Dispatch,
getState: GetState,
) => {
dispatch({ type: GET_OVERVIEW_START });
try {
const { getVisitsOverview } = buildShlinkApiClient(getState);
const result = await getVisitsOverview();
dispatch({ type: GET_OVERVIEW, ...result });
} catch (e) {
dispatch({ type: GET_OVERVIEW_ERROR });
}
};

View file

@ -7,6 +7,7 @@ import { createNewVisits } from '../reducers/visitCreation';
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
import TagVisits from '../TagVisits';
import { ConnectDecorator } from '../../container/types';
import { loadVisitsOverview } from '../reducers/visitsOverview';
import * as visitsParser from './VisitsParser';
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
@ -35,6 +36,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
bottle.serviceFactory('createNewVisits', () => createNewVisits);
bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient');
};
export default provideServices;

View file

@ -17,7 +17,7 @@ describe('<AsideMenu />', () => {
it('contains links to different sections', () => {
const links = wrapped.find('[to]');
expect(links).toHaveLength(4);
expect(links).toHaveLength(5);
links.forEach((link) => expect(link.prop('to')).toContain('abc123'));
});

View file

@ -0,0 +1,79 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery';
import { CardText } from 'reactstrap';
import { Link } from 'react-router-dom';
import { ShortUrlsList as ShortUrlsListState } from '../../src/short-urls/reducers/shortUrlsList';
import { Overview as overviewCreator } from '../../src/servers/Overview';
import { TagsList } from '../../src/tags/reducers/tagsList';
import { VisitsOverview } from '../../src/visits/reducers/visitsOverview';
import { MercureInfo } from '../../src/mercure/reducers/mercureInfo';
import { ReachableServer } from '../../src/servers/data';
import { prettify } from '../../src/utils/helpers/numbers';
describe('<Overview />', () => {
let wrapper: ShallowWrapper;
const ShortUrlsTable = () => null;
const CreateShortUrl = () => null;
const listShortUrls = jest.fn();
const listTags = jest.fn();
const loadVisitsOverview = jest.fn();
const Overview = overviewCreator(ShortUrlsTable, CreateShortUrl);
const shortUrls = {
pagination: { totalItems: 83710 },
};
const serverId = '123';
const createWrapper = (loading = false) => {
wrapper = shallow(
<Overview
listShortUrls={listShortUrls}
listTags={listTags}
loadVisitsOverview={loadVisitsOverview}
shortUrlsList={Mock.of<ShortUrlsListState>({ loading, shortUrls })}
tagsList={Mock.of<TagsList>({ loading, tags: [ 'foo', 'bar', 'baz' ] })}
visitsOverview={Mock.of<VisitsOverview>({ loading, visitsCount: 3456 })}
selectedServer={Mock.of<ReachableServer>({ id: serverId })}
createNewVisits={jest.fn()}
loadMercureInfo={jest.fn()}
mercureInfo={Mock.all<MercureInfo>()}
/>,
).dive(); // Dive is needed as this component is wrapped in a HOC
return wrapper;
};
afterEach(() => wrapper?.unmount());
test('cards display loading messages when still loading', () => {
const wrapper = createWrapper(true);
const cards = wrapper.find(CardText);
expect(cards).toHaveLength(3);
cards.forEach((card) => expect(card.html()).toContain('Loading...'));
});
test('amounts are displayed in cards after finishing loading', () => {
const wrapper = createWrapper();
const cards = wrapper.find(CardText);
expect(cards).toHaveLength(3);
expect(cards.at(0).html()).toContain(prettify(3456));
expect(cards.at(1).html()).toContain(prettify(83710));
expect(cards.at(2).html()).toContain(prettify(3));
});
test('nests complex components', () => {
const wrapper = createWrapper();
expect(wrapper.find(CreateShortUrl)).toHaveLength(1);
expect(wrapper.find(ShortUrlsTable)).toHaveLength(1);
});
test('links to other sections are displayed', () => {
const wrapper = createWrapper();
const links = wrapper.find(Link);
expect(links).toHaveLength(2);
expect(links.at(0).prop('to')).toEqual(`/server/${serverId}/create-short-url`);
expect(links.at(1).prop('to')).toEqual(`/server/${serverId}/list-short-urls/1`);
});
});

View file

@ -2,6 +2,7 @@ import { shallow, ShallowWrapper } from 'enzyme';
import moment from 'moment';
import { identity } from 'ramda';
import { Mock } from 'ts-mockery';
import { Input } from 'reactstrap';
import createShortUrlsCreator from '../../src/short-urls/CreateShortUrl';
import DateInput from '../../src/utils/DateInput';
import { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation';
@ -31,7 +32,7 @@ describe('<CreateShortUrl />', () => {
const validSince = moment('2017-01-01');
const validUntil = moment('2017-01-06');
wrapper.find('.form-control-lg').simulate('change', { target: { value: 'https://long-domain.com/foo/bar' } });
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' } });

View file

@ -16,6 +16,7 @@ describe('<Paginator />', () => {
const paginator = {
currentPage: 1,
pagesCount: 5,
totalItems: 10,
};
const extraPagesPrevNext = 2;
const expectedItems = paginator.pagesCount + extraPagesPrevNext;

View file

@ -1,19 +1,30 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
import { Mock } from 'ts-mockery';
import shortUrlsListCreator, { ShortUrlsListProps } from '../../src/short-urls/ShortUrlsList';
import { ShortUrl } from '../../src/short-urls/data';
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { SORTABLE_FIELDS } from '../../src/short-urls/reducers/shortUrlsListParams';
import { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList';
import SortingDropdown from '../../src/utils/SortingDropdown';
describe('<ShortUrlsList />', () => {
let wrapper: ShallowWrapper;
const ShortUrlsRow = () => null;
const ShortUrlsTable = () => null;
const listShortUrlsMock = jest.fn();
const resetShortUrlParamsMock = jest.fn();
const shortUrlsList = Mock.of<ShortUrlsListModel>({
shortUrls: {
data: [
Mock.of<ShortUrl>({
shortCode: 'testShortCode',
shortUrl: 'https://www.example.com/testShortUrl',
longUrl: 'https://www.example.com/testLongUrl',
tags: [ 'test tag' ],
}),
],
},
});
const ShortUrlsList = shortUrlsListCreator(ShortUrlsRow);
const ShortUrlsList = shortUrlsListCreator(ShortUrlsTable);
beforeEach(() => {
wrapper = shallow(
@ -29,18 +40,7 @@ describe('<ShortUrlsList />', () => {
}}
match={{ params: {} } as any}
location={{} as any}
loading={false}
error={false}
shortUrlsList={
[
Mock.of<ShortUrl>({
shortCode: 'testShortCode',
shortUrl: 'https://www.example.com/testShortUrl',
longUrl: 'https://www.example.com/testLongUrl',
tags: [ 'test tag' ],
}),
]
}
shortUrlsList={shortUrlsList}
/>,
).dive(); // Dive is needed as this component is wrapped in a HOC
});
@ -48,50 +48,11 @@ describe('<ShortUrlsList />', () => {
afterEach(jest.resetAllMocks);
afterEach(() => wrapper?.unmount());
it('wraps a ShortUrlsList with 1 ShortUrlsRow', () => {
expect(wrapper.find(ShortUrlsRow)).toHaveLength(1);
it('wraps a ShortUrlsTable', () => {
expect(wrapper.find(ShortUrlsTable)).toHaveLength(1);
});
it('should render inner table by default', () => {
expect(wrapper.find('table')).toHaveLength(1);
});
it('should render table header by default', () => {
expect(wrapper.find('table').find('thead')).toHaveLength(1);
});
it('should render 6 table header cells by default', () => {
expect(wrapper.find('table').find('thead').find('tr').find('th')).toHaveLength(6);
});
it('should render 6 table header cells without order by icon by default', () => {
const thElements = wrapper.find('table').find('thead').find('tr').find('th');
thElements.forEach((thElement) => {
expect(thElement.find(FontAwesomeIcon)).toHaveLength(0);
});
});
it('should render 6 table header cells with conditional order by icon', () => {
const getThElementForSortableField = (sortableField: string) => wrapper.find('table')
.find('thead')
.find('tr')
.find('th')
.filterWhere((e) => e.text().includes(SORTABLE_FIELDS[sortableField as keyof typeof SORTABLE_FIELDS]));
Object.keys(SORTABLE_FIELDS).forEach((sortableField) => {
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0);
getThElementForSortableField(sortableField).simulate('click');
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1);
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(caretUpIcon);
getThElementForSortableField(sortableField).simulate('click');
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1);
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(caretDownIcon);
getThElementForSortableField(sortableField).simulate('click');
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(0);
});
it('wraps a SortingDropdown', () => {
expect(wrapper.find(SortingDropdown)).toHaveLength(1);
});
});

View file

@ -0,0 +1,59 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Mock } from 'ts-mockery';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { ShortUrlsTable as shortUrlsTableCreator } from '../../src/short-urls/ShortUrlsTable';
import { SORTABLE_FIELDS } from '../../src/short-urls/reducers/shortUrlsListParams';
import { ShortUrlsList } from '../../src/short-urls/reducers/shortUrlsList';
describe('<ShortUrlsTable />', () => {
let wrapper: ShallowWrapper;
const shortUrlsList = Mock.all<ShortUrlsList>();
const orderByColumn = jest.fn();
const ShortUrlsRow = () => null;
const ShortUrlsTable = shortUrlsTableCreator(ShortUrlsRow);
beforeEach(() => {
wrapper = shallow(
<ShortUrlsTable shortUrlsList={shortUrlsList} selectedServer={null} orderByColumn={() => orderByColumn} />,
);
});
afterEach(jest.resetAllMocks);
afterEach(() => wrapper?.unmount());
it('should render inner table by default', () => {
expect(wrapper.find('table')).toHaveLength(1);
});
it('should render table header by default', () => {
expect(wrapper.find('table').find('thead')).toHaveLength(1);
});
it('should render 6 table header cells by default', () => {
expect(wrapper.find('table').find('thead').find('tr').find('th')).toHaveLength(6);
});
it('should render 6 table header cells without order by icon by default', () => {
const thElements = wrapper.find('table').find('thead').find('tr').find('th');
thElements.forEach((thElement) => {
expect(thElement.find(FontAwesomeIcon)).toHaveLength(0);
});
});
it('should render 6 table header cells with conditional order by icon', () => {
const getThElementForSortableField = (sortableField: string) => wrapper.find('table')
.find('thead')
.find('tr')
.find('th')
.filterWhere((e) => e.text().includes(SORTABLE_FIELDS[sortableField as keyof typeof SORTABLE_FIELDS]));
const sortableFields = Object.keys(SORTABLE_FIELDS);
expect.assertions(sortableFields.length);
sortableFields.forEach((sortableField) => {
getThElementForSortableField(sortableField).simulate('click');
expect(orderByColumn).toHaveBeenCalled();
});
});
});

View file

@ -11,7 +11,9 @@ import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrl
import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
import { ShortUrl } from '../../../src/short-urls/data';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
import { ShlinkShortUrlsResponse } from '../../../src/utils/services/types';
import { ShlinkPaginator, ShlinkShortUrlsResponse } from '../../../src/utils/services/types';
import { CREATE_SHORT_URL } from '../../../src/short-urls/reducers/shortUrlCreation';
import { SHORT_URL_EDITED } from '../../../src/short-urls/reducers/shortUrlEdition';
describe('shortUrlsListReducer', () => {
describe('reducer', () => {
@ -94,7 +96,7 @@ describe('shortUrlsListReducer', () => {
});
});
it('removes matching URL on SHORT_URL_DELETED', () => {
it('removes matching URL and reduces total on SHORT_URL_DELETED', () => {
const shortCode = 'abc123';
const state = {
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
@ -103,6 +105,9 @@ describe('shortUrlsListReducer', () => {
Mock.of<ShortUrl>({ shortCode, domain: 'example.com' }),
Mock.of<ShortUrl>({ shortCode: 'foo' }),
],
pagination: Mock.of<ShlinkPaginator>({
totalItems: 10,
}),
}),
loading: false,
error: false,
@ -111,6 +116,34 @@ describe('shortUrlsListReducer', () => {
expect(reducer(state, { type: SHORT_URL_DELETED, shortCode } as any)).toEqual({
shortUrls: {
data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }],
pagination: { totalItems: 9 },
},
loading: false,
error: false,
});
});
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,
@ -147,6 +180,34 @@ describe('shortUrlsListReducer', () => {
error: false,
});
});
it('prepends new short URL and increases total on CREATE_SHORT_URL', () => {
const newShortUrl = Mock.of<ShortUrl>({ shortCode: 'newOne' });
const shortCode = 'abc123';
const state = {
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
data: [
Mock.of<ShortUrl>({ shortCode }),
Mock.of<ShortUrl>({ shortCode, domain: 'example.com' }),
Mock.of<ShortUrl>({ shortCode: 'foo' }),
],
pagination: Mock.of<ShlinkPaginator>({
totalItems: 15,
}),
}),
loading: false,
error: false,
};
expect(reducer(state, { type: CREATE_SHORT_URL, result: newShortUrl } as any)).toEqual({
shortUrls: {
data: [{ shortCode: 'newOne' }, { shortCode }, { shortCode, domain: 'example.com' }],
pagination: { totalItems: 16 },
},
loading: false,
error: false,
});
});
});
describe('listShortUrls', () => {

View file

@ -0,0 +1,30 @@
import { shallow } from 'enzyme';
import { Card, CardBody, CardHeader } from 'reactstrap';
import { SimpleCard } from '../../src/utils/SimpleCard';
describe('<SimpleCard />', () => {
it.each([
[{}, 0 ],
[{ title: 'Cool title' }, 1 ],
])('renders header only if title is provided', (props, expectedAmountOfHeaders) => {
const wrapper = shallow(<SimpleCard {...props} />);
expect(wrapper.find(CardHeader)).toHaveLength(expectedAmountOfHeaders);
});
it('renders children inside body', () => {
const wrapper = shallow(<SimpleCard>Hello world</SimpleCard>);
const body = wrapper.find(CardBody);
expect(body).toHaveLength(1);
expect(body.html()).toContain('Hello world');
});
it('passes extra props to nested card', () => {
const wrapper = shallow(<SimpleCard className="foo" color="primary">Hello world</SimpleCard>);
const card = wrapper.find(Card);
expect(card.prop('className')).toEqual('foo');
expect(card.prop('color')).toEqual('primary');
});
});

View file

@ -2,7 +2,7 @@ import { AxiosInstance, AxiosRequestConfig } from 'axios';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
import { OptionalString } from '../../../src/utils/utils';
import { Mock } from 'ts-mockery';
import { ShlinkDomain } from '../../../src/utils/services/types';
import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/utils/services/types';
describe('ShlinkApiClient', () => {
const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
@ -269,4 +269,18 @@ describe('ShlinkApiClient', () => {
expect(result).toEqual(expectedData);
});
});
describe('getVisitsOverview', () => {
it('returns visits overview', async () => {
const expectedData = Mock.all<ShlinkVisitsOverview>();
const resp = { visits: expectedData };
const axiosSpy = createAxiosMock({ data: resp });
const { getVisitsOverview } = new ShlinkApiClient(axiosSpy, '', '');
const result = await getVisitsOverview();
expect(axiosSpy).toHaveBeenCalled();
expect(result).toEqual(expectedData);
});
});
});

View file

@ -125,6 +125,7 @@ describe('shortUrlVisitsReducer', () => {
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
}));
@ -144,6 +145,7 @@ describe('shortUrlVisitsReducer', () => {
pagination: {
currentPage: page,
pagesCount: expectedRequests,
totalItems: 1,
},
}));

View file

@ -124,6 +124,7 @@ describe('tagVisitsReducer', () => {
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
}));

View file

@ -0,0 +1,78 @@
import { Mock } from 'ts-mockery';
import reducer, {
GET_OVERVIEW_START,
GET_OVERVIEW_ERROR,
GET_OVERVIEW,
GetVisitsOverviewAction,
VisitsOverview,
loadVisitsOverview,
} from '../../../src/visits/reducers/visitsOverview';
import { CreateVisitsAction } from '../../../src/visits/reducers/visitCreation';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
import { ShlinkVisitsOverview } from '../../../src/utils/services/types';
import { ShlinkState } from '../../../src/container/types';
describe('visitsOverview', () => {
describe('reducer', () => {
const action = (type: string) =>
Mock.of<GetVisitsOverviewAction>({ type }) as GetVisitsOverviewAction & CreateVisitsAction;
const state = (payload: Partial<VisitsOverview> = {}) => Mock.of<VisitsOverview>(payload);
it('returns loading on GET_OVERVIEW_START', () => {
const { loading } = reducer(state({ loading: false, error: false }), action(GET_OVERVIEW_START));
expect(loading).toEqual(true);
});
it('stops loading and returns error on GET_OVERVIEW_ERROR', () => {
const { loading, error } = reducer(state({ loading: true, error: false }), action(GET_OVERVIEW_ERROR));
expect(loading).toEqual(false);
expect(error).toEqual(true);
});
it('return visits overview on GET_OVERVIEW', () => {
const { loading, error, visitsCount } = reducer(
state({ loading: true, error: false }),
{ type: GET_OVERVIEW, visitsCount: 100 } as unknown as GetVisitsOverviewAction & CreateVisitsAction,
);
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(visitsCount).toEqual(100);
});
});
describe('loadVisitsOverview', () => {
const buildApiClientMock = (returned: Promise<ShlinkVisitsOverview>) => Mock.of<ShlinkApiClient>({
getVisitsOverview: jest.fn(async () => returned),
});
const dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>();
beforeEach(() => dispatchMock.mockReset());
it('dispatches start and error when promise is rejected', async () => {
const ShlinkApiClient = buildApiClientMock(Promise.reject());
await loadVisitsOverview(() => ShlinkApiClient)()(dispatchMock, getState);
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_OVERVIEW_START });
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_OVERVIEW_ERROR });
expect(ShlinkApiClient.getVisitsOverview).toHaveBeenCalledTimes(1);
});
it('dispatches start and success when promise is resolved', async () => {
const resolvedOverview = Mock.of<ShlinkVisitsOverview>({ visitsCount: 50 });
const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedOverview));
await loadVisitsOverview(() => ShlinkApiClient)()(dispatchMock, getState);
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_OVERVIEW_START });
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_OVERVIEW, visitsCount: 50 });
expect(ShlinkApiClient.getVisitsOverview).toHaveBeenCalledTimes(1);
});
});
});