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: patch
Type: changed

Ads: Show payment status as a badge in the Earnings History widget, and shorten the pending statuses to one word with the reason beside them. Negative amounts in the widget are no longer red; only the badge carries colour.
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,13 @@ describe( 'EarningsHistoryList', () => {

expect( hiddenFlags() ).toEqual( [ false, true, true ] );
} );

it( 'renders each status as a badge, with a pending reason in an info button', () => {
mockSizes( 200, 36 );
render( <EarningsHistoryList rows={ [ ...ROWS, { ...ROWS[ 0 ], id: 'p', status: 3 } ] } /> );

expect( screen.getAllByText( 'Paid' ) ).toHaveLength( 3 );
expect( screen.getByText( 'Pending' ) ).toBeInTheDocument();
expect( screen.getByRole( 'button', { name: 'Missing tax info' } ) ).toBeInTheDocument();
} );
} );
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { filterSortAndPaginate, type View } from '@jetpack-premium-analytics/externals';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
EarningsStatusBadge,
flattenEarningsBreakdown,
Expand All @@ -12,8 +13,8 @@ describe( 'getEarningsStatus', () => {
expect( getEarningsStatus( 0 ).label ).toBe( 'Unpaid' );
expect( getEarningsStatus( 1 ).label ).toBe( 'Paid' );
expect( getEarningsStatus( 2 ).label ).toBe( 'a8c-only' );
expect( getEarningsStatus( 3 ).label ).toBe( 'Pending (Missing Tax Info)' );
expect( getEarningsStatus( 4 ).label ).toBe( 'Pending (Invalid PayPal)' );
expect( getEarningsStatus( 3 ).label ).toBe( 'Pending' );
expect( getEarningsStatus( 4 ).label ).toBe( 'Pending' );
} );

it( 'falls back to "?" for unknown or absent statuses', () => {
Expand Down Expand Up @@ -47,6 +48,18 @@ describe( 'EarningsStatusBadge', () => {
render( <EarningsStatusBadge status={ 2 } /> );
expect( screen.getByText( 'a8c-only' ) ).not.toHaveAttribute( 'tabindex' );
} );

it( 'puts a pending reason in an info button beside a one-word badge', async () => {
render( <EarningsStatusBadge status={ 3 } /> );

expect( screen.getByText( 'Pending' ) ).not.toHaveAttribute( 'tabindex' );

await userEvent.click( screen.getByRole( 'button', { name: 'Missing tax info' } ) );

await expect(
screen.findByText( /You can provide tax information in the settings screen/ )
).resolves.toBeInTheDocument();
} );
} );

