Skip to content

Premium Analytics: add a Leaderboard component over the chart pieces - #52965

Merged
retrofox merged 9 commits into
trunkfrom
update/pa-leaderboard-widget-kind
Sep 30, 2026
Merged

retrofox merged 9 commits into
trunkfrom
update/pa-leaderboard-widget-kind

Conversation

@retrofox

@retrofox retrofox commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes WOOA7S-2239

Proposed changes

A ranked-rows widget takes nine toolkit names to render today: the chart, its skeleton, WidgetState, the row limit, the row builder, three math helpers, and the navigation hook.

Nineteen widgets in the package render LeaderboardChart. Thirteen repeat the same composition by hand: the largest value of either period, a share per row, a delta, the row chrome, the skeleton, the states. Seven of them add a drill-down on top, also by hand.

The VideoPress stack was about to expose that construction kit through the SDK (#52885). That is the sign the abstraction stops one level short: a consumer wants to hand over its rows and its request status, not assemble a chart.

The component

Leaderboard lives in packages/widgets-toolkit/src/components/leaderboard/. It takes rows in the widget's own terms and a plain request status, and renders inside WidgetRoot.

// Inside `WidgetRoot`.
const rows = useMemo(
	() =>
		videos.map( video => ( {
			id: video.key,
			label: video.label,
			value: video.plays,
			previousValue: video.previousPlays,
			action: { kind: 'videoLink', id: video.id, href: video.link },
		} ) ),
	[ videos ]
);

return (
	<Leaderboard
		rows={ rows }
		status={ { isLoading, isFetching, isError, hasComparison, refetch } }
		error={ describeError( error, {
			retryDescription: __( "We couldn't load video plays. Please try again in a moment.", 'jetpack-premium-analytics-pkg' ),
			onRetry: refetch,
		} ) }
		footer={ <ReportLink report="videos" /> }
	/>
);

What it owns, so the widget does not:

  • The share of each row against the largest value of either period.
  • The delta where a comparison value exists; buildLeaderboardRow() for the row chrome.
  • The skeleton, sized to the row limit,
  • WidgetState for the error and empty states: the widget's error as describeError() built it, or a generic one with a Retry bound to refetch when the widget passes none.
  • The layout, modeled with Stack, plus an optional footer; the period legend when the widget names its labels; and the dashboard window on a postLink or videoLink action that omits search.

The props are the ones the package's own widgets need and nothing else: rows, status, error, empty, maxRows, format, legend, drillDown, navigation, footer.

Drill-down

Rows may carry children. With a drillDown (backLabel, rowAriaLabel), every such row becomes a button that shows its children under a back link.

<Leaderboard
	rows={ rows }
	status={ status }
	drillDown={ {
		backLabel: __( 'All clicks', 'jetpack-premium-analytics-pkg' ),
		backAriaLabel: __( 'View all clicks', 'jetpack-premium-analytics-pkg' ),
		rowAriaLabel: row => sprintf( __( 'View clicked links for %s', 'jetpack-premium-analytics-pkg' ), row.label ),
	} }
/>

The component keeps the path of selected rows, any number of levels deep, and labels the back link after the list it returns to: the top-level label at depth one, the parent row's label further down, as Referrers does today.

The comparison switches to the children's own. A selection the settled data no longer backs is trimmed, so it survives in-flight fetches and transient failures.

A widget whose rows change under its own control, like the UTM dimension, resets the selection by giving the leaderboard a key.

The toolkit exports the component and its four types (LeaderboardProps, LeaderboardRowInput, LeaderboardStatus, LeaderboardDrillDown). The pure step behind it, buildLeaderboardChartData(), stays internal until a widget needs it. The chart and its pieces stay the same.

Dogfooding

This PR moves three widgets to it: Top videos, Search terms, and Most commented authors. Each loses its data builder, its WidgetState wiring, and its layout CSS. Top videos also moves its error to describeError(), like the other two: an access-denied failure now reads "You don't have access to this data." with no Retry.

The remaining leaderboards follow in batches (WOOA7S-2240). The metric-tabs family gets the same treatment (WOOA7S-2241).

What it changes for the SDK

Nothing yet. Once this lands, #52885 exports Leaderboard and its types instead of the eight leaderboard pieces, and the Top videos widget of #52909 renders <Leaderboard>.

Screenshots

Compare, for instance, the Most commented Authors widget

Before After
image image

Drill-down (Storybook, WithDrillDown)

image

Related product discussion/links

Linear: WOOA7S-2239, under the SDK audit WOOA7S-2228. Follow-ups WOOA7S-2240 and WOOA7S-2241.

Does this pull request change what data or activity we track or use?

No.

Testing instructions

jetpack build packages/premium-analytics
jetpack test js packages/premium-analytics

On the dashboard, the three migrated widgets look and behave as before.

  • Top videos, on Traffic: rows link to the video detail page with the current date window, comparison deltas when the comparison is on, Retry on a failed request, the generic empty state.
  • Search terms, on Traffic.
  • Most commented authors, on Insights: avatars, external links, the "No one has commented" empty state.

In Storybook, Widgets Toolkit/Components/Leaderboard shows the component on its own in every state, including WithDrillDown.

rows plus status in; states, shares, deltas and detail links owned; three widgets migrated
@retrofox retrofox added Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Status] In Progress labels Sep 30, 2026
@retrofox retrofox self-assigned this Sep 30, 2026
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the update/pa-leaderboard-widget-kind branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack update/pa-leaderboard-widget-kind

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@jp-launch-control

