Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Insights: add an all-time and yearly date selection, configurable per dashboard section.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ export type ReportDateFilters = {
onCancel: () => void;
canApply: boolean;
timeZone: string;

/**
* Stage a primary range change and commit it in the same tick, replacing the
* current history entry instead of pushing one.
*
* For range changes the page makes on the user's behalf rather than in
* response to a date edit — reconciling the preset with what the current
* screen can show, for instance. Those must not leave a Back step, or Back
* would return to the state that triggered the reconciliation and be
* corrected straight back out of.
*/
replaceRange: ( range: DateRange, presetId: PrimaryPresetId ) => void;
};

/**
Expand Down Expand Up @@ -175,6 +187,22 @@ export function useReportDateFilters< TFrom extends string >( from: TFrom ): Rep
const onApply = useCallback( () => commit(), [ commit ] );
const onCancel = useCallback( () => revert(), [ revert ] );

/*
* `commit()` reads the staged buffer synchronously, so staging and committing
* in the same tick lands both as one navigation.
*/
const replaceRange = useCallback(
( nextRange: DateRange, nextPresetId: PrimaryPresetId ) => {
const patch = buildRangePatch( { nextRange, nextPresetId, effective } );

if ( patch ) {
stage( patch );
commit( { replace: true } );
}
},
[ stage, commit, effective ]
);

