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 the Most commented authors and Most commented posts widgets.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,78 @@
/**
* 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 };

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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -110,6 +113,7 @@ export {
flattenStatsLeaves,
getStatsChartBucketKey,
getStatsReportItems,
selectStatsCommentsRows,
sliceWordAdsStatsReport,
} from './processing/stats';
export type { FlattenStatsLeavesContext, FlattenStatsLeavesOptions } from './processing/stats';
Expand Down Expand Up @@ -274,13 +278,15 @@ export type {
StatsCommentFollowersRawPost,
StatsCommentFollowersRawResponse,
StatsCommentsAuthorItem,
StatsCommentsGroup,
StatsCommentsGroupItem,
StatsCommentsItem,
StatsCommentsPostItem,
StatsCommentsRawAuthor,
StatsCommentsRawFollowData,
StatsCommentsRawPost,
StatsCommentsRawResponse,
StatsCommentsRow,
StatsEmailBreakdownItem,
StatsDevicesComparisonItem,
StatsDevicesItem,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sanitizeStatsCommentsResponse } from '..';
import { sanitizeStatsCommentsResponse, selectStatsCommentsRows } from '..';
import { commentsFixture } from '../__fixtures__/comments';

describe( 'Stats comments normalizer', () => {
Expand Down Expand Up @@ -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( [] );
} );
} );
Loading
Loading