jp-launch-control Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 3 files.

File Coverage Δ% Δ Uncovered
projects/packages/premium-analytics/widgets/most-commented-authors/render.tsx 5/5 (100.00%) 0.00% 0 💚
projects/packages/premium-analytics/widgets/videopress/render.tsx 9/9 (100.00%) 0.00% 0 💚
projects/packages/premium-analytics/widgets/search-terms/render.tsx 6/6 (100.00%) 36.36% -4 💚

4 files are newly checked for coverage.

File Coverage
projects/packages/premium-analytics/packages/widgets-toolkit/src/components/leaderboard/leaderboard.tsx 32/33 (96.97%) 💚
projects/packages/premium-analytics/packages/widgets-toolkit/src/components/leaderboard/build-leaderboard-chart-data.ts 18/18 (100.00%) 💚
projects/packages/premium-analytics/packages/widgets-toolkit/src/components/leaderboard/drill-down-trail.ts 9/9 (100.00%) 💚
projects/packages/premium-analytics/packages/widgets-toolkit/src/components/leaderboard/index.ts 0/0 (—%) 🤷

Full summary · PHP report · JS report

ReportLink and the detail links render route links; one WidgetRoot per story
header, overlay and legend flags go; legend labels stay as one prop; JSDoc per prop
the component serves the package's own widgets too; nothing goes without a use in hand
rows declare children; the component owns the path, the back link and the healing
the row chrome is the overlay design; the chart keeps the switch for its own callers
no widget styles the body from outside
@retrofox
retrofox marked this pull request as ready for review September 30, 2026 10:33
@retrofox
retrofox requested a review from a team as a code owner September 30, 2026 10:33
@retrofox retrofox added [Status] Needs Review This PR is ready for review. and removed [Status] In Progress labels Sep 30, 2026

@louwie17 louwie17 left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @retrofox, this is a nice cleanup, and the three widgets look the same as trunk on a local Docker site 👍

I left a few inline comments. The main one to fix is Retry showing up again on "access denied" errors. The one about exports is a question, and the README one can be a follow-up.

Trunk is left, LeaderBoard usage is right.
compare-insights-403-All-time-most-commented-authors

);

