Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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, 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,97 @@
/**
* 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' );
} );

it( 'gives an internal link no outbound target and no external icon', () => {
const { container } = 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' );
// The icon marks a destination outside the app; an internal route is not one.
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- the icon renders as an unlabelled SVG.
expect( container.querySelector( 'svg' ) ).toBeNull();
} );

it( 'falls back to the public URL with an external icon when there is no post ID', () => {
const { container } = render(
<PostTitleLink label="Pricing" link="https://example.com/?s=pricing" />
);

const link = screen.getByRole( 'link', { name: 'Open Pricing in a new tab' } );
expect( link ).toHaveAttribute( 'href', 'https://example.com/?s=pricing' );
expect( link ).toHaveAttribute( 'target', '_blank' );
expect( link ).toHaveAttribute( 'rel', 'noopener noreferrer' );
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- the icon renders as an unlabelled SVG.
expect( container.querySelector( 'svg' ) ).not.toBeNull();
} );

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, type PostTitleLinkProps } from './post-title-link';
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* External dependencies
*/
import { safeHttpUrl } from '@jetpack-premium-analytics/ui';
import { __, sprintf } from '@wordpress/i18n';
import { Icon, external } from '@wordpress/icons';
import { Link } from '@wordpress/route';

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. Used only when there is no post ID.
*/
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;
icon?: 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
* external-link icon — the icon marks a destination outside the app, and the
* detail page holds the link out to the live post. Rows without a post ID have
* no detail page, so the public URL becomes the fallback and takes the icon.
*
* @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>;

if ( Number.isInteger( postId ) && postId > 0 ) {
return (
<Link
className={ classNames?.internal }
to="/post/$postId"
params={ { postId: String( postId ) } as unknown as never }
search={ search as unknown as never }
title={ title }
>
{ text }
</Link>
);
}

// 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 ( href ) {
return (
<a
className={ classNames?.external }
href={ href }
target="_blank"
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 }
aria-label={ sprintf(
/* translators: %s is a post, page, or archive page title. */
__( 'Open %s in a new tab', 'jetpack-premium-analytics-pkg' ),
label
) }
>
{ text }
<Icon className={ classNames?.icon } icon={ external } size={ 16 } />
</a>
);
}

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,8 @@ export {
type WidgetFooterProps,
ReportLink,
type ReportLinkProps,
PostTitleLink,
type PostTitleLinkProps,
VideoTitleLink,
type VideoTitleLinkProps,
SubscriberList,
Expand Down Expand Up @@ -156,7 +158,6 @@ export {
useSegmentStyles,
useSeriesStyles,
useWidgetDrillDown,
usePostDetailHrefBuilder,
} from './hooks';

/**
Expand Down
Loading
Loading