diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1787-comment-widgets b/projects/packages/premium-analytics/changelog/wooa7s-1787-comment-widgets
new file mode 100644
index 000000000000..72692ca58650
--- /dev/null
+++ b/projects/packages/premium-analytics/changelog/wooa7s-1787-comment-widgets
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Insights: add the Most commented authors and Most commented posts widgets.
diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1787-remove-comments-widget b/projects/packages/premium-analytics/changelog/wooa7s-1787-remove-comments-widget
new file mode 100644
index 000000000000..2ff29f4353fc
--- /dev/null
+++ b/projects/packages/premium-analytics/changelog/wooa7s-1787-remove-comments-widget
@@ -0,0 +1,4 @@
+Significance: minor
+Type: removed
+
+Remove the combined Comments widget in favor of the Most commented authors and Most commented posts widgets.
diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/index.ts b/projects/packages/premium-analytics/packages/data/src/hooks/index.ts
index 0201a98878c7..72dd14f906f4 100644
--- a/projects/packages/premium-analytics/packages/data/src/hooks/index.ts
+++ b/projects/packages/premium-analytics/packages/data/src/hooks/index.ts
@@ -74,8 +74,11 @@ export { useStatsFollowers } from './use-stats-followers';
export type { StatsFollowersParams, StatsFollowersResponse } from './use-stats-followers';
export {
useStatsComments,
+ useStatsCommentsRows,
type StatsCommentsParams,
type StatsCommentsResponse,
+ type UseStatsCommentsRowsArgs,
+ type UseStatsCommentsRowsResult,
} from './use-stats-comments';
export {
useStatsSubscribersCounts,
diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-comments.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-comments.ts
index e5e2d73382ea..f13d86ca0f6b 100644
--- a/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-comments.ts
+++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-comments.ts
@@ -1,9 +1,15 @@
+/**
+ * External dependencies
+ */
+import { useMemo } from 'react';
/**
* Internal dependencies
*/
+import { selectStatsCommentsRows } from '../processing/stats';
import { statsCommentsQuery } from '../queries/stats-comments-query';
import { useStatsQuery } from './use-stats-query';
import type { UseStatsOptions } from './use-stats-report';
+import type { StatsCommentsGroup, StatsCommentsRow } from '../processing/stats';
import type { StatsCommentsParams, StatsCommentsResponse } from '../queries/stats-comments-query';
export type { StatsCommentsParams, StatsCommentsResponse };
@@ -11,3 +17,62 @@ export type { StatsCommentsParams, StatsCommentsResponse };
export function useStatsComments( params?: StatsCommentsParams, options?: UseStatsOptions ) {
return useStatsQuery< StatsCommentsResponse >( statsCommentsQuery( params ), options );
}
+
+export interface UseStatsCommentsRowsArgs {
+ /**
+ * Which of the report's two groups to read: comment authors or commented
+ * posts and pages.
+ */
+ group: StatsCommentsGroup;
+ /**
+ * Maximum rows to return; `0` or omitted means all.
+ */
+ max?: number;
+}
+
+export interface UseStatsCommentsRowsResult {
+ rows: StatsCommentsRow[];
+ isLoading: boolean;
+ isFetching: boolean;
+ isError: boolean;
+ error: unknown;
+ refetch: () => void;
+}
+
+/**
+ * Read one group of the all-time Comments report as flat, ranked rows.
+ *
+ * The endpoint returns both groups in a single response, so the two comment
+ * widgets share one query and one row shape; only the label, media and link
+ * treatment differ per widget.
+ *
+ * @param args - Hook arguments.
+ * @param args.group - Which of the report's two groups to read.
+ * @param args.max - Maximum rows to return; `0` or omitted means all.
+ * @return The group's rows plus the query's data state.
+ */
+export function useStatsCommentsRows( {
+ group,
+ max,
+}: UseStatsCommentsRowsArgs ): UseStatsCommentsRowsResult {
+ const { data, isLoading, isFetching, isError, error, refetch } = useStatsComments();
+
+ // Memoize on the query's stable `data` reference so the row array keeps a
+ // stable identity across unrelated re-renders; otherwise every render hands
+ // a fresh array to the widget and defeats its downstream `useMemo`.
+ const rows = useMemo( () => selectStatsCommentsRows( data, group, max ), [ data, group, max ] );
+
+ // Only surface the error state when there is nothing to show, so a transient
+ // refetch failure keeps the current rows visible. `error` is gated by the
+ // same predicate so the two fields cannot disagree.
+ const showError = rows.length === 0 && isError;
+
+ return {
+ rows,
+ isLoading,
+ isFetching,
+ isError: showError,
+ error: showError ? error : null,
+ refetch,
+ };
+}
diff --git a/projects/packages/premium-analytics/packages/data/src/index.ts b/projects/packages/premium-analytics/packages/data/src/index.ts
index 1fe261ba8290..f0b965c7b191 100644
--- a/projects/packages/premium-analytics/packages/data/src/index.ts
+++ b/projects/packages/premium-analytics/packages/data/src/index.ts
@@ -79,8 +79,11 @@ export { useStatsFollowers } from './hooks/use-stats-followers';
export type { StatsFollowersParams, StatsFollowersResponse } from './hooks/use-stats-followers';
export {
useStatsComments,
+ useStatsCommentsRows,
type StatsCommentsParams,
type StatsCommentsResponse,
+ type UseStatsCommentsRowsArgs,
+ type UseStatsCommentsRowsResult,
} from './hooks/use-stats-comments';
export {
useStatsSubscribersCounts,
@@ -110,6 +113,7 @@ export {
flattenStatsLeaves,
getStatsChartBucketKey,
getStatsReportItems,
+ selectStatsCommentsRows,
sliceWordAdsStatsReport,
} from './processing/stats';
export type { FlattenStatsLeavesContext, FlattenStatsLeavesOptions } from './processing/stats';
@@ -274,6 +278,7 @@ export type {
StatsCommentFollowersRawPost,
StatsCommentFollowersRawResponse,
StatsCommentsAuthorItem,
+ StatsCommentsGroup,
StatsCommentsGroupItem,
StatsCommentsItem,
StatsCommentsPostItem,
@@ -281,6 +286,7 @@ export type {
StatsCommentsRawFollowData,
StatsCommentsRawPost,
StatsCommentsRawResponse,
+ StatsCommentsRow,
StatsEmailBreakdownItem,
StatsDevicesComparisonItem,
StatsDevicesItem,
diff --git a/projects/packages/premium-analytics/packages/data/src/processing/stats/__tests__/comments.test.ts b/projects/packages/premium-analytics/packages/data/src/processing/stats/__tests__/comments.test.ts
index 5895c20d65e0..68fda951c0d2 100644
--- a/projects/packages/premium-analytics/packages/data/src/processing/stats/__tests__/comments.test.ts
+++ b/projects/packages/premium-analytics/packages/data/src/processing/stats/__tests__/comments.test.ts
@@ -1,4 +1,4 @@
-import { sanitizeStatsCommentsResponse } from '..';
+import { sanitizeStatsCommentsResponse, selectStatsCommentsRows } from '..';
import { commentsFixture } from '../__fixtures__/comments';
describe( 'Stats comments normalizer', () => {
@@ -125,3 +125,141 @@ describe( 'Stats comments normalizer', () => {
} );
} );
} );
+
+describe( 'selectStatsCommentsRows', () => {
+ it( 'flattens the authors group into ranked rows keyed on the gravatar hash', () => {
+ const report = sanitizeStatsCommentsResponse( {
+ authors: [
+ { name: 'Aggie', comments: 2, link: '?s=aggie@example.com', gravatar: 'g/aggie?s=48' },
+ { name: 'Bo', comments: 7, link: '?user_id=1662656', gravatar: 'g/bo?s=48' },
+ ],
+ } );
+
+ expect( selectStatsCommentsRows( report, 'authors' ) ).toEqual( [
+ {
+ id: 'g/bo?d=mm',
+ label: 'Bo',
+ value: 7,
+ avatarUrl: 'g/bo?d=mm',
+ // WPCOM-user rows have no wp-admin equivalent, so they stay unlinked.
+ link: undefined,
+ },
+ {
+ id: 'g/aggie?d=mm',
+ label: 'Aggie',
+ value: 2,
+ avatarUrl: 'g/aggie?d=mm',
+ link: 'edit-comments.php?s=aggie%40example.com',
+ },
+ ] );
+ } );
+
+ it( 'flattens the posts group and keeps the post id for drill-through', () => {
+ const report = sanitizeStatsCommentsResponse( commentsFixture );
+
+ expect( selectStatsCommentsRows( report, 'posts' ) ).toEqual( [
+ {
+ id: '41',
+ label: 'Hello world',
+ value: 10,
+ link: 'https://example.com/hello/',
+ postId: '41',
+ avatarUrl: undefined,
+ },
+ ] );
+ } );
+
+ // An author with no gravatar falls back to a label-derived key, and carries no
+ // avatar. Guards the first step of the authors id fallback chain.
+ it( 'keys an author with no gravatar on the label and leaves the avatar unset', () => {
+ const report = sanitizeStatsCommentsResponse( {
+ authors: [ { name: 'Aggie', comments: 2, link: '?s=aggie@example.com' } ],
+ } );
+
+ expect( selectStatsCommentsRows( report, 'authors' ) ).toEqual( [
+ {
+ id: 'author-Aggie',
+ label: 'Aggie',
+ value: 2,
+ avatarUrl: undefined,
+ link: 'edit-comments.php?s=aggie%40example.com',
+ },
+ ] );
+ } );
+
+ // Consumers guard the permalink themselves, so the raw link has to survive
+ // here: a post with no id keys its row on it. Guards the second step of the
+ // posts id fallback chain, and that `postId` stays unset without a post id.
+ it( 'keeps the raw link as the row id when a post has no id', () => {
+ const report = sanitizeStatsCommentsResponse( {
+ posts: [ { name: 'Hello world', comments: 3, link: 'javascript:alert(1)' } ],
+ } );
+
+ expect( selectStatsCommentsRows( report, 'posts' ) ).toEqual( [
+ {
+ id: 'javascript:alert(1)',
+ label: 'Hello world',
+ value: 3,
+ link: 'javascript:alert(1)',
+ postId: undefined,
+ avatarUrl: undefined,
+ },
+ ] );
+ } );
+
+ // Guards the third step of the posts id fallback chain: neither an id nor a
+ // link to key on.
+ it( 'keys a post with neither an id nor a link on the label', () => {
+ const report = sanitizeStatsCommentsResponse( {
+ posts: [ { name: 'Hello world', comments: 3 } ],
+ } );
+
+ expect( selectStatsCommentsRows( report, 'posts' ) ).toEqual( [
+ {
+ id: 'post-Hello world',
+ label: 'Hello world',
+ value: 3,
+ link: undefined,
+ postId: undefined,
+ avatarUrl: undefined,
+ },
+ ] );
+ } );
+
+ // Post id 0 is falsy but present, so the null check must be `!= null` rather
+ // than a truthiness test — otherwise the row silently falls through to the
+ // link/label key and loses its `postId`.
+ it( 'treats post id 0 as a real id rather than a missing one', () => {
+ const report = sanitizeStatsCommentsResponse( {
+ posts: [ { id: 0, name: 'Hello world', comments: 3, link: 'https://example.com/hello/' } ],
+ } );
+
+ expect( selectStatsCommentsRows( report, 'posts' ) ).toEqual( [
+ expect.objectContaining( { id: '0', postId: '0' } ),
+ ] );
+ } );
+
+ it( 'trims to maxRows, treating 0 and undefined as all rows', () => {
+ const report = sanitizeStatsCommentsResponse( {
+ authors: [
+ { name: 'Aggie', comments: 2 },
+ { name: 'Bo', comments: 7 },
+ { name: 'Cy', comments: 5 },
+ ],
+ } );
+
+ expect( selectStatsCommentsRows( report, 'authors', 2 ).map( row => row.label ) ).toEqual( [
+ 'Bo',
+ 'Cy',
+ ] );
+ expect( selectStatsCommentsRows( report, 'authors', 0 ) ).toHaveLength( 3 );
+ expect( selectStatsCommentsRows( report, 'authors' ) ).toHaveLength( 3 );
+ } );
+
+ it( 'returns no rows for an unresolved or empty report', () => {
+ expect( selectStatsCommentsRows( undefined, 'authors' ) ).toEqual( [] );
+ expect(
+ selectStatsCommentsRows( sanitizeStatsCommentsResponse( { authors: [] } ), 'posts' )
+ ).toEqual( [] );
+ } );
+} );
diff --git a/projects/packages/premium-analytics/packages/data/src/processing/stats/comments.ts b/projects/packages/premium-analytics/packages/data/src/processing/stats/comments.ts
index 74c6d9464515..79af96ac667a 100644
--- a/projects/packages/premium-analytics/packages/data/src/processing/stats/comments.ts
+++ b/projects/packages/premium-analytics/packages/data/src/processing/stats/comments.ts
@@ -4,6 +4,7 @@ import {
coerceStatsRecord,
createStatsListDataPoint,
getStatsLabel,
+ limitStatsRows,
normalizeStatsSummary,
} from './utils';
import type { StatsItemAction, StatsNormalizedItemBase, StatsNormalizedReport } from './types';
@@ -150,3 +151,126 @@ export function sanitizeStatsCommentsResponse(
data: items.length ? [ createStatsListDataPoint( response, query, items ) ] : [],
};
}
+
+/**
+ * The two groups the all-time Comments report is split into.
+ */
+export type StatsCommentsGroup = 'authors' | 'posts';
+
+/**
+ * A flat Comments report row, shared by every consumer of the report: the
+ * "Most commented authors" and "Most commented posts" widgets and the Comments
+ * report page.
+ *
+ * `link` is the value the report carries: a locally built, root-relative
+ * `edit-comments.php` search for authors, and a remote permalink for posts.
+ * Consumers that render the post link must pass it through `safeHttpUrl`
+ * first — the guard cannot live here, because the row id falls back to the raw
+ * link and must stay stable even when the URL is rejected.
+ */
+export type StatsCommentsRow = {
+ /**
+ * Stable row key, derived from the item's own identity rather than its
+ * position so it survives a refetch and cannot collide on a repeated label.
+ */
+ id: string;
+ /**
+ * Display label: the author name or the post title.
+ */
+ label: string;
+ /**
+ * Number of comments attributed to this author or post.
+ */
+ value: number;
+ /**
+ * Author avatar URL. Set for the `authors` group only.
+ */
+ avatarUrl?: string;
+ /**
+ * The link the report carries for this row, when it has one.
+ */
+ link?: string;
+ /**
+ * Numeric post id as a string. Set for the `posts` group only.
+ */
+ postId?: string;
+};
+
+// The normalized item `label` is typed `unknown`; the comments endpoint always
+// yields strings, but coerce defensively so the row shape stays `string`.
+function toCommentsRowLabel( value: unknown ): string {
+ return typeof value === 'string' ? value : String( value );
+}
+
+/**
+ * Map one group child to a flat row.
+ *
+ * `label`, `value` and `link` are derived identically for both groups; only the
+ * row key and the group-specific extras (`avatarUrl`, `postId`) differ, so the
+ * group discriminator selects just those.
+ *
+ * @param item - The group child to map.
+ * @param group - The group the child belongs to.
+ * @return The flat row.
+ */
+function toCommentsRow(
+ item: StatsCommentsAuthorItem | StatsCommentsPostItem,
+ group: StatsCommentsGroup
+): StatsCommentsRow {
+ const label = toCommentsRowLabel( item.label );
+ const shared = { label, value: item.value, link: item.link ?? undefined };
+
+ if ( group === 'authors' ) {
+ const { icon } = item as StatsCommentsAuthorItem;
+
+ return {
+ ...shared,
+ // Authors key on their gravatar hash, falling back to the label.
+ id: icon ?? `author-${ label }`,
+ avatarUrl: icon ?? undefined,
+ };
+ }
+
+ // `!= null` rather than a truthiness test: post id 0 is a real id.
+ const { id } = item as StatsCommentsPostItem;
+ const postId = id != null ? String( id ) : undefined;
+
+ return {
+ ...shared,
+ // Posts key on their post id, falling back to the raw link so row
+ // identity holds even when a consumer rejects that URL, and finally on
+ // the label.
+ id: postId ?? shared.link ?? `post-${ label }`,
+ postId,
+ };
+}
+
+/**
+ * Select one group's rows from a normalized Comments report.
+ *
+ * The endpoint returns a single all-time report whose `data[0].items` are two
+ * group rows — one keyed `authors`, one keyed `posts`. This picks the requested
+ * group, flattens its children to `StatsCommentsRow`, sorts them by comment
+ * count and trims the result to `maxRows` (`0` or omitted means all rows).
+ *
+ * @param report - The normalized Comments report, if it has resolved.
+ * @param group - The group to select.
+ * @param maxRows - Maximum rows to return; `0` or omitted means all.
+ * @return The group's rows, highest comment count first.
+ */
+export function selectStatsCommentsRows(
+ report: StatsCommentsResponse | undefined,
+ group: StatsCommentsGroup,
+ maxRows?: number
+): StatsCommentsRow[] {
+ const items = report?.data?.[ 0 ]?.items ?? [];
+ const groupItem = items.find( item => item.label === group ) as
+ | StatsCommentsGroupItem
+ | undefined;
+
+ const rows = ( groupItem?.children ?? [] )
+ .map( child => toCommentsRow( child, group ) )
+ .sort( ( a, b ) => b.value - a.value );
+
+ return limitStatsRows( rows, maxRows );
+}
diff --git a/projects/packages/premium-analytics/packages/data/src/processing/stats/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/stats/index.ts
index ca7790f2428c..ec5f818fe801 100644
--- a/projects/packages/premium-analytics/packages/data/src/processing/stats/index.ts
+++ b/projects/packages/premium-analytics/packages/data/src/processing/stats/index.ts
@@ -52,7 +52,7 @@ export { compareEmailBreakdownItems, sanitizeStatsEmailBreakdownResponse } from
export { mergeStatsArchivesComparisonRows, sanitizeStatsArchivesResponse } from './archives';
export { sanitizeStatsCommentFollowersResponse } from './comment-followers';
export { sanitizeStatsFollowersResponse } from './followers';
-export { sanitizeStatsCommentsResponse } from './comments';
+export { sanitizeStatsCommentsResponse, selectStatsCommentsRows } from './comments';
export {
sanitizeStatsSubscribersResponse,
sanitizeStatsSubscribersCountsResponse,
@@ -135,6 +135,7 @@ export type {
} from './followers';
export type {
StatsCommentsAuthorItem,
+ StatsCommentsGroup,
StatsCommentsGroupItem,
StatsCommentsItem,
StatsCommentsPostItem,
@@ -143,6 +144,7 @@ export type {
StatsCommentsRawPost,
StatsCommentsRawResponse,
StatsCommentsResponse,
+ StatsCommentsRow,
} from './comments';
export type {
StatsSubscribersCounts,
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/comments.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/comments.ts
index 3faa230c6bc4..342a3aec48df 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/comments.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/comments.ts
@@ -2,8 +2,8 @@
* Mock response for the Stats `comments` endpoint (`/proxy/v1.1/stats/comments`).
*
* The endpoint is all-time and returns two parallel lists — comment authors and
- * commented posts — which the widget shows through its "By authors" / "By posts
- * & pages" selector. This fixture populates both so the toggle is reviewable.
+ * commented posts — surfaced by the Top commented authors and Top commented
+ * posts widgets. This fixture populates both so either widget is reviewable.
*
* Gravatar URLs are used for author avatars: the comments processor strips each
* URL's query string and re-appends `?d=mm`, so Storybook renders Gravatar's
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts
index e87106fe6f2e..39ced35eee9c 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts
@@ -211,6 +211,33 @@ export function forceWordAdsEarningsState( state: ReportMockState ) {
};
}
+/**
+ * Story `beforeEach` that forces the shared `stats/comments` request into a
+ * loading, error, or empty state and drops its cached query on both enter and
+ * cleanup. Shared by the Top commented authors and Top commented posts stories,
+ * which read the same response, so the cache-reset cannot drift between them.
+ *
+ * The comments endpoint is all-time, so its query key does not vary by date and
+ * every comment-widget story shares one cache entry (a distinct date preset
+ * can't separate them). Resetting on both edges gives each forced-state story a
+ * fresh fetch and clears a never-settling `loading` fetch before the next story
+ * reuses the key. Because the override is keyed by path, keep such stories off
+ * the shared autodocs page (`tags: [ '!autodocs' ]`).
+ *
+ * @param state - The forced mock state.
+ * @return A Storybook `beforeEach` implementation returning its cleanup.
+ */
+export function forceStatsCommentsState( state: ReportMockState ) {
+ return () => {
+ setReportMockState( 'stats/comments', state );
+ queryClient.removeQueries( { queryKey: [ 'stats', 'comments' ] } );
+ return () => {
+ setReportMockState( 'stats/comments', null );
+ queryClient.removeQueries( { queryKey: [ 'stats', 'comments' ] } );
+ };
+ };
+}
+
const mockResponseOverrides = new Map< string, unknown >();
/**
diff --git a/projects/packages/premium-analytics/routes/reports/comments/config/use-report-records.ts b/projects/packages/premium-analytics/routes/reports/comments/config/use-report-records.ts
index 40f4789ca02b..d8cb53f1762b 100644
--- a/projects/packages/premium-analytics/routes/reports/comments/config/use-report-records.ts
+++ b/projects/packages/premium-analytics/routes/reports/comments/config/use-report-records.ts
@@ -2,10 +2,8 @@
* External dependencies
*/
import {
+ selectStatsCommentsRows,
useStatsComments,
- type StatsCommentsAuthorItem,
- type StatsCommentsGroupItem,
- type StatsCommentsPostItem,
type StatsCommentsResponse,
} from '@jetpack-premium-analytics/data';
import { safeHttpUrl } from '@jetpack-premium-analytics/ui';
@@ -24,16 +22,6 @@ export type CommentReportRow = {
postId?: string;
};
-/**
- * Convert an API label value into display text.
- *
- * @param value - The raw label value.
- * @return The display label.
- */
-function toLabel( value: unknown ): string {
- return typeof value === 'string' ? value : String( value );
-}
-
/**
* Fetch the all-time Comments report and expose the active tab's rows.
*
@@ -44,42 +32,20 @@ export function useCommentsReportRecords( activeTab: CommentsReportTabId ) {
const report = useStatsComments();
const rows = useMemo< CommentReportRow[] >( () => {
- const data = report.data as StatsCommentsResponse | undefined;
- const items = data?.data?.[ 0 ]?.items ?? [];
- const group = items.find( item => item.label === activeTab ) as
- | StatsCommentsGroupItem
- | undefined;
-
- return ( group?.children ?? [] )
- .map( child => {
- if ( activeTab === 'authors' ) {
- const author = child as StatsCommentsAuthorItem;
- const label = toLabel( author.label );
-
- return {
- id: author.icon ?? `author-${ label }`,
- label,
- value: author.value,
- avatarUrl: author.icon ?? undefined,
- // The author's profile/admin URL from the API, when they have one.
- link: author.link ?? undefined,
- };
- }
-
- const post = child as StatsCommentsPostItem;
- const label = toLabel( post.label );
-
- return {
- // Keyed on the raw link so row identity survives a rejected URL.
- id: post.id != null ? String( post.id ) : post.link ?? `post-${ label }`,
- label,
- value: post.value,
- // Unlike the author link above, this one comes straight from the API.
- link: safeHttpUrl( post.link ) ?? undefined,
- postId: post.id != null ? String( post.id ) : undefined,
- };
- } )
- .sort( ( a, b ) => b.value - a.value );
+ const rawRows = selectStatsCommentsRows(
+ report.data as StatsCommentsResponse | undefined,
+ activeTab
+ );
+
+ // Author links are built locally by the data layer (a root-relative
+ // `edit-comments.php` search), so only the posts tab's remote permalinks
+ // need the scheme guard. Row identity is left untouched: it can key on
+ // the raw link, which must survive a rejected URL.
+ if ( activeTab === 'authors' ) {
+ return rawRows;
+ }
+
+ return rawRows.map( row => ( { ...row, link: safeHttpUrl( row.link ) ?? undefined } ) );
}, [ report.data, activeTab ] );
return {
diff --git a/projects/packages/premium-analytics/src/dashboard-layout.php b/projects/packages/premium-analytics/src/dashboard-layout.php
index 40eda11f67f0..d7b898282d7c 100644
--- a/projects/packages/premium-analytics/src/dashboard-layout.php
+++ b/projects/packages/premium-analytics/src/dashboard-layout.php
@@ -337,7 +337,7 @@ function get_dashboard_default_section_layouts() {
'max' => 10,
)
),
- // Row 3: posting-activity heatmap + comments + shares.
+ // Row 3: posting-activity heatmap + the two comment leaderboards.
get_dashboard_default_widget_instance(
'default-posting-activity-widget-instance',
'jpa/posting-activity',
@@ -345,9 +345,10 @@ function get_dashboard_default_section_layouts() {
2,
2
),
+ // Posts before authors, matching the design's Insights bottom row.
get_dashboard_default_widget_instance(
- 'default-comments-widget-instance',
- 'jpa/comments',
+ 'default-most-commented-posts-widget-instance',
+ 'jpa/most-commented-posts',
7,
1,
2,
@@ -355,10 +356,21 @@ function get_dashboard_default_section_layouts() {
'max' => 10,
)
),
+ get_dashboard_default_widget_instance(
+ 'default-most-commented-authors-widget-instance',
+ 'jpa/most-commented-authors',
+ 8,
+ 1,
+ 2,
+ array(
+ 'max' => 10,
+ )
+ ),
+ // Row 4: shares, joined by the two unported modules noted above.
get_dashboard_default_widget_instance(
'default-shares-widget-instance',
'jpa/shares',
- 8,
+ 9,
1,
2,
array(
diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php
index 490b25b02fa3..7dbb29da3cf3 100644
--- a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php
+++ b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php
@@ -183,7 +183,26 @@ public function test_seed_default_dashboard_layout_adds_insights_widgets() {
$this->assertNotContains( 'jpa/videopress', $layout_types );
// Emails is not an Insights module — it lives on the Subscribers tab.
$this->assertNotContains( 'jpa/stats-emails', $layout_types );
+ // The Comments module ships as two focused widgets, not one toggled widget.
+ $this->assertContains( 'jpa/most-commented-authors', $layout_types );
+ $this->assertContains( 'jpa/most-commented-posts', $layout_types );
+ $this->assertNotContains( 'jpa/comments', $layout_types );
$this->assertContains( 'jpa/shares', $layout_types );
+ $this->assertSame(
+ array(
+ 'uuid' => 'default-most-commented-posts-widget-instance',
+ 'type' => 'jpa/most-commented-posts',
+ 'attributes' => array(
+ 'max' => 10,
+ ),
+ 'placement' => array(
+ 'width' => 1,
+ 'height' => 2,
+ 'order' => 7,
+ ),
+ ),
+ $layout_by_uuid['default-most-commented-posts-widget-instance']
+ );
$this->assertSame(
array(
'uuid' => 'default-shares-widget-instance',
@@ -194,7 +213,7 @@ public function test_seed_default_dashboard_layout_adds_insights_widgets() {
'placement' => array(
'width' => 1,
'height' => 2,
- 'order' => 8,
+ 'order' => 9,
),
),
$layout_by_uuid['default-shares-widget-instance']
diff --git a/projects/packages/premium-analytics/widgets/comments/render.tsx b/projects/packages/premium-analytics/widgets/comments/render.tsx
deleted file mode 100644
index eb2141053e6b..000000000000
--- a/projects/packages/premium-analytics/widgets/comments/render.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * External dependencies
- */
-import {
- LeaderboardChart,
- ReportLink,
- WidgetFooter,
- WidgetRoot,
- WidgetState,
- buildLeaderboardRow,
- safeHttpUrl,
- sharePercentage,
- type LeaderboardChartData,
- type LeaderboardRowChartProps,
- type ReportParamsFieldAttributes,
-} from '@jetpack-premium-analytics/widgets-toolkit';
-import { useMemo } from '@wordpress/element';
-import { __ } from '@wordpress/i18n';
-import { comment } from '@wordpress/icons';
-import { Stack } from '@wordpress/ui';
-/**
- * Internal dependencies
- */
-import styles from './style.module.css';
-import useCommentViews, { type CommentRow } from './use-comment-views';
-import { type CommentsAttributes, type CommentsView } from './widget';
-import type { WidgetRenderProps } from '@wordpress/widget-primitives';
-
-type CommentsRenderAttributes = CommentsAttributes & Partial< ReportParamsFieldAttributes >;
-type CommentsWidgetProps = WidgetRenderProps< CommentsRenderAttributes >;
-
-const DATA_FORMAT = { type: 'number' as const, options: { useMultipliers: true, decimals: 0 } };
-
-const COMMENT_VIEWS: CommentsView[] = [ 'authors', 'posts' ];
-
-function isCommentView( value: unknown ): value is CommentsView {
- return typeof value === 'string' && COMMENT_VIEWS.includes( value as CommentsView );
-}
-
-/**
- * Builds a leaderboard row label. Authors render as a name + avatar, linking to
- * the comments-admin search when the normalized data carries a URL (or a static
- * label when it doesn't); posts render as an external link to the published post
- * (or plain text when a post has no permalink).
- *
- * @param {CommentRow} row - The row to label.
- * @param {CommentsView} view - The active view.
- * @return The label node.
- */
-function buildRowLabel( row: CommentRow, view: CommentsView ): LeaderboardRowChartProps {
- if ( view === 'authors' ) {
- // The author link is constructed locally by the data layer (a relative
- // `edit-comments.php?s=…` search), so it needs no scheme guard — which
- // would reject it as relative anyway.
- return buildLeaderboardRow( {
- label: row.label,
- media: { kind: 'avatar', url: row.avatarUrl, name: row.label },
- action: row.link ? { kind: 'link', href: row.link } : { kind: 'static' },
- } );
- }
-
- // Post permalinks come from report data, so validate the scheme before the
- // row becomes a link.
- const href = safeHttpUrl( row.link );
-
- return buildLeaderboardRow( {
- label: row.label,
- media: { kind: 'none' },
- action: href ? { kind: 'link', href } : { kind: 'static' },
- } );
-}
-
-interface CommentsInnerProps {
- /**
- * Maximum number of rows to display.
- */
- max?: number;
- /**
- * The active view. Owned by the widget host: the `view` attribute is
- * `relevance: 'high'`, so the host renders the "View by" header control.
- */
- view: CommentsView;
-}
-
-/**
- * Comments widget inner component. The comment counts come from the all-time
- * `stats/comments` report, so there is no date range or comparison period to
- * read from context; the host-owned `view` selects which of the report's two
- * groups is shown.
- *
- * @param {CommentsInnerProps} props - The component props.
- * @return The rendered widget content.
- */
-function CommentsInner( { max = 10, view }: CommentsInnerProps ) {
- const { data, isLoading, isFetching, isError, refetch } = useCommentViews( { view, max } );
-
- const leaderboardData = useMemo< LeaderboardChartData >( () => {
- const maxValue = Math.max( ...data.map( row => row.value ), 0 );
-
- return data.map( row => ( {
- id: row.id,
- ...buildRowLabel( row, view ),
- currentValue: row.value,
- currentShare: sharePercentage( row.value, maxValue ),
- } ) );
- }, [ data, view ] );
-
- return (
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-/**
- * Comments widget: the site's comment authors and its most-commented posts and
- * pages, ranked by comment count. The active view is the host-rendered "View by"
- * header control (the `view` attribute). Ported from the Jetpack Stats
- * "Comments" module.
- *
- * @param {CommentsWidgetProps} props - The widget render props.
- * @return The rendered Comments widget.
- */
-export default function Comments( { attributes = {} }: CommentsWidgetProps ) {
- const view = isCommentView( attributes.view ) ? attributes.view : 'authors';
-
- return (
-
-
-
- );
-}
diff --git a/projects/packages/premium-analytics/widgets/comments/stories/comments-widget.stories.tsx b/projects/packages/premium-analytics/widgets/comments/stories/comments-widget.stories.tsx
deleted file mode 100644
index c3ce92f3f10a..000000000000
--- a/projects/packages/premium-analytics/widgets/comments/stories/comments-widget.stories.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import { getDefaultQueryParams, queryClient } from '@jetpack-premium-analytics/data';
-import {
- DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
- WidgetDashboardWithWidget as WidgetDashboardWithWidgetStory,
- widgetDashboardWithWidgetArgTypes,
- type WidgetDashboardWithWidgetControls,
-} from '../../stories/widget-dashboard-with-widget';
-import { withStoryRouter } from '../../stories/with-story-router';
-import { createStoryWidgetType } from '../../stories/create-story-widget-type';
-import { withWidgetCanvas } from '../../stories/with-widget-canvas';
-import {
- registerReportMocks,
- setReportMockState,
-} from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks';
-import CommentsRender from '../render';
-import widgetDefinition from '../widget';
-import widgetManifest from '../widget.json';
-import type { CommentsView } from '../widget';
-import type { Meta, StoryObj } from '@storybook/react';
-import type { WidgetRenderProps } from '@wordpress/widget-primitives';
-import type { ComponentProps, ComponentType } from 'react';
-
-registerReportMocks();
-
-const COMMENTS_RENDER_MODULE = 'storybook/comments';
-
-// attributes/example let the dashboard host render the real "View by" header
-// control for the `relevance: 'high'` attribute, as in Top Platforms.
-const storyWidgetType = createStoryWidgetType( widgetManifest, widgetDefinition );
-
-const VIEW_CONTROL = {
- control: 'inline-radio' as const,
- options: [ 'authors', 'posts' ] as CommentsView[],
-};
-
-interface CommentsStoryControls {
- view: CommentsView;
-}
-
-function renderComments( { view }: CommentsStoryControls ) {
- // The close-up story renders the bare widget without the host chrome, so the
- // "View by" control isn't shown here; the `view` control drives the rendered
- // view directly through the (host-owned) `view` attribute.
- return ;
-}
-
-function CommentsDashboardRender( props: WidgetRenderProps< unknown > ) {
- return ) } />;
-}
-
-// The `stats/comments` endpoint is all-time, so its React Query key does not vary
-// by date. The shared query client would otherwise let a forced-state story read
-// a sibling story's cached success, so the cache is cleared around the story to
-// guarantee a fresh fetch that hits the forced mock.
-const COMMENTS_QUERY_KEY = [ 'stats', 'comments' ];
-
-function forceCommentsState( state: 'loading' | 'error' | 'empty' ) {
- return () => {
- queryClient.removeQueries( { queryKey: COMMENTS_QUERY_KEY } );
- setReportMockState( 'stats/comments', state );
-
- return () => {
- setReportMockState( 'stats/comments', null );
- queryClient.removeQueries( { queryKey: COMMENTS_QUERY_KEY } );
- };
- };
-}
-
-const meta = {
- title: 'Packages/Premium Analytics/Widgets/Comments',
- component: CommentsRender,
- tags: [ 'autodocs' ],
- argTypes: {
- view: VIEW_CONTROL,
- },
- parameters: {
- docs: {
- description: {
- component:
- 'The "Comments" widget. Ranks the site\'s comment authors and its most-commented posts and pages by comment count. The active view is the host-rendered "View by" header control (Authors / Posts & pages). Ported from the Jetpack Stats Comments module.',
- },
- },
- },
-} satisfies Meta< ComponentProps< typeof CommentsRender > & CommentsStoryControls >;
-
-export default meta;
-
-type Story = StoryObj< CommentsStoryControls >;
-
-export const Default: Story = {
- render: renderComments,
- args: { view: 'authors' },
- decorators: [ withWidgetCanvas, withStoryRouter ],
-};
-
-/**
- * First load: the fetch is in flight, so the widget shows its loading state. The
- * mock is forced to never resolve for the duration of this story.
- */
-export const Loading: Story = {
- render: renderComments,
- args: { view: 'authors' },
- // Kept off the shared autodocs page: the mock override is keyed by path, so it
- // would otherwise force the sibling stories on that page into the same state.
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas, withStoryRouter ],
- beforeEach: forceCommentsState( 'loading' ),
-};
-
-/**
- * The fetch failed: the widget shows its error state with a Retry action (which
- * re-runs the query — still mocked as failing while this story is active).
- */
-export const ErrorState: Story = {
- render: renderComments,
- args: { view: 'authors' },
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas, withStoryRouter ],
- beforeEach: forceCommentsState( 'error' ),
-};
-
-/**
- * Resolved with no rows: the widget shows its empty state (the neutral comment
- * glyph and "Learn about the comments your site receives…").
- */
-export const Empty: Story = {
- render: renderComments,
- args: { view: 'authors' },
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas, withStoryRouter ],
- beforeEach: forceCommentsState( 'empty' ),
-};
-
-interface CommentsDashboardStoryProps
- extends WidgetDashboardWithWidgetControls,
- CommentsStoryControls {}
-
-function CommentsDashboardStory( { view, ...dashboardArgs }: CommentsDashboardStoryProps ) {
- return (
- > }
- attributes={ { view, reportParams: getDefaultQueryParams( true ) } }
- />
- );
-}
-
-export const WidgetDashboardWithWidget: StoryObj< CommentsDashboardStoryProps > = {
- render: args => ,
- args: {
- ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
- view: 'authors',
- },
- argTypes: {
- ...widgetDashboardWithWidgetArgTypes,
- view: VIEW_CONTROL,
- },
- decorators: [ withStoryRouter ],
-};
diff --git a/projects/packages/premium-analytics/widgets/comments/use-comment-views.ts b/projects/packages/premium-analytics/widgets/comments/use-comment-views.ts
deleted file mode 100644
index f22661d6f0f3..000000000000
--- a/projects/packages/premium-analytics/widgets/comments/use-comment-views.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-/**
- * External dependencies
- */
-import { useMemo } from '@wordpress/element';
-/**
- * Internal dependencies
- */
-import { useStatsComments } from '@jetpack-premium-analytics/data';
-import type { CommentsView } from './widget';
-import type {
- StatsCommentsAuthorItem,
- StatsCommentsGroupItem,
- StatsCommentsPostItem,
- StatsCommentsResponse,
-} from '@jetpack-premium-analytics/data';
-
-export interface CommentRow {
- /**
- * Stable React key for the row.
- */
- id: string;
- /**
- * Display label: author name (authors view) or post title (posts view).
- */
- label: string;
- /**
- * Number of comments attributed to this author or post.
- */
- value: number;
- /**
- * Author avatar URL. Set in the authors view only.
- */
- avatarUrl?: string;
- /**
- * External link: the published post (posts view) or the author's profile
- * URL (authors view), when the API reports one.
- */
- link?: string;
-}
-
-interface UseCommentViewsArgs {
- /**
- * The active view: comment authors or commented posts.
- */
- view: CommentsView;
- /**
- * Maximum rows to display; `0` means all.
- */
- max: number;
-}
-
-// The normalized item `label` is typed `unknown`; the comments endpoint always
-// yields strings, but coerce defensively so the row shape stays `string`.
-function toLabel( value: unknown ): string {
- return typeof value === 'string' ? value : String( value );
-}
-
-interface CommentViewsState {
- data: CommentRow[];
- isLoading: boolean;
- isFetching: boolean;
- isError: boolean;
- refetch: () => void;
-}
-
-/**
- * Fetch the Comments report and expose the active view's rows.
- *
- * `useStatsComments` returns a single all-time report whose `data[0].items` are
- * two group rows — one keyed `authors`, one keyed `posts`. This selects the
- * group matching `view`, maps its children to a flat row shape (attaching the
- * avatar for authors, and the external link — the comments-admin search for
- * authors, the published post for posts — when the API reports one), sorts by
- * comment count, and trims to `max`. The endpoint has no comparison period, so
- * no previous values are produced.
- *
- * @param {UseCommentViewsArgs} args - Hook arguments.
- * @return The current data/loading/error state for the active view.
- */
-export default function useCommentViews( { view, max }: UseCommentViewsArgs ): CommentViewsState {
- const { data, isLoading, isFetching, isError, refetch } = useStatsComments();
-
- // Memoize on the query's stable `data` reference so the row array keeps a
- // stable identity across unrelated re-renders; otherwise every render hands
- // a fresh array to render.tsx and defeats its downstream `useMemo`.
- const rows: CommentRow[] = useMemo( () => {
- const report = data as StatsCommentsResponse | undefined;
- const items = report?.data?.[ 0 ]?.items ?? [];
- const group = items.find( item => item.label === view ) as StatsCommentsGroupItem | undefined;
- const children = group?.children ?? [];
-
- // Derive the row key from the item's own identity, not its position, so it
- // stays stable across refetches and can't collide on a repeated label (e.g.
- // two "Anonymous" authors): posts key on their post id, authors on their
- // gravatar hash, each falling back to the label when that is missing.
- return children
- .map( child => {
- if ( view === 'authors' ) {
- const author = child as StatsCommentsAuthorItem;
- const label = toLabel( author.label );
- return {
- id: author.icon ?? `author-${ label }`,
- label,
- value: author.value,
- avatarUrl: author.icon ?? undefined,
- link: author.link ?? undefined,
- };
- }
-
- const post = child as StatsCommentsPostItem;
- const label = toLabel( post.label );
- return {
- id: post.id != null ? String( post.id ) : post.link ?? `post-${ label }`,
- label,
- value: post.value,
- link: post.link ?? undefined,
- };
- } )
- .sort( ( a, b ) => b.value - a.value )
- .slice( 0, max > 0 ? max : undefined );
- }, [ data, view, max ] );
-
- return {
- data: rows,
- isLoading,
- isFetching,
- // Only surface the error state when there is nothing to show, so a
- // transient refetch failure keeps the current rows visible.
- isError: rows.length === 0 && isError,
- refetch,
- };
-}
diff --git a/projects/packages/premium-analytics/widgets/comments/widget.json b/projects/packages/premium-analytics/widgets/comments/widget.json
deleted file mode 100644
index 21274e773691..000000000000
--- a/projects/packages/premium-analytics/widgets/comments/widget.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "name": "jpa/comments",
- "title": "Comments",
- "description": "The authors and posts that receive the most comments on your site.",
- "help": {
- "content": "A breakdown of comments, grouped by author and by post or page.",
- "links": [
- {
- "label": "Learn more",
- "href": "https://jetpack.com/support/jetpack-stats/"
- }
- ]
- },
- "category": "stats",
- "presentation": "framed"
-}
diff --git a/projects/packages/premium-analytics/widgets/comments/widget.ts b/projects/packages/premium-analytics/widgets/comments/widget.ts
deleted file mode 100644
index 9dc277a8a6f1..000000000000
--- a/projects/packages/premium-analytics/widgets/comments/widget.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { __ } from '@wordpress/i18n';
-import { comment } from '@wordpress/icons';
-import type { WidgetAttributeField } from '@wordpress/widget-primitives';
-
-/**
- * Internal dependencies
- */
-import { SelectField } from '@jetpack-premium-analytics/fields';
-
-/**
- * Which of the two Comments views is shown: comment authors or commented posts.
- */
-export type CommentsView = 'authors' | 'posts';
-
-export type CommentsAttributes = {
- /**
- * Maximum number of rows to display.
- */
- max?: number;
- /**
- * The active view: comment authors or commented posts. The host renders
- * this as the "View by" header control (the attribute is `relevance:
- * 'high'`), so it is not shown in the widget body.
- */
- view?: CommentsView;
-};
-
-/**
- * Widget type definition for the Comments widget.
- *
- * Ported from the Jetpack Stats "Comments" module. Ranks the site's comment
- * authors and its most-commented posts and pages by comment count. The active
- * view is the `view` attribute (`relevance: 'high'`), so the widget host renders
- * the "View by" control in the widget header rather than the widget body.
- *
- * Data: fetched via the PA proxy at `stats/comments` through `useStatsComments`.
- * The endpoint is all-time and has no comparison period, so the widget ignores
- * the dashboard date range.
- */
-export default {
- icon: comment,
- attributes: [
- {
- id: 'max',
- label: __( 'Number of results', 'jetpack-premium-analytics-pkg' ),
- type: 'integer',
- },
- {
- id: 'view',
- label: __( 'View by', 'jetpack-premium-analytics-pkg' ),
- type: 'text',
- Edit: SelectField,
- elements: [
- { label: __( 'Authors', 'jetpack-premium-analytics-pkg' ), value: 'authors' },
- { label: __( 'Posts & pages', 'jetpack-premium-analytics-pkg' ), value: 'posts' },
- ],
- relevance: 'high',
- },
- ] as WidgetAttributeField< CommentsAttributes >[],
- example: {
- attributes: {
- max: 10,
- view: 'authors',
- },
- },
-};
diff --git a/projects/packages/premium-analytics/widgets/comments/__tests__/comments.test.tsx b/projects/packages/premium-analytics/widgets/most-commented-authors/__tests__/most-commented-authors.test.tsx
similarity index 67%
rename from projects/packages/premium-analytics/widgets/comments/__tests__/comments.test.tsx
rename to projects/packages/premium-analytics/widgets/most-commented-authors/__tests__/most-commented-authors.test.tsx
index 639624990ea8..ec87e50d41ff 100644
--- a/projects/packages/premium-analytics/widgets/comments/__tests__/comments.test.tsx
+++ b/projects/packages/premium-analytics/widgets/most-commented-authors/__tests__/most-commented-authors.test.tsx
@@ -7,7 +7,7 @@ import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
-import CommentsWidget from '../render';
+import MostCommentedAuthorsWidget from '../render';
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
@@ -15,7 +15,7 @@ jest.mock( '@wordpress/route', () => jest.requireActual( '../../test-utils' ).mo
const mockApiFetch = apiFetch as unknown as jest.Mock;
-describe( 'CommentsWidget', () => {
+describe( 'MostCommentedAuthorsWidget', () => {
beforeEach( () => {
queryClient.clear();
mockApiFetch.mockReset();
@@ -35,19 +35,25 @@ describe( 'CommentsWidget', () => {
gravatar: 'https://www.gravatar.com/avatar/member?s=96',
},
],
- posts: [],
+ posts: [
+ {
+ id: 42,
+ name: 'Hello world',
+ comments: 20,
+ link: 'https://example.com/hello-world/',
+ },
+ ],
} );
} );
- it( 'links guest authors to the comments search with a decorative avatar', async () => {
- render(
-
+ function renderWidget() {
+ return render(
+
);
+ }
+
+ it( 'links guest authors to the comments search with a decorative avatar', async () => {
+ renderWidget();
const link = await screen.findByRole( 'link', { name: /Guest Author/ } );
expect( link ).toHaveAttribute( 'href', 'edit-comments.php?s=guest%40example.com' );
@@ -56,17 +62,19 @@ describe( 'CommentsWidget', () => {
} );
it( 'keeps WordPress.com users unlinked and preserves their avatar alt text', async () => {
- render(
-
- );
+ renderWidget();
await expect( screen.findByText( 'Member Author' ) ).resolves.toBeInTheDocument();
expect( screen.queryByRole( 'link', { name: /Member Author/ } ) ).not.toBeInTheDocument();
expect( screen.getByAltText( 'Avatar of Member Author' ) ).toBeInTheDocument();
} );
+
+ // Both comment widgets read the same response; this one must show only the
+ // authors group, never the posts rows the sibling widget renders.
+ it( 'shows only the authors group from the shared report', async () => {
+ renderWidget();
+
+ await expect( screen.findByText( 'Guest Author' ) ).resolves.toBeInTheDocument();
+ expect( screen.queryByText( 'Hello world' ) ).not.toBeInTheDocument();
+ } );
} );
diff --git a/projects/packages/premium-analytics/widgets/most-commented-authors/package.json b/projects/packages/premium-analytics/widgets/most-commented-authors/package.json
new file mode 100644
index 000000000000..1c5898373855
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-authors/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@automattic/jetpack-premium-analytics-widget-most-commented-authors",
+ "version": "0.1.0-alpha",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@jetpack-premium-analytics/data": "link:../../packages/data",
+ "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit",
+ "@wordpress/element": "8.3.0",
+ "@wordpress/i18n": "^6.9.0",
+ "@wordpress/icons": "^15.0.0",
+ "@wordpress/ui": "0.17.0",
+ "@wordpress/widget-primitives": "0.2.0",
+ "react": "18.3.1"
+ }
+}
diff --git a/projects/packages/premium-analytics/widgets/most-commented-authors/render.tsx b/projects/packages/premium-analytics/widgets/most-commented-authors/render.tsx
new file mode 100644
index 000000000000..92123ad539a2
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-authors/render.tsx
@@ -0,0 +1,138 @@
+/**
+ * External dependencies
+ */
+import { useStatsCommentsRows } from '@jetpack-premium-analytics/data';
+import {
+ LeaderboardChart,
+ ReportLink,
+ WidgetFooter,
+ WidgetRoot,
+ WidgetState,
+ buildLeaderboardRow,
+ describeError,
+ sharePercentage,
+ toMaxRows,
+ type LeaderboardChartData,
+ type ReportParamsFieldAttributes,
+} from '@jetpack-premium-analytics/widgets-toolkit';
+import { useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { commentAuthorAvatar } from '@wordpress/icons';
+import { Stack } from '@wordpress/ui';
+/**
+ * Internal dependencies
+ */
+import styles from './style.module.css';
+import { type MostCommentedAuthorsAttributes } from './widget';
+import type { WidgetRenderProps } from '@wordpress/widget-primitives';
+
+type MostCommentedAuthorsRenderAttributes = MostCommentedAuthorsAttributes &
+ Partial< ReportParamsFieldAttributes >;
+type MostCommentedAuthorsWidgetProps = WidgetRenderProps< MostCommentedAuthorsRenderAttributes >;
+
+const DATA_FORMAT = { type: 'number' as const, options: { useMultipliers: true, decimals: 0 } };
+
+const DEFAULT_MAX = 10;
+
+interface MostCommentedAuthorsInnerProps {
+ /**
+ * Maximum number of rows to display. `0` means all rows.
+ */
+ max: number;
+}
+
+/**
+ * Most commented authors inner component. The comment counts come from the
+ * all-time `stats/comments` report, so there is no date range or comparison
+ * period to read from context.
+ *
+ * @param {MostCommentedAuthorsInnerProps} props - The component props.
+ * @return The rendered widget content.
+ */
+function MostCommentedAuthorsInner( { max }: MostCommentedAuthorsInnerProps ) {
+ const { rows, isLoading, isFetching, isError, error, refetch } = useStatsCommentsRows( {
+ group: 'authors',
+ max,
+ } );
+
+ const leaderboardData = useMemo< LeaderboardChartData >( () => {
+ const maxValue = Math.max( ...rows.map( row => row.value ), 0 );
+
+ return rows.map( row => ( {
+ id: row.id,
+ // The author link is constructed locally by the data layer (a relative
+ // `edit-comments.php?s=…` search), so it needs no scheme guard — which
+ // would reject it as relative anyway.
+ ...buildLeaderboardRow( {
+ label: row.label,
+ media: { kind: 'avatar', url: row.avatarUrl, name: row.label },
+ action: row.link ? { kind: 'link', href: row.link } : { kind: 'static' },
+ } ),
+ currentValue: row.value,
+ currentShare: sharePercentage( row.value, maxValue ),
+ } ) );
+ }, [ rows ] );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Most commented authors widget: the people who comment the most on the site,
+ * ranked by comment count. Each row links to the comment management screen
+ * filtered to that author when the report reports an email for them.
+ *
+ * One half of the Jetpack Stats "Comments" module; `jpa/most-commented-posts`
+ * covers the other. Both read the same `stats/comments` response through
+ * `useStatsCommentsRows`, so showing both costs a single request.
+ *
+ * @param {MostCommentedAuthorsWidgetProps} props - The widget render props.
+ * @return The rendered Most commented authors widget.
+ */
+export default function MostCommentedAuthors( {
+ attributes = {},
+}: MostCommentedAuthorsWidgetProps ) {
+ return (
+
+
+
+ );
+}
diff --git a/projects/packages/premium-analytics/widgets/most-commented-authors/stories/most-commented-authors-widget.stories.tsx b/projects/packages/premium-analytics/widgets/most-commented-authors/stories/most-commented-authors-widget.stories.tsx
new file mode 100644
index 000000000000..fdeecebaa1c5
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-authors/stories/most-commented-authors-widget.stories.tsx
@@ -0,0 +1,128 @@
+import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
+import {
+ DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
+ WidgetDashboardWithWidget as WidgetDashboardWithWidgetStory,
+ widgetDashboardWithWidgetArgTypes,
+ type WidgetDashboardWithWidgetControls,
+} from '../../stories/widget-dashboard-with-widget';
+import { createStoryWidgetType } from '../../stories/create-story-widget-type';
+import { withStoryRouter } from '../../stories/with-story-router';
+import { withWidgetCanvas } from '../../stories/with-widget-canvas';
+import {
+ forceStatsCommentsState,
+ registerReportMocks,
+} from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks';
+import MostCommentedAuthorsRender from '../render';
+import widgetDefinition from '../widget';
+import widgetManifest from '../widget.json';
+import type { Meta, StoryObj } from '@storybook/react';
+import type { WidgetRenderProps } from '@wordpress/widget-primitives';
+import type { ComponentProps, ComponentType } from 'react';
+
+registerReportMocks();
+
+const MOST_COMMENTED_AUTHORS_RENDER_MODULE = 'storybook/most-commented-authors';
+
+function renderMostCommentedAuthors() {
+ return ;
+}
+
+const meta = {
+ title: 'Packages/Premium Analytics/Widgets/MostCommentedAuthors',
+ component: MostCommentedAuthorsRender,
+ tags: [ 'autodocs' ],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'The "Most commented authors" widget. Ranks the people who comment most on the site by comment count, linking each guest commenter to the comment management screen filtered to them. One half of the Jetpack Stats Comments module; "Most commented posts" covers the other.',
+ },
+ },
+ },
+} satisfies Meta< typeof MostCommentedAuthorsRender >;
+
+export default meta;
+
+// No widget-specific story controls, so the story args are just the render
+// component's (optional) props.
+type Story = StoryObj< Partial< ComponentProps< typeof MostCommentedAuthorsRender > > >;
+
+export const Default: Story = {
+ render: renderMostCommentedAuthors,
+ decorators: [ withWidgetCanvas, withStoryRouter ],
+};
+
+/**
+ * First load: the fetch is in flight, so the widget shows its loading state. The
+ * mock is forced to never resolve for the duration of this story.
+ */
+export const Loading: Story = {
+ render: renderMostCommentedAuthors,
+ // Kept off the shared autodocs page: the mock override is keyed by path, so it
+ // would otherwise force the sibling stories on that page into the same state.
+ tags: [ '!autodocs' ],
+ decorators: [ withWidgetCanvas, withStoryRouter ],
+ beforeEach: forceStatsCommentsState( 'loading' ),
+};
+
+/**
+ * A permission-gated 403: `describeError` maps it to neutral copy with no Retry
+ * action, because the failure is deterministic.
+ */
+export const ErrorState: Story = {
+ render: renderMostCommentedAuthors,
+ tags: [ '!autodocs' ],
+ decorators: [ withWidgetCanvas, withStoryRouter ],
+ beforeEach: forceStatsCommentsState( 'error' ),
+};
+
+/**
+ * The proxy's `no_connection` 403: a broken Jetpack connection can heal, so this
+ * one keeps its Retry action (which re-runs the query — still mocked as failing
+ * while this story is active).
+ */
+export const RetryableErrorState: Story = {
+ render: renderMostCommentedAuthors,
+ tags: [ '!autodocs' ],
+ decorators: [ withWidgetCanvas, withStoryRouter ],
+ beforeEach: forceStatsCommentsState( 'error-retryable' ),
+};
+
+/**
+ * Resolved with no rows: the widget shows its empty state (the neutral comment
+ * author glyph and "No one has commented on your site yet.").
+ */
+export const Empty: Story = {
+ render: renderMostCommentedAuthors,
+ tags: [ '!autodocs' ],
+ decorators: [ withWidgetCanvas, withStoryRouter ],
+ beforeEach: forceStatsCommentsState( 'empty' ),
+};
+
+function MostCommentedAuthorsDashboardStory( dashboardArgs: WidgetDashboardWithWidgetControls ) {
+ return (
+ >
+ }
+ // The report is all-time, so comparison params change nothing here; they
+ // are passed anyway to cover the widget against inventing deltas when the
+ // host supplies comparison dates.
+ attributes={ { reportParams: getDefaultQueryParams( true ) } }
+ />
+ );
+}
+
+export const WidgetDashboardWithWidget: StoryObj< WidgetDashboardWithWidgetControls > = {
+ render: args => ,
+ args: {
+ ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
+ },
+ argTypes: {
+ ...widgetDashboardWithWidgetArgTypes,
+ },
+ decorators: [ withStoryRouter ],
+};
diff --git a/projects/packages/premium-analytics/widgets/comments/style.module.css b/projects/packages/premium-analytics/widgets/most-commented-authors/style.module.css
similarity index 100%
rename from projects/packages/premium-analytics/widgets/comments/style.module.css
rename to projects/packages/premium-analytics/widgets/most-commented-authors/style.module.css
diff --git a/projects/packages/premium-analytics/widgets/most-commented-authors/widget.json b/projects/packages/premium-analytics/widgets/most-commented-authors/widget.json
new file mode 100644
index 000000000000..4b645ad24c03
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-authors/widget.json
@@ -0,0 +1,16 @@
+{
+ "name": "jpa/most-commented-authors",
+ "title": "Most commented authors",
+ "description": "The people who comment the most on your site.",
+ "help": {
+ "content": "Your most active commenters, ranked by the number of comments they have left.",
+ "links": [
+ {
+ "label": "Learn more",
+ "href": "https://jetpack.com/support/jetpack-stats/"
+ }
+ ]
+ },
+ "category": "stats",
+ "presentation": "framed"
+}
diff --git a/projects/packages/premium-analytics/widgets/most-commented-authors/widget.ts b/projects/packages/premium-analytics/widgets/most-commented-authors/widget.ts
new file mode 100644
index 000000000000..3220e5a25774
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-authors/widget.ts
@@ -0,0 +1,40 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { commentAuthorAvatar } from '@wordpress/icons';
+import type { WidgetAttributeField } from '@wordpress/widget-primitives';
+
+export type MostCommentedAuthorsAttributes = {
+ /**
+ * Maximum number of rows to display. `0` means all rows.
+ */
+ max?: number;
+};
+
+/**
+ * Widget type definition for the Most commented authors widget.
+ *
+ * One half of the Jetpack Stats "Comments" module: the site's most active
+ * commenters, ranked by comment count. The other half ships as
+ * `jpa/most-commented-posts`.
+ *
+ * Data: fetched via the PA proxy at `stats/comments` through
+ * `useStatsCommentsRows`. The endpoint is all-time and has no comparison
+ * period, so the widget ignores the dashboard date range.
+ */
+export default {
+ icon: commentAuthorAvatar,
+ attributes: [
+ {
+ id: 'max',
+ label: __( 'Number of results', 'jetpack-premium-analytics-pkg' ),
+ type: 'integer',
+ },
+ ] as WidgetAttributeField< MostCommentedAuthorsAttributes >[],
+ example: {
+ attributes: {
+ max: 10,
+ },
+ },
+};
diff --git a/projects/packages/premium-analytics/widgets/most-commented-posts/__tests__/most-commented-posts.test.tsx b/projects/packages/premium-analytics/widgets/most-commented-posts/__tests__/most-commented-posts.test.tsx
new file mode 100644
index 000000000000..1614f6d8cd00
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-posts/__tests__/most-commented-posts.test.tsx
@@ -0,0 +1,80 @@
+/**
+ * External dependencies
+ */
+import { getDefaultQueryParams, queryClient } from '@jetpack-premium-analytics/data';
+import { render, screen } from '@testing-library/react';
+import apiFetch from '@wordpress/api-fetch';
+/**
+ * Internal dependencies
+ */
+import MostCommentedPostsWidget from '../render';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+jest.mock( '@wordpress/route', () => jest.requireActual( '../../test-utils' ).mockWordPressRoute );
+
+const mockApiFetch = apiFetch as unknown as jest.Mock;
+
+const posts = [
+ {
+ id: 42,
+ name: 'Hello world',
+ comments: 20,
+ link: 'https://example.com/hello-world/',
+ },
+ {
+ id: 43,
+ name: 'Unsafe permalink',
+ comments: 5,
+ link: 'javascript:alert(1)',
+ },
+];
+
+describe( 'MostCommentedPostsWidget', () => {
+ beforeEach( () => {
+ queryClient.clear();
+ mockApiFetch.mockReset();
+ mockApiFetch.mockResolvedValue( {
+ date: '2026-07-20',
+ authors: [
+ {
+ name: 'Guest Author',
+ comments: 12,
+ link: '?s=guest@example.com',
+ gravatar: 'https://www.gravatar.com/avatar/guest?s=96',
+ },
+ ],
+ posts,
+ } );
+ } );
+
+ function renderWidget() {
+ return render(
+
+ );
+ }
+
+ it( 'links a post to its permalink in a new tab', async () => {
+ renderWidget();
+
+ const link = await screen.findByRole( 'link', { name: /Hello world/ } );
+ expect( link ).toHaveAttribute( 'href', 'https://example.com/hello-world/' );
+ expect( link ).toHaveAttribute( 'target', '_blank' );
+ } );
+
+ it( 'drops a permalink that is not a safe http(s) URL, keeping the row', async () => {
+ renderWidget();
+
+ await expect( screen.findByText( 'Unsafe permalink' ) ).resolves.toBeInTheDocument();
+ expect( screen.queryByRole( 'link', { name: /Unsafe permalink/ } ) ).not.toBeInTheDocument();
+ } );
+
+ // Both comment widgets read the same response; this one must show only the
+ // posts group, never the author rows the sibling widget renders.
+ it( 'shows only the posts group from the shared report', async () => {
+ renderWidget();
+
+ await expect( screen.findByText( 'Hello world' ) ).resolves.toBeInTheDocument();
+ expect( screen.queryByText( 'Guest Author' ) ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/projects/packages/premium-analytics/widgets/comments/package.json b/projects/packages/premium-analytics/widgets/most-commented-posts/package.json
similarity index 84%
rename from projects/packages/premium-analytics/widgets/comments/package.json
rename to projects/packages/premium-analytics/widgets/most-commented-posts/package.json
index eb4a21c05089..36f80930ac2e 100644
--- a/projects/packages/premium-analytics/widgets/comments/package.json
+++ b/projects/packages/premium-analytics/widgets/most-commented-posts/package.json
@@ -1,5 +1,5 @@
{
- "name": "@automattic/jetpack-premium-analytics-widget-comments",
+ "name": "@automattic/jetpack-premium-analytics-widget-most-commented-posts",
"version": "0.1.0-alpha",
"private": true,
"type": "module",
diff --git a/projects/packages/premium-analytics/widgets/most-commented-posts/render.tsx b/projects/packages/premium-analytics/widgets/most-commented-posts/render.tsx
new file mode 100644
index 000000000000..b4b7e71bd553
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-posts/render.tsx
@@ -0,0 +1,140 @@
+/**
+ * External dependencies
+ */
+import { useStatsCommentsRows } from '@jetpack-premium-analytics/data';
+import {
+ LeaderboardChart,
+ ReportLink,
+ WidgetFooter,
+ WidgetRoot,
+ WidgetState,
+ buildLeaderboardRow,
+ describeError,
+ safeHttpUrl,
+ sharePercentage,
+ toMaxRows,
+ type LeaderboardChartData,
+ type ReportParamsFieldAttributes,
+} from '@jetpack-premium-analytics/widgets-toolkit';
+import { useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { commentContent } from '@wordpress/icons';
+import { Stack } from '@wordpress/ui';
+/**
+ * Internal dependencies
+ */
+import styles from './style.module.css';
+import { type MostCommentedPostsAttributes } from './widget';
+import type { WidgetRenderProps } from '@wordpress/widget-primitives';
+
+type MostCommentedPostsRenderAttributes = MostCommentedPostsAttributes &
+ Partial< ReportParamsFieldAttributes >;
+type MostCommentedPostsWidgetProps = WidgetRenderProps< MostCommentedPostsRenderAttributes >;
+
+const DATA_FORMAT = { type: 'number' as const, options: { useMultipliers: true, decimals: 0 } };
+
+const DEFAULT_MAX = 10;
+
+interface MostCommentedPostsInnerProps {
+ /**
+ * Maximum number of rows to display. `0` means all rows.
+ */
+ max: number;
+}
+
+/**
+ * Most commented posts inner component. The comment counts come from the
+ * all-time `stats/comments` report, so there is no date range or comparison
+ * period to read from context.
+ *
+ * @param {MostCommentedPostsInnerProps} props - The component props.
+ * @return The rendered widget content.
+ */
+function MostCommentedPostsInner( { max }: MostCommentedPostsInnerProps ) {
+ const { rows, isLoading, isFetching, isError, error, refetch } = useStatsCommentsRows( {
+ group: 'posts',
+ max,
+ } );
+
+ const leaderboardData = useMemo< LeaderboardChartData >( () => {
+ const maxValue = Math.max( ...rows.map( row => row.value ), 0 );
+
+ return rows.map( row => {
+ // Post permalinks come from report data, so validate the scheme before
+ // the row becomes a link.
+ const href = safeHttpUrl( row.link );
+
+ return {
+ id: row.id,
+ ...buildLeaderboardRow( {
+ label: row.label,
+ media: { kind: 'none' },
+ action: href ? { kind: 'link', href } : { kind: 'static' },
+ } ),
+ currentValue: row.value,
+ currentShare: sharePercentage( row.value, maxValue ),
+ };
+ } );
+ }, [ rows ] );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Most commented posts widget: the posts and pages that receive the most
+ * comments, ranked by comment count. Each row links to the published post when
+ * the report reports a permalink for it.
+ *
+ * One half of the Jetpack Stats "Comments" module; `jpa/most-commented-authors`
+ * covers the other. Both read the same `stats/comments` response through
+ * `useStatsCommentsRows`, so showing both costs a single request.
+ *
+ * @param {MostCommentedPostsWidgetProps} props - The widget render props.
+ * @return The rendered Most commented posts widget.
+ */
+export default function MostCommentedPosts( { attributes = {} }: MostCommentedPostsWidgetProps ) {
+ return (
+
+
+
+ );
+}
diff --git a/projects/packages/premium-analytics/widgets/most-commented-posts/stories/most-commented-posts-widget.stories.tsx b/projects/packages/premium-analytics/widgets/most-commented-posts/stories/most-commented-posts-widget.stories.tsx
new file mode 100644
index 000000000000..a9b37b0826a2
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-posts/stories/most-commented-posts-widget.stories.tsx
@@ -0,0 +1,126 @@
+import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
+import {
+ DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
+ WidgetDashboardWithWidget as WidgetDashboardWithWidgetStory,
+ widgetDashboardWithWidgetArgTypes,
+ type WidgetDashboardWithWidgetControls,
+} from '../../stories/widget-dashboard-with-widget';
+import { createStoryWidgetType } from '../../stories/create-story-widget-type';
+import { withStoryRouter } from '../../stories/with-story-router';
+import { withWidgetCanvas } from '../../stories/with-widget-canvas';
+import {
+ forceStatsCommentsState,
+ registerReportMocks,
+} from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks';
+import MostCommentedPostsRender from '../render';
+import widgetDefinition from '../widget';
+import widgetManifest from '../widget.json';
+import type { Meta, StoryObj } from '@storybook/react';
+import type { WidgetRenderProps } from '@wordpress/widget-primitives';
+import type { ComponentProps, ComponentType } from 'react';
+
+registerReportMocks();
+
+const MOST_COMMENTED_POSTS_RENDER_MODULE = 'storybook/most-commented-posts';
+
+function renderMostCommentedPosts() {
+ return ;
+}
+
+const meta = {
+ title: 'Packages/Premium Analytics/Widgets/MostCommentedPosts',
+ component: MostCommentedPostsRender,
+ tags: [ 'autodocs' ],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'The "Most commented posts" widget. Ranks the posts and pages that receive the most comments, linking each row to the published post. One half of the Jetpack Stats Comments module; "Most commented authors" covers the other.',
+ },
+ },
+ },
+} satisfies Meta< typeof MostCommentedPostsRender >;
+
+export default meta;
+
+// No widget-specific story controls, so the story args are just the render
+// component's (optional) props.
+type Story = StoryObj< Partial< ComponentProps< typeof MostCommentedPostsRender > > >;
+
+export const Default: Story = {
+ render: renderMostCommentedPosts,
+ decorators: [ withWidgetCanvas, withStoryRouter ],
+};
+
+/**
+ * First load: the fetch is in flight, so the widget shows its loading state. The
+ * mock is forced to never resolve for the duration of this story.
+ */
+export const Loading: Story = {
+ render: renderMostCommentedPosts,
+ // Kept off the shared autodocs page: the mock override is keyed by path, so it
+ // would otherwise force the sibling stories on that page into the same state.
+ tags: [ '!autodocs' ],
+ decorators: [ withWidgetCanvas, withStoryRouter ],
+ beforeEach: forceStatsCommentsState( 'loading' ),
+};
+
+/**
+ * A permission-gated 403: `describeError` maps it to neutral copy with no Retry
+ * action, because the failure is deterministic.
+ */
+export const ErrorState: Story = {
+ render: renderMostCommentedPosts,
+ tags: [ '!autodocs' ],
+ decorators: [ withWidgetCanvas, withStoryRouter ],
+ beforeEach: forceStatsCommentsState( 'error' ),
+};
+
+/**
+ * The proxy's `no_connection` 403: a broken Jetpack connection can heal, so this
+ * one keeps its Retry action (which re-runs the query — still mocked as failing
+ * while this story is active).
+ */
+export const RetryableErrorState: Story = {
+ render: renderMostCommentedPosts,
+ tags: [ '!autodocs' ],
+ decorators: [ withWidgetCanvas, withStoryRouter ],
+ beforeEach: forceStatsCommentsState( 'error-retryable' ),
+};
+
+/**
+ * Resolved with no rows: the widget shows its empty state (the neutral comment
+ * content glyph and "None of your posts or pages have comments yet.").
+ */
+export const Empty: Story = {
+ render: renderMostCommentedPosts,
+ tags: [ '!autodocs' ],
+ decorators: [ withWidgetCanvas, withStoryRouter ],
+ beforeEach: forceStatsCommentsState( 'empty' ),
+};
+
+function MostCommentedPostsDashboardStory( dashboardArgs: WidgetDashboardWithWidgetControls ) {
+ return (
+ > }
+ // The report is all-time, so comparison params change nothing here; they
+ // are passed anyway to cover the widget against inventing deltas when the
+ // host supplies comparison dates.
+ attributes={ { reportParams: getDefaultQueryParams( true ) } }
+ />
+ );
+}
+
+export const WidgetDashboardWithWidget: StoryObj< WidgetDashboardWithWidgetControls > = {
+ render: args => ,
+ args: {
+ ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
+ },
+ argTypes: {
+ ...widgetDashboardWithWidgetArgTypes,
+ },
+ decorators: [ withStoryRouter ],
+};
diff --git a/projects/packages/premium-analytics/widgets/most-commented-posts/style.module.css b/projects/packages/premium-analytics/widgets/most-commented-posts/style.module.css
new file mode 100644
index 000000000000..cad56012643f
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-posts/style.module.css
@@ -0,0 +1,17 @@
+.root {
+ container-type: inline-size;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+/* Positioned anchor so WidgetState's overlay (position: absolute; inset: 0)
+ resolves against the widget body, not the host frame. */
+.content {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 0;
+ min-height: 0;
+}
diff --git a/projects/packages/premium-analytics/widgets/most-commented-posts/widget.json b/projects/packages/premium-analytics/widgets/most-commented-posts/widget.json
new file mode 100644
index 000000000000..69ba67baae68
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-posts/widget.json
@@ -0,0 +1,16 @@
+{
+ "name": "jpa/most-commented-posts",
+ "title": "Most commented posts",
+ "description": "The posts and pages that receive the most comments.",
+ "help": {
+ "content": "Your posts and pages, ranked by the number of comments they have received.",
+ "links": [
+ {
+ "label": "Learn more",
+ "href": "https://jetpack.com/support/jetpack-stats/"
+ }
+ ]
+ },
+ "category": "stats",
+ "presentation": "framed"
+}
diff --git a/projects/packages/premium-analytics/widgets/most-commented-posts/widget.ts b/projects/packages/premium-analytics/widgets/most-commented-posts/widget.ts
new file mode 100644
index 000000000000..701cedacc6d5
--- /dev/null
+++ b/projects/packages/premium-analytics/widgets/most-commented-posts/widget.ts
@@ -0,0 +1,40 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { commentContent } from '@wordpress/icons';
+import type { WidgetAttributeField } from '@wordpress/widget-primitives';
+
+export type MostCommentedPostsAttributes = {
+ /**
+ * Maximum number of rows to display. `0` means all rows.
+ */
+ max?: number;
+};
+
+/**
+ * Widget type definition for the Most commented posts widget.
+ *
+ * One half of the Jetpack Stats "Comments" module: the posts and pages that
+ * receive the most comments. The other half ships as
+ * `jpa/most-commented-authors`.
+ *
+ * Data: fetched via the PA proxy at `stats/comments` through
+ * `useStatsCommentsRows`. The endpoint is all-time and has no comparison
+ * period, so the widget ignores the dashboard date range.
+ */
+export default {
+ icon: commentContent,
+ attributes: [
+ {
+ id: 'max',
+ label: __( 'Number of results', 'jetpack-premium-analytics-pkg' ),
+ type: 'integer',
+ },
+ ] as WidgetAttributeField< MostCommentedPostsAttributes >[],
+ example: {
+ attributes: {
+ max: 10,
+ },
+ },
+};