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 ? ( + + ) : 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; - } -}