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: patch
Type: changed

Post detail page: apply the video-page parity fixes — inline date filters on the title row with row-width degradation and no comparison, long-title overflow and featured-image sizing fixes, and the post-views day-shift fix.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
export { deriveComparisonRange } from './search/comparison';
export {
REPORT_DATE_PARAM_KEYS,
omitComparisonReportParams,
pickReportDateParams,
buildDashboardLink,
buildReportLink,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
REPORT_DATE_PARAM_KEYS,
omitComparisonReportParams,
pickReportDateParams,
buildDashboardLink,
buildReportLink,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { buildDashboardLink, buildReportLink, pickReportDateParams } from './report-params';
import {
buildDashboardLink,
buildReportLink,
omitComparisonReportParams,
pickReportDateParams,
} from './report-params';

/**
* Read a link's querystring the way the router reads it, so a test asserts the
Expand Down Expand Up @@ -51,6 +56,47 @@ describe( 'pickReportDateParams', () => {
} );
} );

describe( 'omitComparisonReportParams', () => {
it( 'drops only the comparison params, keeping the window and page scope', () => {
expect(
omitComparisonReportParams( {
from: '2026-01-01',
to: '2026-01-31',
interval: 'day',
preset: 'last-30-days',
date_type: 'created',
post_id: '42',
section: 'email-opens',
comp: '1',
compare_from: '2025-12-02',
compare_to: '2025-12-31',
compare_preset: 'previous-period',
} )
).toEqual( {
from: '2026-01-01',
to: '2026-01-31',
interval: 'day',
preset: 'last-30-days',
date_type: 'created',
post_id: '42',
section: 'email-opens',
} );
} );

it( 'returns a copy when no comparison params are present', () => {
const search = { from: '2026-01-01', to: '2026-01-31' };

const result = omitComparisonReportParams( search );

expect( result ).toEqual( search );
expect( result ).not.toBe( search );
} );

it( 'returns an empty object for missing search', () => {
expect( omitComparisonReportParams( undefined ) ).toEqual( {} );
} );
} );

describe( 'buildDashboardLink', () => {
it( 'returns the bare dashboard path when no report params are set', () => {
expect( buildDashboardLink( {} ) ).toBe( '/' );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,39 @@ export function pickReportDateParams(
return picked;
}

/**
* The subset of `REPORT_DATE_PARAM_KEYS` that carries the period-over-period
* comparison.
*/
const COMPARISON_PARAM_KEYS = [ 'comp', 'compare_from', 'compare_to', 'compare_preset' ] as const;

/**
* Drop the comparison params from a search object, keeping everything else.
*
* Detail pages have no period-over-period comparison by design. The params
* stay in the URL so the breadcrumb round trip preserves the dashboard's
* comparison state, but the page strips them from the `reportParams` it
* injects into its widgets, so no widget can render comparison data — the
* page-wide invariant holds by construction instead of relying on every
* widget to ignore them.
*
* @param search - The current route search params.
* @return A new object without the comparison params.
*/
export function omitComparisonReportParams(
search: Record< string, unknown > | undefined
): Record< string, unknown > {
if ( ! search ) {
return {};
}

const stripped: Record< string, unknown > = { ...search };
for ( const key of COMPARISON_PARAM_KEYS ) {
delete stripped[ key ];
}
return stripped;
}

/**
* Serialize one search value the way the router does.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ export type DateFiltersPanelProps = {
* Required for proper date/time handling.
*/
timeZone: string;

/**
* Whether to render the period-over-period Compare control. Pages whose
* design has no comparison (the post/email detail page) opt out; their
* widgets ignore comparison params, and hiding the control keeps the UI
* honest about it.
*/
showComparison?: boolean;
};

