mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-16 20:43:48 +00:00
Merge pull request #394 from acelaya-forks/feature/orphan-visits-stats
Feature/orphan visits stats
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"ignoreComments": true
|
"ignoreComments": true
|
||||||
}],
|
}],
|
||||||
"no-mixed-operators": "off",
|
"no-mixed-operators": "off",
|
||||||
|
"object-shorthand": "off",
|
||||||
"react/display-name": "off",
|
"react/display-name": "off",
|
||||||
"react/react-in-jsx-scope": "off",
|
"react/react-in-jsx-scope": "off",
|
||||||
"@typescript-eslint/require-array-sort-compare": "off"
|
"@typescript-eslint/require-array-sort-compare": "off"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* [#385](https://github.com/shlinkio/shlink-web-client/issues/385) Added setting to determine if "validate URL" should be enabled or disabled by default.
|
* [#385](https://github.com/shlinkio/shlink-web-client/issues/385) Added setting to determine if "validate URL" should be enabled or disabled by default.
|
||||||
* [#386](https://github.com/shlinkio/shlink-web-client/issues/386) Added new card in overview section to display amount of orphan visits when using Shlink 2.6.0 or higher.
|
* [#386](https://github.com/shlinkio/shlink-web-client/issues/386) Added new card in overview section to display amount of orphan visits when using Shlink 2.6.0 or higher.
|
||||||
* [#177](https://github.com/shlinkio/shlink-web-client/issues/177) Added dark theme.
|
* [#177](https://github.com/shlinkio/shlink-web-client/issues/177) Added dark theme.
|
||||||
|
* [#387](https://github.com/shlinkio/shlink-web-client/issues/387) Added section to see orphan visits stats, when consuming Shlink >=2.6.0.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* [#382](https://github.com/shlinkio/shlink-web-client/issues/382) Ensured short URL tags are edited through the `PATCH /short-urls/{shortCode}` endpoint when using Shlink 2.6.0 or higher.
|
* [#382](https://github.com/shlinkio/shlink-web-client/issues/382) Ensured short URL tags are edited through the `PATCH /short-urls/{shortCode}` endpoint when using Shlink 2.6.0 or higher.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"start": "node scripts/start.js",
|
"start": "node scripts/start.js",
|
||||||
"serve:build": "serve ./build",
|
"serve:build": "serve ./build",
|
||||||
"build": "node scripts/build.js",
|
"build": "node scripts/build.js",
|
||||||
"test": "node scripts/test.js --env=jsdom --colors",
|
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
||||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
||||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||||
|
|||||||
@@ -51,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 getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
|
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
|
||||||
|
.then(({ data }) => data.visits);
|
||||||
|
|
||||||
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
|
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
|
||||||
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
|
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
|
||||||
.then(({ data }) => data.visits);
|
.then(({ data }) => data.visits);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||||
import { useSwipeable } from 'react-swipeable';
|
|
||||||
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||||
import { versionMatch } from '../utils/helpers/version';
|
import { versionMatch } from '../utils/helpers/version';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
@@ -19,6 +18,7 @@ const MenuLayout = (
|
|||||||
CreateShortUrl: FC,
|
CreateShortUrl: FC,
|
||||||
ShortUrlVisits: FC,
|
ShortUrlVisits: FC,
|
||||||
TagVisits: FC,
|
TagVisits: FC,
|
||||||
|
OrphanVisits: FC,
|
||||||
ServerError: FC,
|
ServerError: FC,
|
||||||
Overview: FC,
|
Overview: FC,
|
||||||
) => withSelectedServer(({ location, selectedServer }) => {
|
) => withSelectedServer(({ location, selectedServer }) => {
|
||||||
@@ -31,25 +31,9 @@ const MenuLayout = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
|
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
|
||||||
const burgerClasses = classNames('menu-layout__burger-icon', {
|
const addOrphanVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.6.0' });
|
||||||
'menu-layout__burger-icon--active': sidebarVisible,
|
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||||
});
|
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||||
const swipeMenuIfNoModalExists = (callback: () => void) => (e: any) => {
|
|
||||||
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
|
|
||||||
({ classList }) => classList?.contains('visits-table'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (swippedOnVisitsTable || document.querySelector('.modal')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
const swipeableProps = useSwipeable({
|
|
||||||
delta: 40,
|
|
||||||
onSwipedLeft: swipeMenuIfNoModalExists(hideSidebar),
|
|
||||||
onSwipedRight: swipeMenuIfNoModalExists(showSidebar),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -67,6 +51,7 @@ const MenuLayout = (
|
|||||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||||
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||||
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||||
|
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
||||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||||
<Route
|
<Route
|
||||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
'TagVisits',
|
'TagVisits',
|
||||||
|
'OrphanVisits',
|
||||||
'ServerError',
|
'ServerError',
|
||||||
'Overview',
|
'Overview',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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';
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
|
import { VisitsInfo } from '../visits/types';
|
||||||
|
|
||||||
export interface ShlinkState {
|
export interface ShlinkState {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
@@ -29,6 +30,7 @@ export interface ShlinkState {
|
|||||||
shortUrlEdition: ShortUrlEdition;
|
shortUrlEdition: ShortUrlEdition;
|
||||||
shortUrlVisits: ShortUrlVisits;
|
shortUrlVisits: ShortUrlVisits;
|
||||||
tagVisits: TagVisits;
|
tagVisits: TagVisits;
|
||||||
|
orphanVisits: VisitsInfo;
|
||||||
shortUrlDetail: ShortUrlDetail;
|
shortUrlDetail: ShortUrlDetail;
|
||||||
tagsList: TagsList;
|
tagsList: TagsList;
|
||||||
tagDelete: TagDeletion;
|
tagDelete: TagDeletion;
|
||||||
|
|||||||
7
src/mercure/helpers/Topics.ts
Normal file
7
src/mercure/helpers/Topics.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export class Topics {
|
||||||
|
public static visits = () => 'https://shlink.io/new-visit';
|
||||||
|
|
||||||
|
public static shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||||
|
|
||||||
|
public static orphanVisits = () => 'https://shlink.io/new-orphan-visit';
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export interface MercureBoundProps {
|
|||||||
|
|
||||||
export function boundToMercureHub<T = {}>(
|
export function boundToMercureHub<T = {}>(
|
||||||
WrappedComponent: FC<MercureBoundProps & T>,
|
WrappedComponent: FC<MercureBoundProps & T>,
|
||||||
getTopicForProps: (props: T) => string,
|
getTopicsForProps: (props: T) => string[],
|
||||||
) {
|
) {
|
||||||
const pendingUpdates = new Set<CreateVisit>();
|
const pendingUpdates = new Set<CreateVisit>();
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function boundToMercureHub<T = {}>(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
|
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
|
||||||
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo);
|
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicsForProps(props), onMessage, loadMercureInfo);
|
||||||
|
|
||||||
if (!interval) {
|
if (!interval) {
|
||||||
return closeEventSource;
|
return closeEventSource;
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||||
import { MercureInfo } from '../reducers/mercureInfo';
|
import { MercureInfo } from '../reducers/mercureInfo';
|
||||||
|
|
||||||
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => { // eslint-disable-line max-len
|
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topics: string[], onMessage: (message: T) => void, onTokenExpired: Function) => { // eslint-disable-line max-len
|
||||||
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
||||||
|
|
||||||
if (loading || error || !mercureHubUrl) {
|
if (loading || error || !mercureHubUrl) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hubUrl = new URL(mercureHubUrl);
|
const onEventSourceMessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
|
||||||
|
const onEventSourceError = ({ status }: { status: number }) => status === 401 && onTokenExpired();
|
||||||
|
|
||||||
hubUrl.searchParams.append('topic', topic);
|
const subscriptions: EventSource[] = topics.map((topic) => {
|
||||||
const es = new EventSource(hubUrl, {
|
const hubUrl = new URL(mercureHubUrl);
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
hubUrl.searchParams.append('topic', topic);
|
||||||
},
|
const es = new EventSource(hubUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onmessage = onEventSourceMessage;
|
||||||
|
es.onerror = onEventSourceError;
|
||||||
|
|
||||||
|
return es;
|
||||||
});
|
});
|
||||||
|
|
||||||
es.onmessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
|
return () => subscriptions.forEach((es) => es.close());
|
||||||
es.onerror = ({ status }: { status: number }) => status === 401 && onTokenExpired();
|
|
||||||
|
|
||||||
return () => es.close();
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
|
|||||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
||||||
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
||||||
|
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
|
||||||
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
|
||||||
import tagsListReducer from '../tags/reducers/tagsList';
|
import tagsListReducer from '../tags/reducers/tagsList';
|
||||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||||
@@ -32,6 +33,7 @@ export default combineReducers<ShlinkState>({
|
|||||||
shortUrlEdition: shortUrlEditionReducer,
|
shortUrlEdition: shortUrlEditionReducer,
|
||||||
shortUrlVisits: shortUrlVisitsReducer,
|
shortUrlVisits: shortUrlVisitsReducer,
|
||||||
tagVisits: tagVisitsReducer,
|
tagVisits: tagVisitsReducer,
|
||||||
|
orphanVisits: orphanVisitsReducer,
|
||||||
shortUrlDetail: shortUrlDetailReducer,
|
shortUrlDetail: shortUrlDetailReducer,
|
||||||
tagsList: tagsListReducer,
|
tagsList: tagsListReducer,
|
||||||
tagDelete: tagDeleteReducer,
|
tagDelete: tagDeleteReducer,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
.overview__card.overview__card {
|
.overview__card.overview__card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-top: 3px solid var(--brand-color);
|
border-top: 3px solid var(--brand-color);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview__card-title {
|
.overview__card-title {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
|||||||
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
import { Versions } from '../utils/helpers/version';
|
import { Versions } from '../utils/helpers/version';
|
||||||
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { isServerWithId, SelectedServer } from './data';
|
import { isServerWithId, SelectedServer } from './data';
|
||||||
import './Overview.scss';
|
import './Overview.scss';
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ export const Overview = (
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-md-6 col-xl-3">
|
||||||
<Card className="overview__card mb-2" body>
|
<Card className="overview__card mb-2" body tag={Link} to={`/server/${serverId}/orphan-visits`}>
|
||||||
<CardTitle tag="h5" className="overview__card-title">Orphan visits</CardTitle>
|
<CardTitle tag="h5" className="overview__card-title">Orphan visits</CardTitle>
|
||||||
<CardText tag="h2">
|
<CardText tag="h2">
|
||||||
<ForServerVersion minVersion="2.6.0">
|
<ForServerVersion minVersion="2.6.0">
|
||||||
@@ -78,7 +79,7 @@ export const Overview = (
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-md-6 col-xl-3">
|
||||||
<Card className="overview__card mb-2" body>
|
<Card className="overview__card mb-2" body tag={Link} to={`/server/${serverId}/list-short-urls/1`}>
|
||||||
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
|
<CardTitle tag="h5" className="overview__card-title">Short URLs</CardTitle>
|
||||||
<CardText tag="h2">
|
<CardText tag="h2">
|
||||||
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
{loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)}
|
||||||
@@ -86,7 +87,7 @@ export const Overview = (
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 col-xl-3">
|
<div className="col-md-6 col-xl-3">
|
||||||
<Card className="overview__card mb-2" body>
|
<Card className="overview__card mb-2" body tag={Link} to={`/server/${serverId}/manage-tags`}>
|
||||||
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
|
<CardTitle tag="h5" className="overview__card-title">Tags</CardTitle>
|
||||||
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
|
<CardText tag="h2">{loadingTags ? 'Loading...' : prettify(tagsList.tags.length)}</CardText>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -119,4 +120,4 @@ export const Overview = (
|
|||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => 'https://shlink.io/new-visit');
|
}, () => [ Topics.visits(), Topics.orphanVisits() ]);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const isServerWithId = (server: SelectedServer | ServerWithId): server is
|
|||||||
!!server?.hasOwnProperty('id');
|
!!server?.hasOwnProperty('id');
|
||||||
|
|
||||||
export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
|
export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
|
||||||
!!server?.hasOwnProperty('printableVersion');
|
!!server?.hasOwnProperty('version');
|
||||||
|
|
||||||
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||||
!!server?.hasOwnProperty('serverNotFound');
|
!!server?.hasOwnProperty('serverNotFound');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { determineOrderDir, OrderDir } from '../utils/utils';
|
|||||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
import { isReachableServer, SelectedServer } from '../servers/data';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
import { parseQuery } from '../utils/helpers/query';
|
||||||
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
||||||
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||||
@@ -98,6 +99,6 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
|
|||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => 'https://shlink.io/new-visit');
|
}, () => [ Topics.visits() ]);
|
||||||
|
|
||||||
export default ShortUrlsList;
|
export default ShortUrlsList;
|
||||||
|
|||||||
@@ -83,10 +83,14 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
|||||||
(currentShortUrl) => {
|
(currentShortUrl) => {
|
||||||
// Find the last of the new visit for this short URL, and pick the amount of visits from it
|
// Find the last of the new visit for this short URL, and pick the amount of visits from it
|
||||||
const lastVisit = last(
|
const lastVisit = last(
|
||||||
createdVisits.filter(({ shortUrl }) => shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain)),
|
createdVisits.filter(
|
||||||
|
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return lastVisit ? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl) : currentShortUrl;
|
return lastVisit?.shortUrl
|
||||||
|
? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl)
|
||||||
|
: currentShortUrl;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Result } from '../utils/Result';
|
|||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||||
import { TagCardProps } from './TagCard';
|
import { TagCardProps } from './TagCard';
|
||||||
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
|
|
||||||
const { ceil } = Math;
|
const { ceil } = Math;
|
||||||
const TAGS_GROUPS_AMOUNT = 4;
|
const TAGS_GROUPS_AMOUNT = 4;
|
||||||
@@ -75,6 +76,6 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
|||||||
{renderContent()}
|
{renderContent()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, () => 'https://shlink.io/new-visit');
|
}, () => [ Topics.visits() ]);
|
||||||
|
|
||||||
export default TagsList;
|
export default TagsList;
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags
|
|||||||
}, { ...stats });
|
}, { ...stats });
|
||||||
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
|
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
|
||||||
createdVisits.reduce((acc, { shortUrl }) => {
|
createdVisits.reduce((acc, { shortUrl }) => {
|
||||||
shortUrl.tags.forEach((tag) => {
|
shortUrl?.tags.forEach((tag) => {
|
||||||
acc[tag] = (acc[tag] || 0) + 1;
|
acc[tag] = (acc[tag] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
|
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
|
||||||
|
|
||||||
const DEFAULT_DELAY = 2000;
|
const DEFAULT_DELAY = 2000;
|
||||||
|
|
||||||
@@ -30,3 +31,23 @@ export const useToggle = (initialValue = false): ToggleResult => {
|
|||||||
|
|
||||||
return [ flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false) ];
|
return [ flag, () => setFlag(!flag), () => setFlag(true), () => setFlag(false) ];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useSwipeable = (showSidebar: () => void, hideSidebar: () => void) => {
|
||||||
|
const swipeMenuIfNoModalExists = (callback: () => void) => (e: any) => {
|
||||||
|
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
|
||||||
|
({ classList }) => classList?.contains('visits-table'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (swippedOnVisitsTable || document.querySelector('.modal')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
return useReactSwipeable({
|
||||||
|
delta: 40,
|
||||||
|
onSwipedLeft: swipeMenuIfNoModalExists(hideSidebar),
|
||||||
|
onSwipedRight: swipeMenuIfNoModalExists(showSidebar),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
30
src/visits/OrphanVisits.tsx
Normal file
30
src/visits/OrphanVisits.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { RouteComponentProps } from 'react-router';
|
||||||
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
|
import { ShlinkVisitsParams } from '../api/types';
|
||||||
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
|
import VisitsStats from './VisitsStats';
|
||||||
|
import { OrphanVisitsHeader } from './OrphanVisitsHeader';
|
||||||
|
import { VisitsInfo } from './types';
|
||||||
|
|
||||||
|
export interface OrphanVisitsProps extends RouteComponentProps {
|
||||||
|
getOrphanVisits: (params: ShlinkVisitsParams) => void;
|
||||||
|
orphanVisits: VisitsInfo;
|
||||||
|
cancelGetOrphanVisits: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrphanVisits = boundToMercureHub(({
|
||||||
|
history: { goBack },
|
||||||
|
match: { url },
|
||||||
|
getOrphanVisits,
|
||||||
|
orphanVisits,
|
||||||
|
cancelGetOrphanVisits,
|
||||||
|
}: OrphanVisitsProps) => (
|
||||||
|
<VisitsStats
|
||||||
|
getVisits={getOrphanVisits}
|
||||||
|
cancelGetVisits={cancelGetOrphanVisits}
|
||||||
|
visitsInfo={orphanVisits}
|
||||||
|
baseUrl={url}
|
||||||
|
>
|
||||||
|
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
|
||||||
|
</VisitsStats>
|
||||||
|
), () => [ Topics.orphanVisits() ]);
|
||||||
14
src/visits/OrphanVisitsHeader.tsx
Normal file
14
src/visits/OrphanVisitsHeader.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import VisitsHeader from './VisitsHeader';
|
||||||
|
import './ShortUrlVisitsHeader.scss';
|
||||||
|
import { VisitsInfo } from './types';
|
||||||
|
|
||||||
|
interface OrphanVisitsHeader {
|
||||||
|
orphanVisits: VisitsInfo;
|
||||||
|
goBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrphanVisitsHeader = ({ orphanVisits, goBack }: OrphanVisitsHeader) => {
|
||||||
|
const { visits } = orphanVisits;
|
||||||
|
|
||||||
|
return <VisitsHeader title="Orphan visits" goBack={goBack} visits={visits} />;
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { RouteComponentProps } from 'react-router';
|
|||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { ShlinkVisitsParams } from '../api/types';
|
import { ShlinkVisitsParams } from '../api/types';
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
import { parseQuery } from '../utils/helpers/query';
|
||||||
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
|
import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
|
||||||
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
|
import ShortUrlVisitsHeader from './ShortUrlVisitsHeader';
|
||||||
import { ShortUrlDetail } from './reducers/shortUrlDetail';
|
import { ShortUrlDetail } from './reducers/shortUrlDetail';
|
||||||
@@ -45,6 +46,6 @@ const ShortUrlVisits = boundToMercureHub(({
|
|||||||
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
|
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />
|
||||||
</VisitsStats>
|
</VisitsStats>
|
||||||
);
|
);
|
||||||
}, ({ match }) => `https://shlink.io/new-visit/${match.params.shortCode}`);
|
}, ({ match }) => [ Topics.shortUrlVisits(match.params.shortCode) ]);
|
||||||
|
|
||||||
export default ShortUrlVisits;
|
export default ShortUrlVisits;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { RouteComponentProps } from 'react-router';
|
|||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
import { ShlinkVisitsParams } from '../api/types';
|
import { ShlinkVisitsParams } from '../api/types';
|
||||||
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { TagVisits as TagVisitsState } from './reducers/tagVisits';
|
import { TagVisits as TagVisitsState } from './reducers/tagVisits';
|
||||||
import TagVisitsHeader from './TagVisitsHeader';
|
import TagVisitsHeader from './TagVisitsHeader';
|
||||||
import VisitsStats from './VisitsStats';
|
import VisitsStats from './VisitsStats';
|
||||||
@@ -27,6 +28,6 @@ const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({
|
|||||||
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
|
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
|
||||||
</VisitsStats>
|
</VisitsStats>
|
||||||
);
|
);
|
||||||
}, () => 'https://shlink.io/new-visit');
|
}, () => [ Topics.visits() ]);
|
||||||
|
|
||||||
export default TagVisits;
|
export default TagVisits;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title
|
|||||||
tag={RouterNavLink}
|
tag={RouterNavLink}
|
||||||
className="visits-stats__nav-link"
|
className="visits-stats__nav-link"
|
||||||
to={to}
|
to={to}
|
||||||
isActive={(_: null, { pathname }: Location) => pathname.endsWith(`/visits${subPath}`)}
|
isActive={(_: null, { pathname }: Location) => pathname.endsWith(`visits${subPath}`)}
|
||||||
replace
|
replace
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={icon} />
|
<FontAwesomeIcon icon={icon} />
|
||||||
|
|||||||
69
src/visits/reducers/orphanVisits.ts
Normal file
69
src/visits/reducers/orphanVisits.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Action, Dispatch } from 'redux';
|
||||||
|
import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types';
|
||||||
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
import { GetState } from '../../container/types';
|
||||||
|
import { getVisitsWithLoader } from './common';
|
||||||
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
export const GET_ORPHAN_VISITS_START = 'shlink/orphanVisits/GET_ORPHAN_VISITS_START';
|
||||||
|
export const GET_ORPHAN_VISITS_ERROR = 'shlink/orphanVisits/GET_ORPHAN_VISITS_ERROR';
|
||||||
|
export const GET_ORPHAN_VISITS = 'shlink/orphanVisits/GET_ORPHAN_VISITS';
|
||||||
|
export const GET_ORPHAN_VISITS_LARGE = 'shlink/orphanVisits/GET_ORPHAN_VISITS_LARGE';
|
||||||
|
export const GET_ORPHAN_VISITS_CANCEL = 'shlink/orphanVisits/GET_ORPHAN_VISITS_CANCEL';
|
||||||
|
export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_ORPHAN_VISITS_PROGRESS_CHANGED';
|
||||||
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
|
export interface OrphanVisitsAction extends Action<string> {
|
||||||
|
visits: Visit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
||||||
|
& VisitsLoadProgressChangedAction
|
||||||
|
& CreateVisitsAction
|
||||||
|
& VisitsLoadFailedAction;
|
||||||
|
|
||||||
|
const initialState: VisitsInfo = {
|
||||||
|
visits: [],
|
||||||
|
loading: false,
|
||||||
|
loadingLarge: false,
|
||||||
|
error: false,
|
||||||
|
cancelLoad: false,
|
||||||
|
progress: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
|
||||||
|
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
|
||||||
|
[GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
|
[GET_ORPHAN_VISITS]: (_, { visits }) => ({ ...initialState, visits }),
|
||||||
|
[GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||||
|
[GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||||
|
[GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||||
|
[CREATE_VISITS]: (state, { createdVisits }) => {
|
||||||
|
const { visits } = state;
|
||||||
|
const newVisits = createdVisits.map(({ visit }) => visit);
|
||||||
|
|
||||||
|
return { ...state, visits: [ ...visits, ...newVisits ] };
|
||||||
|
},
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (query = {}) => async (
|
||||||
|
dispatch: Dispatch,
|
||||||
|
getState: GetState,
|
||||||
|
) => {
|
||||||
|
const { getOrphanVisits } = buildShlinkApiClient(getState);
|
||||||
|
const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage });
|
||||||
|
const shouldCancel = () => getState().orphanVisits.cancelLoad;
|
||||||
|
const actionMap = {
|
||||||
|
start: GET_ORPHAN_VISITS_START,
|
||||||
|
large: GET_ORPHAN_VISITS_LARGE,
|
||||||
|
finish: GET_ORPHAN_VISITS,
|
||||||
|
error: GET_ORPHAN_VISITS_ERROR,
|
||||||
|
progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED,
|
||||||
|
};
|
||||||
|
|
||||||
|
return getVisitsWithLoader(visitsLoader, {}, actionMap, dispatch, shouldCancel);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);
|
||||||
@@ -52,11 +52,10 @@ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
|
|||||||
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||||
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||||
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => { // eslint-disable-line object-shorthand
|
[CREATE_VISITS]: (state, { createdVisits }) => {
|
||||||
const { shortCode, domain, visits } = state;
|
const { shortCode, domain, visits } = state;
|
||||||
|
|
||||||
const newVisits = createdVisits
|
const newVisits = createdVisits
|
||||||
.filter(({ shortUrl }) => shortUrlMatches(shortUrl, shortCode, domain))
|
.filter(({ shortUrl }) => shortUrl && shortUrlMatches(shortUrl, shortCode, domain))
|
||||||
.map(({ visit }) => visit);
|
.map(({ visit }) => visit);
|
||||||
|
|
||||||
return { ...state, visits: [ ...visits, ...newVisits ] };
|
return { ...state, visits: [ ...visits, ...newVisits ] };
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
|
|||||||
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||||
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||||
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => { // eslint-disable-line object-shorthand
|
[CREATE_VISITS]: (state, { createdVisits }) => {
|
||||||
const { tag, visits } = state;
|
const { tag, visits } = state;
|
||||||
const newVisits = createdVisits
|
const newVisits = createdVisits
|
||||||
.filter(({ shortUrl }) => shortUrl.tags.includes(tag))
|
.filter(({ shortUrl }) => shortUrl?.tags.includes(tag))
|
||||||
.map(({ visit }) => visit);
|
.map(({ visit }) => visit);
|
||||||
|
|
||||||
return { ...state, visits: [ ...visits, ...newVisits ] };
|
return { ...state, visits: [ ...visits, ...newVisits ] };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ShlinkVisitsOverview } from '../../api/types';
|
|||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
|
import { groupNewVisitsByType } from '../types/helpers';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
@@ -31,10 +32,15 @@ export default buildReducer<VisitsOverview, GetVisitsOverviewAction & CreateVisi
|
|||||||
[GET_OVERVIEW_START]: () => ({ ...initialState, loading: true }),
|
[GET_OVERVIEW_START]: () => ({ ...initialState, loading: true }),
|
||||||
[GET_OVERVIEW_ERROR]: () => ({ ...initialState, error: true }),
|
[GET_OVERVIEW_ERROR]: () => ({ ...initialState, error: true }),
|
||||||
[GET_OVERVIEW]: (_, { visitsCount, orphanVisitsCount }) => ({ ...initialState, visitsCount, orphanVisitsCount }),
|
[GET_OVERVIEW]: (_, { visitsCount, orphanVisitsCount }) => ({ ...initialState, visitsCount, orphanVisitsCount }),
|
||||||
[CREATE_VISITS]: ({ visitsCount, ...rest }, { createdVisits }) => ({
|
[CREATE_VISITS]: ({ visitsCount, orphanVisitsCount = 0, ...rest }, { createdVisits }) => {
|
||||||
...rest,
|
const { regularVisits, orphanVisits } = groupNewVisitsByType(createdVisits);
|
||||||
visitsCount: visitsCount + createdVisits.length,
|
|
||||||
}),
|
return {
|
||||||
|
...rest,
|
||||||
|
visitsCount: visitsCount + regularVisits.length,
|
||||||
|
orphanVisitsCount: orphanVisitsCount + orphanVisits.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { isNil, map } from 'ramda';
|
|||||||
import { extractDomain, parseUserAgent } from '../../utils/helpers/visits';
|
import { extractDomain, parseUserAgent } from '../../utils/helpers/visits';
|
||||||
import { hasValue } from '../../utils/utils';
|
import { hasValue } from '../../utils/utils';
|
||||||
import { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types';
|
import { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types';
|
||||||
|
import { isOrphanVisit } from '../types/helpers';
|
||||||
|
|
||||||
const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) =>
|
const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) =>
|
||||||
!isNil(visit) && hasValue(visit[propertyName]);
|
!isNil(visit) && hasValue(visit[propertyName]);
|
||||||
@@ -68,15 +69,24 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.redu
|
|||||||
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} },
|
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const normalizeVisits = map(({ userAgent, date, referer, visitLocation }: Visit): NormalizedVisit => ({
|
export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
|
||||||
date,
|
const { userAgent, date, referer, visitLocation } = visit;
|
||||||
...parseUserAgent(userAgent),
|
const common = {
|
||||||
referer: extractDomain(referer),
|
date,
|
||||||
country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
|
...parseUserAgent(userAgent),
|
||||||
city: visitLocation?.cityName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
|
referer: extractDomain(referer),
|
||||||
latitude: visitLocation?.latitude,
|
country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
longitude: visitLocation?.longitude,
|
city: visitLocation?.cityName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
}));
|
latitude: visitLocation?.latitude,
|
||||||
|
longitude: visitLocation?.longitude,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOrphanVisit(visit)) {
|
||||||
|
return common;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...common, type: visit.type, visitedUrl: visit.visitedUrl };
|
||||||
|
});
|
||||||
|
|
||||||
export interface VisitsParser {
|
export interface VisitsParser {
|
||||||
processStatsFromVisits: (normalizedVisits: NormalizedVisit[]) => VisitsStats;
|
processStatsFromVisits: (normalizedVisits: NormalizedVisit[]) => VisitsStats;
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrl
|
|||||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||||
import MapModal from '../helpers/MapModal';
|
import MapModal from '../helpers/MapModal';
|
||||||
import { createNewVisits } from '../reducers/visitCreation';
|
import { createNewVisits } from '../reducers/visitCreation';
|
||||||
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
|
|
||||||
import TagVisits from '../TagVisits';
|
import TagVisits from '../TagVisits';
|
||||||
|
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits';
|
||||||
|
import { OrphanVisits } from '../OrphanVisits';
|
||||||
|
import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { loadVisitsOverview } from '../reducers/visitsOverview';
|
import { loadVisitsOverview } from '../reducers/visitsOverview';
|
||||||
import * as visitsParser from './VisitsParser';
|
import * as visitsParser from './VisitsParser';
|
||||||
@@ -13,17 +15,25 @@ import * as visitsParser from './VisitsParser';
|
|||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('MapModal', () => MapModal);
|
bottle.serviceFactory('MapModal', () => MapModal);
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlVisits', () => ShortUrlVisits);
|
bottle.serviceFactory('ShortUrlVisits', () => ShortUrlVisits);
|
||||||
bottle.decorator('ShortUrlVisits', connect(
|
bottle.decorator('ShortUrlVisits', connect(
|
||||||
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ],
|
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ],
|
||||||
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ],
|
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator');
|
bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator');
|
||||||
bottle.decorator('TagVisits', connect(
|
bottle.decorator('TagVisits', connect(
|
||||||
[ 'tagVisits', 'mercureInfo' ],
|
[ 'tagVisits', 'mercureInfo' ],
|
||||||
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ],
|
[ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
bottle.serviceFactory('OrphanVisits', () => OrphanVisits);
|
||||||
|
bottle.decorator('OrphanVisits', connect(
|
||||||
|
[ 'orphanVisits', 'mercureInfo' ],
|
||||||
|
[ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ],
|
||||||
|
));
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.serviceFactory('VisitsParser', () => visitsParser);
|
bottle.serviceFactory('VisitsParser', () => visitsParser);
|
||||||
|
|
||||||
@@ -35,6 +45,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||||||
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient');
|
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
|
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits);
|
||||||
|
|
||||||
|
bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits);
|
||||||
|
|
||||||
bottle.serviceFactory('createNewVisits', () => createNewVisits);
|
bottle.serviceFactory('createNewVisits', () => createNewVisits);
|
||||||
bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient');
|
bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient');
|
||||||
};
|
};
|
||||||
|
|||||||
14
src/visits/types/helpers.ts
Normal file
14
src/visits/types/helpers.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { groupBy, pipe } from 'ramda';
|
||||||
|
import { Visit, OrphanVisit, CreateVisit } from './index';
|
||||||
|
|
||||||
|
export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl');
|
||||||
|
|
||||||
|
export interface GroupedNewVisits {
|
||||||
|
orphanVisits: CreateVisit[];
|
||||||
|
regularVisits: CreateVisit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groupNewVisitsByType = pipe(
|
||||||
|
groupBy((newVisit: CreateVisit) => isOrphanVisit(newVisit.visit) ? 'orphanVisits' : 'regularVisits'),
|
||||||
|
(result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }),
|
||||||
|
);
|
||||||
@@ -20,6 +20,8 @@ export interface VisitsLoadFailedAction extends Action<string> {
|
|||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
|
||||||
|
|
||||||
interface VisitLocation {
|
interface VisitLocation {
|
||||||
countryCode: string | null;
|
countryCode: string | null;
|
||||||
countryName: string | null;
|
countryName: string | null;
|
||||||
@@ -31,19 +33,26 @@ interface VisitLocation {
|
|||||||
isEmpty: boolean;
|
isEmpty: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Visit {
|
export interface RegularVisit {
|
||||||
referer: string;
|
referer: string;
|
||||||
date: string;
|
date: string;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
visitLocation: VisitLocation | null;
|
visitLocation: VisitLocation | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrphanVisit extends RegularVisit {
|
||||||
|
visitedUrl: string;
|
||||||
|
type: OrphanVisitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Visit = RegularVisit | OrphanVisit;
|
||||||
|
|
||||||
export interface UserAgent {
|
export interface UserAgent {
|
||||||
browser: string;
|
browser: string;
|
||||||
os: string;
|
os: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedVisit extends UserAgent {
|
export interface NormalizedRegularVisit extends UserAgent {
|
||||||
date: string;
|
date: string;
|
||||||
referer: string;
|
referer: string;
|
||||||
country: string;
|
country: string;
|
||||||
@@ -52,8 +61,15 @@ export interface NormalizedVisit extends UserAgent {
|
|||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NormalizedOrphanVisit extends NormalizedRegularVisit {
|
||||||
|
visitedUrl: string;
|
||||||
|
type: OrphanVisitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NormalizedVisit = NormalizedRegularVisit | NormalizedOrphanVisit;
|
||||||
|
|
||||||
export interface CreateVisit {
|
export interface CreateVisit {
|
||||||
shortUrl: ShortUrl;
|
shortUrl?: ShortUrl;
|
||||||
visit: Visit;
|
visit: Visit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { OptionalString } from '../../../src/utils/utils';
|
|||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types';
|
import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types';
|
||||||
import { ShortUrl } from '../../../src/short-urls/data';
|
import { ShortUrl } from '../../../src/short-urls/data';
|
||||||
|
import { Visit } from '../../../src/visits/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;
|
||||||
@@ -285,4 +286,18 @@ describe('ShlinkApiClient', () => {
|
|||||||
expect(result).toEqual(expectedData);
|
expect(result).toEqual(expectedData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getOrphanVisits', () => {
|
||||||
|
it('returns orphan visits', async () => {
|
||||||
|
const expectedData: Visit[] = [];
|
||||||
|
const resp = { visits: expectedData };
|
||||||
|
const axiosSpy = createAxiosMock({ data: resp });
|
||||||
|
const { getOrphanVisits } = new ShlinkApiClient(axiosSpy, '', '');
|
||||||
|
|
||||||
|
const result = await getOrphanVisits();
|
||||||
|
|
||||||
|
expect(axiosSpy).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(expectedData);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
63
test/common/MenuLayout.test.tsx
Normal file
63
test/common/MenuLayout.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { History, Location } from 'history';
|
||||||
|
import { match } from 'react-router'; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
import { Route } from 'react-router-dom';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import createMenuLayout from '../../src/common/MenuLayout';
|
||||||
|
import { NonReachableServer, NotFoundServer, ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||||
|
import NoMenuLayout from '../../src/common/NoMenuLayout';
|
||||||
|
|
||||||
|
describe('<MenuLayout />', () => {
|
||||||
|
const ServerError = jest.fn();
|
||||||
|
const C = jest.fn();
|
||||||
|
const MenuLayout = createMenuLayout(C, C, C, C, C, C, C, ServerError, C);
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const createWrapper = (selectedServer: SelectedServer) => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<MenuLayout
|
||||||
|
selectServer={jest.fn()}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
history={Mock.all<History>()}
|
||||||
|
location={Mock.all<Location>()}
|
||||||
|
match={Mock.of<match<{ serverId: string }>>({
|
||||||
|
params: { serverId: 'abc123' },
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ null, NoMenuLayout ],
|
||||||
|
[ Mock.of<NotFoundServer>({ serverNotFound: true }), ServerError ],
|
||||||
|
])('returns error when server is not found', (selectedServer, ExpectedComp) => {
|
||||||
|
const wrapper = createWrapper(selectedServer);
|
||||||
|
const comp = wrapper.find(ExpectedComp);
|
||||||
|
|
||||||
|
expect(comp).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error if server is not reachable', () => {
|
||||||
|
const wrapper = createWrapper(Mock.of<NonReachableServer>()).dive();
|
||||||
|
const serverError = wrapper.find(ServerError);
|
||||||
|
|
||||||
|
expect(serverError).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ '2.1.0', 6 ],
|
||||||
|
[ '2.2.0', 7 ],
|
||||||
|
[ '2.5.0', 7 ],
|
||||||
|
[ '2.6.0', 8 ],
|
||||||
|
[ '2.7.0', 8 ],
|
||||||
|
])('has expected amount of routes based on selected server\'s version', (version, expectedAmountOfRoutes) => {
|
||||||
|
const selectedServer = Mock.of<ReachableServer>({ version });
|
||||||
|
const wrapper = createWrapper(selectedServer).dive();
|
||||||
|
const routes = wrapper.find(Route);
|
||||||
|
|
||||||
|
expect(routes).toHaveLength(expectedAmountOfRoutes);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,11 +14,11 @@ describe('<ShlinkVersions />', () => {
|
|||||||
afterEach(() => wrapper?.unmount());
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[ '1.2.3', Mock.of<ReachableServer>({ printableVersion: 'foo' }), 'v1.2.3', 'foo' ],
|
[ '1.2.3', Mock.of<ReachableServer>({ version: '', printableVersion: 'foo' }), 'v1.2.3', 'foo' ],
|
||||||
[ 'foo', Mock.of<ReachableServer>({ printableVersion: '1.2.3' }), 'latest', '1.2.3' ],
|
[ 'foo', Mock.of<ReachableServer>({ version: '', printableVersion: '1.2.3' }), 'latest', '1.2.3' ],
|
||||||
[ 'latest', Mock.of<ReachableServer>({ printableVersion: 'latest' }), 'latest', 'latest' ],
|
[ 'latest', Mock.of<ReachableServer>({ version: '', printableVersion: 'latest' }), 'latest', 'latest' ],
|
||||||
[ '5.5.0', Mock.of<ReachableServer>({ printableVersion: '0.2.8' }), 'v5.5.0', '0.2.8' ],
|
[ '5.5.0', Mock.of<ReachableServer>({ version: '', printableVersion: '0.2.8' }), 'v5.5.0', '0.2.8' ],
|
||||||
[ 'not-semver', Mock.of<ReachableServer>({ printableVersion: 'something' }), 'latest', 'something' ],
|
[ 'not-semver', Mock.of<ReachableServer>({ version: '', printableVersion: 'something' }), 'latest', 'something' ],
|
||||||
])(
|
])(
|
||||||
'displays expected versions when selected server is reachable',
|
'displays expected versions when selected server is reachable',
|
||||||
(clientVersion, selectedServer, expectedClientVersion, expectedServerVersion) => {
|
(clientVersion, selectedServer, expectedClientVersion, expectedServerVersion) => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe('<ShlinkVersionsContainer />', () => {
|
|||||||
[ null, 'text-center' ],
|
[ null, 'text-center' ],
|
||||||
[ Mock.of<NotFoundServer>({ serverNotFound: true }), 'text-center' ],
|
[ Mock.of<NotFoundServer>({ serverNotFound: true }), 'text-center' ],
|
||||||
[ Mock.of<NonReachableServer>({ serverNotReachable: true }), 'text-center' ],
|
[ Mock.of<NonReachableServer>({ serverNotReachable: true }), 'text-center' ],
|
||||||
[ Mock.of<ReachableServer>({ printableVersion: 'v1.0.0' }), 'text-center shlink-versions-container--with-server' ],
|
[ Mock.of<ReachableServer>({ version: '1.0.0' }), 'text-center shlink-versions-container--with-server' ],
|
||||||
])('renders proper col classes based on type of selected server', (selectedServer, expectedClasses) => {
|
])('renders proper col classes based on type of selected server', (selectedServer, expectedClasses) => {
|
||||||
const wrapper = createWrapper(selectedServer);
|
const wrapper = createWrapper(selectedServer);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe('helpers', () => {
|
|||||||
[ Mock.of<MercureInfo>({ loading: false, error: false, mercureHubUrl: undefined }) ],
|
[ Mock.of<MercureInfo>({ loading: false, error: false, mercureHubUrl: undefined }) ],
|
||||||
[ Mock.of<MercureInfo>({ loading: true, error: true, mercureHubUrl: undefined }) ],
|
[ Mock.of<MercureInfo>({ loading: true, error: true, mercureHubUrl: undefined }) ],
|
||||||
])('does not bind an EventSource when loading, error or no hub URL', (mercureInfo) => {
|
])('does not bind an EventSource when loading, error or no hub URL', (mercureInfo) => {
|
||||||
bindToMercureTopic(mercureInfo, '', identity, identity);
|
bindToMercureTopic(mercureInfo, [ '' ], identity, identity);
|
||||||
|
|
||||||
expect(EventSource).not.toHaveBeenCalled();
|
expect(EventSource).not.toHaveBeenCalled();
|
||||||
expect(onMessage).not.toHaveBeenCalled();
|
expect(onMessage).not.toHaveBeenCalled();
|
||||||
@@ -40,7 +40,7 @@ describe('helpers', () => {
|
|||||||
error: false,
|
error: false,
|
||||||
mercureHubUrl,
|
mercureHubUrl,
|
||||||
token,
|
token,
|
||||||
}, topic, onMessage, onTokenExpired);
|
}, [ topic ], onMessage, onTokenExpired);
|
||||||
|
|
||||||
expect(EventSource).toHaveBeenCalledWith(hubUrl, {
|
expect(EventSource).toHaveBeenCalledWith(hubUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -150,12 +150,18 @@ describe('shortUrlsListReducer', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates visits count on CREATE_VISIT', () => {
|
const createNewShortUrlVisit = (visitsCount: number) => ({
|
||||||
|
shortUrl: { shortCode: 'abc123', visitsCount },
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[[ createNewShortUrlVisit(11) ], 11 ],
|
||||||
|
[[ createNewShortUrlVisit(30) ], 30 ],
|
||||||
|
[[ createNewShortUrlVisit(20), createNewShortUrlVisit(40) ], 40 ],
|
||||||
|
[[{}], 10 ],
|
||||||
|
[[], 10 ],
|
||||||
|
])('updates visits count on CREATE_VISITS', (createdVisits, expectedCount) => {
|
||||||
const shortCode = 'abc123';
|
const shortCode = 'abc123';
|
||||||
const shortUrl = {
|
|
||||||
shortCode,
|
|
||||||
visitsCount: 11,
|
|
||||||
};
|
|
||||||
const state = {
|
const state = {
|
||||||
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
|
shortUrls: Mock.of<ShlinkShortUrlsResponse>({
|
||||||
data: [
|
data: [
|
||||||
@@ -168,11 +174,11 @@ describe('shortUrlsListReducer', () => {
|
|||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(reducer(state, { type: CREATE_VISITS, createdVisits: [{ shortUrl }] } as any)).toEqual({
|
expect(reducer(state, { type: CREATE_VISITS, createdVisits } as any)).toEqual({
|
||||||
shortUrls: {
|
shortUrls: {
|
||||||
data: [
|
data: [
|
||||||
{ shortCode, domain: 'example.com', visitsCount: 5 },
|
{ shortCode, domain: 'example.com', visitsCount: 5 },
|
||||||
{ shortCode, visitsCount: 11 },
|
{ shortCode, visitsCount: expectedCount },
|
||||||
{ shortCode: 'foo', visitsCount: 8 },
|
{ shortCode: 'foo', visitsCount: 8 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
41
test/visits/OrphanVisits.test.tsx
Normal file
41
test/visits/OrphanVisits.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { History, Location } from 'history';
|
||||||
|
import { match } from 'react-router'; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
import { OrphanVisits } from '../../src/visits/OrphanVisits';
|
||||||
|
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
|
||||||
|
import { VisitsInfo } from '../../src/visits/types';
|
||||||
|
import VisitsStats from '../../src/visits/VisitsStats';
|
||||||
|
import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader';
|
||||||
|
|
||||||
|
describe('<OrphanVisits />', () => {
|
||||||
|
it('wraps visits stats and header', () => {
|
||||||
|
const goBack = jest.fn();
|
||||||
|
const getOrphanVisits = jest.fn();
|
||||||
|
const cancelGetOrphanVisits = jest.fn();
|
||||||
|
const orphanVisits = Mock.all<VisitsInfo>();
|
||||||
|
|
||||||
|
const wrapper = shallow(
|
||||||
|
<OrphanVisits
|
||||||
|
{...Mock.of<MercureBoundProps>({ mercureInfo: {} })}
|
||||||
|
getOrphanVisits={getOrphanVisits}
|
||||||
|
orphanVisits={orphanVisits}
|
||||||
|
cancelGetOrphanVisits={cancelGetOrphanVisits}
|
||||||
|
history={Mock.of<History>({ goBack })}
|
||||||
|
location={Mock.all<Location>()}
|
||||||
|
match={Mock.of<match>({ url: 'the_base_url' })}
|
||||||
|
/>,
|
||||||
|
).dive();
|
||||||
|
const stats = wrapper.find(VisitsStats);
|
||||||
|
const header = wrapper.find(OrphanVisitsHeader);
|
||||||
|
|
||||||
|
expect(stats).toHaveLength(1);
|
||||||
|
expect(header).toHaveLength(1);
|
||||||
|
expect(stats.prop('getVisits')).toEqual(getOrphanVisits);
|
||||||
|
expect(stats.prop('cancelGetVisits')).toEqual(cancelGetOrphanVisits);
|
||||||
|
expect(stats.prop('visitsInfo')).toEqual(orphanVisits);
|
||||||
|
expect(stats.prop('baseUrl')).toEqual('the_base_url');
|
||||||
|
expect(header.prop('orphanVisits')).toEqual(orphanVisits);
|
||||||
|
expect(header.prop('goBack')).toEqual(goBack);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
test/visits/OrphanVisitsHeader.test.tsx
Normal file
21
test/visits/OrphanVisitsHeader.test.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { shallow } from 'enzyme';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { OrphanVisitsHeader } from '../../src/visits/OrphanVisitsHeader';
|
||||||
|
import VisitsHeader from '../../src/visits/VisitsHeader';
|
||||||
|
import { Visit, VisitsInfo } from '../../src/visits/types';
|
||||||
|
|
||||||
|
describe('<OrphanVisitsHeader />', () => {
|
||||||
|
it('wraps a VisitsHeader with provided data', () => {
|
||||||
|
const visits: Visit[] = [];
|
||||||
|
const orphanVisits = Mock.of<VisitsInfo>({ visits });
|
||||||
|
const goBack = jest.fn();
|
||||||
|
|
||||||
|
const wrapper = shallow(<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />);
|
||||||
|
const visitsHeader = wrapper.find(VisitsHeader);
|
||||||
|
|
||||||
|
expect(visitsHeader).toHaveLength(1);
|
||||||
|
expect(visitsHeader.prop('visits')).toEqual(visits);
|
||||||
|
expect(visitsHeader.prop('goBack')).toEqual(goBack);
|
||||||
|
expect(visitsHeader.prop('title')).toEqual('Orphan visits');
|
||||||
|
});
|
||||||
|
});
|
||||||
136
test/visits/reducers/orphanVisits.test.ts
Normal file
136
test/visits/reducers/orphanVisits.test.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import reducer, {
|
||||||
|
getOrphanVisits,
|
||||||
|
cancelGetOrphanVisits,
|
||||||
|
GET_ORPHAN_VISITS_START,
|
||||||
|
GET_ORPHAN_VISITS_ERROR,
|
||||||
|
GET_ORPHAN_VISITS,
|
||||||
|
GET_ORPHAN_VISITS_LARGE,
|
||||||
|
GET_ORPHAN_VISITS_CANCEL,
|
||||||
|
GET_ORPHAN_VISITS_PROGRESS_CHANGED,
|
||||||
|
} from '../../../src/visits/reducers/orphanVisits';
|
||||||
|
import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
|
||||||
|
import { rangeOf } from '../../../src/utils/utils';
|
||||||
|
import { Visit, VisitsInfo } from '../../../src/visits/types';
|
||||||
|
import { ShlinkVisits } from '../../../src/api/types';
|
||||||
|
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
||||||
|
import { ShlinkState } from '../../../src/container/types';
|
||||||
|
|
||||||
|
describe('orphanVisitsReducer', () => {
|
||||||
|
const visitsMocks = rangeOf(2, () => Mock.all<Visit>());
|
||||||
|
|
||||||
|
describe('reducer', () => {
|
||||||
|
const buildState = (data: Partial<VisitsInfo>) => Mock.of<VisitsInfo>(data);
|
||||||
|
|
||||||
|
it('returns loading on GET_ORPHAN_VISITS_START', () => {
|
||||||
|
const state = reducer(buildState({ loading: false }), { type: GET_ORPHAN_VISITS_START } as any);
|
||||||
|
const { loading } = state;
|
||||||
|
|
||||||
|
expect(loading).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns loadingLarge on GET_ORPHAN_VISITS_LARGE', () => {
|
||||||
|
const state = reducer(buildState({ loadingLarge: false }), { type: GET_ORPHAN_VISITS_LARGE } as any);
|
||||||
|
const { loadingLarge } = state;
|
||||||
|
|
||||||
|
expect(loadingLarge).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cancelLoad on GET_ORPHAN_VISITS_CANCEL', () => {
|
||||||
|
const state = reducer(buildState({ cancelLoad: false }), { type: GET_ORPHAN_VISITS_CANCEL } as any);
|
||||||
|
const { cancelLoad } = state;
|
||||||
|
|
||||||
|
expect(cancelLoad).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops loading and returns error on GET_ORPHAN_VISITS_ERROR', () => {
|
||||||
|
const state = reducer(buildState({ loading: true, error: false }), { type: GET_ORPHAN_VISITS_ERROR } as any);
|
||||||
|
const { loading, error } = state;
|
||||||
|
|
||||||
|
expect(loading).toEqual(false);
|
||||||
|
expect(error).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('return visits on GET_ORPHAN_VISITS', () => {
|
||||||
|
const actionVisits = [{}, {}];
|
||||||
|
const state = reducer(
|
||||||
|
buildState({ loading: true, error: false }),
|
||||||
|
{ type: GET_ORPHAN_VISITS, visits: actionVisits } as any,
|
||||||
|
);
|
||||||
|
const { loading, error, visits } = state;
|
||||||
|
|
||||||
|
expect(loading).toEqual(false);
|
||||||
|
expect(error).toEqual(false);
|
||||||
|
expect(visits).toEqual(actionVisits);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a new visits on CREATE_VISIT', () => {
|
||||||
|
const prevState = buildState({ visits: visitsMocks });
|
||||||
|
|
||||||
|
const { visits } = reducer(
|
||||||
|
prevState,
|
||||||
|
{ type: CREATE_VISITS, createdVisits: [{ visit: {} }, { visit: {} }] } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(visits).toEqual([ ...visitsMocks, {}, {}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns new progress on GET_ORPHAN_VISITS_PROGRESS_CHANGED', () => {
|
||||||
|
const state = reducer(undefined, { type: GET_ORPHAN_VISITS_PROGRESS_CHANGED, progress: 85 } as any);
|
||||||
|
|
||||||
|
expect(state).toEqual(expect.objectContaining({ progress: 85 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOrphanVisits', () => {
|
||||||
|
type GetVisitsReturn = Promise<ShlinkVisits> | ((query: any) => Promise<ShlinkVisits>);
|
||||||
|
|
||||||
|
const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of<ShlinkApiClient>({
|
||||||
|
getOrphanVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned),
|
||||||
|
});
|
||||||
|
const dispatchMock = jest.fn();
|
||||||
|
const getState = () => Mock.of<ShlinkState>({
|
||||||
|
orphanVisits: { cancelLoad: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(jest.resetAllMocks);
|
||||||
|
|
||||||
|
it('dispatches start and error when promise is rejected', async () => {
|
||||||
|
const ShlinkApiClient = buildApiClientMock(Promise.reject({}));
|
||||||
|
|
||||||
|
await getOrphanVisits(() => ShlinkApiClient)()(dispatchMock, getState);
|
||||||
|
|
||||||
|
expect(dispatchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START });
|
||||||
|
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS_ERROR });
|
||||||
|
expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ undefined ],
|
||||||
|
[{}],
|
||||||
|
])('dispatches start and success when promise is resolved', async (query) => {
|
||||||
|
const visits = visitsMocks;
|
||||||
|
const ShlinkApiClient = buildApiClientMock(Promise.resolve({
|
||||||
|
data: visitsMocks,
|
||||||
|
pagination: {
|
||||||
|
currentPage: 1,
|
||||||
|
pagesCount: 1,
|
||||||
|
totalItems: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await getOrphanVisits(() => ShlinkApiClient)(query)(dispatchMock, getState);
|
||||||
|
|
||||||
|
expect(dispatchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START });
|
||||||
|
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS, visits });
|
||||||
|
expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancelGetOrphanVisits', () => {
|
||||||
|
it('just returns the action with proper type', () =>
|
||||||
|
expect(cancelGetOrphanVisits()).toEqual({ type: GET_ORPHAN_VISITS_CANCEL }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,12 +7,13 @@ import reducer, {
|
|||||||
VisitsOverview,
|
VisitsOverview,
|
||||||
loadVisitsOverview,
|
loadVisitsOverview,
|
||||||
} from '../../../src/visits/reducers/visitsOverview';
|
} from '../../../src/visits/reducers/visitsOverview';
|
||||||
import { CreateVisitsAction } from '../../../src/visits/reducers/visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from '../../../src/visits/reducers/visitCreation';
|
||||||
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
||||||
import { ShlinkVisitsOverview } from '../../../src/api/types';
|
import { ShlinkVisitsOverview } from '../../../src/api/types';
|
||||||
import { ShlinkState } from '../../../src/container/types';
|
import { ShlinkState } from '../../../src/container/types';
|
||||||
|
import { CreateVisit, OrphanVisit, Visit } from '../../../src/visits/types';
|
||||||
|
|
||||||
describe('visitsOverview', () => {
|
describe('visitsOverviewReducer', () => {
|
||||||
describe('reducer', () => {
|
describe('reducer', () => {
|
||||||
const action = (type: string) =>
|
const action = (type: string) =>
|
||||||
Mock.of<GetVisitsOverviewAction>({ type }) as GetVisitsOverviewAction & CreateVisitsAction;
|
Mock.of<GetVisitsOverviewAction>({ type }) as GetVisitsOverviewAction & CreateVisitsAction;
|
||||||
@@ -41,6 +42,31 @@ describe('visitsOverview', () => {
|
|||||||
expect(error).toEqual(false);
|
expect(error).toEqual(false);
|
||||||
expect(visitsCount).toEqual(100);
|
expect(visitsCount).toEqual(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns updated amounts on CREATE_VISITS', () => {
|
||||||
|
const { visitsCount, orphanVisitsCount } = reducer(
|
||||||
|
state({ visitsCount: 100, orphanVisitsCount: 50 }),
|
||||||
|
{
|
||||||
|
type: CREATE_VISITS,
|
||||||
|
createdVisits: [
|
||||||
|
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
|
||||||
|
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
|
||||||
|
}),
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
|
||||||
|
}),
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
} as unknown as GetVisitsOverviewAction & CreateVisitsAction,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(visitsCount).toEqual(102);
|
||||||
|
expect(orphanVisitsCount).toEqual(53);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadVisitsOverview', () => {
|
describe('loadVisitsOverview', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { processStatsFromVisits, normalizeVisits } from '../../../src/visits/services/VisitsParser';
|
import { processStatsFromVisits, normalizeVisits } from '../../../src/visits/services/VisitsParser';
|
||||||
import { Visit, VisitsStats } from '../../../src/visits/types';
|
import { OrphanVisit, Visit, VisitsStats } from '../../../src/visits/types';
|
||||||
|
|
||||||
describe('VisitsParser', () => {
|
describe('VisitsParser', () => {
|
||||||
const visits: Visit[] = [
|
const visits: Visit[] = [
|
||||||
@@ -45,6 +45,36 @@ describe('VisitsParser', () => {
|
|||||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 OPR/38.0.2220.41',
|
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 OPR/38.0.2220.41',
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
const orphanVisits: OrphanVisit[] = [
|
||||||
|
Mock.of<OrphanVisit>({
|
||||||
|
type: 'base_url',
|
||||||
|
visitedUrl: 'foo',
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0',
|
||||||
|
referer: 'https://google.com',
|
||||||
|
visitLocation: {
|
||||||
|
countryName: 'United States',
|
||||||
|
cityName: 'New York',
|
||||||
|
latitude: 1029,
|
||||||
|
longitude: 6758,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Mock.of<OrphanVisit>({
|
||||||
|
type: 'regular_404',
|
||||||
|
visitedUrl: 'bar',
|
||||||
|
}),
|
||||||
|
Mock.of<OrphanVisit>({
|
||||||
|
type: 'invalid_short_url',
|
||||||
|
visitedUrl: 'baz',
|
||||||
|
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
|
||||||
|
referer: 'https://m.facebook.com',
|
||||||
|
visitLocation: {
|
||||||
|
countryName: 'Spain',
|
||||||
|
cityName: 'Zaragoza',
|
||||||
|
latitude: 123.45,
|
||||||
|
longitude: -543.21,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
describe('processStatsFromVisits', () => {
|
describe('processStatsFromVisits', () => {
|
||||||
let stats: VisitsStats;
|
let stats: VisitsStats;
|
||||||
@@ -180,5 +210,46 @@ describe('VisitsParser', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('properly parses the list of orphan visits', () => {
|
||||||
|
expect(normalizeVisits(orphanVisits)).toEqual([
|
||||||
|
{
|
||||||
|
browser: 'Firefox',
|
||||||
|
os: 'macOS',
|
||||||
|
referer: 'google.com',
|
||||||
|
country: 'United States',
|
||||||
|
city: 'New York',
|
||||||
|
date: undefined,
|
||||||
|
latitude: 1029,
|
||||||
|
longitude: 6758,
|
||||||
|
type: 'base_url',
|
||||||
|
visitedUrl: 'foo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'regular_404',
|
||||||
|
visitedUrl: 'bar',
|
||||||
|
browser: 'Others',
|
||||||
|
city: 'Unknown',
|
||||||
|
country: 'Unknown',
|
||||||
|
date: undefined,
|
||||||
|
latitude: undefined,
|
||||||
|
longitude: undefined,
|
||||||
|
os: 'Others',
|
||||||
|
referer: 'Direct',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
browser: 'Chrome',
|
||||||
|
os: 'Linux',
|
||||||
|
referer: 'm.facebook.com',
|
||||||
|
country: 'Spain',
|
||||||
|
city: 'Zaragoza',
|
||||||
|
date: undefined,
|
||||||
|
latitude: 123.45,
|
||||||
|
longitude: -543.21,
|
||||||
|
type: 'invalid_short_url',
|
||||||
|
visitedUrl: 'baz',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
59
test/visits/types/helpers.test.ts
Normal file
59
test/visits/types/helpers.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { GroupedNewVisits, groupNewVisitsByType } from '../../../src/visits/types/helpers';
|
||||||
|
import { CreateVisit, OrphanVisit, Visit } from '../../../src/visits/types';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
|
||||||
|
describe('visitsTypeHelpers', () => {
|
||||||
|
describe('groupNewVisitsByType', () => {
|
||||||
|
it.each([
|
||||||
|
[[], { orphanVisits: [], regularVisits: [] }],
|
||||||
|
((): [CreateVisit[], GroupedNewVisits] => {
|
||||||
|
const orphanVisits: CreateVisit[] = [
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
|
||||||
|
}),
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const regularVisits: CreateVisit[] = [
|
||||||
|
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
|
||||||
|
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
|
||||||
|
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
|
||||||
|
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
|
||||||
|
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
[ ...orphanVisits, ...regularVisits ],
|
||||||
|
{ orphanVisits, regularVisits },
|
||||||
|
];
|
||||||
|
})(),
|
||||||
|
((): [CreateVisit[], GroupedNewVisits] => {
|
||||||
|
const orphanVisits: CreateVisit[] = [
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
|
||||||
|
}),
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
|
||||||
|
}),
|
||||||
|
Mock.of<CreateVisit>({
|
||||||
|
visit: Mock.of<OrphanVisit>({ visitedUrl: '' }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [ orphanVisits, { orphanVisits, regularVisits: [] }];
|
||||||
|
})(),
|
||||||
|
((): [CreateVisit[], GroupedNewVisits] => {
|
||||||
|
const regularVisits: CreateVisit[] = [
|
||||||
|
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
|
||||||
|
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
|
||||||
|
Mock.of<CreateVisit>({ visit: Mock.all<Visit>() }),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [ regularVisits, { orphanVisits: [], regularVisits }];
|
||||||
|
})(),
|
||||||
|
])('groups new visits as expected', (createdVisits, expectedResult) => {
|
||||||
|
expect(groupNewVisitsByType(createdVisits)).toEqual(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user