From 5479210366d8707f80d79a61819a3db3b82d6db1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Feb 2021 20:03:51 +0100 Subject: [PATCH 1/9] 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', + }, + ]); + }); }); }); From 115038f80ff971e930fe9825662d7a4de387f7c1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 27 Feb 2021 20:13:18 +0100 Subject: [PATCH 2/9] Created visits type helpers test --- src/visits/types/helpers.ts | 2 +- test/visits/types/helpers.test.ts | 59 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 test/visits/types/helpers.test.ts diff --git a/src/visits/types/helpers.ts b/src/visits/types/helpers.ts index c87d95f9..51a896f2 100644 --- a/src/visits/types/helpers.ts +++ b/src/visits/types/helpers.ts @@ -3,7 +3,7 @@ import { Visit, OrphanVisit, CreateVisit } from './index'; export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl'); -interface GroupedNewVisits { +export interface GroupedNewVisits { orphanVisits: CreateVisit[]; regularVisits: CreateVisit[]; } diff --git a/test/visits/types/helpers.test.ts b/test/visits/types/helpers.test.ts new file mode 100644 index 00000000..d87c7bd1 --- /dev/null +++ b/test/visits/types/helpers.test.ts @@ -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({ + visit: Mock.of({ visitedUrl: '' }), + }), + Mock.of({ + visit: Mock.of({ visitedUrl: '' }), + }), + ]; + const regularVisits: CreateVisit[] = [ + Mock.of({ visit: Mock.all() }), + Mock.of({ visit: Mock.all() }), + Mock.of({ visit: Mock.all() }), + Mock.of({ visit: Mock.all() }), + Mock.of({ visit: Mock.all() }), + ]; + + return [ + [ ...orphanVisits, ...regularVisits ], + { orphanVisits, regularVisits }, + ]; + })(), + ((): [CreateVisit[], GroupedNewVisits] => { + const orphanVisits: CreateVisit[] = [ + Mock.of({ + visit: Mock.of({ visitedUrl: '' }), + }), + Mock.of({ + visit: Mock.of({ visitedUrl: '' }), + }), + Mock.of({ + visit: Mock.of({ visitedUrl: '' }), + }), + ]; + + return [ orphanVisits, { orphanVisits, regularVisits: [] }]; + })(), + ((): [CreateVisit[], GroupedNewVisits] => { + const regularVisits: CreateVisit[] = [ + Mock.of({ visit: Mock.all() }), + Mock.of({ visit: Mock.all() }), + Mock.of({ visit: Mock.all() }), + ]; + + return [ regularVisits, { orphanVisits: [], regularVisits }]; + })(), + ])('groups new visits as expected', (createdVisits, expectedResult) => { + expect(groupNewVisitsByType(createdVisits)).toEqual(expectedResult); + }); + }); +}); From d7edd69e60d20bb7cdb1fc39cc27c0bc4472a342 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Feb 2021 08:52:31 +0100 Subject: [PATCH 3/9] Created OrphanVisitsTitle test --- src/visits/OrphanVisitsHeader.tsx | 3 +-- test/visits/OrphanVisitsHeader.test.tsx | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 test/visits/OrphanVisitsHeader.test.tsx diff --git a/src/visits/OrphanVisitsHeader.tsx b/src/visits/OrphanVisitsHeader.tsx index dd600523..87ab78f0 100644 --- a/src/visits/OrphanVisitsHeader.tsx +++ b/src/visits/OrphanVisitsHeader.tsx @@ -9,7 +9,6 @@ interface OrphanVisitsHeader { export const OrphanVisitsHeader = ({ orphanVisits, goBack }: OrphanVisitsHeader) => { const { visits } = orphanVisits; - const visitsStatsTitle = Orphan visits; - return ; + return ; }; diff --git a/test/visits/OrphanVisitsHeader.test.tsx b/test/visits/OrphanVisitsHeader.test.tsx new file mode 100644 index 00000000..66eccad0 --- /dev/null +++ b/test/visits/OrphanVisitsHeader.test.tsx @@ -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('', () => { + it('wraps a VisitsHeader with provided data', () => { + const visits: Visit[] = []; + const orphanVisits = Mock.of({ visits }); + const goBack = jest.fn(); + + const wrapper = shallow(); + 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'); + }); +}); From 25e53bf627a25b6ea2b9ff5cd4a02d45872dba71 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Feb 2021 09:28:46 +0100 Subject: [PATCH 4/9] Created MenuLayout test --- src/common/MenuLayout.tsx | 24 +------- src/servers/data/index.ts | 2 +- src/utils/helpers/hooks.ts | 21 +++++++ test/common/MenuLayout.test.tsx | 63 ++++++++++++++++++++ test/common/ShlinkVersions.test.tsx | 10 ++-- test/common/ShlinkVersionsContainer.test.tsx | 2 +- 6 files changed, 94 insertions(+), 28 deletions(-) create mode 100644 test/common/MenuLayout.test.tsx diff --git a/src/common/MenuLayout.tsx b/src/common/MenuLayout.tsx index 7528a860..57bd1189 100644 --- a/src/common/MenuLayout.tsx +++ b/src/common/MenuLayout.tsx @@ -1,11 +1,10 @@ import { FC, useEffect } from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; -import { useSwipeable } from 'react-swipeable'; import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; 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 { isReachableServer } from '../servers/data'; import NotFound from './NotFound'; @@ -33,25 +32,8 @@ 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, - }); - 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), - }); + const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible }); + const swipeableProps = useSwipeable(showSidebar, hideSidebar); return ( <> diff --git a/src/servers/data/index.ts b/src/servers/data/index.ts index 22294143..32c8a75a 100644 --- a/src/servers/data/index.ts +++ b/src/servers/data/index.ts @@ -34,7 +34,7 @@ export const isServerWithId = (server: SelectedServer | ServerWithId): server is !!server?.hasOwnProperty('id'); export const isReachableServer = (server: SelectedServer): server is ReachableServer => - !!server?.hasOwnProperty('printableVersion'); + !!server?.hasOwnProperty('version'); export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer => !!server?.hasOwnProperty('serverNotFound'); diff --git a/src/utils/helpers/hooks.ts b/src/utils/helpers/hooks.ts index 3eadd866..a44b4c20 100644 --- a/src/utils/helpers/hooks.ts +++ b/src/utils/helpers/hooks.ts @@ -1,4 +1,5 @@ import { useState, useRef } from 'react'; +import { useSwipeable as useReactSwipeable } from 'react-swipeable'; const DEFAULT_DELAY = 2000; @@ -30,3 +31,23 @@ export const useToggle = (initialValue = false): ToggleResult => { 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), + }); +}; diff --git a/test/common/MenuLayout.test.tsx b/test/common/MenuLayout.test.tsx new file mode 100644 index 00000000..85cbc109 --- /dev/null +++ b/test/common/MenuLayout.test.tsx @@ -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('', () => { + 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( + ()} + location={Mock.all()} + match={Mock.of>({ + params: { serverId: 'abc123' }, + })} + />, + ); + + return wrapper; + }; + + afterEach(() => wrapper?.unmount()); + + it.each([ + [ null, NoMenuLayout ], + [ Mock.of({ 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()).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({ version }); + const wrapper = createWrapper(selectedServer).dive(); + const routes = wrapper.find(Route); + + expect(routes).toHaveLength(expectedAmountOfRoutes); + }); +}); diff --git a/test/common/ShlinkVersions.test.tsx b/test/common/ShlinkVersions.test.tsx index b16e4105..0a97010f 100644 --- a/test/common/ShlinkVersions.test.tsx +++ b/test/common/ShlinkVersions.test.tsx @@ -14,11 +14,11 @@ describe('', () => { afterEach(() => wrapper?.unmount()); it.each([ - [ '1.2.3', Mock.of({ printableVersion: 'foo' }), 'v1.2.3', 'foo' ], - [ 'foo', Mock.of({ printableVersion: '1.2.3' }), 'latest', '1.2.3' ], - [ 'latest', Mock.of({ printableVersion: 'latest' }), 'latest', 'latest' ], - [ '5.5.0', Mock.of({ printableVersion: '0.2.8' }), 'v5.5.0', '0.2.8' ], - [ 'not-semver', Mock.of({ printableVersion: 'something' }), 'latest', 'something' ], + [ '1.2.3', Mock.of({ version: '', printableVersion: 'foo' }), 'v1.2.3', 'foo' ], + [ 'foo', Mock.of({ version: '', printableVersion: '1.2.3' }), 'latest', '1.2.3' ], + [ 'latest', Mock.of({ version: '', printableVersion: 'latest' }), 'latest', 'latest' ], + [ '5.5.0', Mock.of({ version: '', printableVersion: '0.2.8' }), 'v5.5.0', '0.2.8' ], + [ 'not-semver', Mock.of({ version: '', printableVersion: 'something' }), 'latest', 'something' ], ])( 'displays expected versions when selected server is reachable', (clientVersion, selectedServer, expectedClientVersion, expectedServerVersion) => { diff --git a/test/common/ShlinkVersionsContainer.test.tsx b/test/common/ShlinkVersionsContainer.test.tsx index ba4bec86..534972af 100644 --- a/test/common/ShlinkVersionsContainer.test.tsx +++ b/test/common/ShlinkVersionsContainer.test.tsx @@ -18,7 +18,7 @@ describe('', () => { [ null, 'text-center' ], [ Mock.of({ serverNotFound: true }), 'text-center' ], [ Mock.of({ serverNotReachable: true }), 'text-center' ], - [ Mock.of({ printableVersion: 'v1.0.0' }), 'text-center shlink-versions-container--with-server' ], + [ Mock.of({ version: '1.0.0' }), 'text-center shlink-versions-container--with-server' ], ])('renders proper col classes based on type of selected server', (selectedServer, expectedClasses) => { const wrapper = createWrapper(selectedServer); From 71ee886e24114eed70461d6f292955f85b20c406 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Feb 2021 09:50:01 +0100 Subject: [PATCH 5/9] Updated overview page cards to be links to other sections when suitable --- src/servers/Overview.scss | 2 ++ src/servers/Overview.tsx | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/servers/Overview.scss b/src/servers/Overview.scss index f8b49ad7..9f939a2f 100644 --- a/src/servers/Overview.scss +++ b/src/servers/Overview.scss @@ -3,6 +3,8 @@ .overview__card.overview__card { text-align: center; border-top: 3px solid var(--brand-color); + color: inherit; + text-decoration: none; } .overview__card-title { diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 71bc58d6..12cd6909 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -65,7 +65,7 @@ export const Overview = (
- + Orphan visits @@ -78,7 +78,7 @@ export const Overview = (
- + Short URLs {loading ? 'Loading...' : prettify(shortUrls?.pagination.totalItems ?? 0)} @@ -86,7 +86,7 @@ export const Overview = (
- + Tags {loadingTags ? 'Loading...' : prettify(tagsList.tags.length)} From 9904ac757bb9e9bf270d0f3dd41669b82ac542ba Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Feb 2021 10:12:30 +0100 Subject: [PATCH 6/9] Updated mercure integration so that the hook accepts a list of topics to subscribe --- src/mercure/helpers/Topics.ts | 7 ++++++ src/mercure/helpers/boundToMercureHub.tsx | 4 ++-- src/mercure/helpers/index.ts | 29 ++++++++++++++--------- src/servers/Overview.tsx | 3 ++- src/short-urls/ShortUrlsList.tsx | 3 ++- src/tags/TagsList.tsx | 3 ++- src/visits/OrphanVisits.tsx | 3 ++- src/visits/ShortUrlVisits.tsx | 3 ++- src/visits/TagVisits.tsx | 3 ++- test/mercure/helpers/index.test.tsx | 4 ++-- 10 files changed, 41 insertions(+), 21 deletions(-) create mode 100644 src/mercure/helpers/Topics.ts diff --git a/src/mercure/helpers/Topics.ts b/src/mercure/helpers/Topics.ts new file mode 100644 index 00000000..42e08d4f --- /dev/null +++ b/src/mercure/helpers/Topics.ts @@ -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'; +} diff --git a/src/mercure/helpers/boundToMercureHub.tsx b/src/mercure/helpers/boundToMercureHub.tsx index 38cae023..5b5d5680 100644 --- a/src/mercure/helpers/boundToMercureHub.tsx +++ b/src/mercure/helpers/boundToMercureHub.tsx @@ -12,7 +12,7 @@ export interface MercureBoundProps { export function boundToMercureHub( WrappedComponent: FC, - getTopicForProps: (props: T) => string, + getTopicsForProps: (props: T) => string[], ) { const pendingUpdates = new Set(); @@ -22,7 +22,7 @@ export function boundToMercureHub( useEffect(() => { 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) { return closeEventSource; diff --git a/src/mercure/helpers/index.ts b/src/mercure/helpers/index.ts index 19c2176c..33073818 100644 --- a/src/mercure/helpers/index.ts +++ b/src/mercure/helpers/index.ts @@ -1,24 +1,31 @@ import { EventSourcePolyfill as EventSource } from 'event-source-polyfill'; import { MercureInfo } from '../reducers/mercureInfo'; -export const bindToMercureTopic = (mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => { // eslint-disable-line max-len +export const bindToMercureTopic = (mercureInfo: MercureInfo, topics: string[], onMessage: (message: T) => void, onTokenExpired: Function) => { // eslint-disable-line max-len const { mercureHubUrl, token, loading, error } = mercureInfo; if (loading || error || !mercureHubUrl) { 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 es = new EventSource(hubUrl, { - headers: { - Authorization: `Bearer ${token}`, - }, + const subscriptions: EventSource[] = topics.map((topic) => { + const hubUrl = new URL(mercureHubUrl); + + 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); - es.onerror = ({ status }: { status: number }) => status === 401 && onTokenExpired(); - - return () => es.close(); + return () => subscriptions.forEach((es) => es.close()); }; diff --git a/src/servers/Overview.tsx b/src/servers/Overview.tsx index 12cd6909..703cf717 100644 --- a/src/servers/Overview.tsx +++ b/src/servers/Overview.tsx @@ -10,6 +10,7 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { CreateShortUrlProps } from '../short-urls/CreateShortUrl'; import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { Versions } from '../utils/helpers/version'; +import { Topics } from '../mercure/helpers/Topics'; import { isServerWithId, SelectedServer } from './data'; import './Overview.scss'; @@ -119,4 +120,4 @@ export const Overview = ( ); -}, () => 'https://shlink.io/new-visit'); +}, () => [ Topics.visits(), Topics.orphanVisits() ]); diff --git a/src/short-urls/ShortUrlsList.tsx b/src/short-urls/ShortUrlsList.tsx index 971cc128..4736bc8c 100644 --- a/src/short-urls/ShortUrlsList.tsx +++ b/src/short-urls/ShortUrlsList.tsx @@ -9,6 +9,7 @@ import { determineOrderDir, OrderDir } from '../utils/utils'; import { isReachableServer, SelectedServer } from '../servers/data'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { parseQuery } from '../utils/helpers/query'; +import { Topics } from '../mercure/helpers/Topics'; import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList'; import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams'; import { ShortUrlsTableProps } from './ShortUrlsTable'; @@ -98,6 +99,6 @@ const ShortUrlsList = (ShortUrlsTable: FC) => boundToMercur ); -}, () => 'https://shlink.io/new-visit'); +}, () => [ Topics.visits() ]); export default ShortUrlsList; diff --git a/src/tags/TagsList.tsx b/src/tags/TagsList.tsx index a7d21bce..7d30c5c1 100644 --- a/src/tags/TagsList.tsx +++ b/src/tags/TagsList.tsx @@ -8,6 +8,7 @@ import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; import { TagsList as TagsListState } from './reducers/tagsList'; import { TagCardProps } from './TagCard'; +import { Topics } from '../mercure/helpers/Topics'; const { ceil } = Math; const TAGS_GROUPS_AMOUNT = 4; @@ -75,6 +76,6 @@ const TagsList = (TagCard: FC) => boundToMercureHub(( {renderContent()} ); -}, () => 'https://shlink.io/new-visit'); +}, () => [ Topics.visits() ]); export default TagsList; diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index a0538fd2..a6ae86a7 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -1,6 +1,7 @@ import { RouteComponentProps } from 'react-router'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { ShlinkVisitsParams } from '../api/types'; +import { Topics } from '../mercure/helpers/Topics'; import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import VisitsStats from './VisitsStats'; import { OrphanVisitsHeader } from './OrphanVisitsHeader'; @@ -26,4 +27,4 @@ export const OrphanVisits = boundToMercureHub(({ > -), () => 'https://shlink.io/new-orphan-visit'); +), () => [ Topics.orphanVisits() ]); diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx index a65df735..3d71b8cf 100644 --- a/src/visits/ShortUrlVisits.tsx +++ b/src/visits/ShortUrlVisits.tsx @@ -3,6 +3,7 @@ import { RouteComponentProps } from 'react-router'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { ShlinkVisitsParams } from '../api/types'; import { parseQuery } from '../utils/helpers/query'; +import { Topics } from '../mercure/helpers/Topics'; import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import ShortUrlVisitsHeader from './ShortUrlVisitsHeader'; import { ShortUrlDetail } from './reducers/shortUrlDetail'; @@ -45,6 +46,6 @@ const ShortUrlVisits = boundToMercureHub(({ ); -}, ({ match }) => `https://shlink.io/new-visit/${match.params.shortCode}`); +}, ({ match }) => [ Topics.shortUrlVisits(match.params.shortCode) ]); export default ShortUrlVisits; diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index acf60a9e..d6772682 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -2,6 +2,7 @@ import { RouteComponentProps } from 'react-router'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import ColorGenerator from '../utils/services/ColorGenerator'; import { ShlinkVisitsParams } from '../api/types'; +import { Topics } from '../mercure/helpers/Topics'; import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import TagVisitsHeader from './TagVisitsHeader'; import VisitsStats from './VisitsStats'; @@ -27,6 +28,6 @@ const TagVisits = (colorGenerator: ColorGenerator) => boundToMercureHub(({ ); -}, () => 'https://shlink.io/new-visit'); +}, () => [ Topics.visits() ]); export default TagVisits; diff --git a/test/mercure/helpers/index.test.tsx b/test/mercure/helpers/index.test.tsx index 2fb971b4..f4a3d187 100644 --- a/test/mercure/helpers/index.test.tsx +++ b/test/mercure/helpers/index.test.tsx @@ -20,7 +20,7 @@ describe('helpers', () => { [ Mock.of({ loading: false, error: false, mercureHubUrl: undefined }) ], [ Mock.of({ loading: true, error: true, mercureHubUrl: undefined }) ], ])('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(onMessage).not.toHaveBeenCalled(); @@ -40,7 +40,7 @@ describe('helpers', () => { error: false, mercureHubUrl, token, - }, topic, onMessage, onTokenExpired); + }, [ topic ], onMessage, onTokenExpired); expect(EventSource).toHaveBeenCalledWith(hubUrl, { headers: { From eb0ab92472ac3a72789502b6a71fcf43f251cf8e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Feb 2021 10:36:56 +0100 Subject: [PATCH 7/9] Created OrphanVisits test --- package.json | 2 +- src/visits/OrphanVisits.tsx | 6 ++--- test/visits/OrphanVisits.test.tsx | 41 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 test/visits/OrphanVisits.test.tsx diff --git a/package.json b/package.json index 2f4a282a..fc81f1d2 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "start": "node scripts/start.js", "serve:build": "serve ./build", "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:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html", "mutate": "./node_modules/.bin/stryker run --concurrency 4" diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index a6ae86a7..cb959869 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -2,13 +2,13 @@ import { RouteComponentProps } from 'react-router'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; -import { TagVisits as TagVisitsState } from './reducers/tagVisits'; import VisitsStats from './VisitsStats'; import { OrphanVisitsHeader } from './OrphanVisitsHeader'; +import { VisitsInfo } from './types'; -export interface OrphanVisitsProps extends RouteComponentProps<{ tag: string }> { +export interface OrphanVisitsProps extends RouteComponentProps { getOrphanVisits: (params: ShlinkVisitsParams) => void; - orphanVisits: TagVisitsState; + orphanVisits: VisitsInfo; cancelGetOrphanVisits: () => void; } diff --git a/test/visits/OrphanVisits.test.tsx b/test/visits/OrphanVisits.test.tsx new file mode 100644 index 00000000..be362905 --- /dev/null +++ b/test/visits/OrphanVisits.test.tsx @@ -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('', () => { + it('wraps visits stats and header', () => { + const goBack = jest.fn(); + const getOrphanVisits = jest.fn(); + const cancelGetOrphanVisits = jest.fn(); + const orphanVisits = Mock.all(); + + const wrapper = shallow( + ({ mercureInfo: {} })} + getOrphanVisits={getOrphanVisits} + orphanVisits={orphanVisits} + cancelGetOrphanVisits={cancelGetOrphanVisits} + history={Mock.of({ goBack })} + location={Mock.all()} + match={Mock.of({ 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); + }); +}); From d921c44d3bebecd0471aaab4047dd7d62df27c20 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Feb 2021 10:45:14 +0100 Subject: [PATCH 8/9] Created orphanVisitsReducer test --- test/visits/reducers/orphanVisits.test.ts | 136 ++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 test/visits/reducers/orphanVisits.test.ts diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts new file mode 100644 index 00000000..9c4b0d80 --- /dev/null +++ b/test/visits/reducers/orphanVisits.test.ts @@ -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()); + + describe('reducer', () => { + const buildState = (data: Partial) => Mock.of(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 | ((query: any) => Promise); + + const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ + getOrphanVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), + }); + const dispatchMock = jest.fn(); + const getState = () => Mock.of({ + 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 })); + }); +}); From 4d77c3abf98fbbc749ef790d3e3885679ab736c8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Feb 2021 10:46:57 +0100 Subject: [PATCH 9/9] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 464ed70f..cc0a187a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. * [#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. +* [#387](https://github.com/shlinkio/shlink-web-client/issues/387) Added section to see orphan visits stats, when consuming Shlink >=2.6.0. ### 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.