Ensured new visits are pushed to the state only if they match selected date range

This commit is contained in:
Alejandro Celaya 2021-10-24 10:31:32 +02:00
parent 36af3c3dd0
commit e135dd92ec
8 changed files with 61 additions and 19 deletions

View file

@ -1,4 +1,4 @@
import { format, formatISO, parse } from 'date-fns'; import { format, formatISO, isAfter, isBefore, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns';
import { OptionalString } from '../utils'; import { OptionalString } from '../utils';
type DateOrString = Date | string; type DateOrString = Date | string;
@ -21,3 +21,25 @@ export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date,
export const formatInternational = formatDate(); export const formatInternational = formatDate();
export const parseDate = (date: string, format: string) => parse(date, format, new Date()); export const parseDate = (date: string, format: string) => parse(date, format, new Date());
const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date);
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
if (!start && !end) {
return true;
}
if (!start && end) {
return isBefore(parseISO(date), parseISO(end));
}
if (start && !end) {
return isAfter(parseISO(date), parseISO(start));
}
if (start && end) {
return isWithinInterval(parseISO(date), { start: parseISO(start), end: parseISO(end) });
}
return false;
};

View file

@ -6,6 +6,7 @@ import { GetState } from '../../container/types';
import { ShlinkVisitsParams } from '../../api/types'; import { ShlinkVisitsParams } from '../../api/types';
import { isOrphanVisit } from '../types/helpers'; import { isOrphanVisit } from '../types/helpers';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
@ -20,6 +21,7 @@ export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_ORPHA
export interface OrphanVisitsAction extends Action<string> { export interface OrphanVisitsAction extends Action<string> {
visits: Visit[]; visits: Visit[];
query?: ShlinkVisitsParams;
} }
type OrphanVisitsCombinedAction = OrphanVisitsAction type OrphanVisitsCombinedAction = OrphanVisitsAction
@ -39,13 +41,16 @@ const initialState: VisitsInfo = {
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({ export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[GET_ORPHAN_VISITS]: (_, { visits }) => ({ ...initialState, visits }), [GET_ORPHAN_VISITS]: (_, { visits, query }) => ({ ...initialState, visits, query }),
[GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), [GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[CREATE_VISITS]: (state, { createdVisits }) => { [CREATE_VISITS]: (state, { createdVisits }) => {
const { visits } = state; const { visits, query = {} } = state;
const newVisits = createdVisits.map(({ visit }) => visit); const { startDate, endDate } = query;
const newVisits = createdVisits
.filter(({ visit }) => isBetween(visit.date, startDate, endDate))
.map(({ visit }) => visit);
return { ...state, visits: [ ...newVisits, ...visits ] }; return { ...state, visits: [ ...newVisits, ...visits ] };
}, },
@ -66,6 +71,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
return { ...result, data: visits }; return { ...result, data: visits };
}); });
const shouldCancel = () => getState().orphanVisits.cancelLoad; const shouldCancel = () => getState().orphanVisits.cancelLoad;
const extraFinishActionData: Partial<OrphanVisitsAction> = { query };
const actionMap = { const actionMap = {
start: GET_ORPHAN_VISITS_START, start: GET_ORPHAN_VISITS_START,
large: GET_ORPHAN_VISITS_LARGE, large: GET_ORPHAN_VISITS_LARGE,
@ -74,7 +80,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED, progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED,
}; };
return getVisitsWithLoader(visitsLoader, {}, actionMap, dispatch, shouldCancel); return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
}; };
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL); export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);

View file

