import { createAction, createSlice } from '@reduxjs/toolkit'; import { flatten, prop, range, splitEvery } from 'ramda'; import type { DateInterval } from '../../../src/utils/helpers/dateIntervals'; import { dateToMatchingInterval } from '../../../src/utils/helpers/dateIntervals'; import type { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api-contract'; import { parseApiError } from '../../api-contract/utils'; import type { RootState } from '../../container/store'; import { createAsyncThunk } from '../../utils/redux'; import type { CreateVisit, Visit } from '../types'; import type { LoadVisits, VisitsInfo, VisitsLoaded } from './types'; import { createNewVisits } from './visitCreation'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; const PARALLEL_STARTING_PAGE = 2; const isLastPage = ({ currentPage, pagesCount }: ShlinkPaginator): boolean => currentPage >= pagesCount; const calcProgress = (total: number, current: number): number => (current * 100) / total; type VisitsLoader = (page: number, itemsPerPage: number) => Promise; type LastVisitLoader = (excludeBots?: boolean) => Promise; interface VisitsAsyncThunkOptions { typePrefix: string; createLoaders: (params: T) => [VisitsLoader, LastVisitLoader]; getExtraFulfilledPayload: (params: T) => Partial; shouldCancel: (getState: () => RootState) => boolean; } export const createVisitsAsyncThunk = ( { typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, ) => { const progressChanged = createAction(`${typePrefix}/progressChanged`); const large = createAction(`${typePrefix}/large`); const fallbackToInterval = createAction(`${typePrefix}/fallbackToInterval`); const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise> => { const [visitsLoader, lastVisitLoader] = createLoaders(params); const loadVisitsInParallel = async (pages: number[]): Promise => Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise => { if (shouldCancel(getState)) { return []; } const data = await loadVisitsInParallel(pagesBlocks[index]); dispatch(progressChanged(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE))); if (index < pagesBlocks.length - 1) { return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); } return data; }; const loadVisits = async (page = 1) => { const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE); // If pagination was not returned, then this is an old shlink version. Just return data if (!pagination || isLastPage(pagination)) { return data; } // If there are more pages, make requests in blocks of 4 const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1); const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange); if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) { dispatch(large()); } return data.concat(await loadPagesBlocks(pagesBlocks)); }; const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader(params.query?.excludeBots)]); if (!visits.length && lastVisit) { dispatch(fallbackToInterval(dateToMatchingInterval(lastVisit.date))); } return { ...getExtraFulfilledPayload(params), visits }; }); // Enhance the async thunk with extra actions return Object.assign(asyncThunk, { progressChanged, large, fallbackToInterval }); }; export const lastVisitLoaderForLoader = ( doIntervalFallback: boolean, loader: (params: ShlinkVisitsParams) => Promise, ): LastVisitLoader => async (excludeBots?: boolean) => ( !doIntervalFallback ? Promise.resolve(undefined) : loader({ page: 1, itemsPerPage: 1, excludeBots }).then(({ data }) => data[0]) ); interface VisitsReducerOptions> { name: string; asyncThunkCreator: AT; initialState: State; filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[]; } export const createVisitsReducer = >( { name, asyncThunkCreator, initialState, filterCreatedVisits }: VisitsReducerOptions, ) => { const { pending, rejected, fulfilled, large, progressChanged, fallbackToInterval } = asyncThunkCreator; const { reducer, actions } = createSlice({ name, initialState, reducers: { cancelGetVisits: (state) => ({ ...state, cancelLoad: true }), }, extraReducers: (builder) => { builder.addCase(pending, () => ({ ...initialState, loading: true })); builder.addCase(rejected, (_, { error }) => ( { ...initialState, error: true, errorData: parseApiError(error) } )); builder.addCase(fulfilled, (state, { payload }) => ( { ...state, ...payload, loading: false, loadingLarge: false, error: false } )); builder.addCase(large, (state) => ({ ...state, loadingLarge: true })); builder.addCase(progressChanged, (state, { payload: progress }) => ({ ...state, progress })); builder.addCase(fallbackToInterval, (state, { payload: fallbackInterval }) => ( { ...state, fallbackInterval } )); builder.addCase(createNewVisits, (state, { payload }) => { const { visits } = state; // @ts-expect-error TODO Fix type inference const newVisits = filterCreatedVisits(state, payload.createdVisits).map(({ visit }) => visit); return !newVisits.length ? state : { ...state, visits: [...newVisits, ...visits] }; }); }, }); const { cancelGetVisits } = actions; return { reducer, cancelGetVisits }; };