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

Filter by extension

Filter by extension


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

Most viewed: navigate to post details in the app instead of reloading the page.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<a href={ queryString ? `${ path }?${ queryString }` : path } { ...props }>
{ children }
</a>
);
},
} ) );

describe( 'PostTitleLink', () => {
it( 'routes a row with a post ID to the detail page, carrying the report window', () => {
render(
<PostTitleLink
id={ 41 }
label="Hello world"
link="https://example.com/hello-world/"
search={ { from: '2026-03-01', to: '2026-03-10' } }
/>
);

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( <PostTitleLink id={ 41 } label="Hello world" /> );

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( <PostTitleLink label="Pricing" link="https://example.com/?s=pricing" /> );

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( <PostTitleLink id={ 0 } label="Homepage (Latest posts)" /> );

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( <PostTitleLink label="Sketchy" link="javascript:alert(1)" /> );

expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument();
expect( screen.getByText( 'Sketchy' ) ).toBeInTheDocument();
} );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PostTitleLink, POST_URL_SEARCH_PARAM, type PostTitleLinkProps } from './post-title-link';
Original file line number Diff line number Diff line change
@@ -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 = <span className={ classNames?.text }>{ label }</span>;
// 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 (
<Link
className={ classNames?.internal }
to="/post/$postId"
params={ { postId: String( postId ) } as unknown as never }
search={
( href ? { ...search, [ POST_URL_SEARCH_PARAM ]: href } : search ) as unknown as never
}
title={ title }
>
{ text }
</Link>
);
}

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 (
<UiLink
className={ classNames?.external }
href={ href }
variant="unstyled"
openInNewTab
rel="noopener noreferrer"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW just a nit but you don't need to add these in modern browsers anymore. Link and ExternalLink components don't add them anymore either in Gutenberg.

title={ title }
>
{ text }
</UiLink>
);
}

return (
<span className={ classNames?.plain } title={ title }>
{ text }
</span>
);
}
Original file line number Diff line number Diff line change
@@ -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';

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ export {
type WidgetFooterProps,
ReportLink,
type ReportLinkProps,
PostTitleLink,
POST_URL_SEARCH_PARAM,
type PostTitleLinkProps,
VideoTitleLink,
type VideoTitleLinkProps,
SubscriberList,
Expand Down Expand Up @@ -156,7 +159,6 @@ export {
useSegmentStyles,
useSeriesStyles,
useWidgetDrillDown,
usePostDetailHrefBuilder,
} from './hooks';

/**
Expand Down
Loading
Loading