const errorState = useMemo< WidgetStateError | undefined >( () => {
if ( error?.actions || ! refetch ) {

@louwie17 louwie17 Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

describeError() leaves actions out on purpose for 403s and response-shape errors, since retrying can't help there. This check treats a missing actions as "add Retry", so Search terms and Most commented authors show a Retry button again under "You don't have access to this data." I forced the stats request to return 403 on a local Docker site to confirm it.

Maybe only add the default Retry when no error is passed at all, and have Top videos use describeError() like the other two?

Suggested change
if ( error?.actions || ! refetch ) {
if ( error || ! refetch ) {

The test at __tests__/leaderboard.test.tsx L131 passes actions: [], a shape describeError() never returns, so it doesn't catch this. It would be good to cover it with describeError( { status: 403 } ).

Trunk is left, this PR on the right.
compare-traffic-403-Top-searched-terms

@retrofox retrofox Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, and thanks for forcing the 403 to confirm it. Fixed in 2687d48 with your suggestion: the component only adds a Retry to the default error, the one it shows when the widget passes none. An error from the widget renders as given, so describeError() decides.

Top videos moved to describeError() like the other two, so a denied request reads "You don't have access to this data." there as well.

The actions: [] test is gone. The suite now covers the three cases with real shapes: no error, describeError( { status: 403 } ), and a retryable describeError() result. The widget suite has the 403 case too.

type LeaderboardChartData,
type LeaderboardSkeletonProps,
Leaderboard,
buildLeaderboardChartData,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are all of these supposed to be public? I couldn't find anything outside components/leaderboard/ that imports buildLeaderboardChartData, BuildLeaderboardChartDataOptions or LeaderboardRowInputAction. Once #52885 exposes this through the SDK, they'd be hard to take back. Should we keep them internal until a widget needs to drive the chart itself?

@retrofox retrofox Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, they were not meant to be public. The toolkit now exports the component and its four types: LeaderboardProps, LeaderboardRowInput, LeaderboardStatus and LeaderboardDrillDown.

buildLeaderboardChartData(), its options type and LeaderboardRowInputAction stay internal to components/leaderboard/ until a widget needs to drive the chart itself.

```tsx
<WidgetRoot attributes={ attributes }>
<Leaderboard
rows={ rows.map( row => ( {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Small nitpick: this example passes rows={ rows.map( … ) } inline, which rebuilds the chart data every render. The three widgets memoize their rows correctly, so maybe the example should show the useMemo form, given SDK consumers will likely copy it. Fine as a follow-up.

@retrofox retrofox Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done here rather than as a follow-up, since it is the snippet people will copy: the example memoizes the rows and passes describeError().

The PR description shows the same form.

a describeError() result decides for itself; the chart-data builder stays internal
@retrofox
retrofox requested a review from louwie17 September 30, 2026 12:16

@louwie17 louwie17 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the quick updates @retrofox, and for moving Top videos onto describeError() too. Retested on a local Docker site: a 403 now reads "You don't have access to this data." with no Retry on all three widgets, and a 500 still offers Retry. The exports and README look good as well.

Lets ship it :) 🚢

@dognose24 dognose24 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Read the new component and the three migrated widgets, and ran the package's Jest suite (including the two extra timezones) and the tsgo typecheck at 8b50bd8: all pass. The three widgets build the same rows, shares and states as before, and the Stack-based layout matches the CSS it replaces. The 403 handling and the narrowed exports from the earlier round look right.

Three small suggestions inline, all on the drill-down path that no widget uses yet, so none of them changes what ships here. They can go with the follow-up that moves the drill-down widgets (WOOA7S-2240).

Comment on lines +167 to +170
const select = useCallback(
( row: LeaderboardRowInput ) => setPath( [ ...( path ?? [] ), row.id ] ),
[ path, setPath ]
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] select appends to the raw path, while goBack steps from the resolved trail. If the rows change while isFetching is still true (say the primary query lands before the comparison one), path still holds an id the new rows no longer have, so the clicked row is appended after it and never resolves. The click does nothing, and once the data settles the effect resets the selection.

I reproduced it with a throwaway test: drill into a row, rerender with different rows and isFetching: true, then click a row with children.

Building from the trail fixes it:

Suggested change
const select = useCallback(
( row: LeaderboardRowInput ) => setPath( [ ...( path ?? [] ), row.id ] ),
[ path, setPath ]
);
const select = useCallback(
( row: LeaderboardRowInput ) => setPath( [ ...trail.map( step => step.id ), row.id ] ),
[ trail, setPath ]
);

Referrers' drillInto has the same shape today, so this is inherited rather than new. Fine as a follow-up.

Comment on lines +223 to +238
const backLink =
parent && drillDown ? (
<WidgetBackLink
label={ grandparent ? grandparent.label : drillDown.backLabel }
ariaLabel={
grandparent
? sprintf(
/* translators: %s is the label of the list the back link returns to. */
__( 'Back to %s', 'jetpack-premium-analytics-pkg' ),
grandparent.label
)
: ( drillDown.backAriaLabel ?? drillDown.backLabel )
}
onClick={ goBack }
/>
) : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] The back link sits outside WidgetState, so when it unmounts on the way back to the top level, focus falls to <body>: WidgetState only restores focus for elements inside its own root. Drilling in is fine, focus lands on the WidgetState root. Checked with a throwaway test: after clicking the top-level back link, document.activeElement is body.

Referrers behaves the same today. Since this component will own the drill-down for the remaining widgets, it may be worth fixing once here, for example by moving focus to the list when the back link goes away. Happy for it to go with WOOA7S-2240.

Comment on lines +78 to +81
/**
* Accessible name of a row that drills down, e.g. "View clicked links for %s".
*/
rowAriaLabel: ( row: LeaderboardRowInput ) => string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] nit: this sentence is the same as the one on LeaderboardDrillDown.rowAriaLabel in leaderboard.tsx. The public type is the one consumers read, so it could stay there and this internal copy could go.

@retrofox
retrofox merged commit 81e9369 into trunk Sep 30, 2026
85 checks passed
@retrofox
retrofox deleted the update/pa-leaderboard-widget-kind branch September 30, 2026 14:54
@github-actions github-actions Bot removed the [Status] Needs Review This PR is ready for review. label Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Docs Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Package] Premium Analytics [Status] UI Changes Add this to PRs that change the UI so documentation can be updated. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants