Created section to display orphan visits stats

This commit is contained in:
Alejandro Celaya 2021-02-27 20:03:51 +01:00
parent 46d012b6ff
commit 5479210366
23 changed files with 342 additions and 36 deletions

View file

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

View file

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

View file

@ -19,6 +19,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,6 +32,7 @@ const MenuLayout = (
} }
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' }); const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
const addOrphanVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.6.0' });
const burgerClasses = classNames('menu-layout__burger-icon', { const burgerClasses = classNames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': sidebarVisible, 'menu-layout__burger-icon--active': sidebarVisible,
}); });
@ -67,6 +69,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>}

View file

@ -34,6 +34,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
'CreateShortUrl', 'CreateShortUrl',
'ShortUrlVisits', 'ShortUrlVisits',
'TagVisits', 'TagVisits',
'OrphanVisits',
'ServerError', 'ServerError',
'Overview', 'Overview',
); );

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,29 @@
import { RouteComponentProps } from 'react-router';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../api/types';
import { TagVisits as TagVisitsState } from './reducers/tagVisits';
import VisitsStats from './VisitsStats';
import { OrphanVisitsHeader } from './OrphanVisitsHeader';
export interface OrphanVisitsProps extends RouteComponentProps<{ tag: string }> {
getOrphanVisits: (params: ShlinkVisitsParams) => void;
orphanVisits: TagVisitsState;
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>
), () => 'https://shlink.io/new-orphan-visit');

View file

@ -0,0 +1,15 @@
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;
const visitsStatsTitle = <span className="mr-2">Orphan visits</span>;
return <VisitsHeader title={visitsStatsTitle} goBack={goBack} visits={visits} />;
};

View file

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

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

View file

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

View file

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

View file

@ -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 }) => {
const { regularVisits, orphanVisits } = groupNewVisitsByType(createdVisits);
return {
...rest, ...rest,
visitsCount: visitsCount + createdVisits.length, visitsCount: visitsCount + regularVisits.length,
}), orphanVisitsCount: orphanVisitsCount + orphanVisits.length,
};
},
}, initialState); }, initialState);
export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (

View file

@ -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,7 +69,9 @@ 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 => {
const { userAgent, date, referer, visitLocation } = visit;
const common = {
date, date,
...parseUserAgent(userAgent), ...parseUserAgent(userAgent),
referer: extractDomain(referer), referer: extractDomain(referer),
@ -76,7 +79,14 @@ export const normalizeVisits = map(({ userAgent, date, referer, visitLocation }:
city: visitLocation?.cityName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing city: visitLocation?.cityName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
latitude: visitLocation?.latitude, latitude: visitLocation?.latitude,
longitude: visitLocation?.longitude, 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;

View file

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

View 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');
interface GroupedNewVisits {
orphanVisits: CreateVisit[];
regularVisits: CreateVisit[];
}
export const groupNewVisitsByType = pipe(
groupBy((newVisit: CreateVisit) => isOrphanVisit(newVisit.visit) ? 'orphanVisits' : 'regularVisits'),
(result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }),
);

View file

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

View file

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

View file

@ -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 },
], ],
}, },

View file

@ -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', () => {

View file

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