From 05254326cb0066aa3ba808c5c07d7f45d8a85fee Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 24 Apr 2022 18:36:25 +0200 Subject: [PATCH] Implemented domain visits section --- src/api/services/ShlinkApiClient.ts | 4 ++ src/container/types.ts | 2 + src/domains/helpers/DomainDropdown.tsx | 11 +-- src/reducers/index.ts | 2 + src/short-urls/helpers/index.ts | 9 +++ src/visits/DomainVisits.tsx | 47 +++++++++++- src/visits/reducers/domainVisits.ts | 98 ++++++++++++++++++++++++++ src/visits/services/provideServices.ts | 10 ++- 8 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 src/visits/reducers/domainVisits.ts diff --git a/src/api/services/ShlinkApiClient.ts b/src/api/services/ShlinkApiClient.ts index 39e3ade1..552254e9 100644 --- a/src/api/services/ShlinkApiClient.ts +++ b/src/api/services/ShlinkApiClient.ts @@ -56,6 +56,10 @@ export default class ShlinkApiClient { this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query) .then(({ data }) => data.visits); + public readonly getDomainVisits = async (domain: string, query?: Omit): Promise => + this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/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); diff --git a/src/container/types.ts b/src/container/types.ts index aeb4a8b5..84cb2df1 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -15,6 +15,7 @@ import { DomainsList } from '../domains/reducers/domainsList'; import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { VisitsInfo } from '../visits/types'; import { Sidebar } from '../common/reducers/sidebar'; +import { DomainVisits } from '../visits/reducers/domainVisits'; export interface ShlinkState { servers: ServersMap; @@ -25,6 +26,7 @@ export interface ShlinkState { shortUrlEdition: ShortUrlEdition; shortUrlVisits: ShortUrlVisits; tagVisits: TagVisits; + domainVisits: DomainVisits; orphanVisits: VisitsInfo; nonOrphanVisits: VisitsInfo; shortUrlDetail: ShortUrlDetail; diff --git a/src/domains/helpers/DomainDropdown.tsx b/src/domains/helpers/DomainDropdown.tsx index 2ed2f63a..4ab36f0f 100644 --- a/src/domains/helpers/DomainDropdown.tsx +++ b/src/domains/helpers/DomainDropdown.tsx @@ -27,14 +27,17 @@ export const DomainDropdown: FC = ({ domain, editDomainRedi return ( - - Edit redirects - {withVisits && ( - + Visit stats )} + + Edit redirects + ({ shortUrlEdition: shortUrlEditionReducer, shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, + domainVisits: domainVisitsReducer, orphanVisits: orphanVisitsReducer, nonOrphanVisits: nonOrphanVisitsReducer, shortUrlDetail: shortUrlDetailReducer, diff --git a/src/short-urls/helpers/index.ts b/src/short-urls/helpers/index.ts index e40fa4a3..e5994edb 100644 --- a/src/short-urls/helpers/index.ts +++ b/src/short-urls/helpers/index.ts @@ -1,6 +1,7 @@ import { isNil } from 'ramda'; import { ShortUrl } from '../data'; import { OptionalString } from '../../utils/utils'; +import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits'; export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: OptionalString): boolean => { if (isNil(domain)) { @@ -9,3 +10,11 @@ export const shortUrlMatches = (shortUrl: ShortUrl, shortCode: string, domain: O return shortUrl.shortCode === shortCode && shortUrl.domain === domain; }; + +export const domainMatches = (shortUrl: ShortUrl, domain: string): boolean => { + if (!shortUrl.domain && domain === DEFAULT_DOMAIN) { + return true; + } + + return shortUrl.domain === domain; +}; diff --git a/src/visits/DomainVisits.tsx b/src/visits/DomainVisits.tsx index ccd4a493..b0bdca5f 100644 --- a/src/visits/DomainVisits.tsx +++ b/src/visits/DomainVisits.tsx @@ -1,3 +1,46 @@ -import { FC } from 'react'; +import { useParams } from 'react-router-dom'; +import { CommonVisitsProps } from './types/CommonVisitsProps'; +import { ShlinkVisitsParams } from '../api/types'; +import { DomainVisits as DomainVisitsState } from './reducers/domainVisits'; +import { ReportExporter } from '../common/services/ReportExporter'; +import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; +import { Topics } from '../mercure/helpers/Topics'; +import { useGoBack } from '../utils/helpers/hooks'; +import { toApiParams } from './types/helpers'; +import { NormalizedVisit } from './types'; +import VisitsStats from './VisitsStats'; +import VisitsHeader from './VisitsHeader'; -export const DomainVisits = (): FC => () => DomainVisits; +export interface DomainVisitsProps extends CommonVisitsProps { + getDomainVisits: (domain: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; + domainVisits: DomainVisitsState; + cancelGetDomainVisits: () => void; +} + +export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercureHub(({ + getDomainVisits, + domainVisits, + cancelGetDomainVisits, + settings, + selectedServer, +}: DomainVisitsProps) => { + const goBack = useGoBack(); + const { domain = '' } = useParams(); + const [authority, domainId = authority] = domain.split('_'); + const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) => + getDomainVisits(domainId, toApiParams(params), doIntervalFallback); + const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`domain_${authority}_visits.csv`, visits); + + return ( + + + + ); +}, () => [Topics.visits]); diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts new file mode 100644 index 00000000..e5652140 --- /dev/null +++ b/src/visits/reducers/domainVisits.ts @@ -0,0 +1,98 @@ +import { Action, Dispatch } from 'redux'; +import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; +import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; +import { GetState } from '../../container/types'; +import { ShlinkVisitsParams } from '../../api/types'; +import { ApiErrorAction } from '../../api/types/actions'; +import { isBetween } from '../../utils/helpers/date'; +import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; +import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; +import { domainMatches } from '../../short-urls/helpers'; + +export const GET_DOMAIN_VISITS_START = 'shlink/domainVisits/GET_DOMAIN_VISITS_START'; +export const GET_DOMAIN_VISITS_ERROR = 'shlink/domainVisits/GET_DOMAIN_VISITS_ERROR'; +export const GET_DOMAIN_VISITS = 'shlink/domainVisits/GET_DOMAIN_VISITS'; +export const GET_DOMAIN_VISITS_LARGE = 'shlink/domainVisits/GET_DOMAIN_VISITS_LARGE'; +export const GET_DOMAIN_VISITS_CANCEL = 'shlink/domainVisits/GET_DOMAIN_VISITS_CANCEL'; +export const GET_DOMAIN_VISITS_PROGRESS_CHANGED = 'shlink/domainVisits/GET_DOMAIN_VISITS_PROGRESS_CHANGED'; +export const GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/domainVisits/GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL'; + +export const DEFAULT_DOMAIN = 'DEFAULT'; + +export interface DomainVisits extends VisitsInfo { + domain: string; + domainId: string; +} + +export interface DomainVisitsAction extends Action { + visits: Visit[]; + domain: string; + query?: ShlinkVisitsParams; +} + +type DomainVisitsCombinedAction = DomainVisitsAction +& VisitsLoadProgressChangedAction +& VisitsFallbackIntervalAction +& CreateVisitsAction +& ApiErrorAction; + +const initialState: DomainVisits = { + visits: [], + domain: '', + domainId: '', + loading: false, + loadingLarge: false, + error: false, + cancelLoad: false, + progress: 0, +}; + +export default buildReducer({ + [GET_DOMAIN_VISITS_START]: () => ({ ...initialState, loading: true }), + [GET_DOMAIN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), + [GET_DOMAIN_VISITS]: (state, { visits, domain, query }) => ( + { ...state, visits, domain, query, loading: false, error: false } + ), + [GET_DOMAIN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), + [GET_DOMAIN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), + [GET_DOMAIN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), + [GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), + [CREATE_VISITS]: (state, { createdVisits }) => { + const { domain, visits, query = {} } = state; + const { startDate, endDate } = query; + const newVisits = createdVisits + .filter(({ shortUrl, visit }) => + shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate)) + .map(({ visit }) => visit); + + return { ...state, visits: [...newVisits, ...visits] }; + }, +}, initialState); + +export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( + domain: string, + query: ShlinkVisitsParams = {}, + doIntervalFallback = false, +) => async (dispatch: Dispatch, getState: GetState) => { + const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); + const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( + domain, + { ...query, page, itemsPerPage }, + ); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params)); + const shouldCancel = () => getState().domainVisits.cancelLoad; + const extraFinishActionData: Partial = { domain, query }; + const actionMap = { + start: GET_DOMAIN_VISITS_START, + large: GET_DOMAIN_VISITS_LARGE, + finish: GET_DOMAIN_VISITS, + error: GET_DOMAIN_VISITS_ERROR, + progress: GET_DOMAIN_VISITS_PROGRESS_CHANGED, + fallbackToInterval: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, + }; + + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); +}; + +export const cancelGetDomainVisits = buildActionCreator(GET_DOMAIN_VISITS_CANCEL); diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 15d3a797..efc9d620 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -7,6 +7,7 @@ import { OrphanVisits } from '../OrphanVisits'; import { NonOrphanVisits } from '../NonOrphanVisits'; import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; +import { cancelGetDomainVisits, getDomainVisits } from '../reducers/domainVisits'; import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits'; import { ConnectDecorator } from '../../container/types'; @@ -30,7 +31,11 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { ['getTagVisits', 'cancelGetTagVisits', 'createNewVisits', 'loadMercureInfo'], )); - bottle.serviceFactory('DomainVisits', DomainVisits); + bottle.serviceFactory('DomainVisits', DomainVisits, 'ReportExporter'); + bottle.decorator('DomainVisits', connect( + ['domainVisits', 'mercureInfo', 'settings', 'selectedServer'], + ['getDomainVisits', 'cancelGetDomainVisits', 'createNewVisits', 'loadMercureInfo'], + )); bottle.serviceFactory('OrphanVisits', OrphanVisits, 'ReportExporter'); bottle.decorator('OrphanVisits', connect( @@ -54,6 +59,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); + bottle.serviceFactory('getDomainVisits', getDomainVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('cancelGetDomainVisits', () => cancelGetDomainVisits); + bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits);