Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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,3 @@
Significance: patch
Type: changed
Comment: Register the premium-analytics widgets directory as a story discovery path.
1 change: 1 addition & 0 deletions projects/js-packages/storybook/storybook/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const projects = [
'projects/js-packages/social-logos/src/react',
'projects/packages/my-jetpack/_inc/components',
'projects/packages/premium-analytics/packages',
'projects/packages/premium-analytics/widgets',
'projects/packages/publicize/_inc/components',
'projects/packages/search/src/dashboard/components',
'projects/packages/videopress/src/client/admin/components',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: added

Storybook: Add a prop-driven Top posts & pages widget story under Packages/Premium Analytics/Widgets.
Comment thread
louwie17 marked this conversation as resolved.
Outdated
2 changes: 1 addition & 1 deletion projects/packages/premium-analytics/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
"@jetpack-premium-analytics/*": [ "./packages/*/src" ]
}
},
"include": [ "routes/**/*", "packages/**/*", "types/**/*" ]
"include": [ "routes/**/*", "packages/**/*", "types/**/*", "widgets/**/*" ]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { TopPostsWidget, type TopPostsWidgetProps } from './top-posts-widget';
export type { TopPostRow } from './types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* Internal dependencies
*/
import { withChartTheme } from '../../../packages/widgets-toolkit/src/stories/with-chart-theme';
import { TopPostsWidget } from '../top-posts-widget';
import type { TopPostRow } from '../types';
import type { Meta, StoryObj, Decorator } from '@storybook/react';

const meta: Meta< typeof TopPostsWidget > = {
title: 'Packages/Premium Analytics/Widgets/TopPosts',
component: TopPostsWidget,
tags: [ 'autodocs' ],
parameters: {
docs: {
description: {
component:
'The "Top posts & pages" widget. Renders the most-viewed posts and pages for the period as a leaderboard, with each row linking to the published content. This is a presentational component — it takes already-fetched rows via props and handles the loading, error, empty, and populated states.',
},
},
},
decorators: [ withChartTheme ],
};

export default meta;

type Story = StoryObj< typeof TopPostsWidget >;

const mockRows: TopPostRow[] = [
{
label: 'How we cut our build times in half',
value: 12840,
previousValue: 9870,
href: 'https://example.com/cut-build-times-in-half',
type: 'post',
},
{
label: 'Pricing',
value: 9320,
previousValue: 10110,
href: 'https://example.com/pricing',
type: 'page',
},
{
label: '10 lessons from scaling to a million users',
value: 7610,
previousValue: 5400,
href: 'https://example.com/lessons-scaling-million-users',
type: 'post',
},
{
label: 'About us',
value: 4180,
previousValue: 4360,
href: 'https://example.com/about',
type: 'page',
},
{
label: 'A practical guide to feature flags',
value: 2950,
previousValue: 0,
href: 'https://example.com/guide-to-feature-flags',
type: 'post',
},
];

const mockLongLabelRows: TopPostRow[] = [
{
label:
'An exhaustively long, keyword-stuffed headline that almost certainly needs to be truncated before it overflows the row',
value: 8400,
href: 'https://example.com/very-long-headline-that-needs-truncation',
type: 'post',
},
{
label: 'Frequently asked questions about billing, refunds, and account management',
value: 5120,
href: 'https://example.com/faq-billing-refunds-account-management',
type: 'page',
},
{
label: 'Changelog',
value: 2010,
href: 'https://example.com/changelog',
type: 'page',
},
];

/**
* Default populated state — a mix of posts and pages ranked by views.
*/
export const Default: Story = {
args: {
rows: mockRows,
},
};

/**
* Comparison state — each value shows its change versus the previous period
* (green for gains, red for losses), driven by each row's `previousValue`.
* Mirrors the overlay comparison mode of the toolkit's `LeaderboardChart`.
*/
export const WithComparison: Story = {
args: {
rows: mockRows,
withComparison: true,
showLegend: true,
legendLabels: {
primary: 'Jun 1 – 18, 2026',
comparison: 'May 14 – 31, 2026',
},
},
};

/**
* Loading state — the chart renders its loading overlay while data is fetched.
*/
export const Loading: Story = {
args: {
rows: [],
isLoading: true,
},
};

/**
* Empty state — no views were recorded for the selected period.
*/
export const NoViews: Story = {
args: {
rows: [],
},
};

/**
* Error state — the report could not be loaded.
*/
export const ErrorState: Story = {
args: {
isError: true,
},
};

/**
* Long titles are truncated with an ellipsis so rows stay single-line.
*/
export const LongLabels: Story = {
args: {
rows: mockLongLabelRows,
},
};

/**
* Creates a decorator that wraps the story in a fixed-size container so the
* widget's responsiveness can be inspected at a given width.
*
* @param width - The container width (any CSS length).
* @param [height] - The container height; defaults to `auto`.
* @return A Storybook decorator.
*/
const createSizeDecorator = ( width: string, height = 'auto' ): Decorator => {
return Story => (
<div
style={ {
width,
height,
border: '1px dashed #ccc',
borderRadius: '8px',
padding: '16px',
background: '#fafafa',
containerType: 'inline-size',
containerName: 'widget',
} }
>
<Story />
</div>
);
};

/**
* Medium container (448px / md breakpoint).
*/
export const SizeMedium: Story = {
args: {
rows: mockRows,
},
decorators: [ createSizeDecorator( '448px' ) ],
};