@ -7,6 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
import { GetState } from '../../container/types'; import { GetState } from '../../container/types';
import { ShlinkVisitsParams } from '../../api/types'; import { ShlinkVisitsParams } from '../../api/types';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
@ -23,6 +24,7 @@ export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier { interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier {
visits: Visit[]; visits: Visit[];
query?: ShlinkVisitsParams;
} }
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
@ -33,7 +35,7 @@ type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
const initialState: ShortUrlVisits = { const initialState: ShortUrlVisits = {
visits: [], visits: [],
shortCode: '', shortCode: '',
domain: undefined, domain: undefined, // Deprecated. Value from query params can be used instead
loading: false, loading: false,
loadingLarge: false, loadingLarge: false,
error: false, error: false,
@ -44,22 +46,27 @@ const initialState: ShortUrlVisits = {
export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[GET_SHORT_URL_VISITS]: (_, { visits, shortCode, domain }) => ({ [GET_SHORT_URL_VISITS]: (_, { visits, query, shortCode, domain }) => ({
...initialState, ...initialState,
visits, visits,
shortCode, shortCode,
domain, domain,
query,
}), }),
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), [GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[CREATE_VISITS]: (state, { createdVisits }) => { [CREATE_VISITS]: (state, { createdVisits }) => {
const { shortCode, domain, visits } = state; const { shortCode, domain, visits, query = {} } = state;
const { startDate, endDate } = query;
const newVisits = createdVisits const newVisits = createdVisits
.filter(({ shortUrl }) => shortUrl && shortUrlMatches(shortUrl, shortCode, domain)) .filter(
({ shortUrl, visit }) =>
shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate),
)
.map(({ visit }) => visit); .map(({ visit }) => visit);
return { ...state, visits: [ ...newVisits, ...visits ] }; return newVisits.length === 0 ? state : { ...state, visits: [ ...newVisits, ...visits ] };
}, },
}, initialState); }, initialState);
@ -73,7 +80,7 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder)
{ ...query, page, itemsPerPage }, { ...query, page, itemsPerPage },
); );
const shouldCancel = () => getState().shortUrlVisits.cancelLoad; const shouldCancel = () => getState().shortUrlVisits.cancelLoad;
const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, domain: query.domain }; const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, query, domain: query.domain };
const actionMap = { const actionMap = {
start: GET_SHORT_URL_VISITS_START, start: GET_SHORT_URL_VISITS_START,
large: GET_SHORT_URL_VISITS_LARGE, large: GET_SHORT_URL_VISITS_LARGE,

View file

@ -7,6 +7,7 @@ import { ShlinkVisitsParams } from '../../api/types';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
import { isBetween } from '../../utils/helpers/date';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
export const GET_TAG_VISITS_START = 'shlink/tagVisits/GET_TAG_VISITS_START'; export const GET_TAG_VISITS_START = 'shlink/tagVisits/GET_TAG_VISITS_START';
@ -24,6 +25,7 @@ export interface TagVisits extends VisitsInfo {
export interface TagVisitsAction extends Action<string> { export interface TagVisitsAction extends Action<string> {
visits: Visit[]; visits: Visit[];
tag: string; tag: string;
query?: ShlinkVisitsParams;
} }
type TagsVisitsCombinedAction = TagVisitsAction type TagsVisitsCombinedAction = TagVisitsAction
@ -44,14 +46,15 @@ const initialState: TagVisits = {
export default buildReducer<TagVisits, TagsVisitsCombinedAction>({ export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
[GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[GET_TAG_VISITS]: (_, { visits, tag }) => ({ ...initialState, visits, tag }), [GET_TAG_VISITS]: (_, { visits, tag, query }) => ({ ...initialState, visits, tag, query }),
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), [GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[CREATE_VISITS]: (state, { createdVisits }) => { [CREATE_VISITS]: (state, { createdVisits }) => {
const { tag, visits } = state; const { tag, visits, query = {} } = state;
const { startDate, endDate } = query;
const newVisits = createdVisits const newVisits = createdVisits
.filter(({ shortUrl }) => shortUrl?.tags.includes(tag)) .filter(({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate))
.map(({ visit }) => visit); .map(({ visit }) => visit);
return { ...state, visits: [ ...newVisits, ...visits ] }; return { ...state, visits: [ ...newVisits, ...visits ] };
@ -68,7 +71,7 @@ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
{ ...query, page, itemsPerPage }, { ...query, page, itemsPerPage },
); );
const shouldCancel = () => getState().tagVisits.cancelLoad; const shouldCancel = () => getState().tagVisits.cancelLoad;
const extraFinishActionData: Partial<TagVisitsAction> = { tag }; const extraFinishActionData: Partial<TagVisitsAction> = { tag, query };
const actionMap = { const actionMap = {
start: GET_TAG_VISITS_START, start: GET_TAG_VISITS_START,
large: GET_TAG_VISITS_LARGE, large: GET_TAG_VISITS_LARGE,

View file

@ -1,6 +1,6 @@
import { Action } from 'redux'; import { Action } from 'redux';
import { ShortUrl } from '../../short-urls/data'; import { ShortUrl } from '../../short-urls/data';
import { ProblemDetailsError } from '../../api/types'; import { ProblemDetailsError, ShlinkVisitsParams } from '../../api/types';
import { DateRange } from '../../utils/dates/types'; import { DateRange } from '../../utils/dates/types';
export interface VisitsInfo { export interface VisitsInfo {
@ -11,6 +11,7 @@ export interface VisitsInfo {
errorData?: ProblemDetailsError; errorData?: ProblemDetailsError;
progress: number; progress: number;
cancelLoad: boolean; cancelLoad: boolean;
query?: ShlinkVisitsParams;
} }
export interface VisitsLoadProgressChangedAction extends Action<string> { export interface VisitsLoadProgressChangedAction extends Action<string> {

View file

@ -124,7 +124,7 @@ describe('orphanVisitsReducer', () => {
expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START });
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS, visits }); expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS, visits, query: query ?? {} });
expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1); expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1);
}); });
}); });

View file

@ -133,7 +133,10 @@ describe('shortUrlVisitsReducer', () => {
expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START });
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS, visits, shortCode, domain }); expect(dispatchMock).toHaveBeenNthCalledWith(
2,
{ type: GET_SHORT_URL_VISITS, visits, shortCode, domain, query: query ?? {} },
);
expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1);
}); });

View file

@ -132,7 +132,7 @@ describe('tagVisitsReducer', () => {
expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START });
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS, visits, tag }); expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS, visits, tag, query: query ?? {} });
expect(ShlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); expect(ShlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1);
}); });
}); });