describe( 'flattenEarningsBreakdown', () => {
Expand Down Expand Up @@ -120,17 +133,31 @@ describe( 'getWordAdsHistoryFields', () => {
expect( data.map( row => row.period ) ).toEqual( [ period ] );
} );

it( 'offers every status but a8c-only in the Status filter', () => {
it( 'offers every status but a8c-only in the Status filter, pending once', () => {
const status = fields.find( field => field.id === 'status' );

expect( status?.elements?.map( element => element.value ) ).toEqual( [
'Unpaid',
'Paid',
'Pending (Missing Tax Info)',
'Pending (Invalid PayPal)',
'Pending',
] );
} );

it( 'filters "Pending" to both pending codes', () => {
const pending = [
...rows,
{ id: '2026-03', period: '2026-03', amount: 1, pageviews: 1, status: 3 },
{ id: '2026-04', period: '2026-04', amount: 1, pageviews: 1, status: 4 },
];
const { data } = filterSortAndPaginate(
pending,
{ ...view, filters: [ { field: 'status', operator: 'is', value: 'Pending' } ] } as View,
fields
);

expect( data.map( row => row.period ) ).toEqual( [ '2026-03', '2026-04' ] );
} );

it.each( [
[ 'asc', [ '2025-12', '2026-09', '2012-03' ] ],
[ 'desc', [ '2026-09', '2025-12', '2012-03' ] ],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,3 @@
color: var(--wpds-color-foreground-content-neutral-weak);
text-align: end;
}

/* Negative amounts and unpaid periods. */
.attention {
color: var(--wpds-color-foreground-content-error-weak);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useMemo } from 'react';
*/
import { useElementSize } from '../../hooks/use-element-size';
import styles from './earnings-history-list.module.scss';
import { EarningsStatusLabel, formatEarningsPeriod, type EarningsHistoryRow } from './fields';
import { EarningsStatusBadge, formatEarningsPeriod, type EarningsHistoryRow } from './fields';

export type EarningsHistoryListProps = {
rows?: EarningsHistoryRow[];
Expand Down Expand Up @@ -56,11 +56,9 @@ export function EarningsHistoryList( { rows = [], className }: EarningsHistoryLi
hidden={ index >= visibleCount }
>
<span className={ styles.period }>{ formatEarningsPeriod( row.period ) }</span>
<span className={ clsx( styles.amount, row.amount < 0 && styles.attention ) }>
{ formatMetricValue( row.amount, 'currency' ) }
</span>
<span className={ clsx( styles.status, row.status === 0 && styles.attention ) }>
<EarningsStatusLabel status={ row.status } />
<span className={ styles.amount }>{ formatMetricValue( row.amount, 'currency' ) }</span>
<span className={ styles.status }>
<EarningsStatusBadge status={ row.status } />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth a list test with a pending row? earnings-history-list.test.tsx never asserts the status cell, so swapping the badge out would stay green.

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.

Added: a list case with a pending row that asserts the Paid badges, the Pending badge and the reason button.

</span>
</li>
) ) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* Inline-flex blockifies the badge, so it stands its full line height plus
* padding, as in the design, and every status comes out the same height. */
.root {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could .root and .reason be Stack and Text variant="heading-sm" from externals? With the tip shared, this stylesheet may not need to exist.

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.

Yes, once the tip is shared most of this stylesheet goes. Folded into WOOA7S-2195 with the InfoTip extraction so the stylesheet is removed once rather than trimmed twice.

display: inline-flex;
align-items: center;
gap: var(--wpds-dimension-gap-xs);
white-space: nowrap;
}

.info {
display: inline-flex;
align-items: center;
border: none;
padding: 0;
background: none;
color: var(--wpds-color-foreground-content-neutral-weak);
cursor: pointer;
}

.popup {
max-inline-size: 20rem;
}

/* The reason leads the explanation; the popover title is heading-sized,
* so it stays hidden. */
.reason {
display: block;
margin-block-end: var(--wpds-dimension-gap-xs);
font-weight: var(--wpds-typography-font-weight-emphasis);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
* External dependencies
*/
import { parseSiteDateTime } from '@jetpack-premium-analytics/datetime';
import { Badge } from '@jetpack-premium-analytics/externals';
import { Badge, Icon, Popover, VisuallyHidden } from '@jetpack-premium-analytics/externals';
import { formatDate, formatMetricValue } from '@jetpack-premium-analytics/formatters';
import { Tooltip } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { info } from '@wordpress/icons';
/**
* Internal dependencies
*/
import styles from './earnings-status-badge.module.scss';
import type { StatsWordAdsEarningsBreakdown } from '@jetpack-premium-analytics/data';
import type { Field } from '@jetpack-premium-analytics/externals';
import type { ComponentProps } from 'react';
Expand All @@ -24,21 +26,27 @@ export type EarningsHistoryRow = {

type EarningsStatusIntent = NonNullable< ComponentProps< typeof Badge >[ 'intent' ] >;

type EarningsStatus = { label: string; tooltip?: string; intent: EarningsStatusIntent };
type EarningsStatus = {
label: string;
tooltip?: string;
intent: EarningsStatusIntent;
/** Why a payment is pending; shown beside the badge, not in it, so the label stays short. */
detail?: string;
};

/** Automattic-internal status: never shown to site owners, so kept out of the Status filter. */
const A8C_ONLY_STATUS = 2;

/**
* WordAds payment statuses by code, ported verbatim from the Jetpack Stats WordAds
* `getStatus` map (wp-calypso client/my-sites/stats/wordads/earnings.jsx).
* WordAds payment statuses by code, adapted from the Jetpack Stats WordAds
* `getStatus` map (wp-calypso client/my-sites/stats/wordads/earnings.jsx),
*
* @return The label, optional tooltip and badge intent for each known code.
* @return The label, optional tooltip, badge intent and pending detail for each known code.
*/
function getEarningsStatuses(): Record< number, EarningsStatus > {
return {
// Unpaid is red as in the design and the widget; the pending codes wait on
// the site owner, so they get the warning tint instead.
// Unpaid is red as in the design; the pending codes wait on the site owner,
// so they get the warning tint, one word, and the reason beside the badge.
0: {
label: __( 'Unpaid', 'jetpack-premium-analytics-pkg' ),
tooltip: __(
Expand All @@ -57,15 +65,17 @@ function getEarningsStatuses(): Record< number, EarningsStatus > {
intent: 'draft',
},
3: {
label: __( 'Pending (Missing Tax Info)', 'jetpack-premium-analytics-pkg' ),
label: __( 'Pending', 'jetpack-premium-analytics-pkg' ),
detail: __( 'Missing tax info', 'jetpack-premium-analytics-pkg' ),
tooltip: __(
'Payment is pending due to missing information. You can provide tax information in the settings screen.',
'jetpack-premium-analytics-pkg'
),
intent: 'medium',
},
4: {
label: __( 'Pending (Invalid PayPal)', 'jetpack-premium-analytics-pkg' ),
label: __( 'Pending', 'jetpack-premium-analytics-pkg' ),
detail: __( 'Invalid PayPal', 'jetpack-premium-analytics-pkg' ),
tooltip: __(
'Payment processing has failed due to invalid PayPal address. You can correct the PayPal address in the settings screen.',
'jetpack-premium-analytics-pkg'
Expand Down Expand Up @@ -129,25 +139,6 @@ export function formatEarningsPeriod( period: string ): string {
return parsed ? formatDate( parsed, 'monthYear' ) : period;
}

/**
* A payment status label, with its explanation in a tooltip when there is one.
*
* @param props - The component props.
* @param props.status - The numeric status from the earnings payload, if any.
* @return The rendered label.
*/
export function EarningsStatusLabel( { status }: { status: number | undefined } ) {
const { label, tooltip } = getEarningsStatus( status );

return tooltip ? (
<Tooltip text={ tooltip }>
<span tabIndex={ 0 }>{ label }</span>
</Tooltip>
) : (
<span>{ label }</span>
);
}

/**
* Numeric sort that keeps rows without a count last in either direction.
*
Expand All @@ -168,22 +159,49 @@ function compareOptionalCounts( a: unknown, b: unknown, direction: 'asc' | 'desc
}

/**
* A payment status as a badge, with its explanation in a tooltip when there is
* one. The report table's rendering; the widget list keeps the plain label.
* A payment status as a badge. A pending status puts its reason in an info icon
* beside the badge; any other status keeps its explanation on the badge itself.
*
* @param props - The component props.
* @param props.status - The numeric status from the earnings payload, if any.
* @return The rendered badge.
*/
export function EarningsStatusBadge( { status }: { status: number | undefined } ) {
const { label, tooltip, intent } = getEarningsStatus( status );
const { label, tooltip, intent, detail } = getEarningsStatus( status );

if ( detail ) {
// Click-open like the widget header's info icon; non-modal, so Tab leaves and closes it.
return (
<span className={ styles.root }>
<Popover.Root>
<Popover.Trigger aria-label={ detail } className={ styles.info }>
<Icon icon={ info } size={ 16 } />
</Popover.Trigger>
<Popover.Popup className={ styles.popup }>
<Popover.Arrow />
<VisuallyHidden render={ <Popover.Title /> }>{ detail }</VisuallyHidden>
<Popover.Description>
<span className={ styles.reason }>{ detail }</span>
{ tooltip }
</Popover.Description>
</Popover.Popup>
</Popover.Root>
<Badge intent={ intent }>{ label }</Badge>
</span>
);
}

const badge = (
<Badge intent={ intent } tabIndex={ tooltip ? 0 : undefined }>
{ label }
</Badge>
);

return tooltip ? <Tooltip text={ tooltip }>{ badge }</Tooltip> : badge;
return (
<span className={ styles.root }>
{ tooltip ? <Tooltip text={ tooltip }>{ badge }</Tooltip> : badge }
</span>
);
}

/**
Expand Down Expand Up @@ -227,9 +245,13 @@ export function getWordAdsHistoryFields(): Field< EarningsHistoryRow >[] {
// A filter rather than search: a substring match for "Paid" also finds "Unpaid".
// Sorts and filters by the visible label rather than the numeric code.
getValue: ( { item } ) => getEarningsStatus( item.status ).label,
// Both pending codes share a label, so one "Pending" option covers them.
elements: Object.entries( getEarningsStatuses() )
.filter( ( [ code ] ) => Number( code ) !== A8C_ONLY_STATUS )
.map( ( [ , { label } ] ) => ( { value: label, label } ) ),
.map( ( [ , { label } ] ) => ( { value: label, label } ) )
.filter(
( option, index, all ) => all.findIndex( o => o.value === option.value ) === index
),
filterBy: { operators: [ 'is' ] },
render: ( { item } ) => <EarningsStatusBadge status={ item.status } />,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type EarningsHistoryRow,
} from '@jetpack-premium-analytics/widgets-toolkit';
import { useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
Expand Down Expand Up @@ -95,8 +95,19 @@ function EarningsReport(): JSX.Element {
: [] ),
{
label: __( 'Status', 'jetpack-premium-analytics-pkg' ),
// The numeric code says nothing to a reader of the export.
getValue: row => getEarningsStatus( row.status ).label,
// The numeric code says nothing to a reader of the export; a pending
// row keeps its reason, which the table shows in an icon.
getValue: row => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Worth a pending row in report-csv-exports.test.tsx? The Earnings history case only has Unpaid and Paid, so this branch never runs there.

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.

Added: a second Earnings history case with a status 3 row, expecting Pending (Missing tax info).

const { label, detail } = getEarningsStatus( row.status );
return detail
? sprintf(
/* translators: 1: payment status, e.g. "Pending"; 2: the reason, e.g. "Missing tax info". */
__( '%1$s (%2$s)', 'jetpack-premium-analytics-pkg' ),
label,
detail
)
: label;
},
},
],
[ showAdsServed ]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,23 @@ describe( 'report CSV exports', () => {
);
} );

it( 'exports a pending status with its reason', () => {
const rows = [ { id: '2026-09', period: '2026-09', amount: 30.25, pageviews: 300, status: 3 } ];
useEarningsReportRecordsMock.mockReturnValue( {
...reportStatus,
tab: 'wordads',
availableTabs: [ 'wordads' ],
rows,
} as ReturnType< typeof useEarningsReportRecords > );

expectCsvExport( EarningsReportPage, 'earnings-wordads', rows, [
'2026-09',
30.25,
300,
'Pending (Missing tax info)',
] );
} );

it( 'configures the Clicks export with parent rows in hierarchy order', () => {
const group = { id: 'social', clickedUrl: 'Social', isGroup: true, clicks: 10 };
const lowerRow = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: enhancement

Premium Analytics: Show payment status as a badge in the Earnings History widget, and shorten the pending statuses to one word with the reason beside them. Negative amounts in the widget are no longer red; only the badge carries colour.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Ads: Show payment status as a badge in the Earnings History widget, and shorten the pending statuses to one word with the reason beside them. Negative amounts in the widget are no longer red; only the badge carries colour.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Premium Analytics: Show payment status as a badge in the Earnings History widget, and shorten the pending statuses to one word with the reason beside them. Negative amounts in the widget are no longer red; only the badge carries colour.
Loading