/**
Expand Down Expand Up @@ -140,6 +148,7 @@ export function DateFiltersPanel( {
onCancel,
canApply = true,
timeZone,
showComparison = true,
}: DateFiltersPanelProps ) {
/**
* Validate and normalize the primary preset ID.
Expand Down Expand Up @@ -342,20 +351,25 @@ export function DateFiltersPanel( {
/>
</BaseControl>

<BaseControl className="date-filters-panel__comparison" help={ comparisonControlProps.help }>
<DateComparisonDropdown
presets={ presets }
enabled={ comparisonEnabled }
presetId={ validatedComparisonPresetId }
label={
typeof comparisonControlProps.label === 'string'
? comparisonControlProps.label
: undefined
}
onPresetChange={ presetChange }
onClear={ clearComparison }
/>
</BaseControl>
{ showComparison && (
<BaseControl
className="date-filters-panel__comparison"
help={ comparisonControlProps.help }
>
<DateComparisonDropdown
presets={ presets }
enabled={ comparisonEnabled }
presetId={ validatedComparisonPresetId }
label={
typeof comparisonControlProps.label === 'string'
? comparisonControlProps.label
: undefined
}
onPresetChange={ presetChange }
onClear={ clearComparison }
/>
</BaseControl>
) }
</Stack>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@

.image {
object-fit: cover;
// The boot shell resets `.boot-layout img { height: auto; max-width: 100% }`
// with higher specificity than this class, squashing the box to the
// image's natural ratio. With that `height: auto` in force, a square
// aspect ratio restores the 72px box without a specificity contest.
aspect-ratio: 1;
}

.imagePlaceholder {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ import type { DashboardWidget } from '@wordpress/widget-dashboard';

jest.mock( '@jetpack-premium-analytics/routing', () => ( {
useStagedSearch: jest.fn(),
omitComparisonReportParams: jest.requireActual(
'../../../packages/routing/src/search/report-params'
).omitComparisonReportParams,
} ) );

// The hook reads the raw URL search to build each layout entry's stripped
// reportParams; the real useSearch throws outside a matched route.
let mockRouteSearch: Record< string, unknown > = {};
jest.mock( '@wordpress/route', () => ( {
useSearch: () => mockRouteSearch,
} ) );

// The email tabs gate on the per-post opens rate summary; the query itself is
Expand Down Expand Up @@ -82,6 +92,34 @@ describe( 'usePostDetailTabs', () => {
beforeEach( () => {
jest.clearAllMocks();
mockEmailSends( 3 );
mockRouteSearch = {};
} );

it( 'injects comparison-stripped report params into every layout entry', () => {
mockSearch( 'post-traffic' );
mockRouteSearch = {
from: '2026-07-01',
to: '2026-07-07',
interval: 'day',
post_id: String( POST_ID ),
comp: '1',
compare_from: '2026-06-24',
compare_to: '2026-06-30',
compare_preset: 'previous-period',
};

const { result } = renderHook( () => usePostDetailTabs( POST_ID ) );

expect( result.current.layout.length ).toBeGreaterThan( 0 );
for ( const widget of result.current.layout ) {
const attributes = widget.attributes as { reportParams?: unknown } | undefined;
expect( attributes?.reportParams ).toEqual( {
from: '2026-07-01',
to: '2026-07-07',
interval: 'day',
post_id: String( POST_ID ),
} );
}
} );

it( 'falls back from a hidden tab and replaces the URL', async () => {
Expand Down Expand Up @@ -120,7 +158,17 @@ describe( 'usePostDetailTabs', () => {
'email-clicks',
] );
expect( result.current.activeTab ).toBe( 'email-clicks' );
expect( result.current.layout ).toEqual( POST_DETAIL_TAB_LAYOUTS[ 'email-clicks' ] );
// The hook overlays each fixed entry with the comparison-stripped
// reportParams (empty here — the mocked route search is empty).
expect( result.current.layout ).toEqual(
POST_DETAIL_TAB_LAYOUTS[ 'email-clicks' ].map( widget => ( {
...widget,
attributes: {
...( widget.attributes as Record< string, unknown > | undefined ),
reportParams: {},
},
} ) )
);
expect( stage ).not.toHaveBeenCalled();
expect( commit ).not.toHaveBeenCalled();
} );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
useStatsEmailOpensBreakdown,
type StatsEmailBreakdown,
} from '@jetpack-premium-analytics/data';
import { omitComparisonReportParams } from '@jetpack-premium-analytics/routing';
/**
* WordPress dependencies
*/
import { useEffect, useMemo } from '@wordpress/element';
import { useSearch } from '@wordpress/route';
/**
* Internal dependencies
*/
Expand Down Expand Up @@ -78,10 +80,31 @@ export function usePostDetailTabs( postId: number ) {
}
}, [ canNormalize, storedTab, activeTab, setActiveTab ] );

