mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-13 11:03:50 +00:00
Merge pull request #343 from acelaya-forks/feature/overview-page
Feature/overview page
This commit is contained in:
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### 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.
|
* [#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.
|
* [#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.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
faLink as createIcon,
|
faLink as createIcon,
|
||||||
faTags as tagsIcon,
|
faTags as tagsIcon,
|
||||||
faPen as editIcon,
|
faPen as editIcon,
|
||||||
|
faHome as overviewIcon,
|
||||||
} 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';
|
||||||
@@ -48,6 +49,10 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|||||||
return (
|
return (
|
||||||
<aside className={asideClass}>
|
<aside className={asideClass}>
|
||||||
<nav className="nav flex-column aside-menu__nav">
|
<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}>
|
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||||
<FontAwesomeIcon icon={listIcon} />
|
<FontAwesomeIcon icon={listIcon} />
|
||||||
<span className="aside-menu__item-text">List short URLs</span>
|
<span className="aside-menu__item-text">List short URLs</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, useEffect } from 'react';
|
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 { useSwipeable } from 'react-swipeable';
|
||||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
@@ -20,6 +20,7 @@ const MenuLayout = (
|
|||||||
ShortUrlVisits: FC,
|
ShortUrlVisits: FC,
|
||||||
TagVisits: FC,
|
TagVisits: FC,
|
||||||
ServerError: FC,
|
ServerError: FC,
|
||||||
|
Overview: FC,
|
||||||
) => withSelectedServer(({ location, selectedServer }) => {
|
) => withSelectedServer(({ location, selectedServer }) => {
|
||||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
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="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
|
||||||
<div className="menu-layout__container">
|
<div className="menu-layout__container">
|
||||||
<Switch>
|
<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/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 exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 2.6rem;
|
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;
|
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,9 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 3px 5px;
|
padding: 1px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #495057;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
'TagVisits',
|
'TagVisits',
|
||||||
'ServerError',
|
'ServerError',
|
||||||
|
'Overview',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
bottle.decorator('MenuLayout', withRouter);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
|
|||||||
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||||
import { TagVisits } from '../visits/reducers/tagVisits';
|
import { TagVisits } from '../visits/reducers/tagVisits';
|
||||||
import { DomainsList } from '../domains/reducers/domainsList';
|
import { DomainsList } from '../domains/reducers/domainsList';
|
||||||
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
|
|
||||||
export interface ShlinkState {
|
export interface ShlinkState {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
@@ -35,6 +36,7 @@ export interface ShlinkState {
|
|||||||
mercureInfo: MercureInfo;
|
mercureInfo: MercureInfo;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
domainsList: DomainsList;
|
domainsList: DomainsList;
|
||||||
|
visitsOverview: VisitsOverview;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
@import './utils/base';
|
@import './utils/base';
|
||||||
|
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||||
|
@import './common/react-tagsinput.scss';
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
@@ -52,6 +54,10 @@ body,
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.text-ellipsis {
|
.text-ellipsis {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import { homepage } from '../package.json';
|
|||||||
import container from './container';
|
import container from './container';
|
||||||
import store from './container/store';
|
import store from './container/store';
|
||||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './common/react-tagsinput.scss';
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import tagEditReducer from '../tags/reducers/tagEdit';
|
|||||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||||
import settingsReducer from '../settings/reducers/settings';
|
import settingsReducer from '../settings/reducers/settings';
|
||||||
import domainsListReducer from '../domains/reducers/domainsList';
|
import domainsListReducer from '../domains/reducers/domainsList';
|
||||||
|
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
||||||
import { ShlinkState } from '../container/types';
|
import { ShlinkState } from '../container/types';
|
||||||
|
|
||||||
export default combineReducers<ShlinkState>({
|
export default combineReducers<ShlinkState>({
|
||||||
@@ -38,4 +39,5 @@ export default combineReducers<ShlinkState>({
|
|||||||
mercureInfo: mercureInfoReducer,
|
mercureInfo: mercureInfoReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
domainsList: domainsListReducer,
|
domainsList: domainsListReducer,
|
||||||
|
visitsOverview: visitsOverviewReducer,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
|||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
|
||||||
createServer({ ...serverData, id });
|
createServer({ ...serverData, id });
|
||||||
push(`/server/${id}/list-short-urls/1`);
|
push(`/server/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
||||||
<p>
|
<p>
|
||||||
<i>
|
<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.
|
You can create it again at any moment.
|
||||||
</i>
|
</i>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
|||||||
|
|
||||||
const handleSubmit = (serverData: ServerData) => {
|
const handleSubmit = (serverData: ServerData) => {
|
||||||
editServer(selectedServer.id, serverData);
|
editServer(selectedServer.id, serverData);
|
||||||
push(`/server/${selectedServer.id}/list-short-urls/1`);
|
push(`/server/${selectedServer.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
8
src/servers/Overview.scss
Normal file
8
src/servers/Overview.scss
Normal 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
94
src/servers/Overview.tsx
Normal 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 »</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 »</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<ShortUrlsTable shortUrlsList={shortUrlsList} selectedServer={selectedServer} className="mb-0" />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, () => 'https://shlink.io/new-visit');
|
||||||
@@ -23,7 +23,7 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
|
|||||||
<DropdownItem
|
<DropdownItem
|
||||||
key={id}
|
key={id}
|
||||||
tag={Link}
|
tag={Link}
|
||||||
to={`/server/${id}/list-short-urls/1`}
|
to={`/server/${id}`}
|
||||||
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface ServersListGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
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}
|
{name}
|
||||||
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
||||||
</ListGroupItem>
|
</ListGroupItem>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import ForServerVersion from '../helpers/ForServerVersion';
|
|||||||
import { ServerError } from '../helpers/ServerError';
|
import { ServerError } from '../helpers/ServerError';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
||||||
|
import { Overview } from '../Overview';
|
||||||
import ServersImporter from './ServersImporter';
|
import ServersImporter from './ServersImporter';
|
||||||
import ServersExporter from './ServersExporter';
|
import ServersExporter from './ServersExporter';
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
|
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
|
||||||
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
|
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
|
// Services
|
||||||
bottle.constant('csvjson', csvjson);
|
bottle.constant('csvjson', csvjson);
|
||||||
bottle.constant('fileReaderFactory', () => new FileReader());
|
bottle.constant('fileReaderFactory', () => new FileReader());
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Card, CardBody, CardHeader, FormGroup, Input } from 'reactstrap';
|
import { FormGroup, Input } from 'reactstrap';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { Settings } from './reducers/settings';
|
import { Settings } from './reducers/settings';
|
||||||
|
|
||||||
interface RealTimeUpdatesProps {
|
interface RealTimeUpdatesProps {
|
||||||
@@ -14,39 +15,36 @@ const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
|||||||
const RealTimeUpdates = (
|
const RealTimeUpdates = (
|
||||||
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
||||||
) => (
|
) => (
|
||||||
<Card>
|
<SimpleCard title="Real-time updates">
|
||||||
<CardHeader>Real-time updates</CardHeader>
|
<FormGroup>
|
||||||
<CardBody>
|
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||||
<FormGroup>
|
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
</ToggleSwitch>
|
||||||
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
</FormGroup>
|
||||||
</ToggleSwitch>
|
<FormGroup className="mb-0">
|
||||||
</FormGroup>
|
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
|
||||||
<FormGroup className="mb-0">
|
Real-time updates frequency (in minutes):
|
||||||
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
|
</label>
|
||||||
Real-time updates frequency (in minutes):
|
<Input
|
||||||
</label>
|
type="number"
|
||||||
<Input
|
min={0}
|
||||||
type="number"
|
placeholder="Immediate"
|
||||||
min={0}
|
disabled={!realTimeUpdates.enabled}
|
||||||
placeholder="Immediate"
|
value={intervalValue(realTimeUpdates.interval)}
|
||||||
disabled={!realTimeUpdates.enabled}
|
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
||||||
value={intervalValue(realTimeUpdates.interval)}
|
/>
|
||||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
{realTimeUpdates.enabled && (
|
||||||
/>
|
<small className="form-text text-muted">
|
||||||
{realTimeUpdates.enabled && (
|
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
||||||
<small className="form-text text-muted">
|
<span>
|
||||||
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
|
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
|
||||||
<span>
|
</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>
|
||||||
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
|
)}
|
||||||
</small>
|
</FormGroup>
|
||||||
)}
|
</SimpleCard>
|
||||||
</FormGroup>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default RealTimeUpdates;
|
export default RealTimeUpdates;
|
||||||
|
|||||||
13
src/short-urls/CreateShortUrl.scss
Normal file
13
src/short-urls/CreateShortUrl.scss
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 { isEmpty, pipe, replace, trim } from 'ramda';
|
||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { Collapse, FormGroup, Input } from 'reactstrap';
|
import { Button, FormGroup, Input } from 'reactstrap';
|
||||||
import { InputType } from 'reactstrap/lib/Input';
|
import { InputType } from 'reactstrap/lib/Input';
|
||||||
import * as m from 'moment';
|
import * as m from 'moment';
|
||||||
import DateInput, { DateInputProps } from '../utils/DateInput';
|
import DateInput, { DateInputProps } from '../utils/DateInput';
|
||||||
import Checkbox from '../utils/Checkbox';
|
import Checkbox from '../utils/Checkbox';
|
||||||
import { versionMatch, Versions } from '../utils/helpers/version';
|
import { versionMatch, Versions } from '../utils/helpers/version';
|
||||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
|
||||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
import { TagsSelectorProps } from '../tags/helpers/TagsSelector';
|
||||||
import { DomainSelectorProps } from '../domains/DomainSelector';
|
import { DomainSelectorProps } from '../domains/DomainSelector';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { ShortUrlData } from './data';
|
import { ShortUrlData } from './data';
|
||||||
import { ShortUrlCreation } from './reducers/shortUrlCreation';
|
import { ShortUrlCreation } from './reducers/shortUrlCreation';
|
||||||
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon';
|
||||||
import { CreateShortUrlResultProps } from './helpers/CreateShortUrlResult';
|
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;
|
shortUrlCreationResult: ShortUrlCreation;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
createShortUrl: Function;
|
createShortUrl: (data: ShortUrlData) => Promise<void>;
|
||||||
resetCreateShortUrl: () => void;
|
resetCreateShortUrl: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||||
|
|
||||||
const initialState: ShortUrlData = {
|
const initialState: ShortUrlData = {
|
||||||
longUrl: '',
|
longUrl: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -49,17 +52,22 @@ const CreateShortUrl = (
|
|||||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||||
ForServerVersion: FC<Versions>,
|
ForServerVersion: FC<Versions>,
|
||||||
DomainSelector: FC<DomainSelectorProps>,
|
DomainSelector: FC<DomainSelectorProps>,
|
||||||
) => ({ createShortUrl, shortUrlCreationResult, resetCreateShortUrl, selectedServer }: CreateShortUrlProps) => {
|
) => ({
|
||||||
|
createShortUrl,
|
||||||
|
shortUrlCreationResult,
|
||||||
|
resetCreateShortUrl,
|
||||||
|
selectedServer,
|
||||||
|
basicMode = false,
|
||||||
|
}: CreateShortUrlConnectProps) => {
|
||||||
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
const [ shortUrlCreation, setShortUrlCreation ] = useState(initialState);
|
||||||
const [ moreOptionsVisible, toggleMoreOptionsVisible ] = useToggle();
|
|
||||||
|
|
||||||
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
|
const changeTags = (tags: string[]) => setShortUrlCreation({ ...shortUrlCreation, tags: tags.map(normalizeTag) });
|
||||||
const reset = () => setShortUrlCreation(initialState);
|
const reset = () => setShortUrlCreation(initialState);
|
||||||
const save = handleEventPreventingDefault(() => {
|
const save = handleEventPreventingDefault(() => {
|
||||||
const shortUrlData = {
|
const shortUrlData = {
|
||||||
...shortUrlCreation,
|
...shortUrlCreation,
|
||||||
validSince: formatIsoDate(shortUrlCreation.validSince),
|
validSince: formatIsoDate(shortUrlCreation.validSince) ?? undefined,
|
||||||
validUntil: formatIsoDate(shortUrlCreation.validUntil),
|
validUntil: formatIsoDate(shortUrlCreation.validUntil) ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
createShortUrl(shortUrlData).then(reset).catch(() => {});
|
createShortUrl(shortUrlData).then(reset).catch(() => {});
|
||||||
@@ -87,6 +95,24 @@ const CreateShortUrl = (
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 currentServerVersion = isReachableServer(selectedServer) ? selectedServer.version : '';
|
||||||
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
const disableDomain = !versionMatch(currentServerVersion, { minVersion: '1.19.0-beta.1' });
|
||||||
@@ -94,109 +120,100 @@ const CreateShortUrl = (
|
|||||||
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
const disableShortCodeLength = !versionMatch(currentServerVersion, { minVersion: '2.1.0' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={save}>
|
<form className="create-short-url" onSubmit={save}>
|
||||||
<div className="form-group">
|
{basicMode && basicComponents}
|
||||||
<input
|
{!basicMode && (
|
||||||
className="form-control form-control-lg"
|
<>
|
||||||
type="url"
|
<SimpleCard title="Basic options" className="mb-3">
|
||||||
placeholder="Insert the URL to be shortened"
|
{basicComponents}
|
||||||
required
|
</SimpleCard>
|
||||||
value={shortUrlCreation.longUrl}
|
|
||||||
onChange={(e) => setShortUrlCreation({ ...shortUrlCreation, longUrl: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Collapse isOpen={moreOptionsVisible}>
|
<div className="row">
|
||||||
<div className="form-group">
|
<div className="col-sm-6 mb-3">
|
||||||
<TagsSelector tags={shortUrlCreation.tags ?? []} onChange={changeTags} />
|
<SimpleCard title="Customize the short URL">
|
||||||
</div>
|
{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-6 mb-3">
|
||||||
<div className="col-sm-4">
|
<SimpleCard title="Limit access to the short URL">
|
||||||
{renderOptionalInput('customSlug', 'Custom slug', 'text', {
|
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
||||||
disabled: hasValue(shortUrlCreation.shortCodeLength),
|
{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>
|
||||||
<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">
|
<ForServerVersion minVersion="1.16.0">
|
||||||
<div className="col-sm-4">
|
<SimpleCard title="Extra validations" className="mb-3">
|
||||||
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })}
|
<p>
|
||||||
</div>
|
Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all
|
||||||
<div className="col-sm-4">
|
provided data.
|
||||||
{renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlCreation.validUntil as m.Moment | undefined })}
|
</p>
|
||||||
</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="2.4.0">
|
<ForServerVersion minVersion="2.4.0">
|
||||||
|
<p>
|
||||||
|
<Checkbox
|
||||||
|
inline
|
||||||
|
checked={shortUrlCreation.validateUrl}
|
||||||
|
onChange={(validateUrl) => setShortUrlCreation({ ...shortUrlCreation, validateUrl })}
|
||||||
|
>
|
||||||
|
Validate URL
|
||||||
|
</Checkbox>
|
||||||
|
</p>
|
||||||
|
</ForServerVersion>
|
||||||
|
<p>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
inline
|
inline
|
||||||
checked={shortUrlCreation.validateUrl}
|
className="mr-2"
|
||||||
onChange={(validateUrl) => setShortUrlCreation({ ...shortUrlCreation, validateUrl })}
|
checked={shortUrlCreation.findIfExists}
|
||||||
|
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
||||||
>
|
>
|
||||||
Validate URL
|
Use existing URL if found
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</ForServerVersion>
|
<UseExistingIfFoundInfoIcon />
|
||||||
</div>
|
</p>
|
||||||
<div className="col-sm-6 text-center text-sm-right">
|
</SimpleCard>
|
||||||
<Checkbox
|
</ForServerVersion>
|
||||||
inline
|
</>
|
||||||
className="mr-2"
|
)}
|
||||||
checked={shortUrlCreation.findIfExists}
|
|
||||||
onChange={(findIfExists) => setShortUrlCreation({ ...shortUrlCreation, findIfExists })}
|
|
||||||
>
|
|
||||||
Use existing URL if found
|
|
||||||
</Checkbox>
|
|
||||||
<UseExistingIfFoundInfoIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ForServerVersion>
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
<div>
|
<div className="text-center">
|
||||||
<button type="button" className="btn btn-outline-secondary" onClick={toggleMoreOptionsVisible}>
|
<Button
|
||||||
<FontAwesomeIcon icon={moreOptionsVisible ? upIcon : downIcon} />
|
outline
|
||||||
|
color="primary"
|
||||||
{moreOptionsVisible ? 'Less' : 'More'} options
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary float-right"
|
|
||||||
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
|
disabled={shortUrlCreationResult.saving || isEmpty(shortUrlCreation.longUrl)}
|
||||||
|
className="create-short-url__save-btn"
|
||||||
>
|
>
|
||||||
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
|
{shortUrlCreationResult.saving ? 'Creating...' : 'Create'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateShortUrlResult {...shortUrlCreationResult} resetCreateShortUrl={resetCreateShortUrl} />
|
<CreateShortUrlResult
|
||||||
|
{...shortUrlCreationResult}
|
||||||
|
resetCreateShortUrl={resetCreateShortUrl}
|
||||||
|
canBeClosed={basicMode}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { ShlinkShortUrlsResponse } from '../utils/services/types';
|
|
||||||
import Paginator from './Paginator';
|
import Paginator from './Paginator';
|
||||||
import { ShortUrlsListProps, WithList } from './ShortUrlsList';
|
import { ShortUrlsListProps } from './ShortUrlsList';
|
||||||
|
|
||||||
interface ShortUrlsProps extends ShortUrlsListProps {
|
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps>) => (props: ShortUrlsListProps) => {
|
||||||
shortUrlsList?: ShlinkShortUrlsResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps & WithList>) => (props: ShortUrlsProps) => {
|
|
||||||
const { match, shortUrlsList } = props;
|
const { match, shortUrlsList } = props;
|
||||||
const { page = '1', serverId = '' } = match?.params ?? {};
|
const { page = '1', serverId = '' } = match?.params ?? {};
|
||||||
const { data = [], pagination } = shortUrlsList ?? {};
|
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||||
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
||||||
|
|
||||||
// Using a key on a component makes react to create a new instance every time the key changes
|
// 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 className="form-group"><SearchBar /></div>
|
||||||
<div>
|
<div>
|
||||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
<ShortUrlsList {...props} key={urlsListKey} />
|
||||||
<Paginator paginator={pagination} serverId={serverId} />
|
<Paginator paginator={pagination} serverId={serverId} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 {
|
.short-urls-list__header-icon {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.short-urls-list__header-cell--with-action {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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 { FC, useEffect, useState } from 'react';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
@@ -9,9 +9,8 @@ import { determineOrderDir, OrderDir } from '../utils/utils';
|
|||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
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 { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
||||||
|
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||||
import './ShortUrlsList.scss';
|
import './ShortUrlsList.scss';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@@ -19,28 +18,23 @@ interface RouteParams {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WithList {
|
export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
||||||
shortUrlsList: ShortUrl[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortUrlsListProps extends ShortUrlsListState, RouteComponentProps<RouteParams> {
|
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
shortUrlsListParams: ShortUrlsListParams;
|
||||||
resetShortUrlParams: () => void;
|
resetShortUrlParams: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub(({
|
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
|
||||||
listShortUrls,
|
listShortUrls,
|
||||||
resetShortUrlParams,
|
resetShortUrlParams,
|
||||||
shortUrlsListParams,
|
shortUrlsListParams,
|
||||||
match,
|
match,
|
||||||
location,
|
location,
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
selectedServer,
|
selectedServer,
|
||||||
}: ShortUrlsListProps & WithList) => {
|
}: ShortUrlsListProps) => {
|
||||||
const { orderBy } = shortUrlsListParams;
|
const { orderBy } = shortUrlsListParams;
|
||||||
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
||||||
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
|
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(() => {
|
useEffect(() => {
|
||||||
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
const query = qs.parse(location.search, { ignoreQueryPrefix: true });
|
||||||
const tags = query.tag ? [ query.tag as string ] : shortUrlsListParams.tags;
|
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;
|
return resetShortUrlParams;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -116,44 +83,14 @@ const ShortUrlsList = (ShortUrlsRow: FC<ShortUrlsRowProps>) => boundToMercureHub
|
|||||||
onChange={handleOrderBy}
|
onChange={handleOrderBy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<table className="table table-striped table-hover">
|
<ShortUrlsTable
|
||||||
<thead className="short-urls-list__header">
|
orderByColumn={orderByColumn}
|
||||||
<tr>
|
renderOrderIcon={renderOrderIcon}
|
||||||
<th
|
selectedServer={selectedServer}
|
||||||
className="short-urls-list__header-cell short-urls-list__header-cell--with-action"
|
refreshList={refreshList}
|
||||||
onClick={orderByColumn('dateCreated')}
|
shortUrlsListParams={shortUrlsListParams}
|
||||||
>
|
shortUrlsList={shortUrlsList}
|
||||||
{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"> </th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{renderShortUrls()}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => 'https://shlink.io/new-visit');
|
}, () => 'https://shlink.io/new-visit');
|
||||||
|
|||||||
11
src/short-urls/ShortUrlsTable.scss
Normal file
11
src/short-urls/ShortUrlsTable.scss
Normal 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;
|
||||||
|
}
|
||||||
91
src/short-urls/ShortUrlsTable.tsx
Normal file
91
src/short-urls/ShortUrlsTable.tsx
Normal 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"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{renderShortUrls()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { isNil } from 'ramda';
|
import { isNil } from 'ramda';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -10,10 +11,11 @@ import './CreateShortUrlResult.scss';
|
|||||||
|
|
||||||
export interface CreateShortUrlResultProps extends ShortUrlCreation {
|
export interface CreateShortUrlResultProps extends ShortUrlCreation {
|
||||||
resetCreateShortUrl: () => void;
|
resetCreateShortUrl: () => void;
|
||||||
|
canBeClosed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
||||||
{ error, result, resetCreateShortUrl }: CreateShortUrlResultProps,
|
{ error, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
|
||||||
) => {
|
) => {
|
||||||
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
|
const [ showCopyTooltip, setShowCopyTooltip ] = useStateFlagTimeout();
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ const CreateShortUrlResult = (useStateFlagTimeout: StateFlagTimeout) => (
|
|||||||
return (
|
return (
|
||||||
<Card inverse className="bg-main mt-3">
|
<Card inverse className="bg-main mt-3">
|
||||||
<CardBody>
|
<CardBody>
|
||||||
|
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-right pointer" onClick={resetCreateShortUrl} />}
|
||||||
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
<b>Great!</b> The short URL is <b>{shortUrl}</b>
|
||||||
|
|
||||||
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
<CopyToClipboard text={shortUrl} onCopy={setShowCopyTooltip}>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
|
|||||||
import './ShortUrlsRow.scss';
|
import './ShortUrlsRow.scss';
|
||||||
|
|
||||||
export interface ShortUrlsRowProps {
|
export interface ShortUrlsRowProps {
|
||||||
refreshList: Function;
|
refreshList?: Function;
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
shortUrlsListParams?: ShortUrlsListParams;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrl: ShortUrl;
|
shortUrl: ShortUrl;
|
||||||
}
|
}
|
||||||
@@ -36,14 +36,14 @@ const ShortUrlsRow = (
|
|||||||
return <i className="indivisible"><small>No tags</small></i>;
|
return <i className="indivisible"><small>No tags</small></i>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedTags = shortUrlsListParams.tags ?? [];
|
const selectedTags = shortUrlsListParams?.tags ?? [];
|
||||||
|
|
||||||
return tags.map((tag) => (
|
return tags.map((tag) => (
|
||||||
<Tag
|
<Tag
|
||||||
colorGenerator={colorGenerator}
|
colorGenerator={colorGenerator}
|
||||||
key={tag}
|
key={tag}
|
||||||
text={tag}
|
text={tag}
|
||||||
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
|
onClick={() => refreshList?.({ tags: [ ...selectedTags, tag ] })}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface ShortUrlDeletion {
|
|||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeleteShortUrlAction extends Action<string> {
|
export interface DeleteShortUrlAction extends Action<string> {
|
||||||
shortCode: string;
|
shortCode: string;
|
||||||
domain?: string | null;
|
domain?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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';
|
||||||
@@ -8,10 +8,11 @@ import { GetState } from '../../container/types';
|
|||||||
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
|
||||||
import { ShlinkShortUrlsResponse } from '../../utils/services/types';
|
import { ShlinkShortUrlsResponse } from '../../utils/services/types';
|
||||||
import { EditShortUrlTagsAction, SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
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_META_EDITED, ShortUrlMetaEditedAction } from './shortUrlMeta';
|
||||||
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
||||||
import { ShortUrlsListParams } from './shortUrlsListParams';
|
import { ShortUrlsListParams } from './shortUrlsListParams';
|
||||||
|
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
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 = (
|
export type ListShortUrlsCombinedAction = (
|
||||||
ListShortUrlsAction & EditShortUrlTagsAction & ShortUrlEditedAction & ShortUrlMetaEditedAction & CreateVisitsAction
|
ListShortUrlsAction
|
||||||
|
& EditShortUrlTagsAction
|
||||||
|
& ShortUrlEditedAction
|
||||||
|
& ShortUrlMetaEditedAction
|
||||||
|
& CreateVisitsAction
|
||||||
|
& CreateShortUrlAction
|
||||||
|
& DeleteShortUrlAction
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialState: ShortUrlsList = {
|
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_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||||
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
|
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
|
||||||
[LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
[LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
||||||
[SHORT_URL_DELETED]: (state, { shortCode, domain }) => !state.shortUrls ? state : assocPath(
|
[SHORT_URL_DELETED]: pipe(
|
||||||
[ 'shortUrls', 'data' ],
|
(state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => !state.shortUrls ? state : assocPath(
|
||||||
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
|
[ 'shortUrls', 'data' ],
|
||||||
state,
|
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_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl<EditShortUrlTagsAction>('tags'),
|
||||||
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlMetaEditedAction>('meta'),
|
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl<ShortUrlMetaEditedAction>('meta'),
|
||||||
@@ -77,6 +91,20 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
|||||||
),
|
),
|
||||||
state,
|
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);
|
}, initialState);
|
||||||
|
|
||||||
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
|||||||
|
|
||||||
export interface ShortUrlsListParams {
|
export interface ShortUrlsListParams {
|
||||||
page?: string;
|
page?: string;
|
||||||
|
itemsPerPage?: number;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { connect as reduxConnect } from 'react-redux';
|
|
||||||
import { assoc } from 'ramda';
|
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import ShortUrls from '../ShortUrls';
|
import ShortUrls from '../ShortUrls';
|
||||||
import SearchBar from '../SearchBar';
|
import SearchBar from '../SearchBar';
|
||||||
@@ -19,27 +17,22 @@ import { editShortUrlTags, resetShortUrlsTags } from '../reducers/shortUrlTags';
|
|||||||
import { editShortUrlMeta, resetShortUrlMeta } from '../reducers/shortUrlMeta';
|
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, ShlinkState } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
import { ShortUrlsTable } from '../ShortUrlsTable';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
|
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
|
||||||
bottle.decorator('ShortUrls', reduxConnect(
|
bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ]));
|
||||||
(state: ShlinkState) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Services
|
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable');
|
||||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
|
|
||||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
|
||||||
bottle.decorator('ShortUrlsList', connect(
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
||||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
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',
|
||||||
ShortUrlsRowMenu,
|
ShortUrlsRowMenu,
|
||||||
@@ -76,6 +69,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
|
bottle.serviceFactory('EditShortUrlModal', () => EditShortUrlModal);
|
||||||
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
|
bottle.decorator('EditShortUrlModal', connect([ 'shortUrlEdition' ], [ 'editShortUrl' ]));
|
||||||
|
|
||||||
|
// Services
|
||||||
|
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator', 'ForServerVersion');
|
||||||
|
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
|
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
|
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
|
||||||
|
|||||||
13
src/utils/SimpleCard.tsx
Normal file
13
src/utils/SimpleCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -19,3 +19,8 @@ $mediumGrey: #dee2e6;
|
|||||||
$headerHeight: 57px;
|
$headerHeight: 57px;
|
||||||
$footer-height: 2.3rem;
|
$footer-height: 2.3rem;
|
||||||
$footer-margin: .8rem;
|
$footer-margin: .8rem;
|
||||||
|
|
||||||
|
// Bootstrap overwrites
|
||||||
|
//$theme-colors: (
|
||||||
|
// 'primary': $mainColor
|
||||||
|
//);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ShlinkShortUrlMeta,
|
ShlinkShortUrlMeta,
|
||||||
ShlinkDomain,
|
ShlinkDomain,
|
||||||
ShlinkDomainsResponse,
|
ShlinkDomainsResponse,
|
||||||
|
ShlinkVisitsOverview,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
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)
|
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
||||||
.then(({ data }) => data.visits);
|
.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> =>
|
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
|
||||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
|
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
|
||||||
.then(({ data }) => data);
|
.then(({ data }) => data);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface ShlinkTagsResponse {
|
|||||||
export interface ShlinkPaginator {
|
export interface ShlinkPaginator {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
pagesCount: number;
|
pagesCount: number;
|
||||||
|
totalItems: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkVisits {
|
export interface ShlinkVisits {
|
||||||
@@ -43,6 +44,10 @@ export interface ShlinkVisits {
|
|||||||
pagination?: ShlinkPaginator; // Is only optional in old Shlink versions
|
pagination?: ShlinkPaginator; // Is only optional in old Shlink versions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShlinkVisitsOverview {
|
||||||
|
visitsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShlinkVisitsParams {
|
export interface ShlinkVisitsParams {
|
||||||
domain?: OptionalString;
|
domain?: OptionalString;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|||||||
52
src/visits/reducers/visitsOverview.ts
Normal file
52
src/visits/reducers/visitsOverview.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { createNewVisits } from '../reducers/visitCreation';
|
|||||||
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
|
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
|
||||||
import TagVisits from '../TagVisits';
|
import TagVisits from '../TagVisits';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
import { loadVisitsOverview } from '../reducers/visitsOverview';
|
||||||
import * as visitsParser from './VisitsParser';
|
import * as visitsParser from './VisitsParser';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
@@ -35,6 +36,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
|
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
|
||||||
|
|
||||||
bottle.serviceFactory('createNewVisits', () => createNewVisits);
|
bottle.serviceFactory('createNewVisits', () => createNewVisits);
|
||||||
|
bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('<AsideMenu />', () => {
|
|||||||
it('contains links to different sections', () => {
|
it('contains links to different sections', () => {
|
||||||
const links = wrapped.find('[to]');
|
const links = wrapped.find('[to]');
|
||||||
|
|
||||||
expect(links).toHaveLength(4);
|
expect(links).toHaveLength(5);
|
||||||
links.forEach((link) => expect(link.prop('to')).toContain('abc123'));
|
links.forEach((link) => expect(link.prop('to')).toContain('abc123'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
79
test/servers/Overview.test.tsx
Normal file
79
test/servers/Overview.test.tsx
Normal 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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { shallow, ShallowWrapper } from 'enzyme';
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { identity } from 'ramda';
|
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 DateInput from '../../src/utils/DateInput';
|
||||||
import { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation';
|
import { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation';
|
||||||
@@ -31,7 +32,7 @@ describe('<CreateShortUrl />', () => {
|
|||||||
const validSince = moment('2017-01-01');
|
const validSince = moment('2017-01-01');
|
||||||
const validUntil = moment('2017-01-06');
|
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('TagsSelector').simulate('change', [ 'tag_foo', 'tag_bar' ]);
|
||||||
wrapper.find('#customSlug').simulate('change', { target: { value: 'my-slug' } });
|
wrapper.find('#customSlug').simulate('change', { target: { value: 'my-slug' } });
|
||||||
wrapper.find('#domain').simulate('change', { target: { value: 'example.com' } });
|
wrapper.find('#domain').simulate('change', { target: { value: 'example.com' } });
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('<Paginator />', () => {
|
|||||||
const paginator = {
|
const paginator = {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
pagesCount: 5,
|
pagesCount: 5,
|
||||||
|
totalItems: 10,
|
||||||
};
|
};
|
||||||
const extraPagesPrevNext = 2;
|
const extraPagesPrevNext = 2;
|
||||||
const expectedItems = paginator.pagesCount + extraPagesPrevNext;
|
const expectedItems = paginator.pagesCount + extraPagesPrevNext;
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
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 { Mock } from 'ts-mockery';
|
||||||
import shortUrlsListCreator, { ShortUrlsListProps } from '../../src/short-urls/ShortUrlsList';
|
import shortUrlsListCreator, { ShortUrlsListProps } from '../../src/short-urls/ShortUrlsList';
|
||||||
import { ShortUrl } from '../../src/short-urls/data';
|
import { ShortUrl } from '../../src/short-urls/data';
|
||||||
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
|
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 />', () => {
|
describe('<ShortUrlsList />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const ShortUrlsRow = () => null;
|
const ShortUrlsTable = () => null;
|
||||||
const listShortUrlsMock = jest.fn();
|
const listShortUrlsMock = jest.fn();
|
||||||
const resetShortUrlParamsMock = 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(() => {
|
beforeEach(() => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
@@ -29,18 +40,7 @@ describe('<ShortUrlsList />', () => {
|
|||||||
}}
|
}}
|
||||||
match={{ params: {} } as any}
|
match={{ params: {} } as any}
|
||||||
location={{} as any}
|
location={{} as any}
|
||||||
loading={false}
|
shortUrlsList={shortUrlsList}
|
||||||
error={false}
|
|
||||||
shortUrlsList={
|
|
||||||
[
|
|
||||||
Mock.of<ShortUrl>({
|
|
||||||
shortCode: 'testShortCode',
|
|
||||||
shortUrl: 'https://www.example.com/testShortUrl',
|
|
||||||
longUrl: 'https://www.example.com/testLongUrl',
|
|
||||||
tags: [ 'test tag' ],
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>,
|
/>,
|
||||||
).dive(); // Dive is needed as this component is wrapped in a HOC
|
).dive(); // Dive is needed as this component is wrapped in a HOC
|
||||||
});
|
});
|
||||||
@@ -48,50 +48,11 @@ describe('<ShortUrlsList />', () => {
|
|||||||
afterEach(jest.resetAllMocks);
|
afterEach(jest.resetAllMocks);
|
||||||
afterEach(() => wrapper?.unmount());
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
it('wraps a ShortUrlsList with 1 ShortUrlsRow', () => {
|
it('wraps a ShortUrlsTable', () => {
|
||||||
expect(wrapper.find(ShortUrlsRow)).toHaveLength(1);
|
expect(wrapper.find(ShortUrlsTable)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render inner table by default', () => {
|
it('wraps a SortingDropdown', () => {
|
||||||
expect(wrapper.find('table')).toHaveLength(1);
|
expect(wrapper.find(SortingDropdown)).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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
59
test/short-urls/ShortUrlsTable.test.tsx
Normal file
59
test/short-urls/ShortUrlsTable.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,9 @@ import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrl
|
|||||||
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/utils/services/ShlinkApiClient';
|
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('shortUrlsListReducer', () => {
|
||||||
describe('reducer', () => {
|
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 shortCode = 'abc123';
|
||||||
const state = {
|
const state = {
|
||||||
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
|
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
|
||||||
@@ -103,6 +105,9 @@ describe('shortUrlsListReducer', () => {
|
|||||||
Mock.of<ShortUrl>({ shortCode, domain: 'example.com' }),
|
Mock.of<ShortUrl>({ shortCode, domain: 'example.com' }),
|
||||||
Mock.of<ShortUrl>({ shortCode: 'foo' }),
|
Mock.of<ShortUrl>({ shortCode: 'foo' }),
|
||||||
],
|
],
|
||||||
|
pagination: Mock.of<ShlinkPaginator>({
|
||||||
|
totalItems: 10,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
@@ -111,6 +116,34 @@ describe('shortUrlsListReducer', () => {
|
|||||||
expect(reducer(state, { type: SHORT_URL_DELETED, shortCode } as any)).toEqual({
|
expect(reducer(state, { type: SHORT_URL_DELETED, shortCode } as any)).toEqual({
|
||||||
shortUrls: {
|
shortUrls: {
|
||||||
data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }],
|
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,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
@@ -147,6 +180,34 @@ describe('shortUrlsListReducer', () => {
|
|||||||
error: false,
|
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', () => {
|
describe('listShortUrls', () => {
|
||||||
|
|||||||
30
test/utils/SimpleCard.test.tsx
Normal file
30
test/utils/SimpleCard.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { AxiosInstance, AxiosRequestConfig } from 'axios';
|
|||||||
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
|
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
|
||||||
import { OptionalString } from '../../../src/utils/utils';
|
import { OptionalString } from '../../../src/utils/utils';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { ShlinkDomain } from '../../../src/utils/services/types';
|
import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/utils/services/types';
|
||||||
|
|
||||||
describe('ShlinkApiClient', () => {
|
describe('ShlinkApiClient', () => {
|
||||||
const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
|
const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance;
|
||||||
@@ -269,4 +269,18 @@ describe('ShlinkApiClient', () => {
|
|||||||
expect(result).toEqual(expectedData);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ describe('shortUrlVisitsReducer', () => {
|
|||||||
pagination: {
|
pagination: {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
pagesCount: 1,
|
pagesCount: 1,
|
||||||
|
totalItems: 1,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -144,6 +145,7 @@ describe('shortUrlVisitsReducer', () => {
|
|||||||
pagination: {
|
pagination: {
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
pagesCount: expectedRequests,
|
pagesCount: expectedRequests,
|
||||||
|
totalItems: 1,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ describe('tagVisitsReducer', () => {
|
|||||||
pagination: {
|
pagination: {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
pagesCount: 1,
|
pagesCount: 1,
|
||||||
|
totalItems: 1,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
78
test/visits/reducers/visitsOverview.test.ts
Normal file
78
test/visits/reducers/visitsOverview.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user