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: minor
Type: added

Insights: add a Popular post widget, and adapt the single-post highlight card to both the width and the height of its dashboard cell.
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type {
export { useStatsPostLikes } from './hooks/use-stats-post-likes';
export type { StatsPostLikesParams, StatsPostLikesResponse } from './hooks/use-stats-post-likes';
export { useStatsQuery } from './hooks/use-stats-query';
export { latestPostQuery } from './queries/latest-post-query';
export { latestPostQuery, postContentQuery } from './queries/latest-post-query';
export type { LatestPost, LatestPostResponse } from './processing/latest-post';
export { useStatsTopPosts } from './hooks/use-stats-top-posts';
export { useStatsReferrers } from './hooks/use-stats-referrers';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export type StatsPostMeta = {
post_date?: string;
post_date_gmt?: string;
post_status?: string;
comment_count?: StatsPostRawNumeric;
comment_count?: number;
};

export type StatsPostRawResponse = {
Expand Down Expand Up @@ -172,6 +172,24 @@ function normalizeStatsPostWeek( value: unknown ): StatsPostWeek {
};
}

/**
* Normalizes the post meta, parsing `comment_count` and leaving an absent count
* absent so consumers can tell unknown from a real zero.
*
* @param value - The raw post meta.
* @return The normalized meta.
*/
function normalizeStatsPostMeta( value: unknown ): StatsPostMeta {
const meta = coerceStatsRecord( value );

return {
...( meta as StatsPostMeta ),
...( meta.comment_count !== undefined
? { comment_count: safeParseFloat( meta.comment_count ) }
: {} ),
};
}

export function sanitizeStatsPostResponse( response: unknown ): StatsPostResponse {
if ( ! isStatsRecord( response ) ) {
return {};
Expand Down Expand Up @@ -202,6 +220,6 @@ export function sanitizeStatsPostResponse( response: unknown ): StatsPostRespons
...( payload.highest_week_average !== undefined
? { highest_week_average: safeParseFloat( payload.highest_week_average ) }
: {} ),
...( payload.post !== undefined ? { post: payload.post as StatsPostMeta } : {} ),
...( payload.post !== undefined ? { post: normalizeStatsPostMeta( payload.post ) } : {} ),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ import type { UseQueryOptions } from '@tanstack/react-query';

export type { LatestPostResponse };

// The headline fields a single-post highlight card needs, plus the embedded
// featured media. Shared by both queries below so they stay in one shape.
const POST_CONTENT_FIELDS =
'id,title,link,date,featured_media,_links.wp:featuredmedia,_embedded.wp:featuredmedia';

const LATEST_POST_PATH = addQueryArgs( '/wp/v2/posts', {
per_page: 1,
status: 'publish',
orderby: 'date',
order: 'desc',
_embed: 'wp:featuredmedia',
_fields: 'id,title,link,date,featured_media,_links.wp:featuredmedia,_embedded.wp:featuredmedia',
_fields: POST_CONTENT_FIELDS,
} );

/**
Expand All @@ -36,3 +41,38 @@ export function latestPostQuery(): UseQueryOptions< LatestPostResponse > {
placeholderData: previousData => previousData,
};
}

/**
* React Query options for one published post's headline content, read locally
* from the core WordPress posts endpoint. Same source and shape as
* `latestPostQuery()`, addressed by ID: report data identifies a post but carries
* no featured image, so a widget highlighting a reported post reads its content
* on-site in a dependent request.
*
* Disabled until a post ID is known, and deliberately without `placeholderData`:
* the key changes with the post, and carrying the previous post's title and image
* over would briefly mislabel the new one.
*
* @param postId - The post to read. Values <= 0 leave the query disabled.
* @return The query options for the post-content request.
*/
export function postContentQuery( postId: number ): UseQueryOptions< LatestPostResponse > {
return {
queryKey: [ 'post-content', postId ],
// The path is built inside the fetcher so `postId` stays its only input,
// which is already part of the query key above.
queryFn: async () =>
sanitizeLatestPostResponse(
await apiFetch( {
path: addQueryArgs( '/wp/v2/posts', {
include: postId,
per_page: 1,
status: 'publish',
_embed: 'wp:featuredmedia',
_fields: POST_CONTENT_FIELDS,
} ),
} )
),
enabled: Number.isInteger( postId ) && postId > 0,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export {
type LeaderboardPostLabelProps,
type LeaderboardPostLabelVariant,
} from './leaderboard-post-label';
export {
PostHighlightCard,
type PostHighlightCardMetric,
type PostHighlightCardProps,
} from './post-highlight-card';
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,172 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
/**
* Internal dependencies
*/
import { PostHighlightCard } from '../post-highlight-card';
import type { PostHighlightCardProps } from '../post-highlight-card';
import type { AnchorHTMLAttributes, ReactNode } from 'react';

type MockRouteLinkProps = {
to: string;
params?: Record< string, unknown >;
search?: Record< string, unknown >;
children: ReactNode;
} & Omit< AnchorHTMLAttributes< HTMLAnchorElement >, 'href' >;

// `forwardRef`, because the design system link that renders this forwards a ref.
jest.mock( '@wordpress/route', () => {
const { forwardRef } = jest.requireActual( 'react' ) as typeof import('react');

return {
Link: forwardRef< HTMLAnchorElement, MockRouteLinkProps >(
( { to, params, search, children, ...props }, ref ) => {
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 ref={ ref } href={ queryString ? `${ path }?${ queryString }` : path } { ...props }>
{ children }
</a>
);
}
),
};
} );

const props: PostHighlightCardProps = {
title: 'Quarterly update',
url: 'https://example.com/quarterly-update/',
date: '2026-06-05T00:00:00+00:00',
metrics: [
{ key: 'views', label: 'Views', value: 42 },
{ key: 'likes', label: 'Likes', value: 3, note: 'All-time total.' },
],
};

describe( 'PostHighlightCard', () => {
it( 'links the title to the detail route and carries the report window', () => {
render(
<PostHighlightCard
{ ...props }
postId={ 12 }
detailSearch={ { from: '2026-06-01', to: '2026-06-30' } }
/>
);

const href = screen.getByRole( 'link', { name: /^Quarterly update/ } ).getAttribute( 'href' );
const url = new URL( href ?? '', 'http://localhost' );

expect( url.pathname ).toBe( '/post/12' );
expect( url.searchParams.get( 'from' ) ).toBe( '2026-06-01' );
expect( url.searchParams.get( 'to' ) ).toBe( '2026-06-30' );
} );

it( 'falls back to the published post when there is no post ID', () => {
render( <PostHighlightCard { ...props } /> );

const link = screen.getByRole( 'link', { name: /^Quarterly update/ } );
expect( link ).toHaveAttribute( 'href', 'https://example.com/quarterly-update/' );
expect( link ).toHaveAttribute( 'target', '_blank' );
} );

it( 'keeps the title as plain text when the post URL is unsafe', () => {
render( <PostHighlightCard { ...props } url="javascript:alert(1)" /> );

expect( screen.getByText( 'Quarterly update' ) ).toBeInTheDocument();
expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument();
} );

it( 'links the title to the post', () => {
render( <PostHighlightCard { ...props } /> );

// `openInNewTab` appends a screen-reader hint to the accessible name.
expect( screen.getByRole( 'link', { name: /^Quarterly update/ } ) ).toHaveAttribute(
'href',
'https://example.com/quarterly-update/'
);
} );

// The title used to be wrapped in `<Link>` unconditionally, so a revert here is plausible.
it( 'keeps the title readable as plain text when the post URL is unsafe', () => {
render( <PostHighlightCard { ...props } url="javascript:alert(1)" /> );

expect( screen.getByText( 'Quarterly update' ) ).toBeInTheDocument();
expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument();
} );

it( 'renders the publish line and the metric tiles', () => {
render( <PostHighlightCard { ...props } /> );

expect( screen.getByText( 'Post published on Jun 5, 2026' ) ).toBeInTheDocument();
expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
expect( screen.getByText( '42' ) ).toBeInTheDocument();
} );

it( 'omits the publish line when the post has no date', () => {
render( <PostHighlightCard { ...props } date="" /> );

expect( screen.queryByText( /^Post published on/ ) ).not.toBeInTheDocument();
} );

// A metric whose request failed must not be shown as a real count: on a
// private site the Stats endpoint 403s, and "Likes 0" would be a wrong number
// rather than a missing one.
it( 'renders an unavailable metric as a dash, not as zero', () => {
render(
<PostHighlightCard
{ ...props }
metrics={ [
{ key: 'views', label: 'Views', value: undefined },
{ key: 'likes', label: 'Likes', value: 0 },
] }
/>
);

expect( screen.getByText( '—' ) ).toBeInTheDocument();
// Spelled out for assistive tech, which may skip the dash entirely.
expect( screen.getByText( 'Not available' ) ).toBeInTheDocument();
// A genuine zero still renders as a number.
expect( screen.getByText( '0' ) ).toBeInTheDocument();
} );

// A lifetime metric shown next to a period-scoped one must say so, and the
// `title` tooltip alone is invisible to assistive technology.
it( 'exposes a metric note as both a tooltip and visually hidden text', () => {
render( <PostHighlightCard { ...props } /> );

expect( screen.getByTitle( 'All-time total.' ) ).toBeInTheDocument();
expect( screen.getByText( 'All-time total.' ) ).toBeInTheDocument();
} );

it( 'renders the featured image only when one is present', () => {
const { rerender } = render( <PostHighlightCard { ...props } /> );

// Scoped by name: `openInNewTab` renders its own `role="img"` link glyph.
expect( screen.queryByRole( 'img', { name: 'Hero image' } ) ).not.toBeInTheDocument();

rerender(
<PostHighlightCard
{ ...props }
imageUrl="https://example.com/hero.jpg"
imageAlt="Hero image"
/>
);

expect( screen.getByRole( 'img', { name: 'Hero image' } ) ).toHaveAttribute(
'src',
'https://example.com/hero.jpg'
);
} );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
PostHighlightCard,
type PostHighlightCardMetric,
type PostHighlightCardProps,
} from './post-highlight-card';
Loading
Loading