/*
* The page has no period-over-period comparison by design, but the
* comparison params stay in the URL so the breadcrumb carries the
* dashboard's state back out. Without explicit `reportParams`, every
* `WidgetRoot` falls back to reading the raw URL search — comparison
* included — so comparison-capable widgets (UTM, highlights) would render
* deltas. Injecting the stripped params into each layout entry makes the
* page-wide no-comparison invariant hold by construction.
*/
const search = useSearch( { strict: false } ) as Record< string, unknown > | undefined;
const layout = useMemo( () => {
const reportParams = omitComparisonReportParams( search );
return POST_DETAIL_TAB_LAYOUTS[ activeTab ].map( widget => ( {
...widget,
attributes: {
...( widget.attributes as Record< string, unknown > | undefined ),
reportParams,
},
} ) );
}, [ activeTab, search ] );

return {
tabs,
activeTab,
setActiveTab,
layout: POST_DETAIL_TAB_LAYOUTS[ activeTab ],
layout,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ export const route = {
post_id: postId,
};

/*
* Comparison params ride along untouched: this page renders no
* comparison (its widgets ignore them), but the breadcrumb's
* dashboard link carries the URL state back out, so stripping them
* here would silently lose the user's comparison settings on a
* Dashboard → Post → Dashboard round trip.
*/

throw redirect( {
to: '/post/$postId',
/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
min-block-size: 0;
}

// The scroll container below the fixed tab bar and date filters: the summary
// The scroll container below the fixed tab bar: the summary
// header scrolls away with the widgets instead of permanently occupying the
// viewport.
.scrollArea {
Expand All @@ -26,21 +26,52 @@
--wp-grid-gap: var(--wpds-dimension-gap-lg);
}

.dateFilters {
// Sits directly below the tab bar, outside the scroll container, so it
// stays fixed exactly like the dashboard's filters. The tab list already
// adds a bottom margin, so only pad below here to keep the spacing even;
// inline padding matches the tabs and the widget grid so it lines up.
padding-block-end: var(--wpds-dimension-gap-lg);
.header {
// The summary and the date filter presets share one header row — title on
// the left, presets on the right and vertically centered, per the design
// mocks. Flexbox breaks lines from unshrunk sizes, and the summary's
// inline-size containment (below) clamps its unshrunk size to the 400px
// floor — so the row wraps only when the panel's current layout no longer
// fits beside that floor (the compact dropdown on a phone), never because
// of a long title. On tight single rows the summary absorbs the squeeze
// (title ellipsis), and the panel's own step-down on narrow rows needs
// external measurement and ships separately (#51088). gap-2xl
// above matches the space the old two-row layout added up to; gap-2xl
// below mirrors it symmetrically.
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--wpds-dimension-gap-lg);
padding-block: var(--wpds-dimension-gap-2xl);
padding-inline: var(--wpds-dimension-padding-2xl);
// The boot shell's surfaces element is a flex item with the default
// `min-inline-size: auto`, so it asks the page for its min-content width —
// and a specified min-inline-size can only raise an intrinsic
// contribution, never lower it, so the summary title's nowrap min-content
// would widen the whole page into horizontal scrolling before the title's
// own ellipsis could engage. Inline-size containment excludes the header's
// contents from intrinsic sizing.
contain: inline-size;
}

.header {
// The summary header below the filters. The top gap stacks with the
// filters row's bottom padding (gap-lg + gap-lg = gap-2xl of space above
// the heading); gap-2xl below mirrors it symmetrically.
padding-block: var(--wpds-dimension-gap-lg) var(--wpds-dimension-gap-2xl);
padding-inline: var(--wpds-dimension-padding-2xl);
.summary {
// Grows into the row's free space (pushing the presets to the row's end)
// and absorbs the squeeze on tight rows, where the title's ellipsis
// engages. The 400px floor keeps the title readable. Inline-size
// containment zeroes the card's intrinsic contribution: its width comes
// from the row's flex sizing, and the wrap decision above sees the 400px
// floor instead of the title's nowrap max-content.
flex: 1 1 auto;
min-inline-size: min(100%, 400px);
contain: inline-size;
}

.dateFilters {
// Content-sized at the row's end. The auto margin keeps it at the inline
// end both beside the summary and on its
// own wrapped line.
flex: 0 0 auto;
margin-inline-start: auto;
}

// The header action is a Button rendered as an anchor, so it can carry a real
Expand Down
Loading
Loading