diff --git a/projects/packages/premium-analytics/changelog/fix-top-posts-router-navigation b/projects/packages/premium-analytics/changelog/fix-top-posts-router-navigation
new file mode 100644
index 000000000000..d3b848e3821f
--- /dev/null
+++ b/projects/packages/premium-analytics/changelog/fix-top-posts-router-navigation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Most viewed: navigate to post details in the app instead of reloading the page.
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts
index a0fc7862d805..0d7130de03f0 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts
@@ -52,6 +52,7 @@ export {
export { WidgetBackLink, type WidgetBackLinkProps } from './widget-back-link';
export { WidgetFooter, type WidgetFooterProps } from './widget-footer';
export { ReportLink, type ReportLinkProps } from './report-link';
+export { PostTitleLink, POST_URL_SEARCH_PARAM, type PostTitleLinkProps } from './post-title-link';
export { VideoTitleLink, type VideoTitleLinkProps } from './video-title-link';
export {
SubscriberList,
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-title-link/__tests__/post-title-link.test.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-title-link/__tests__/post-title-link.test.tsx
new file mode 100644
index 000000000000..569f8a922309
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-title-link/__tests__/post-title-link.test.tsx
@@ -0,0 +1,93 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+/**
+ * Internal dependencies
+ */
+import { PostTitleLink } from '../post-title-link';
+import type { AnchorHTMLAttributes, ReactNode } from 'react';
+
+type MockRouteLinkProps = {
+ to: string;
+ params?: Record< string, unknown >;
+ search?: Record< string, unknown >;
+ children: ReactNode;
+} & Omit< AnchorHTMLAttributes< HTMLAnchorElement >, 'href' >;
+
+jest.mock( '@wordpress/route', () => ( {
+ Link: ( { to, params, search, children, ...props }: MockRouteLinkProps ) => {
+ const path = Object.entries( params ?? {} ).reduce(
+ ( result, [ key, value ] ) => result.replace( `$${ key }`, String( value ) ),
+ to
+ );
+ const query = new URLSearchParams();
+ Object.entries( search ?? {} ).forEach( ( [ key, value ] ) => {
+ if ( value !== undefined && value !== null ) {
+ query.set( key, String( value ) );
+ }
+ } );
+ const queryString = query.toString();
+
+ return (
+
+ { children }
+
+ );
+ },
+} ) );
+
+describe( 'PostTitleLink', () => {
+ it( 'routes a row with a post ID to the detail page, carrying the report window', () => {
+ render(
+
+ );
+
+ const link = screen.getByRole( 'link', { name: 'Hello world' } );
+ const href = link.getAttribute( 'href' ) ?? '';
+ const search = new URL( href, 'https://example.com' ).searchParams;
+
+ expect( link ).toHaveAttribute( 'href', expect.stringContaining( '/post/41' ) );
+ expect( search.get( 'from' ) ).toBe( '2026-03-01' );
+ expect( search.get( 'to' ) ).toBe( '2026-03-10' );
+ expect( search.get( 'post_url' ) ).toBe( 'https://example.com/hello-world/' );
+ } );
+
+ it( 'gives an internal link no outbound target and no external marker', () => {
+ render( );
+
+ const link = screen.getByRole( 'link', { name: 'Hello world' } );
+ expect( link ).not.toHaveAttribute( 'target' );
+ expect( link ).not.toHaveAttribute( 'rel' );
+ expect( screen.queryByRole( 'img', { name: '(opens in a new tab)' } ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'falls back to the public URL with an external marker when there is no post ID', () => {
+ render( );
+
+ const link = screen.getByRole( 'link', { name: 'Pricing(opens in a new tab)' } );
+ expect( link ).toHaveAttribute( 'href', 'https://example.com/?s=pricing' );
+ expect( link ).toHaveAttribute( 'target', '_blank' );
+ expect( link ).toHaveAttribute( 'rel', 'noopener noreferrer' );
+ expect( screen.getByRole( 'img', { name: '(opens in a new tab)' } ) ).toBeInTheDocument();
+ } );
+
+ it( 'treats the homepage entry (id 0) as having no detail page', () => {
+ render( );
+
+ expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument();
+ expect( screen.getByText( 'Homepage (Latest posts)' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders plain text when the fallback URL has an unsupported scheme', () => {
+ render( );
+
+ expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument();
+ expect( screen.getByText( 'Sketchy' ) ).toBeInTheDocument();
+ } );
+} );
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-title-link/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-title-link/index.ts
new file mode 100644
index 000000000000..0cc4fd6272ab
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-title-link/index.ts
@@ -0,0 +1 @@
+export { PostTitleLink, POST_URL_SEARCH_PARAM, type PostTitleLinkProps } from './post-title-link';
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-title-link/post-title-link.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-title-link/post-title-link.tsx
new file mode 100644
index 000000000000..54602d7cc03d
--- /dev/null
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-title-link/post-title-link.tsx
@@ -0,0 +1,132 @@
+/**
+ * External dependencies
+ */
+import { safeHttpUrl } from '@jetpack-premium-analytics/ui';
+import { Link } from '@wordpress/route';
+import { Link as UiLink } from '@wordpress/ui';
+
+/**
+ * Route search param carrying a row's public URL to the post detail page.
+ *
+ * A public post type that sets `show_in_rest: false` has no core-data entity,
+ * so the detail page cannot resolve its permalink. The report row does have a
+ * URL, so it travels with the link and the detail page validates it against the
+ * site before offering it.
+ */
+export const POST_URL_SEARCH_PARAM = 'post_url';
+
+export type PostTitleLinkProps = {
+ /**
+ * Post or page ID. Rows carrying one link to the internal detail route.
+ */
+ id?: number | string;
+
+ /**
+ * Visible row title.
+ */
+ label: string;
+
+ /**
+ * Public URL of the content. It becomes the link itself when there is no
+ * post ID, and travels to the detail route as a fallback when there is one.
+ */
+ link?: string | null;
+
+ /**
+ * Search parameters for the detail route. Pass the shared report window from
+ * `pickReportDateParams()` so the detail page opens on the same date range.
+ */
+ search?: Record< string, unknown >;
+
+ /**
+ * Optional classes for each rendering branch. `text` applies to the label
+ * span in every branch, so one rule can ellipsize the title everywhere.
+ */
+ classNames?: {
+ internal?: string;
+ external?: string;
+ plain?: string;
+ text?: string;
+ };
+
+ /**
+ * Optional native title attribute, for the full text on hover.
+ */
+ title?: string;
+};
+
+/**
+ * Render a post or page title as an internal detail link, an external fallback
+ * link, or plain text.
+ *
+ * Rows with a post ID stay inside the app: they navigate to `/post/{postId}`
+ * through the router, so the dashboard does not reload. They carry no outbound
+ * marker — it marks a destination outside the app, and the detail page holds the
+ * link out to the live post. Such a row still passes its public URL along in
+ * `POST_URL_SEARCH_PARAM`, because the detail page cannot resolve a permalink
+ * for a post type that is absent from the REST API. Rows without a post ID have
+ * no detail page, so the public URL becomes the link itself and takes the
+ * marker.
+ *
+ * @param props - Component props.
+ * @param props.id - Post or page ID.
+ * @param props.label - Visible row title.
+ * @param props.link - Public URL, used only without a post ID.
+ * @param props.search - Search parameters for the detail route.
+ * @param props.classNames - Optional classes for each rendering branch.
+ * @param props.title - Optional native title attribute.
+ * @return The linked or plain post title.
+ */
+export function PostTitleLink( {
+ id,
+ label,
+ link,
+ search,
+ classNames,
+ title,
+}: PostTitleLinkProps ): JSX.Element {
+ const postId = Number( id );
+ const text = { label };
+ // Callers pass `link` straight from report data, so the scheme is guarded
+ // here at the sink rather than in each consuming widget or route.
+ const href = safeHttpUrl( link );
+
+ if ( Number.isInteger( postId ) && postId > 0 ) {
+ return (
+
+ { text }
+
+ );
+ }
+
+ if ( href ) {
+ // `openInNewTab` appends the design system's outbound marker, so the row
+ // carries the same arrow as every other external link in the dashboard.
+ return (
+
+ { text }
+
+ );
+ }
+
+ return (
+
+ { text }
+
+ );
+}
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/index.ts
index 8756d2bd82de..d5e32beb3a3a 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/index.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/index.ts
@@ -1,7 +1,6 @@
export { useAttributesWithSearchFallback } from './use-attributes-with-search-fallback';
export { useChartTheme, type WooChartTheme } from './use-chart-theme';
export { useElementSize, type ElementSize } from './use-element-size';
-export { usePostDetailHrefBuilder } from './use-post-detail-href';
export { useSegmentStyles } from '../widgets/common';
export { useSeriesStyles } from './use-series-styles';
export { useWidgetDrillDown } from './use-widget-drill-down';
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-post-detail-href.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-post-detail-href.ts
deleted file mode 100644
index 9db6a249921d..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-post-detail-href.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * External dependencies
- */
-import { pickReportDateParams } from '@jetpack-premium-analytics/routing';
-import { useSearch } from '@wordpress/route';
-/**
- * Internal dependencies
- */
-import { useCallback } from 'react';
-
-/**
- * Build browser hrefs for the analytics post-detail route (`/post/{postId}`),
- * carrying the shared report window (date range + comparison) from the
- * current URL.
- *
- * Returns a plain href rather than router navigation so widgets stay
- * renderable outside a router (Storybook, tests): the app's history encodes
- * the SPA path in the `p` query param (see `@wordpress/boot`'s path history),
- * so a regular anchor pointing at the same admin page with an updated `p`
- * lands on the detail route.
- *
- * @return A builder mapping a post ID to a post-detail href.
- */
-export function usePostDetailHrefBuilder(): ( postId: number | string ) => string {
- let search: Record< string, unknown > | undefined;
- try {
- // eslint-disable-next-line react-hooks/rules-of-hooks -- useSearch may throw outside a matched route
- search = useSearch( { strict: false } ) as Record< string, unknown > | undefined;
- } catch {
- // Rendered outside a router (Storybook, tests): fall back to no params.
- }
-
- return useCallback(
- ( postId: number | string ) => {
- const params = pickReportDateParams( search );
- const query = new URLSearchParams(
- Object.entries( params ).map( ( [ key, value ] ) => [ key, String( value ) ] )
- ).toString();
- const to = `/post/${ postId }${ query ? `?${ query }` : '' }`;
-
- // Outside a browser (SSR, bare test environments) the SPA path is
- // still a usable href.
- if ( typeof window === 'undefined' ) {
- return to;
- }
-
- const browserParams = new URLSearchParams( window.location.search );
- browserParams.set( 'p', to );
-
- return `${ window.location.pathname }?${ browserParams.toString() }`;
- },
- [ search ]
- );
-}
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts
index b2c88eeb6043..b4acff806adc 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts
@@ -53,6 +53,9 @@ export {
type WidgetFooterProps,
ReportLink,
type ReportLinkProps,
+ PostTitleLink,
+ POST_URL_SEARCH_PARAM,
+ type PostTitleLinkProps,
VideoTitleLink,
type VideoTitleLinkProps,
SubscriberList,
@@ -156,7 +159,6 @@ export {
useSegmentStyles,
useSeriesStyles,
useWidgetDrillDown,
- usePostDetailHrefBuilder,
} from './hooks';
/**
diff --git a/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.test.tsx b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.test.tsx
new file mode 100644
index 000000000000..8bd60f76b590
--- /dev/null
+++ b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.test.tsx
@@ -0,0 +1,207 @@
+/**
+ * External dependencies
+ */
+import { useSiteHomeUrl, useStatsPost } from '@jetpack-premium-analytics/data';
+import { renderHook } from '@testing-library/react';
+import { useSelect } from '@wordpress/data';
+import { useSearch } from '@wordpress/route';
+/**
+ * Internal dependencies
+ */
+import { usePostSummary } from './use-post-summary';
+
+jest.mock( '@jetpack-premium-analytics/data', () => ( {
+ useSiteHomeUrl: jest.fn(),
+ useStatsPost: jest.fn(),
+} ) );
+
+// Load the URL guard directly so importing its public UI barrel does not pull
+// unrelated components into this hook-level test.
+jest.mock( '@jetpack-premium-analytics/ui', () =>
+ jest.requireActual( '../../../packages/ui/src/utils/safe-http-url' )
+);
+
+jest.mock( '@jetpack-premium-analytics/widgets-toolkit', () => ( {
+ POST_URL_SEARCH_PARAM: 'post_url',
+} ) );
+
+// The core store is only ever passed straight back into the stubbed `select`,
+// so a token stands in for the real store descriptor.
+jest.mock( '@wordpress/core-data', () => ( { store: 'core' } ) );
+
+// Run each `useSelect` mapper eagerly against a stub registry, so the entity
+// lookups are exercised without mounting a real data store.
+jest.mock( '@wordpress/data', () => ( {
+ useSelect: jest.fn(),
+} ) );
+
+// The route is assembled at runtime; the hook only needs its parsed search
+// object, so provide that boundary directly.
+jest.mock( '@wordpress/route', () => ( {
+ useSearch: jest.fn(),
+} ) );
+
+const mockUseSiteHomeUrl = useSiteHomeUrl as jest.MockedFunction< typeof useSiteHomeUrl >;
+const mockUseStatsPost = useStatsPost as jest.MockedFunction< typeof useStatsPost >;
+const mockUseSelect = useSelect as jest.MockedFunction< typeof useSelect >;
+const mockUseSearch = useSearch as jest.MockedFunction< typeof useSearch >;
+
+const POST_ID = 41;
+
+type EntityKey = string;
+
+/**
+ * Drive `useSelect` with a fixed set of entity records.
+ *
+ * @param records - Entity records keyed as `:`.
+ */
+function mockEntities( records: Record< EntityKey, unknown > ) {
+ const getEntityRecord = ( _kind: string, name: string, key: number ) =>
+ records[ `${ name }:${ key }` ];
+
+ mockUseSelect.mockImplementation( ( mapSelect: ( select: unknown ) => unknown ) =>
+ mapSelect( () => ( { getEntityRecord } ) )
+ );
+}
+
+/**
+ * Mock the Stats `post` payload the header reads its text fields from.
+ *
+ * @param post - The raw post row, or `undefined` for a query with no data.
+ * @param isLoading - Whether the query is still resolving.
+ */
+function mockStatsPost( post?: Record< string, unknown >, isLoading = false ) {
+ mockUseStatsPost.mockReturnValue( {
+ data: post ? { post } : undefined,
+ isLoading,
+ } as unknown as ReturnType< typeof useStatsPost > );
+}
+
+describe( 'usePostSummary', () => {
+ beforeEach( () => {
+ mockUseSiteHomeUrl.mockReset();
+ mockUseStatsPost.mockReset();
+ mockUseSelect.mockReset();
+ mockUseSearch.mockReset();
+ mockUseSiteHomeUrl.mockReturnValue( 'https://example.com/' );
+ mockUseSearch.mockReturnValue( {} as never );
+ } );
+
+ it( 'resolves the title, type, publish date, image, and public URL', () => {
+ mockStatsPost( {
+ post_title: 'Hello world',
+ post_type: 'post',
+ post_date: '2026-06-22 10:00:00',
+ post_date_gmt: '2026-06-22 18:00:00',
+ } );
+ mockEntities( {
+ 'post:41': { featured_media: 7, link: 'https://example.com/hello-world/' },
+ 'attachment:7': {
+ media_details: { sizes: { thumbnail: { source_url: 'https://example.com/thumb.jpg' } } },
+ },
+ } );
+
+ const { result } = renderHook( () => usePostSummary( POST_ID ) );
+
+ expect( result.current ).toEqual( {
+ title: 'Hello world',
+ type: 'post',
+ // The GMT date wins over the local one when both are present.
+ publishedDate: '2026-06-22 18:00:00',
+ imageUrl: 'https://example.com/thumb.jpg',
+ url: 'https://example.com/hello-world/',
+ isLoading: false,
+ } );
+ } );
+
+ it( 'leaves the public URL undefined when the entity carries no link', () => {
+ mockStatsPost( { post_title: 'Hello world', post_type: 'post' } );
+ mockEntities( { 'post:41': { featured_media: 0 } } );
+
+ const { result } = renderHook( () => usePostSummary( POST_ID ) );
+
+ expect( result.current.url ).toBeUndefined();
+ expect( result.current.imageUrl ).toBeUndefined();
+ } );
+
+ it( 'falls back to a same-origin public URL carried by the route', () => {
+ mockStatsPost( { post_title: 'A hidden post type', post_type: 'hidden-type' } );
+ mockEntities( {} );
+ mockUseSearch.mockReturnValue( { post_url: 'https://example.com/hidden-post/' } as never );
+
+ const { result } = renderHook( () => usePostSummary( POST_ID ) );
+
+ expect( result.current.url ).toBe( 'https://example.com/hidden-post/' );
+ } );
+
+ it.each( [
+ [ 'a URL on another origin', 'https://attacker.example/hidden-post/' ],
+ [ 'an unsafe URL', 'javascript:alert(1)' ],
+ ] )( 'rejects %s carried by the route', ( _description, postUrl ) => {
+ mockStatsPost( { post_title: 'A hidden post type', post_type: 'hidden-type' } );
+ mockEntities( {} );
+ mockUseSearch.mockReturnValue( { post_url: postUrl } as never );
+
+ const { result } = renderHook( () => usePostSummary( POST_ID ) );
+
+ expect( result.current.url ).toBeUndefined();
+ } );
+
+ it( 'prefers the entity permalink over a carried URL', () => {
+ mockStatsPost( { post_title: 'Hello world', post_type: 'post' } );
+ mockEntities( {
+ 'post:41': { link: 'https://example.com/canonical/' },
+ } );
+ mockUseSearch.mockReturnValue( { post_url: 'https://example.com/carried-fallback/' } as never );
+
+ const { result } = renderHook( () => usePostSummary( POST_ID ) );
+
+ expect( result.current.url ).toBe( 'https://example.com/canonical/' );
+ } );
+
+ it( 'skips the entity lookups while the post type is unknown', () => {
+ mockStatsPost( undefined, true );
+ mockEntities( { 'post:41': { link: 'https://example.com/hello-world/' } } );
+
+ const { result } = renderHook( () => usePostSummary( POST_ID ) );
+
+ expect( result.current.url ).toBeUndefined();
+ expect( result.current.imageUrl ).toBeUndefined();
+ expect( result.current.isLoading ).toBe( true );
+ } );
+
+ it( 'skips the entity lookups for an invalid post ID', () => {
+ mockStatsPost( { post_title: 'Homepage', post_type: 'post' } );
+ mockEntities( { 'post:0': { link: 'https://example.com/' } } );
+
+ const { result } = renderHook( () => usePostSummary( 0 ) );
+
+ expect( result.current.url ).toBeUndefined();
+ expect( result.current.imageUrl ).toBeUndefined();
+ } );
+
+ it( 'falls back to the full-size media URL when there is no thumbnail size', () => {
+ mockStatsPost( { post_title: 'A page', post_type: 'page', post_date: '2026-06-01 09:00:00' } );
+ mockEntities( {
+ 'page:41': { featured_media: 9, link: 'https://example.com/a-page/' },
+ 'attachment:9': { source_url: 'https://example.com/full.jpg' },
+ } );
+
+ const { result } = renderHook( () => usePostSummary( POST_ID ) );
+
+ expect( result.current.imageUrl ).toBe( 'https://example.com/full.jpg' );
+ // Only the local date is present, so it stands in for the GMT one.
+ expect( result.current.publishedDate ).toBe( '2026-06-01 09:00:00' );
+ expect( result.current.url ).toBe( 'https://example.com/a-page/' );
+ } );
+
+ it( 'leaves the image undefined when the featured media record is missing', () => {
+ mockStatsPost( { post_title: 'Hello world', post_type: 'post' } );
+ mockEntities( { 'post:41': { featured_media: 12, link: 'https://example.com/hello-world/' } } );
+
+ const { result } = renderHook( () => usePostSummary( POST_ID ) );
+
+ expect( result.current.imageUrl ).toBeUndefined();
+ expect( result.current.url ).toBe( 'https://example.com/hello-world/' );
+ } );
+} );
diff --git a/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.ts b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.ts
index 465d0074d2b1..da4b6afeb26f 100644
--- a/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.ts
+++ b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-summary.ts
@@ -1,9 +1,13 @@
/**
* External dependencies
*/
-import { useStatsPost } from '@jetpack-premium-analytics/data';
+import { useStatsPost, useSiteHomeUrl } from '@jetpack-premium-analytics/data';
+import { safeHttpUrl } from '@jetpack-premium-analytics/ui';
+import { POST_URL_SEARCH_PARAM } from '@jetpack-premium-analytics/widgets-toolkit';
import { store as coreStore } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
+import { useMemo } from '@wordpress/element';
+import { useSearch } from '@wordpress/route';
type MediaEntity = {
source_url?: string;
@@ -19,10 +23,49 @@ export type PostSummary = {
publishedDate?: string;
/** Featured image URL, when available. */
imageUrl?: string;
+ /** Public URL of the post/page, when available. */
+ url?: string;
/** Whether the underlying stats request is still resolving. */
isLoading: boolean;
};
+/**
+ * Resolve the public URL a list surface carried into this route.
+ *
+ * A public post type that sets `show_in_rest: false` has no core-data entity
+ * config, so the permalink lookup below can never resolve it. The Stats report
+ * does hold a URL for those rows, so the list surfaces pass it through the
+ * route as a search param.
+ *
+ * That param comes from the address bar, so it is only honoured for an http(s)
+ * URL on the site's own origin. Otherwise a crafted dashboard link could point
+ * the header's link out at any host. The site URL needs `manage_options`, so
+ * where it is unreadable the origin serving the dashboard stands in for it —
+ * that is the site itself on a self-hosted install. A URL that passes neither
+ * check resolves to `undefined`, and the header simply offers no link out.
+ *
+ * @return The carried URL when it is safe to use, otherwise `undefined`.
+ */
+function useCarriedPostUrl(): string | undefined {
+ const search = useSearch( { strict: false } ) as Record< string, unknown > | undefined;
+ const siteUrl = useSiteHomeUrl();
+ const carried = search?.[ POST_URL_SEARCH_PARAM ];
+
+ return useMemo( () => {
+ const candidate = safeHttpUrl( carried );
+ if ( ! candidate ) {
+ return undefined;
+ }
+
+ try {
+ const allowedOrigin = siteUrl ? new URL( siteUrl ).origin : window.location.origin;
+ return new URL( candidate ).origin === allowedOrigin ? candidate : undefined;
+ } catch {
+ return undefined;
+ }
+ }, [ carried, siteUrl ] );
+}
+
/**
* Resolve the header summary for a single post/page.
*
@@ -67,11 +110,39 @@ export function usePostSummary( postId: number ): PostSummary {
[ postId, type ]
);
+ // The public URL is not part of the Stats payload, so it comes from the same
+ // post entity the featured image is read from — already resolved, so this
+ // adds no request. Kept as its own selector so the mapped value stays a
+ // primitive and cannot re-render the header on every store change.
+ const url = useSelect(
+ select => {
+ if ( ! type || ! Number.isInteger( postId ) || postId <= 0 ) {
+ return undefined;
+ }
+
+ const core = select( coreStore ) as unknown as {
+ getEntityRecord: ( kind: string, name: string, key: number ) => unknown;
+ };
+
+ const entity = core.getEntityRecord( 'postType', type, postId ) as
+ | { link?: string }
+ | undefined;
+
+ return entity?.link;
+ },
+ [ postId, type ]
+ );
+
+ const carriedUrl = useCarriedPostUrl();
+
return {
title: post?.post_title,
type,
publishedDate: post?.post_date_gmt ?? post?.post_date,
imageUrl,
+ // The entity permalink is authoritative; the carried URL only covers the
+ // post types core data cannot resolve.
+ url: url ?? carriedUrl,
isLoading,
};
}
diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.module.scss b/projects/packages/premium-analytics/routes/post-detail/stage.module.scss
index f8a354f584d2..671a9afcde91 100644
--- a/projects/packages/premium-analytics/routes/post-detail/stage.module.scss
+++ b/projects/packages/premium-analytics/routes/post-detail/stage.module.scss
@@ -36,3 +36,12 @@
padding-block: var(--wpds-dimension-gap-lg) var(--wpds-dimension-gap-2xl);
padding-inline: var(--wpds-dimension-padding-2xl);
}
+
+// The header action is a Button rendered as an anchor, so it can carry a real
+// href to the live post. @wordpress/ui styles the button inside `@layer wp-ui`,
+// and wp-admin colours bare anchors outside any cascade layer — unlayered wins
+// over layered whatever the specificity, so admin's link blue overrode the
+// button foreground. Re-apply the button's own colour from here (unlayered).
+.viewPost:any-link {
+ color: var(--wp-ui-button-foreground-color);
+}
diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.test.tsx b/projects/packages/premium-analytics/routes/post-detail/stage.test.tsx
new file mode 100644
index 000000000000..aae5d0d13fe3
--- /dev/null
+++ b/projects/packages/premium-analytics/routes/post-detail/stage.test.tsx
@@ -0,0 +1,162 @@
+import { render, screen } from '@testing-library/react';
+import { usePostSummary } from './hooks';
+import { stage } from './stage';
+import type { ReactNode } from 'react';
+
+jest.mock( '@jetpack-premium-analytics/data', () => ( {
+ AnalyticsQueryClientProvider: ( { children }: { children: ReactNode } ) => <>{ children }>,
+ GlobalErrorProvider: ( { children }: { children: ReactNode } ) => <>{ children }>,
+} ) );
+
+jest.mock( '@jetpack-premium-analytics/routing', () => ( {
+ useDashboardLink: () => '/?from=2026-06-01&to=2026-06-16',
+ useReportDateFilters: () => ( {} ),
+} ) );
+
+jest.mock( '@jetpack-premium-analytics/ui', () => ( {
+ DateFiltersPanel: () => Date filters
,
+ SectionTabPanel: ( { children }: { children: ReactNode } ) => { children }
,
+ // The real guard is covered in the ui package; keep the scheme check here so
+ // the header still refuses a non-http URL.
+ safeHttpUrl: ( value?: string | null ) =>
+ value && /^https?:\/\//.test( value ) ? value : undefined,
+} ) );
+
+jest.mock( '@wordpress/core-data', () => ( { store: {} } ) );
+
+jest.mock( '@wordpress/data', () => ( { useSelect: () => [] } ) );
+
+jest.mock( '@wordpress/widget-dashboard', () => {
+ const WidgetDashboard = ( { children }: { children: ReactNode } ) => <>{ children }>;
+ WidgetDashboard.Widgets = () => Post widgets
;
+
+ return {
+ WidgetDashboard,
+ DEFAULT_GRID: {},
+ ROW_HEIGHT_PRESETS: { small: 200 },
+ };
+} );
+
+jest.mock( '@wordpress/widget-primitives', () => ( {
+ useWidgetTypes: () => [ [], false ],
+} ) );
+
+jest.mock( '@wordpress/admin-ui', () => ( {
+ Breadcrumbs: ( { items }: { items: Array< { label: string } > } ) => (
+
+ ),
+ Page: ( {
+ breadcrumbs,
+ actions,
+ children,
+ }: {
+ breadcrumbs: ReactNode;
+ actions: ReactNode;
+ children: ReactNode;
+ } ) => (
+
+ { breadcrumbs }
+ { actions }
+ { children }
+
+ ),
+} ) );
+
+jest.mock( '@wordpress/route', () => ( {
+ useParams: () => ( { postId: '41' } ),
+} ) );
+
+jest.mock( './components', () => ( {
+ PostDetailTabs: ( { children }: { children: ReactNode } ) => { children }
,
+ PostSummaryCard: () => Post summary
,
+} ) );
+
+jest.mock( './hooks', () => ( {
+ usePostSummary: jest.fn(),
+ usePostDetailTabs: () => ( {
+ tabs: [],
+ activeTab: 'traffic',
+ setActiveTab: jest.fn(),
+ layout: [],
+ } ),
+} ) );
+
+const mockUsePostSummary = usePostSummary as jest.Mock;
+
+/**
+ * Stub the post summary hook, defaulting to a resolved post with a public URL.
+ *
+ * @param overrides - Fields to override on the default summary.
+ */
+function mockSummary( overrides: Record< string, unknown > = {} ) {
+ mockUsePostSummary.mockReturnValue( {
+ title: 'Hello world',
+ type: 'post',
+ publishedDate: '2026-06-22 18:00:00',
+ imageUrl: undefined,
+ url: 'https://example.com/hello-world/',
+ isLoading: false,
+ ...overrides,
+ } );
+}
+
+describe( 'post detail stage', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'puts a View post action in the page header, opening the live post in a new tab', () => {
+ mockSummary();
+
+ render( stage() );
+
+ const action = screen.getByRole( 'link', { name: 'View post' } );
+ expect( action ).toHaveAttribute( 'href', 'https://example.com/hello-world/' );
+ expect( action ).toHaveAttribute( 'target', '_blank' );
+ expect( action ).toHaveAttribute( 'rel', 'noopener noreferrer' );
+ } );
+
+ it( 'labels the action View page for a page', () => {
+ mockSummary( { type: 'page', url: 'https://example.com/about/' } );
+
+ render( stage() );
+
+ expect( screen.getByRole( 'link', { name: 'View page' } ) ).toHaveAttribute(
+ 'href',
+ 'https://example.com/about/'
+ );
+ expect( screen.queryByRole( 'link', { name: 'View post' } ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'omits the action while the post URL is unresolved', () => {
+ mockSummary( { url: undefined } );
+
+ render( stage() );
+
+ expect( screen.queryByRole( 'link', { name: /^View (post|page)$/ } ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'omits the action when the post URL carries an unsupported scheme', () => {
+ mockSummary( { url: 'javascript:alert(1)' } );
+
+ render( stage() );
+
+ expect( screen.queryByRole( 'link', { name: /^View (post|page)$/ } ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders the breadcrumb trail with the resolved title', () => {
+ mockSummary();
+
+ render( stage() );
+
+ const nav = screen.getByRole( 'navigation', { name: 'Breadcrumbs' } );
+ expect( nav ).toHaveTextContent( 'Stats' );
+ expect( nav ).toHaveTextContent( 'Hello world' );
+ } );
+} );
diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.tsx b/projects/packages/premium-analytics/routes/post-detail/stage.tsx
index bcbba8d4eced..83979ffc3d44 100644
--- a/projects/packages/premium-analytics/routes/post-detail/stage.tsx
+++ b/projects/packages/premium-analytics/routes/post-detail/stage.tsx
@@ -1,12 +1,13 @@
import { AnalyticsQueryClientProvider, GlobalErrorProvider } from '@jetpack-premium-analytics/data';
import { useDashboardLink, useReportDateFilters } from '@jetpack-premium-analytics/routing';
-import { DateFiltersPanel, SectionTabPanel } from '@jetpack-premium-analytics/ui';
+import { DateFiltersPanel, SectionTabPanel, safeHttpUrl } from '@jetpack-premium-analytics/ui';
import { Breadcrumbs, Page } from '@wordpress/admin-ui';
import { store as coreStore } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
import { useMemo, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useParams } from '@wordpress/route';
+import { Button } from '@wordpress/ui';
import { DEFAULT_GRID, ROW_HEIGHT_PRESETS, WidgetDashboard } from '@wordpress/widget-dashboard';
import { useWidgetTypes, type WidgetModuleRecord } from '@wordpress/widget-primitives';
import { PostDetailTabs, PostSummaryCard } from './components';
@@ -46,6 +47,8 @@ function PostDetail(): JSX.Element {
const summary = usePostSummary( postId );
+ const publicUrl = safeHttpUrl( summary.url );
+
const widgetModules = useSelect(
select =>
(
@@ -116,6 +119,23 @@ function PostDetail(): JSX.Element {
] }
/>
}
+ actions={
+ publicUrl ? (
+ }
+ >
+ { summary.type === 'page'
+ ? __( 'View page', 'jetpack-premium-analytics-pkg' )
+ : __( 'View post', 'jetpack-premium-analytics-pkg' ) }
+
+ ) : undefined
+ }
className={ styles.page }
>
diff --git a/projects/packages/premium-analytics/routes/reports/posts/config/fields.test.tsx b/projects/packages/premium-analytics/routes/reports/posts/config/fields.test.tsx
index 394a30e574ca..ac913b6e079f 100644
--- a/projects/packages/premium-analytics/routes/reports/posts/config/fields.test.tsx
+++ b/projects/packages/premium-analytics/routes/reports/posts/config/fields.test.tsx
@@ -20,18 +20,32 @@ jest.mock( '@jetpack-premium-analytics/data', () => ( {
} ) );
// The router is built dynamically at runtime, so a field-level test has no
-// router to mount. Render `Link` as the anchor it becomes, keeping `to`/
-// `params` assertable, matching the other report field tests.
+// router to mount. Render `Link` as the anchor it becomes, keeping `to`,
+// `params`, and `search` assertable, matching the other report field tests.
jest.mock( '@wordpress/route', () => ( {
Link: ( {
to,
params,
+ search,
children,
}: {
to: string;
params: Record< string, string >;
+ search?: Record< string, unknown >;
children: ReactNode;
- } ) => params[ key ] ) }>{ children },
+ } ) => {
+ const path = to.replace( /\$(\w+)/g, ( _match, key ) => params[ key ] );
+ const query = new URLSearchParams();
+ Object.entries( search ?? {} ).forEach( ( [ key, value ] ) => {
+ if ( value !== undefined && value !== null ) {
+ query.set( key, String( value ) );
+ }
+ } );
+ const queryString = query.toString();
+
+ return { children };
+ },
+ useSearch: () => ( { from: '2026-03-01', to: '2026-03-10', interval: 'day' } ),
} ) );
const mockUseSiteHomeUrl = useSiteHomeUrl as jest.MockedFunction< typeof useSiteHomeUrl >;
@@ -128,13 +142,14 @@ describe( 'posts title field', () => {
renderTitleField( homepage );
+ // The homepage has no post-detail page, so its title is the outbound link
+ // and carries the external-link marker.
const link = screen.getByRole( 'link', {
- name: /Homepage \(Latest posts\).*opens in a new tab/i,
+ name: 'Homepage (Latest posts)(opens in a new tab)',
} );
expect( link ).toHaveAttribute( 'href', 'https://example.com/' );
expect( link ).toHaveAttribute( 'target', '_blank' );
expect( link ).toHaveAttribute( 'rel', 'noopener noreferrer' );
- expect( screen.getByRole( 'img', { name: '(opens in a new tab)' } ) ).toBeInTheDocument();
} );
it( 'renders plain text when the site home URL is unavailable', () => {
@@ -159,7 +174,7 @@ describe( 'posts title field', () => {
expect( screen.queryByText( '+61%' ) ).not.toBeInTheDocument();
} );
- it( 'drills a row with a post ID into the post detail page', () => {
+ it( 'drills a row with a post ID into the post detail page, carrying the date range', () => {
renderTitleField( {
id: 42,
label: 'Hello world',
@@ -168,22 +183,43 @@ describe( 'posts title field', () => {
type: 'post',
} );
- expect( screen.getByRole( 'link', { name: 'Hello world' } ) ).toHaveAttribute(
- 'href',
- '/post/42'
- );
+ const link = screen.getByRole( 'link', { name: 'Hello world' } );
+ const href = link.getAttribute( 'href' ) ?? '';
+ const search = new URL( href, 'https://example.com' ).searchParams;
+
+ expect( href ).toContain( '/post/42' );
+ // The detail page must open on the range the report is showing, not
+ // reseed its own defaults.
+ expect( search.get( 'from' ) ).toBe( '2026-03-01' );
+ expect( search.get( 'to' ) ).toBe( '2026-03-10' );
+ expect( search.get( 'post_url' ) ).toBe( 'https://example.com/hello-world/' );
+ // A row with a detail page carries no link out to the live post.
+ expect( link ).not.toHaveAttribute( 'target' );
} );
- // Every row the API returns carries an ID, and the ID-less homepage row is
- // caught by the branch above, so this only guards against a malformed row
- // linking to `/post/undefined`.
- it( 'renders a row with no post ID as plain text rather than a broken link', () => {
+ // Guards against a malformed row linking to `/post/undefined`.
+ it( 'renders a row with no post ID and no URL as plain text rather than a broken link', () => {
renderTitleField( { label: 'Uncategorized', views: 3, link: null, type: 'post' } );
expect( screen.getByText( 'Uncategorized' ) ).toBeInTheDocument();
expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument();
} );
+ it( 'falls back to the public URL when a row has no post ID', () => {
+ renderTitleField( {
+ label: 'Uncategorized',
+ views: 3,
+ link: 'https://example.com/uncategorized/',
+ type: 'post',
+ } );
+
+ const link = screen.getByRole( 'link', {
+ name: 'Uncategorized(opens in a new tab)',
+ } );
+ expect( link ).toHaveAttribute( 'href', 'https://example.com/uncategorized/' );
+ expect( link ).toHaveAttribute( 'target', '_blank' );
+ } );
+
it( 'shows the archives views delta when comparison is enabled', () => {
renderArchiveViewsField(
{
diff --git a/projects/packages/premium-analytics/routes/reports/posts/config/fields.tsx b/projects/packages/premium-analytics/routes/reports/posts/config/fields.tsx
index a65ee4b0701f..a37eaf1a3053 100644
--- a/projects/packages/premium-analytics/routes/reports/posts/config/fields.tsx
+++ b/projects/packages/premium-analytics/routes/reports/posts/config/fields.tsx
@@ -7,11 +7,13 @@ import {
type StatsArchivesItem,
type StatsTopPostsComparisonItem,
} from '@jetpack-premium-analytics/data';
+import { pickReportDateParams } from '@jetpack-premium-analytics/routing';
import { safeHttpUrl } from '@jetpack-premium-analytics/ui';
-import { MetricWithComparison } from '@jetpack-premium-analytics/widgets-toolkit';
+import { MetricWithComparison, PostTitleLink } from '@jetpack-premium-analytics/widgets-toolkit';
import { __ } from '@wordpress/i18n';
-import { Link as RouteLink } from '@wordpress/route';
+import { useSearch } from '@wordpress/route';
import { Link as UiLink } from '@wordpress/ui';
+import { useMemo } from 'react';
import type { Field } from '@wordpress/dataviews';
const VIEWS_DATA_FORMAT = {
@@ -20,23 +22,34 @@ const VIEWS_DATA_FORMAT = {
} as const;
/**
- * Render the homepage title using the URL from core site settings.
+ * Render a post row's title. Rows with an ID drill into the internal post/page
+ * detail page, carrying the report's current date window so the detail page
+ * opens on the range being inspected; the public URL is the external fallback
+ * for rows without one.
*
- * @param props - Component props.
- * @param props.title - The homepage row title.
- * @return The linked title, or plain text while settings are unavailable.
+ * The API sends no URL for homepage rows, so they fall back to the site home
+ * resolved from core settings. They never take the detail page: the homepage
+ * is not a single post.
+ *
+ * @param props - Component props.
+ * @param props.item - The posts report row.
+ * @return The linked or plain post title.
*/
-function HomepageTitle( { title }: { title: string } ) {
+function PostTitle( { item }: { item: StatsTopPostsComparisonItem } ) {
+ const search = useSearch( { strict: false } ) as Record< string, unknown > | undefined;
+ const detailSearch = useMemo( () => pickReportDateParams( search ), [ search ] );
const homeUrl = useSiteHomeUrl();
- if ( ! homeUrl ) {
- return <>{ title }>;
- }
+ const isHomepage = item.type === 'homepage';
+ const title = String( item.label ?? '' );
return (
-
- { title }
-
+
);
}
@@ -58,29 +71,7 @@ export function getPostsFields( withComparison = false ): Field< StatsTopPostsCo
enableGlobalSearch: true,
enableHiding: false,
getValue: ( { item } ) => String( item.label ?? '' ),
- render: ( { item } ) => {
- const title = String( item.label ?? '' );
-
- // The API sends no URL for homepage rows, so link them to the site
- // home resolved from core settings. Posts with an ID drill into the
- // post/page detail page; other rows without an ID stay plain text.
- if ( item.type === 'homepage' ) {
- return ;
- }
-
- if ( ! item.id ) {
- return <>{ title }>;
- }
-
- return (
-
- { title }
-
- );
- },
+ render: ( { item } ) => ,
},
{
id: 'views',
diff --git a/projects/packages/premium-analytics/widgets/stories/with-story-router.tsx b/projects/packages/premium-analytics/widgets/stories/with-story-router.tsx
index 11e81ccbd33a..5311ff019106 100644
--- a/projects/packages/premium-analytics/widgets/stories/with-story-router.tsx
+++ b/projects/packages/premium-analytics/widgets/stories/with-story-router.tsx
@@ -16,15 +16,20 @@ const reportRoute = createRoute( {
getParentRoute: () => rootRoute,
path: '/reports/$report',
} );
+const postDetailRoute = createRoute( {
+ getParentRoute: () => rootRoute,
+ path: '/post/$postId',
+} );
const storyRouter = createRouter( {
- routeTree: rootRoute.addChildren( [ reportRoute ] ),
+ routeTree: rootRoute.addChildren( [ reportRoute, postDetailRoute ] ),
history: createMemoryHistory( { initialEntries: [ '/' ] } ),
// TanStack's options type requires strictNullChecks, which this package does not enable.
} as never );
/**
- * Provides the router context that widget footer ReportLinks need in Storybook.
- * The report route lets those links build the same real href shape as the app.
+ * Provides the router context that widget links need in Storybook. The report
+ * and post-detail routes let `ReportLink` and `PostTitleLink` build the same
+ * real href shapes as the app.
*/
export function StoryRouterProvider( { children }: { children: ReactNode } ) {
return { children };
diff --git a/projects/packages/premium-analytics/widgets/top-posts/__tests__/top-posts.test.tsx b/projects/packages/premium-analytics/widgets/top-posts/__tests__/top-posts.test.tsx
index a11fcca7a76d..1df2ad9a215b 100644
--- a/projects/packages/premium-analytics/widgets/top-posts/__tests__/top-posts.test.tsx
+++ b/projects/packages/premium-analytics/widgets/top-posts/__tests__/top-posts.test.tsx
@@ -81,26 +81,45 @@ describe( 'TopPostsWidget', () => {
mockApiFetch.mockResolvedValue( TOP_POSTS_RESPONSE );
} );
- it( 'links titles to the post-detail page and appends an external link icon', async () => {
+ it( 'routes post titles to the post-detail page with no outbound link', async () => {
render( );
- // The title links to the internal analytics post-detail route (the SPA
- // path rides in the `p` query param), in the same tab.
+ // The title navigates through the router to the internal post-detail
+ // route, so the dashboard does not reload.
const titleLink = await screen.findByRole( 'link', { name: /^Hello World Post$/ } );
- expect( titleLink ).toHaveAttribute( 'href', expect.stringContaining( 'p=%2Fpost%2F1' ) );
+ expect( titleLink ).toHaveAttribute( 'href', expect.stringContaining( '/post/1' ) );
+ expect( titleLink ).not.toHaveAttribute( 'target' );
- // The trailing icon opens the public page in a new tab.
- const externalLink = screen.getByRole( 'link', {
- name: /open hello world post in a new tab/i,
- } );
- expect( externalLink ).toHaveAttribute( 'href', 'https://example.com/hello-world/' );
- expect( externalLink ).toHaveAttribute( 'target', '_blank' );
- expect( externalLink ).toHaveAttribute( 'rel', 'noopener noreferrer' );
- expect( screen.queryByLabelText( '(opens in a new tab)' ) ).not.toBeInTheDocument();
+ // A row with a detail page carries no link out to the live post: the
+ // external-link icon marks destinations outside the app, and the detail
+ // page holds that link.
+ expect(
+ screen.queryByRole( 'link', { name: /open hello world post in a new tab/i } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'link', { name: 'https://example.com/hello-world/' } )
+ ).not.toBeInTheDocument();
expect( screen.getByText( 'About Page' ) ).toBeInTheDocument();
} );
+ it( 'carries the dashboard date range into the post-detail link', async () => {
+ render(
+
+ );
+
+ const titleLink = await screen.findByRole( 'link', { name: /^Hello World Post$/ } );
+ const href = titleLink.getAttribute( 'href' ) ?? '';
+ const search = new URL( href, 'https://example.com' ).searchParams;
+
+ expect( href ).toContain( '/post/1' );
+ expect( search.get( 'from' ) ).toBe( '2026-03-01' );
+ expect( search.get( 'to' ) ).toBe( '2026-03-10' );
+ expect( search.get( 'post_url' ) ).toBe( 'https://example.com/hello-world/' );
+ } );
+
it( 'requests the dashboard date range from report params', async () => {
render(
{
// the trailing icon links out to the archive page.
await expect( screen.findByText( 'pricing' ) ).resolves.toBeInTheDocument();
expect( screen.queryByRole( 'link', { name: /^pricing$/ } ) ).not.toBeInTheDocument();
- const termLink = screen.getByRole( 'link', { name: /open pricing in a new tab/i } );
+ const termLink = screen.getByRole( 'link', {
+ name: /pricing.*opens in a new tab/i,
+ } );
expect( termLink ).toHaveAttribute( 'href', 'https://example.com/?s=pricing' );
const backLink = screen.getByRole( 'button', { name: /back to the previous archive list/i } );
@@ -688,7 +709,7 @@ describe( 'TopPostsWidget', () => {
await expect( screen.findByText( 'News' ) ).resolves.toBeInTheDocument();
expect( screen.queryByRole( 'link', { name: /^News$/ } ) ).not.toBeInTheDocument();
- const termLink = screen.getByRole( 'link', { name: /open news in a new tab/i } );
+ const termLink = screen.getByRole( 'link', { name: /news.*opens in a new tab/i } );
expect( termLink ).toHaveAttribute( 'href', 'https://example.com/category/news/' );
} );
} );
diff --git a/projects/packages/premium-analytics/widgets/top-posts/package.json b/projects/packages/premium-analytics/widgets/top-posts/package.json
index a46663d0b91c..0e380bbb5d80 100644
--- a/projects/packages/premium-analytics/widgets/top-posts/package.json
+++ b/projects/packages/premium-analytics/widgets/top-posts/package.json
@@ -6,6 +6,7 @@
"dependencies": {
"@jetpack-premium-analytics/data": "link:../../packages/data",
"@jetpack-premium-analytics/icons": "link:../../packages/icons",
+ "@jetpack-premium-analytics/routing": "link:../../packages/routing",
"@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit",
"@wordpress/i18n": "^6.9.0",
"@wordpress/icons": "^15.0.0",
diff --git a/projects/packages/premium-analytics/widgets/top-posts/render.tsx b/projects/packages/premium-analytics/widgets/top-posts/render.tsx
index f43744bb5f8d..995f2afef0c5 100644
--- a/projects/packages/premium-analytics/widgets/top-posts/render.tsx
+++ b/projects/packages/premium-analytics/widgets/top-posts/render.tsx
@@ -8,9 +8,10 @@ import {
type StatsTopPostsComparisonItem,
} from '@jetpack-premium-analytics/data';
import { reports } from '@jetpack-premium-analytics/icons';
-import { Icon, external } from '@wordpress/icons';
+import { pickReportDateParams } from '@jetpack-premium-analytics/routing';
import {
LeaderboardChart,
+ PostTitleLink,
ReportLink,
RowsCsvDownloadButton,
WidgetBackLink,
@@ -21,7 +22,6 @@ import {
getCombinedPeriodMax,
safeHttpUrl,
sharePercentage,
- usePostDetailHrefBuilder,
useReportCsvExport,
useWidgetDrillDown,
useWidgetRootContext,
@@ -30,7 +30,7 @@ import {
type ReportParamsFieldAttributes,
} from '@jetpack-premium-analytics/widgets-toolkit';
import { __, sprintf } from '@wordpress/i18n';
-import { Link, Text } from '@wordpress/ui';
+import { Text } from '@wordpress/ui';
import { useCallback, useEffect, useMemo } from 'react';
/**
* Internal dependencies
@@ -59,15 +59,14 @@ export type TopPostRow = {
*/
previousValue?: number;
/**
- * Public URL of the content (post, page, or archive page). Rows with one
- * get a trailing external-link icon that opens it in a new tab.
+ * Public URL of the content (post, page, or archive page). Only used when
+ * the row has no post ID, as the fallback link out to the site.
*/
href?: string;
/**
- * Internal analytics post-detail href. Only post/page rows with a real
- * post ID get one; their title links here (same tab).
+ * Post or page ID. Rows carrying one link to the internal detail page.
*/
- detailHref?: string;
+ postId?: number;
/**
* Post type, e.g. `post` or `page`.
*/
@@ -94,21 +93,23 @@ const DATA_FORMAT = { type: 'number' as const, options: { useMultipliers: true,
* and per-row deltas are derived from each row's `previousValue`; otherwise
* the comparison fields are zeroed.
*
- * Post/page titles link to the internal analytics post-detail page (same
- * tab); rows with a public URL also get a trailing external-link icon that
- * opens the site in a new tab. Rows with children instead become drill-down
- * rows (per the widget drill-down convention they carry no anchors), and
- * rows with neither (e.g. the homepage entry) render plain text. The label
- * fills its row so the leaderboard overlay bar gets its height from it.
+ * Titles route through `PostTitleLink`: post/page rows navigate to the
+ * internal detail page through the router, and rows without a post ID (the
+ * Archives view) fall back to the public URL and take the external-link icon.
+ * Rows with children instead become drill-down rows (per the widget
+ * drill-down convention they carry no anchors). The label fills its row so
+ * the leaderboard overlay bar gets its height from it.
*
* @param rows - The normalized top-posts rows.
* @param withComparison - Whether to derive previous-period shares and deltas.
+ * @param detailSearch - Shared report-window parameters for the detail route.
* @param onDrillDown - Callback fired when a row with children is selected.
* @return The leaderboard chart data.
*/
function buildLeaderboardData(
rows: TopPostRow[],
withComparison: boolean,
+ detailSearch: Record< string, unknown >,
onDrillDown?: ( row: TopPostRow ) => void
): LeaderboardChartData {
const maxViews = getCombinedPeriodMax(
@@ -119,43 +120,30 @@ function buildLeaderboardData(
return rows.map( ( row, index ) => {
const previousValue = row.previousValue;
const hasChildren = !! row.children?.length;
- // Rows inside a drill-down button cannot carry anchors, and only rows
- // with a real post ID have an internal detail page to link to.
- const titleLink = ! hasChildren && row.detailHref;
- const externalLink = ! hasChildren && row.href;
return {
id: `${ index }-${ row.href ?? row.label }`,
label: (
- { titleLink ? (
-
- { row.label }
-
- ) : (
+ { /* Rows inside a drill-down button cannot carry anchors. */ }
+ { hasChildren ? (
- { row.label }
+ { row.label }
- ) }
- { externalLink && (
- }
- aria-label={ sprintf(
- /* translators: %s is a post, page, or archive page title. */
- __( 'Open %s in a new tab', 'jetpack-premium-analytics-pkg' ),
- row.label
- ) }
- >
-
-
+ ) : (
+
) }
),
@@ -201,6 +189,11 @@ type TopPostsLeaderboardProps = {
* interactive when this is provided.
*/
onDrillDown?: ( row: TopPostRow ) => void;
+ /**
+ * Shared report-window parameters carried into the post-detail route, so
+ * the detail page opens on the date range the widget is showing.
+ */
+ detailSearch?: Record< string, unknown >;
};
/**
@@ -215,10 +208,11 @@ export const TopPostsLeaderboard = ( {
rows = [],
withComparison = false,
onDrillDown,
+ detailSearch = {},
}: TopPostsLeaderboardProps ) => {
return (
string
-): TopPostRow[] {
+function toTopPostRows( items: StatsTopPostsComparisonItem[] ): TopPostRow[] {
return items.map( item => {
const postId = Number( item.id );
const href = safeHttpUrl( item.link );
@@ -253,9 +243,7 @@ function toTopPostRows(
...( item.previousViews !== undefined ? { previousValue: item.previousViews } : {} ),
...( href ? { href } : {} ),
// The homepage entry (id 0) has no post-detail page.
- ...( Number.isFinite( postId ) && postId > 0
- ? { detailHref: buildDetailHref( postId ) }
- : {} ),
+ ...( Number.isFinite( postId ) && postId > 0 ? { postId } : {} ),
type: String( item.type ?? '' ),
};
} );
@@ -288,11 +276,8 @@ function TopPostsReport( { max }: TopPostsReportProps ) {
const { comparisonRows, hasComparison, isLoading, isFetching, isError, refetch } =
useStatsTopPosts( statsParams, { maxRows: max } );
- const buildDetailHref = usePostDetailHrefBuilder();
- const rows = useMemo(
- () => toTopPostRows( comparisonRows?.rows ?? [], buildDetailHref ),
- [ comparisonRows, buildDetailHref ]
- );
+ const rows = useMemo( () => toTopPostRows( comparisonRows?.rows ?? [] ), [ comparisonRows ] );
+ const detailSearch = useMemo( () => pickReportDateParams( reportParams ), [ reportParams ] );
const withComparison = hasComparison;
// Serialize whatever the leaderboard has loaded, mirroring the Jetpack Stats
@@ -352,7 +337,11 @@ function TopPostsReport( { max }: TopPostsReportProps ) {
description: __( 'No views in this period.', 'jetpack-premium-analytics-pkg' ),
} }
>
-
+
diff --git a/projects/packages/premium-analytics/widgets/top-posts/style.module.css b/projects/packages/premium-analytics/widgets/top-posts/style.module.css
index a7c16b53ba4a..c5b69f6e49fa 100644
--- a/projects/packages/premium-analytics/widgets/top-posts/style.module.css
+++ b/projects/packages/premium-analytics/widgets/top-posts/style.module.css
@@ -30,51 +30,35 @@
}
/* `min-width: 0` lets the flex item shrink below its content width so the
- ellipsis applies; without it a long title pushes the trailing icon out. */
+ ellipsis applies; without it a long title pushes the trailing icon out. The
+ outbound marker brings its own inline spacing, so the box needs no gap. */
.labelTitle,
-.labelTitleLink {
+.labelTitleLink,
+.labelExternalLink {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+}
+
+/* The label text carries the ellipsis in every branch: `text-overflow` needs
+ an inline formatting context, which the flex containers above do not give
+ it. */
+.labelText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.labelTitleLink {
+.labelTitleLink,
+.labelExternalLink {
color: inherit;
text-decoration: none;
}
.labelTitleLink:hover,
-.labelTitleLink:focus {
+.labelTitleLink:focus,
+.labelExternalLink:hover,
+.labelExternalLink:focus {
text-decoration: underline;
}
-
-/* Trailing external-link icon: opens the public page in a new tab. Hidden
- until the row is hovered (or holds keyboard focus) so the resting rows read
- as plain titles; opacity keeps the slot in the layout and the target
- tappable, so nothing shifts on reveal. */
-.externalLink {
- display: inline-flex;
- flex: none;
- color: var(--wpds-color-foreground-content-neutral-weak);
- opacity: 0;
- transition: opacity 120ms ease-in-out;
-}
-
-.labelRow:hover .externalLink,
-.labelRow:focus-within .externalLink {
- opacity: 1;
-}
-
-.externalLink:hover,
-.externalLink:focus-visible {
- color: inherit;
-}
-
-/* Touch-capable devices: keep the icon visible so the affordance isn't lost. */
-@media (hover: none), (any-pointer: coarse) {
-
- .externalLink {
- opacity: 1;
- }
-}