From 5479210366d8707f80d79a61819a3db3b82d6db1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Feb 2021 20:03:51 +0100 Subject: [PATCH] Created section to display orphan visits stats --- .eslintrc | 1 + src/api/services/ShlinkApiClient.ts | 4 + src/common/MenuLayout.tsx | 3 + src/common/services/provideServices.ts | 1 + src/container/types.ts | 2 + src/reducers/index.ts | 2 + src/short-urls/reducers/shortUrlsList.ts | 8 +- src/tags/reducers/tagsList.ts | 2 +- src/visits/OrphanVisits.tsx | 29 ++++++++ src/visits/OrphanVisitsHeader.tsx | 15 ++++ src/visits/VisitsStats.tsx | 2 +- src/visits/reducers/orphanVisits.ts | 69 ++++++++++++++++++ src/visits/reducers/shortUrlVisits.ts | 5 +- src/visits/reducers/tagVisits.ts | 4 +- src/visits/reducers/visitsOverview.ts | 14 +++- src/visits/services/VisitsParser.ts | 28 ++++--- src/visits/services/provideServices.ts | 15 +++- src/visits/types/helpers.ts | 14 ++++ src/visits/types/index.ts | 22 +++++- test/api/services/ShlinkApiClient.test.ts | 15 ++++ .../short-urls/reducers/shortUrlsList.test.ts | 20 +++-- test/visits/reducers/visitsOverview.test.ts | 30 +++++++- test/visits/services/VisitsParser.test.ts | 73 ++++++++++++++++++- 23 files changed, 342 insertions(+), 36 deletions(-) create mode 100644 src/visits/OrphanVisits.tsx create mode 100644 src/visits/OrphanVisitsHeader.tsx create mode 100644 src/visits/reducers/orphanVisits.ts create mode 100644 src/visits/types/helpers.ts diff --git a/.eslintrc b/.eslintrc index 5a5fdc49..0de71fb1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -22,6 +22,7 @@ "ignoreComments": true }], "no-mixed-operators": "off", + "object-shorthand": "off", "react/display-name": "off", "react/react-in-jsx-scope": "off", "@typescript-eslint/require-array-sort-compare": "off" diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index b7c78ba0..1c5c34d9 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -51,6 +51,10 @@ export default class ShlinkApiClient { this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query) .then(({ data }) => data.visits); + public readonly getOrphanVisits = async (query?: Omit): Promise => + this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query) + .then(({ data }) => data.visits); + public readonly getVisitsOverview = async (): Promise => this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET') .then(({ data }) => data.visits); diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index 11326487..7528a860 100644 --- a/src/common/MenuLayout.tsx +++ b/src/common/MenuLayout.tsx @@ -19,6 +19,7 @@ const MenuLayout = ( CreateShortUrl: FC, ShortUrlVisits: FC, TagVisits: FC, + OrphanVisits: FC, ServerError: FC, Overview: FC, ) => withSelectedServer(({ location, selectedServer }) => { @@ -31,6 +32,7 @@ const MenuLayout = ( } 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', { 'menu-layout__burger-icon--active': sidebarVisible, }); @@ -67,6 +69,7 @@ const MenuLayout = ( {addTagsVisitsRoute && } + {addOrphanVisitsRoute && } List short URLs} diff --git a/src/common/services/provideServices.ts b/src/common/services/provideServices.ts index af8f7417..c18689b8 100644 --- a/src/common/services/provideServices.ts +++ b/src/common/services/provideServices.ts @@ -34,6 +34,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: 'CreateShortUrl', 'ShortUrlVisits', 'TagVisits', + 'OrphanVisits', 'ServerError', 'Overview', ); diff --git a/src/container/types.ts b/src/container/types.ts index f6197b54..d51764c2 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -16,6 +16,7 @@ import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits'; import { TagVisits } from '../visits/reducers/tagVisits'; import { DomainsList } from '../domains/reducers/domainsList'; import { VisitsOverview } from '../visits/reducers/visitsOverview'; +import { VisitsInfo } from '../visits/types'; export interface ShlinkState { servers: ServersMap; @@ -29,6 +30,7 @@ export interface ShlinkState { shortUrlEdition: ShortUrlEdition; shortUrlVisits: ShortUrlVisits; tagVisits: TagVisits; + orphanVisits: VisitsInfo; shortUrlDetail: ShortUrlDetail; tagsList: TagsList; tagDelete: TagDeletion; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 705129ba..4efcf1c6 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -10,6 +10,7 @@ import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta'; import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; +import orphanVisitsReducer from '../visits/reducers/orphanVisits'; import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail'; import tagsListReducer from '../tags/reducers/tagsList'; import tagDeleteReducer from '../tags/reducers/tagDelete'; @@ -32,6 +33,7 @@ export default combineReducers({ shortUrlEdition: shortUrlEditionReducer, shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, + orphanVisits: orphanVisitsReducer, shortUrlDetail: shortUrlDetailReducer, tagsList: tagsListReducer, tagDelete: tagDeleteReducer, diff --git a/src/short-urls/reducers/shortUrlsList.ts b/src/short-urls/reducers/shortUrlsList.ts index 1821643a..1ff640f0 100644 --- a/src/short-urls/reducers/shortUrlsList.ts +++ b/src/short-urls/reducers/shortUrlsList.ts @@ -83,10 +83,14 @@ export default buildReducer({ (currentShortUrl) => { // Find the last of the new visit for this short URL, and pick the amount of visits from it 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, diff --git a/src/tags/reducers/tagsList.ts b/src/tags/reducers/tagsList.ts index 3cfc778f..b3c88a86 100644 --- a/src/tags/reducers/tagsList.ts +++ b/src/tags/reducers/tagsList.ts @@ -76,7 +76,7 @@ const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags }, { ...stats }); const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries( createdVisits.reduce((acc, { shortUrl }) => { - shortUrl.tags.forEach((tag) => { + shortUrl?.tags.forEach((tag) => { acc[tag] = (acc[tag] || 0) + 1; }); diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx new file mode 100644 index 00000000..a0538fd2 --- /dev/null +++ b/src/visits/OrphanVisits.tsx @@ -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) => ( + + + +), () => 'https://shlink.io/new-orphan-visit'); diff --git a/src/visits/OrphanVisitsHeader.tsx b/src/visits/OrphanVisitsHeader.tsx new file mode 100644 index 00000000..dd600523 --- /dev/null +++ b/src/visits/OrphanVisitsHeader.tsx @@ -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 = Orphan visits; + + return ; +}; diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 09987202..0d391717 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -66,7 +66,7 @@ const VisitsNavLink: FC = ({ subPath, title tag={RouterNavLink} className="visits-stats__nav-link" to={to} - isActive={(_: null, { pathname }: Location) => pathname.endsWith(`/visits${subPath}`)} + isActive={(_: null, { pathname }: Location) => pathname.endsWith(`visits${subPath}`)} replace > diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts new file mode 100644 index 00000000..d6d30828 --- /dev/null +++ b/src/visits/reducers/orphanVisits.ts @@ -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 { + 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({ + [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); diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 8901a724..1b66a033 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -52,11 +52,10 @@ export default buildReducer({ [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [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 newVisits = createdVisits - .filter(({ shortUrl }) => shortUrlMatches(shortUrl, shortCode, domain)) + .filter(({ shortUrl }) => shortUrl && shortUrlMatches(shortUrl, shortCode, domain)) .map(({ visit }) => visit); return { ...state, visits: [ ...visits, ...newVisits ] }; diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index c0c4106b..c78295af 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -46,10 +46,10 @@ export default buildReducer({ [GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [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 newVisits = createdVisits - .filter(({ shortUrl }) => shortUrl.tags.includes(tag)) + .filter(({ shortUrl }) => shortUrl?.tags.includes(tag)) .map(({ visit }) => visit); return { ...state, visits: [ ...visits, ...newVisits ] }; diff --git a/src/visits/reducers/visitsOverview.ts b/src/visits/reducers/visitsOverview.ts index ac51abf8..292d170c 100644 --- a/src/visits/reducers/visitsOverview.ts +++ b/src/visits/reducers/visitsOverview.ts @@ -3,6 +3,7 @@ import { ShlinkVisitsOverview } from '../../api/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { buildReducer } from '../../utils/helpers/redux'; +import { groupNewVisitsByType } from '../types/helpers'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; /* eslint-disable padding-line-between-statements */ @@ -31,10 +32,15 @@ export default buildReducer ({ ...initialState, loading: true }), [GET_OVERVIEW_ERROR]: () => ({ ...initialState, error: true }), [GET_OVERVIEW]: (_, { visitsCount, orphanVisitsCount }) => ({ ...initialState, visitsCount, orphanVisitsCount }), - [CREATE_VISITS]: ({ visitsCount, ...rest }, { createdVisits }) => ({ - ...rest, - visitsCount: visitsCount + createdVisits.length, - }), + [CREATE_VISITS]: ({ visitsCount, orphanVisitsCount = 0, ...rest }, { createdVisits }) => { + const { regularVisits, orphanVisits } = groupNewVisitsByType(createdVisits); + + return { + ...rest, + visitsCount: visitsCount + regularVisits.length, + orphanVisitsCount: orphanVisitsCount + orphanVisits.length, + }; + }, }, initialState); export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( diff --git a/src/visits/services/VisitsParser.ts b/src/visits/services/VisitsParser.ts index 2febfd16..824fb796 100644 --- a/src/visits/services/VisitsParser.ts +++ b/src/visits/services/VisitsParser.ts @@ -2,6 +2,7 @@ import { isNil, map } from 'ramda'; import { extractDomain, parseUserAgent } from '../../utils/helpers/visits'; import { hasValue } from '../../utils/utils'; import { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types'; +import { isOrphanVisit } from '../types/helpers'; const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) => !isNil(visit) && hasValue(visit[propertyName]); @@ -68,15 +69,24 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.redu { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }, ); -export const normalizeVisits = map(({ userAgent, date, referer, visitLocation }: Visit): NormalizedVisit => ({ - date, - ...parseUserAgent(userAgent), - referer: extractDomain(referer), - country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing - city: visitLocation?.cityName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing - latitude: visitLocation?.latitude, - longitude: visitLocation?.longitude, -})); +export const normalizeVisits = map((visit: Visit): NormalizedVisit => { + const { userAgent, date, referer, visitLocation } = visit; + const common = { + date, + ...parseUserAgent(userAgent), + referer: extractDomain(referer), + country: visitLocation?.countryName || 'Unknown', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing + 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 { processStatsFromVisits: (normalizedVisits: NormalizedVisit[]) => VisitsStats; diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 40809d0e..f550b68f 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -4,8 +4,10 @@ import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrl import { getShortUrlDetail } from '../reducers/shortUrlDetail'; import MapModal from '../helpers/MapModal'; import { createNewVisits } from '../reducers/visitCreation'; -import { cancelGetTagVisits, getTagVisits } from '../reducers/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 { loadVisitsOverview } from '../reducers/visitsOverview'; import * as visitsParser from './VisitsParser'; @@ -13,17 +15,25 @@ import * as visitsParser from './VisitsParser'; const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Components bottle.serviceFactory('MapModal', () => MapModal); + bottle.serviceFactory('ShortUrlVisits', () => ShortUrlVisits); bottle.decorator('ShortUrlVisits', connect( [ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ], [ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisits', 'loadMercureInfo' ], )); + bottle.serviceFactory('TagVisits', TagVisits, 'ColorGenerator'); bottle.decorator('TagVisits', connect( [ 'tagVisits', 'mercureInfo' ], [ 'getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo' ], )); + bottle.serviceFactory('OrphanVisits', () => OrphanVisits); + bottle.decorator('OrphanVisits', connect( + [ 'orphanVisits', 'mercureInfo' ], + [ 'getOrphanVisits', 'cancelGetOrphanVisits', 'createNewVisits', 'loadMercureInfo' ], + )); + // Services bottle.serviceFactory('VisitsParser', () => visitsParser); @@ -35,6 +45,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); + bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits); + bottle.serviceFactory('createNewVisits', () => createNewVisits); bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient'); }; diff --git a/src/visits/types/helpers.ts b/src/visits/types/helpers.ts new file mode 100644 index 00000000..c87d95f9 --- /dev/null +++ b/src/visits/types/helpers.ts @@ -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 }), +); diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index fa076916..813a8767 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -20,6 +20,8 @@ export interface VisitsLoadFailedAction extends Action { errorData?: ProblemDetailsError; } +type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; + interface VisitLocation { countryCode: string | null; countryName: string | null; @@ -31,19 +33,26 @@ interface VisitLocation { isEmpty: boolean; } -export interface Visit { +export interface RegularVisit { referer: string; date: string; userAgent: string; visitLocation: VisitLocation | null; } +export interface OrphanVisit extends RegularVisit { + visitedUrl: string; + type: OrphanVisitType; +} + +export type Visit = RegularVisit | OrphanVisit; + export interface UserAgent { browser: string; os: string; } -export interface NormalizedVisit extends UserAgent { +export interface NormalizedRegularVisit extends UserAgent { date: string; referer: string; country: string; @@ -52,8 +61,15 @@ export interface NormalizedVisit extends UserAgent { longitude?: number | null; } +export interface NormalizedOrphanVisit extends NormalizedRegularVisit { + visitedUrl: string; + type: OrphanVisitType; +} + +export type NormalizedVisit = NormalizedRegularVisit | NormalizedOrphanVisit; + export interface CreateVisit { - shortUrl: ShortUrl; + shortUrl?: ShortUrl; visit: Visit; } diff --git a/test/api/services/ShlinkApiClient.test.ts b/test/api/services/ShlinkApiClient.test.ts index 2c5d0ae7..14303d49 100644 --- a/test/api/services/ShlinkApiClient.test.ts +++ b/test/api/services/ShlinkApiClient.test.ts @@ -4,6 +4,7 @@ import { OptionalString } from '../../../src/utils/utils'; import { Mock } from 'ts-mockery'; import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types'; import { ShortUrl } from '../../../src/short-urls/data'; +import { Visit } from '../../../src/visits/types'; describe('ShlinkApiClient', () => { const createAxios = (data: AxiosRequestConfig) => (async () => Promise.resolve(data)) as unknown as AxiosInstance; @@ -285,4 +286,18 @@ describe('ShlinkApiClient', () => { 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); + }); + }); }); diff --git a/test/short-urls/reducers/shortUrlsList.test.ts b/test/short-urls/reducers/shortUrlsList.test.ts index da14fc13..2266129c 100644 --- a/test/short-urls/reducers/shortUrlsList.test.ts +++ b/test/short-urls/reducers/shortUrlsList.test.ts @@ -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 shortUrl = { - shortCode, - visitsCount: 11, - }; const state = { shortUrls: Mock.of({ data: [ @@ -168,11 +174,11 @@ describe('shortUrlsListReducer', () => { error: false, }; - expect(reducer(state, { type: CREATE_VISITS, createdVisits: [{ shortUrl }] } as any)).toEqual({ + expect(reducer(state, { type: CREATE_VISITS, createdVisits } as any)).toEqual({ shortUrls: { data: [ { shortCode, domain: 'example.com', visitsCount: 5 }, - { shortCode, visitsCount: 11 }, + { shortCode, visitsCount: expectedCount }, { shortCode: 'foo', visitsCount: 8 }, ], }, diff --git a/test/visits/reducers/visitsOverview.test.ts b/test/visits/reducers/visitsOverview.test.ts index 5e3369ce..79fa5c47 100644 --- a/test/visits/reducers/visitsOverview.test.ts +++ b/test/visits/reducers/visitsOverview.test.ts @@ -7,12 +7,13 @@ import reducer, { VisitsOverview, loadVisitsOverview, } 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 { ShlinkVisitsOverview } from '../../../src/api/types'; import { ShlinkState } from '../../../src/container/types'; +import { CreateVisit, OrphanVisit, Visit } from '../../../src/visits/types'; -describe('visitsOverview', () => { +describe('visitsOverviewReducer', () => { describe('reducer', () => { const action = (type: string) => Mock.of({ type }) as GetVisitsOverviewAction & CreateVisitsAction; @@ -41,6 +42,31 @@ describe('visitsOverview', () => { expect(error).toEqual(false); 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({ visit: Mock.all() }), + Mock.of({ visit: Mock.all() }), + Mock.of({ + visit: Mock.of({ visitedUrl: '' }), + }), + Mock.of({ + visit: Mock.of({ visitedUrl: '' }), + }), + Mock.of({ + visit: Mock.of({ visitedUrl: '' }), + }), + ], + } as unknown as GetVisitsOverviewAction & CreateVisitsAction, + ); + + expect(visitsCount).toEqual(102); + expect(orphanVisitsCount).toEqual(53); + }); }); describe('loadVisitsOverview', () => { diff --git a/test/visits/services/VisitsParser.test.ts b/test/visits/services/VisitsParser.test.ts index 524dc7e1..96936f44 100644 --- a/test/visits/services/VisitsParser.test.ts +++ b/test/visits/services/VisitsParser.test.ts @@ -1,6 +1,6 @@ import { Mock } from 'ts-mockery'; 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', () => { 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', }), ]; + const orphanVisits: OrphanVisit[] = [ + Mock.of({ + 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({ + type: 'regular_404', + visitedUrl: 'bar', + }), + Mock.of({ + 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', () => { 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', + }, + ]); + }); }); });