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

Feature/overview page
This commit is contained in:
Alejandro Celaya
2020-12-08 19:44:25 +01:00
committed by GitHub
49 changed files with 912 additions and 336 deletions

View File

@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased] ## [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.

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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;
} }

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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,
}); });

View File

@@ -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 (

View File

@@ -26,7 +26,7 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: De
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p> <p>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>

View File

@@ -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 (

View File

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

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

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

View File

@@ -23,7 +23,7 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select
<DropdownItem <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}

View File

@@ -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>

View File

@@ -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());

View File

@@ -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;

View File

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

View File

@@ -1,33 +1,36 @@
import { faAngleDoubleDown as downIcon, faAngleDoubleUp as upIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, pipe, replace, trim } from 'ramda'; import { 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
&nbsp; 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>
); );
}; };

View File

@@ -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>
</> </>

View File

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

View File

@@ -1,6 +1,6 @@
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons'; import { 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">&nbsp;</th>
</tr>
</thead>
<tbody>
{renderShortUrls()}
</tbody>
</table>
</> </>
); );
}, () => 'https://shlink.io/new-visit'); }, () => 'https://shlink.io/new-visit');

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons'; import { 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}>

View File

@@ -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 ] })}
/> />
)); ));
}; };

View File

@@ -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;
} }

View File

@@ -1,4 +1,4 @@
import { assoc, assocPath, last, reject } from 'ramda'; import { assoc, assocPath, init, last, pipe, reject } from 'ramda';
import { Action, Dispatch } from 'redux'; import { 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) => (

View File

@@ -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;

View File

@@ -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
View File

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

View File

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

View File

@@ -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);

View File

@@ -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;

View File

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

View File

@@ -7,6 +7,7 @@ import { createNewVisits } from '../reducers/visitCreation';
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; import { 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;

View File

@@ -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'));
}); });

View File

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

View File

@@ -2,6 +2,7 @@ import { shallow, ShallowWrapper } from 'enzyme';
import moment from 'moment'; import 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' } });

View File

@@ -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;

View File

@@ -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);
});
}); });
}); });

View File

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

View File

@@ -11,7 +11,9 @@ import { SHORT_URL_META_EDITED } from '../../../src/short-urls/reducers/shortUrl
import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { 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', () => {

View File

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

View File

@@ -2,7 +2,7 @@ import { AxiosInstance, AxiosRequestConfig } from 'axios';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient'; import 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);
});
});
}); });

View File

@@ -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,
}, },
})); }));

View File

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

View File

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