/**
* Large container (576px / xl breakpoint).
*/
export const SizeLarge: Story = {
args: {
rows: mockRows,
},
decorators: [ createSizeDecorator( '576px' ) ],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.labelLink {
display: block;

/* Vertical padding sets the overlay bar height — the bar fills the row,
whose height is driven by this label (there is no image to size it). */
padding-block: var(--wpds-dimension-padding-md, 12px);

/* Inset the text from the bar's rounded left edge. The chart applies this to
its default `.label` in overlay mode (`.is-overlay .label`), but a custom
label element bypasses that rule, so we mirror it here. */
padding-inline-start: var(--wpds-dimension-padding-sm, 8px);
overflow: hidden;
color: inherit;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}

.labelLink:hover,
.labelLink:focus {
text-decoration: underline;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Text } from '@wordpress/ui';
import {
LeaderboardChart,
calculateDelta,
type LeaderboardChartData,
type LegendLabels,
} from '@jetpack-premium-analytics/widgets-toolkit';
/**
* Internal dependencies
*/
import styles from './top-posts-widget.module.css';
import type { TopPostRow } from './types';

export type TopPostsWidgetProps = {
/**
* Normalized top-posts rows to render. When omitted, the empty state is shown
* (unless `isLoading` is set).
*/
rows?: TopPostRow[];
/**
* When `true`, the chart renders its loading overlay instead of data.
*/
isLoading?: boolean;
/**
* When `true`, an error message is rendered in place of the chart.
*/
isError?: boolean;
/**
* When `true`, render the comparison (previous-period) delta next to each
* value, using `previousValue` from each row. Mirrors the overlay
* comparison mode of the toolkit's `LeaderboardChart`.
*/
withComparison?: boolean;
/**
* When `true`, show the period legend below the chart. Requires
* `legendLabels` to be meaningful.
*/
showLegend?: boolean;
/**
* Custom legend labels for the current/comparison periods.
*/
legendLabels?: LegendLabels;
};

/**
* Renders a post/page title as a link that opens in a new tab. The link fills
* its row so the leaderboard overlay bar gets its height from the label.
*
* @param props - Component props.
* @param props.label - The post/page title.
* @param props.href - The published URL of the post/page.
* @return The rendered label link.
*/
const TopPostLabel = ( { label, href }: { label: string; href: string } ) => (
<a
className={ styles.labelLink }
href={ href }
target="_blank"
rel="noopener noreferrer"
title={ label }
>
{ label }
</a>
);

/**
* Maps normalized top-posts rows onto the shape `LeaderboardChart` expects.
* Current shares are computed relative to the most-viewed row so the overlay
* bars are proportional. When `withComparison` is set, previous-period shares
* and per-row deltas are derived from each row's `previousValue`; otherwise
* the comparison fields are zeroed.
*
* @param rows - The normalized top-posts rows.
* @param withComparison - Whether to derive previous-period shares and deltas.
* @return The leaderboard chart data.
*/
function buildLeaderboardData( rows: TopPostRow[], withComparison: boolean ): LeaderboardChartData {
// `1` guards against division by zero when every value is 0.
const maxCurrentViews = Math.max( ...rows.map( row => row.value ), 1 );
const maxPreviousViews = Math.max( ...rows.map( row => row.previousValue ?? 0 ), 1 );

return rows.map( ( row, index ) => {
const previousValue = row.previousValue ?? 0;

return {
id: `${ index }-${ row.href }`,
label: <TopPostLabel label={ row.label } href={ row.href } />,
currentValue: row.value,
currentShare: ( row.value / maxCurrentViews ) * 100,
previousValue,
previousShare:
withComparison && previousValue > 0 ? ( previousValue / maxPreviousViews ) * 100 : 0,
delta: withComparison ? calculateDelta( row.value, previousValue ) : 0,
};
} );
}

/**
* "Top posts & pages" widget. Renders the most-viewed posts and pages for the
* period as a leaderboard, each row linking to the published content.
*
* This is a presentational component: it takes already-fetched rows via props
* and is responsible only for the loading, error, empty, and populated states.
*
* @param props - Component props.
* @param props.rows - Normalized top-posts rows to render.
* @param props.isLoading - Whether the chart should render its loading overlay.
* @param props.isError - Whether to render an error message in place of the chart.
* @param props.withComparison - Whether to render previous-period deltas.
* @param props.showLegend - Whether to show the period legend below the chart.
* @param props.legendLabels - Custom labels for the current/comparison periods.
* @return The rendered widget.
*/
export const TopPostsWidget = ( {
rows = [],
isLoading = false,
isError = false,
withComparison = false,
showLegend = false,
legendLabels,
}: TopPostsWidgetProps ) => {
if ( isError ) {
return <Text>{ __( 'Unable to load top posts.', 'jetpack-premium-analytics' ) }</Text>;
}

if ( ! isLoading && rows.length === 0 ) {
return <Text>{ __( 'No views in this period.', 'jetpack-premium-analytics' ) }</Text>;
}

return (
<LeaderboardChart
data={ buildLeaderboardData( rows, withComparison ) }
loading={ isLoading }
withComparison={ withComparison }
withOverlayLabel
showLegend={ showLegend }
legendLabels={ legendLabels }
dataFormat={ { type: 'number', options: { useMultipliers: true, decimals: 0 } } }
Comment thread
louwie17 marked this conversation as resolved.
Outdated
/>
);
};
Loading
Loading