return {
presetId,
range,
Expand All @@ -188,5 +216,6 @@ export function useReportDateFilters< TFrom extends string >( from: TFrom ): Rep
onCancel,
canApply: isDirty,
timeZone,
replaceRange,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { PRESET_ALL_TIME, toYearPresetId } from '@jetpack-premium-analytics/datetime';
import { DATE_FILTER_RANGE, DATE_FILTER_YEAR, resolvePresetForSurface } from './date-filter';

describe( 'resolvePresetForSurface', () => {
describe( 'on the year surface', () => {
it( 'keeps a preset the surface can show', () => {
expect( resolvePresetForSurface( DATE_FILTER_YEAR, PRESET_ALL_TIME ) ).toBeNull();
expect( resolvePresetForSurface( DATE_FILTER_YEAR, toYearPresetId( 2024 ) ) ).toBeNull();
} );

it( 'takes over from a rolling window, a custom range, or nothing at all', () => {
expect( resolvePresetForSurface( DATE_FILTER_YEAR, 'last-30-days' ) ).toBe( PRESET_ALL_TIME );
expect( resolvePresetForSurface( DATE_FILTER_YEAR, 'custom' ) ).toBe( PRESET_ALL_TIME );
expect( resolvePresetForSurface( DATE_FILTER_YEAR, undefined ) ).toBe( PRESET_ALL_TIME );
} );
} );

describe( 'on the date-range surface', () => {
it( 'keeps a rolling window, a custom range, or an absent preset', () => {
expect( resolvePresetForSurface( DATE_FILTER_RANGE, 'last-7-days' ) ).toBeNull();
expect( resolvePresetForSurface( DATE_FILTER_RANGE, 'custom' ) ).toBeNull();
expect( resolvePresetForSurface( DATE_FILTER_RANGE, undefined ) ).toBeNull();
} );

it( 'takes over from a year-surface preset with the default preset', () => {
expect( resolvePresetForSurface( DATE_FILTER_RANGE, PRESET_ALL_TIME ) ).toBe(
'last-30-days'
);
expect( resolvePresetForSurface( DATE_FILTER_RANGE, toYearPresetId( 2024 ) ) ).toBe(
'last-30-days'
);
} );
} );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* External dependencies
*/
import { getDefaultPreset } from '@jetpack-premium-analytics/data';
import {
PRESET_ALL_TIME,
isYearSurfacePresetId,
type ComputablePresetId,
type PrimaryPresetId,
} from '@jetpack-premium-analytics/datetime';

/**
* The rolling date-range picker plus the comparison control — what every
* section shows unless it opts out.
*/
export const DATE_FILTER_RANGE = 'range';

/**
* All time plus one entry per calendar year, for sections whose data is read as
* whole history rather than as a rolling window.
*/
export const DATE_FILTER_YEAR = 'year';

/**
* The date filter a section's header offers. Mirrors
* `Dashboard_Section::DATE_FILTERS` on the server, which is the source of truth.
*/
export type DateFilterSurface = typeof DATE_FILTER_RANGE | typeof DATE_FILTER_YEAR;

/**
* The preset a surface should take over with when the URL carries one it cannot
* represent, or `null` when the current preset is already coherent.
*
* `?preset=` is shared by every section, so switching sections can land a
* rolling window on the year surface — where no pill matches it and the whole
* control reads as unset — or a single year on the range surface, where the
* picker would label it a custom range. Rather than leave either looking blank,
* the surface the user is now looking at takes over with its own default: all
* time for the year surface, the shared default preset for the range surface.
*
* Presets a surface can represent are left alone. That includes an absent
* preset on the range surface, which is how a `?from=&to=` deep link expresses
* a custom range.
*
* @param surface - The active section's date-filter surface.
* @param presetId - The preset currently in the URL, if any.
* @return The preset to switch to, or null when no switch is needed.
*/
export function resolvePresetForSurface(
surface: DateFilterSurface,
presetId: PrimaryPresetId | undefined
): ComputablePresetId | null {
if ( surface === DATE_FILTER_YEAR ) {
return isYearSurfacePresetId( presetId ) ? null : PRESET_ALL_TIME;
}

// The year surface has no custom range, so a year preset can only have come
// from another section. Fall back to the same preset a fresh load seeds.
return isYearSurfacePresetId( presetId ) ? getDefaultPreset() : null;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
export { resolveSectionId, type DashboardSection, type DashboardSectionId } from './sections';

export {
DATE_FILTER_RANGE,
DATE_FILTER_YEAR,
resolvePresetForSurface,
type DateFilterSurface,
} from './date-filter';

export { isDashboardSectionLayouts, type DashboardSectionLayouts } from './section-layouts';
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import { resolveSectionId, type DashboardSection } from './sections';

const SECTIONS: DashboardSection[] = [
{ id: 'analytics/traffic', slug: 'traffic', label: 'Traffic', order: 10, default_layout: [] },
{ id: 'analytics/insights', slug: 'insights', label: 'Insights', order: 20, default_layout: [] },
{
id: 'analytics/traffic',
slug: 'traffic',
label: 'Traffic',
order: 10,
date_filter: 'range',
default_layout: [],
},
{
id: 'analytics/insights',
slug: 'insights',
label: 'Insights',
order: 20,
date_filter: 'year',
default_layout: [],
},
// Registered without a date filter, the way a payload from a build that
// predates the field arrives.
{
id: 'analytics/subscribers',
slug: 'subscribers',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/**
* Internal dependencies
*/
import type { DateFilterSurface } from './date-filter';
/**
* External dependencies
*/
Expand Down Expand Up @@ -31,6 +35,19 @@ export type DashboardSection = {
*/
order: number;

/**
* Which date filter this section's header offers. Server-registered per
* section, so a section reporting on whole history can ask for the year
* surface while the rest keep the rolling date-range picker.
*
* Optional because the field can genuinely be absent: WPCOM's public-api
* registers this route from its own checkout, so a Simple site can be served
* a sections payload built before the field existed. Read it through
* `resolveDateFilterSurface`, which treats a missing value as the
* date-range surface.
*/
date_filter?: DateFilterSurface;

/**
* Bundled default widget layout, consumed by the reset action.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { useActiveSection } from './use-active-section';
export { useDashboardGridSettings } from './use-dashboard-grid-settings';
export { useDashboardSectionLayout } from './use-dashboard-section-layout';
export { useDashboardSections } from './use-dashboard-sections';
export { useSectionDateFilter } from './use-section-date-filter';
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* External dependencies
*/
import { PRESET_ALL_TIME, toYearPresetId } from '@jetpack-premium-analytics/datetime';
import { renderHook } from '@testing-library/react';
/**
* Internal dependencies
*/
import {
DATE_FILTER_RANGE,
DATE_FILTER_YEAR,
type DashboardSection,
type DateFilterSurface,
} from '../config';
import { useSectionDateFilter } from './use-section-date-filter';
import type { ReportDateFilters } from '@jetpack-premium-analytics/routing';

/**
* Build a section registered with a given date filter.
*
* @param dateFilter - The section's `date_filter` value.
* @return A dashboard section.
*/
function section( dateFilter?: DateFilterSurface | string ): DashboardSection {
const slug = dateFilter ?? 'unset';

return {
id: `analytics/${ slug }`,
slug,
label: slug,
order: 10,
date_filter: dateFilter as DateFilterSurface | undefined,
default_layout: [],
};
}

/**
* Build a date-filter controller whose `replaceRange` records its calls.
*
* @param presetId - The preset currently in the URL.
* @return The controller and the recorded calls.
*/
function dateFilters( presetId?: ReportDateFilters[ 'presetId' ] ) {
const replaceRange = jest.fn();

return {
replaceRange,
filters: {
presetId,
timeZone: 'UTC',
replaceRange,
} as unknown as ReportDateFilters,
};
}

describe( 'useSectionDateFilter', () => {
it( 'returns the surface the active section was registered with', () => {
const { filters } = dateFilters( PRESET_ALL_TIME );

expect(
renderHook( () => useSectionDateFilter( section( 'year' ), filters ) ).result.current
).toBe( DATE_FILTER_YEAR );
expect(
renderHook( () => useSectionDateFilter( section( 'range' ), filters ) ).result.current
).toBe( DATE_FILTER_RANGE );
} );

// `year` is the only opt-in surface, so anything else lands on the range UI:
// a section served without the field, or with one added after this build.
it( 'falls back to the range surface for a missing or unknown filter', () => {
const { filters } = dateFilters( PRESET_ALL_TIME );

expect( renderHook( () => useSectionDateFilter( section(), filters ) ).result.current ).toBe(
DATE_FILTER_RANGE
);
expect(
renderHook( () => useSectionDateFilter( section( 'something-newer' ), filters ) ).result
.current
).toBe( DATE_FILTER_RANGE );
} );

it( 'leaves a preset the active surface can show alone', () => {
const year = dateFilters( toYearPresetId( 2024 ) );
renderHook( () => useSectionDateFilter( section( 'year' ), year.filters ) );
expect( year.replaceRange ).not.toHaveBeenCalled();

const range = dateFilters( 'last-7-days' );
renderHook( () => useSectionDateFilter( section( 'range' ), range.filters ) );
expect( range.replaceRange ).not.toHaveBeenCalled();
} );

it( 'moves a rolling window onto the year surface as all time', () => {
const { filters, replaceRange } = dateFilters( 'last-30-days' );

renderHook( () => useSectionDateFilter( section( 'year' ), filters ) );

expect( replaceRange ).toHaveBeenCalledTimes( 1 );
const [ range, presetId ] = replaceRange.mock.calls[ 0 ];
expect( presetId ).toBe( PRESET_ALL_TIME );
expect( range.from.getTime() ).toBeLessThan( range.to.getTime() );
} );

it( 'moves a year preset onto the date-range surface as the default preset', () => {
const { filters, replaceRange } = dateFilters( toYearPresetId( 2024 ) );

renderHook( () => useSectionDateFilter( section( 'range' ), filters ) );

expect( replaceRange ).toHaveBeenCalledTimes( 1 );
expect( replaceRange.mock.calls[ 0 ][ 1 ] ).toBe( 'last-30-days' );
} );

it( 'waits for the sections to resolve before reconciling', () => {
const { filters, replaceRange } = dateFilters( toYearPresetId( 2024 ) );

// Until the section is known there is no surface to reconcile against,
// and guessing would undo a `?section=` deep link's own preset.
renderHook( () => useSectionDateFilter( undefined, filters ) );

expect( replaceRange ).not.toHaveBeenCalled();
} );
} );
Loading
Loading