+ { trackedUrl ??
+ ( hasParameters
+ ? __(
+ 'Enter a destination URL to see the URL',
+ 'jetpack-premium-analytics-pkg'
+ )
+ : __(
+ 'Fill out campaign parameters to see the URL',
+ 'jetpack-premium-analytics-pkg'
+ ) ) }
+
+
+
+
+
+
+
+ { /* A disabled Button stays clickable (aria-disabled), so the copy listener is attached only with a URL. */ }
+
+
+
+
+ );
+}
diff --git a/projects/plugins/jetpack/changelog/wooa7s-2294-utm-empty-builder b/projects/plugins/jetpack/changelog/wooa7s-2294-utm-empty-builder
new file mode 100644
index 000000000000..dbbf86c7c146
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/wooa7s-2294-utm-empty-builder
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Premium Analytics: Offer a URL builder when the selected UTM report has no visits.
diff --git a/projects/plugins/premium-analytics/changelog/wooa7s-2294-utm-empty-builder b/projects/plugins/premium-analytics/changelog/wooa7s-2294-utm-empty-builder
new file mode 100644
index 000000000000..13fd400df997
--- /dev/null
+++ b/projects/plugins/premium-analytics/changelog/wooa7s-2294-utm-empty-builder
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Offer a URL builder when the selected UTM report has no visits.
From 5905bde61b8551e0eed47845645f9129881a35e1 Mon Sep 17 00:00:00 2001
From: Foteini Giannaropoulou
Date: Tue, 13 Oct 2026 13:58:34 +0300
Subject: [PATCH 02/57] Connection: Transfer ownership without leaving the site
(#53359)
---
.../api/changelog/add-cnx-ownership-transfer | 4 +
projects/js-packages/api/index.jsx | 12 +
projects/js-packages/connection/README.md | 28 ++
.../changelog/add-cnx-ownership-transfer | 4 +
.../manage-connection-dialog/index.tsx | 104 ++++++--
.../manage-connection-dialog/style.scss | 22 +-
.../test/component.tsx | 171 +++++++++++++
.../owner-disconnect-dialog/index.tsx | 36 +--
.../test/component.tsx | 31 ++-
.../candidate-identity.tsx | 85 ++++++
.../candidate-list.tsx | 165 ++++++++++++
.../confirm-step.tsx | 132 ++++++++++
.../done-step.tsx | 47 ++++
.../transfer-connection-ownership/index.tsx | 130 ++++++++++
.../transfer-connection-ownership/style.scss | 62 +++++
.../test/component.tsx | 241 ++++++++++++++++++
.../test/protected-owner.tsx | 48 ++++
.../transfer-connection-ownership/types.ts | 11 +
.../use-ownership-transfer.ts | 202 +++++++++++++++
projects/js-packages/connection/index.jsx | 2 +
.../js-packages/connection/state/actions.jsx | 19 ++
.../js-packages/connection/state/reducers.jsx | 19 +-
.../state/test/reducers-connection-owner.jsx | 64 +++++
.../changelog/add-cnx-ownership-transfer | 4 +
.../connection/src/class-rest-connector.php | 69 +++++
.../tests/php/REST_Endpoints_Test.php | 122 +++++++++
.../changelog/add-cnx-ownership-transfer | 4 +
.../changelog/add-cnx-ownership-transfer | 4 +
.../changelog/add-cnx-ownership-transfer | 4 +
.../changelog/add-cnx-ownership-transfer | 4 +
.../changelog/add-cnx-ownership-transfer | 4 +
.../changelog/add-cnx-ownership-transfer | 4 +
.../changelog/add-cnx-ownership-transfer | 4 +
.../changelog/add-cnx-ownership-transfer | 4 +
34 files changed, 1818 insertions(+), 48 deletions(-)
create mode 100644 projects/js-packages/api/changelog/add-cnx-ownership-transfer
create mode 100644 projects/js-packages/connection/changelog/add-cnx-ownership-transfer
create mode 100644 projects/js-packages/connection/components/transfer-connection-ownership/candidate-identity.tsx
create mode 100644 projects/js-packages/connection/components/transfer-connection-ownership/candidate-list.tsx
create mode 100644 projects/js-packages/connection/components/transfer-connection-ownership/confirm-step.tsx
create mode 100644 projects/js-packages/connection/components/transfer-connection-ownership/done-step.tsx
create mode 100644 projects/js-packages/connection/components/transfer-connection-ownership/index.tsx
create mode 100644 projects/js-packages/connection/components/transfer-connection-ownership/style.scss
create mode 100644 projects/js-packages/connection/components/transfer-connection-ownership/test/component.tsx
create mode 100644 projects/js-packages/connection/components/transfer-connection-ownership/test/protected-owner.tsx
create mode 100644 projects/js-packages/connection/components/transfer-connection-ownership/types.ts
create mode 100644 projects/js-packages/connection/components/transfer-connection-ownership/use-ownership-transfer.ts
create mode 100644 projects/js-packages/connection/state/test/reducers-connection-owner.jsx
create mode 100644 projects/packages/connection/changelog/add-cnx-ownership-transfer
create mode 100644 projects/plugins/backup/changelog/add-cnx-ownership-transfer
create mode 100644 projects/plugins/boost/changelog/add-cnx-ownership-transfer
create mode 100644 projects/plugins/jetpack/changelog/add-cnx-ownership-transfer
create mode 100644 projects/plugins/protect/changelog/add-cnx-ownership-transfer
create mode 100644 projects/plugins/search/changelog/add-cnx-ownership-transfer
create mode 100644 projects/plugins/social/changelog/add-cnx-ownership-transfer
create mode 100644 projects/plugins/stats/changelog/add-cnx-ownership-transfer
create mode 100644 projects/plugins/videopress/changelog/add-cnx-ownership-transfer
diff --git a/projects/js-packages/api/changelog/add-cnx-ownership-transfer b/projects/js-packages/api/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..c6ebb2d7c624
--- /dev/null
+++ b/projects/js-packages/api/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add methods to list connection owner candidates and set a new connection owner.
diff --git a/projects/js-packages/api/index.jsx b/projects/js-packages/api/index.jsx
index c56842e094a6..1f6401f88d0f 100644
--- a/projects/js-packages/api/index.jsx
+++ b/projects/js-packages/api/index.jsx
@@ -172,6 +172,18 @@ function JetpackRestApiClient( root, nonce ) {
.then( parseJsonResponse );
},
+ fetchConnectionOwnerCandidates: () =>
+ getRequest( `${ apiRoot }jetpack/v4/connection/owner/candidates`, getParams )
+ .then( checkStatus )
+ .then( parseJsonResponse ),
+
+ setConnectionOwner: ownerId =>
+ postRequest( `${ apiRoot }jetpack/v4/connection/owner`, postParams, {
+ body: JSON.stringify( { owner: ownerId } ),
+ } )
+ .then( checkStatus )
+ .then( parseJsonResponse ),
+
reconnect: () =>
postRequest( `${ apiRoot }jetpack/v4/connection/reconnect`, postParams )
.then( checkStatus )
diff --git a/projects/js-packages/connection/README.md b/projects/js-packages/connection/README.md
index c16277258397..eb6a5f3813ae 100644
--- a/projects/js-packages/connection/README.md
+++ b/projects/js-packages/connection/README.md
@@ -174,6 +174,34 @@ const onDisconnectedCallback = useCallback( () => alert( 'Successfully Disconnec
```
+## Component `TransferConnectionOwnership`
+The `TransferConnectionOwnership` component hands this site's WordPress.com connection to another connected administrator. It walks the user through choosing one, confirming what moves with ownership, and a success message.
+
+It is not a modal and renders no chrome of its own: the surface that hosts it supplies the container, the heading and the way out. That is what lets the same component serve a dialog, a card or a settings panel.
+
+On success it updates the package store with the new owner, so surfaces reading connection state from the store — including `isMaster` — reflect the change without a page reload. A host that passes its own copy of that data down as props, as `ManageConnectionDialog` does with `connectedUser`, only reflects it if that copy comes from the store.
+
+### Properties
+- *apiRoot* - string (required), API root URL.
+- *apiNonce* - string (required), API Nonce.
+- *onTransferred* - callback, to be called with the new owner's local user ID once WordPress.com has accepted the change. Only needed for logic beyond the store update, such as analytics or closing the surrounding surface.
+
+### Important Notes
+The component renders only the controls its own steps need: `Continue`, `Back` between steps, and the transfer itself. Leaving the flow is the host surface's business, so there is no Cancel, Close or Done button — add one in your own chrome if the surface needs it.
+
+If the site has no other connected administrator, the component explains why rather than offering an action that cannot succeed, so a host should not assume a transfer is always possible.
+
+### Basic Usage
+```jsx
+import { TransferConnectionOwnership } from '@automattic/jetpack-connection';
+
+ setIsPanelOpen( false ) }
+/>
+```
+
## Fetching connection status and other data from the store
The package relies on [controls](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-data/#controls-2)
and [resolvers](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-data/#resolvers)
diff --git a/projects/js-packages/connection/changelog/add-cnx-ownership-transfer b/projects/js-packages/connection/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..c923a7838bc0
--- /dev/null
+++ b/projects/js-packages/connection/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add a component for transferring connection ownership, and offer it from the manage connection dialog instead of linking out to WordPress.com. The connection owner can now be updated in the store, so surfaces reflect a transfer without a page reload.
diff --git a/projects/js-packages/connection/components/manage-connection-dialog/index.tsx b/projects/js-packages/connection/components/manage-connection-dialog/index.tsx
index ee9924207e92..060aa119bc87 100644
--- a/projects/js-packages/connection/components/manage-connection-dialog/index.tsx
+++ b/projects/js-packages/connection/components/manage-connection-dialog/index.tsx
@@ -3,12 +3,11 @@
*/
import jetpackAnalytics from '@automattic/jetpack-analytics';
import restApi from '@automattic/jetpack-api';
-import { getRedirectUrl } from '@automattic/jetpack-components';
-import { getScriptData, isWoASite } from '@automattic/jetpack-script-data';
+import { isWoASite } from '@automattic/jetpack-script-data';
import { Modal } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { Button, Text } from '@wordpress/ui';
-import { useCallback, useState, useMemo } from 'react';
+import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import useRestApiInit from '../../hooks/use-rest-api-init';
/**
* Internal dependencies
@@ -18,6 +17,7 @@ import DisconnectDialog from '../disconnect-dialog';
import OwnerDisconnectDialog from '../owner-disconnect-dialog';
import SharedHelpFooter from '../shared/help-footer';
import ManageConnectionActionCard from '../shared/manage-connection-action-card';
+import TransferConnectionOwnership from '../transfer-connection-ownership';
import type { MouseEvent } from 'react';
import './style.scss';
@@ -39,6 +39,9 @@ interface ManageConnectionUser {
};
}
+/** Which view the dialog is showing. */
+type TransferView = 'choosing' | 'transferred';
+
interface ManageConnectionDialogProps {
/** The modal title. */
title?: string;
@@ -87,6 +90,9 @@ const ManageConnectionDialog = ( {
const [ isDisconnectingUser, setIsDisconnectingUser ] = useState( false );
const [ unlinkError, setUnlinkError ] = useState( '' );
const [ isOwnerDisconnectDialogOpen, setIsOwnerDisconnectDialogOpen ] = useState( false );
+ const [ transferView, setTransferView ] = useState< TransferView | null >( null );
+ const [ isTransferring, setIsTransferring ] = useState( false );
+ const headingRef = useRef< HTMLHeadingElement >( null );
/**
* Initialize the REST API.
@@ -191,8 +197,8 @@ const ManageConnectionDialog = ( {
);
const isControlsDisabled = useMemo( () => {
- return isDisconnectingUser;
- }, [ isDisconnectingUser ] );
+ return isDisconnectingUser || isTransferring;
+ }, [ isDisconnectingUser, isTransferring ] );
// This is silly, but it's an optimizer workaround
const disconnectingText = __( 'Disconnecting…', 'jetpack-connection-js' );
@@ -201,45 +207,98 @@ const ManageConnectionDialog = ( {
setIsOwnerDisconnectDialogOpen( false );
}, [ setIsOwnerDisconnectDialogOpen ] );
+ const openTransferDialog = useCallback( ( e?: MouseEvent< HTMLElement > ) => {
+ e && e.preventDefault();
+ setTransferView( 'choosing' );
+ }, [] );
+
+ // Transferring is the way out of the owner-disconnect warning, so hand the user to
+ // that flow rather than running a second copy of it inside the warning.
+ const openTransferFromOwnerDialog = useCallback( () => {
+ setIsOwnerDisconnectDialogOpen( false );
+ setTransferView( 'choosing' );
+ }, [] );
+
+ const recordNewOwner = useCallback( () => {
+ setTransferView( 'transferred' );
+ }, [] );
+
+ // Closing from anywhere starts the next open on the action list, so a finished
+ // transfer cannot come back as the first thing the user sees.
+ useEffect( () => {
+ if ( ! isOpen ) {
+ setTransferView( null );
+ setIsTransferring( false );
+ }
+ }, [ isOpen ] );
+
+ // Swapping the view removes whatever was focused, which otherwise drops focus to the
+ // body and leaves a keyboard or screen reader user outside the dialog they are in.
+ useEffect( () => {
+ if ( isOpen ) {
+ headingRef.current?.focus();
+ }
+ }, [ isOpen, transferView ] );
+
+ // The dialog's own exit, which always closes it. It cannot read "Back": the step
+ // has its own Back, and this does something else. Statements rather than a ternary
+ // — the minifier folds two `__()` calls into one with a non-literal argument.
+ let footerDismissLabel: string = __( 'Cancel', 'jetpack-connection-js' );
+ if ( 'transferred' === transferView ) {
+ footerDismissLabel = __( 'Close', 'jetpack-connection-js' );
+ }
+
+ const heading = transferView
+ ? __( 'Transfer connection ownership', 'jetpack-connection-js' )
+ : title;
+
return (
<>
{ isOpen && (
<>
diff --git a/projects/js-packages/connection/components/manage-connection-dialog/style.scss b/projects/js-packages/connection/components/manage-connection-dialog/style.scss
index 4683b40d5199..38c2dea2a7ce 100644
--- a/projects/js-packages/connection/components/manage-connection-dialog/style.scss
+++ b/projects/js-packages/connection/components/manage-connection-dialog/style.scss
@@ -8,7 +8,7 @@
&__content {
background: var(--jp-white-off);
- padding: 80px;
+ padding: 2rem 1rem;
text-align: center;
display: flex;
flex-direction: column;
@@ -23,6 +23,13 @@
}
}
+ @media (min-width: 600px) {
+
+ .jp-connection__manage-dialog__content {
+ padding: 80px;
+ }
+ }
+
// shorthand intentionally not used here for overriding the .reset class
.jp-connection__manage-dialog__large-text {
margin-top: calc(var(--spacing-base) * 3);
@@ -32,6 +39,16 @@
font-size: 1.25rem;
}
+ // The content box is a centred flex column, so it shrink-wraps each child.
+ // Width is explicit rather than `align-self: stretch`, which the auto inline
+ // margin below would cancel out, collapsing the step around its shortest view.
+ .jp-connection__transfer-ownership-form {
+ inline-size: 100%;
+ max-inline-size: 600px;
+ margin-inline: auto;
+ text-align: start;
+ }
+
&__actions {
background: var(--jp-white);
margin: 0 !important;
@@ -79,8 +96,7 @@
// The action slug is now applied to the card root itself, not a descendant.
&.transfer,
- &.unlink,
- &.check-users {
+ &.unlink {
color: var(--jp-black);
fill: var(--jp-black);
}
diff --git a/projects/js-packages/connection/components/manage-connection-dialog/test/component.tsx b/projects/js-packages/connection/components/manage-connection-dialog/test/component.tsx
index c76947fe90cf..dd00c26c26bc 100644
--- a/projects/js-packages/connection/components/manage-connection-dialog/test/component.tsx
+++ b/projects/js-packages/connection/components/manage-connection-dialog/test/component.tsx
@@ -10,6 +10,7 @@ const mockSetApiRoot = jest.fn();
const mockSetApiNonce = jest.fn();
const mockRecordEvent = jest.fn();
const mockIsWoASite = jest.fn();
+const mockSetConnectionOwner = jest.fn< ( id: number ) => Promise< unknown > >();
jest.unstable_mockModule( '@automattic/jetpack-api', () => ( {
__esModule: true,
@@ -17,6 +18,16 @@ jest.unstable_mockModule( '@automattic/jetpack-api', () => ( {
setApiRoot: mockSetApiRoot,
setApiNonce: mockSetApiNonce,
unlinkUser: mockUnlinkUser,
+ fetchConnectionOwnerCandidates: () =>
+ Promise.resolve( [
+ {
+ id: 7,
+ login: 'kazz',
+ displayName: 'Kazz',
+ email: 'kazz@example.com',
+ },
+ ] ),
+ setConnectionOwner: mockSetConnectionOwner,
disconnectSite: mockDisconnectSite,
},
} ) );
@@ -57,6 +68,7 @@ describe( 'ManageConnectionDialog', () => {
jest.clearAllMocks();
mockIsWoASite.mockReturnValue( false );
mockUnlinkUser.mockResolvedValue( undefined );
+ mockSetConnectionOwner.mockResolvedValue( { code: 'success' } );
} );
it( 'renders nothing when isOpen is false', () => {
@@ -90,6 +102,165 @@ describe( 'ManageConnectionDialog', () => {
expect( screen.getByRole( 'link', { name: /Disconnect Jetpack/ } ) ).toBeInTheDocument();
} );
+ it( 'shows the owner chooser in the dialog it already has, not a second one', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const transfer = screen.getByRole( 'link', {
+ name: /Transfer ownership to another admin/,
+ } );
+ expect( transfer ).not.toHaveAttribute( 'target', '_blank' );
+
+ await user.click( transfer );
+ await expect(
+ screen.findByRole( 'combobox', { name: /New connection owner/ } )
+ ).resolves.toBeInTheDocument();
+
+ // One dialog, renamed — a second modal would mean two focus traps.
+ expect( screen.getAllByRole( 'dialog' ) ).toHaveLength( 1 );
+ expect(
+ screen.getByRole( 'dialog', { name: 'Transfer connection ownership' } )
+ ).toBeInTheDocument();
+ // The actions behind are gone rather than sitting under an overlay.
+ expect(
+ screen.queryByRole( 'link', { name: /Disconnect Jetpack/ } )
+ ).not.toBeInTheDocument();
+ } );
+
+ // Leaving mid-request would not cancel it: the transfer still lands, and its
+ // refusal would never be seen.
+ it( 'holds the exit shut while the transfer is in flight', async () => {
+ let settle: ( value: unknown ) => void = () => {};
+ mockSetConnectionOwner.mockReturnValue(
+ new Promise( resolve => {
+ settle = resolve;
+ } )
+ );
+
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByRole( 'link', { name: /Transfer ownership to another admin/ } )
+ );
+ await user.selectOptions(
+ await screen.findByRole( 'combobox', { name: /New connection owner/ } ),
+ '7'
+ );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+ await user.click( screen.getByRole( 'button', { name: 'Transfer ownership' } ) );
+
+ await waitFor( () =>
+ expect( screen.getByRole( 'button', { name: 'Cancel' } ) ).toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ )
+ );
+
+ settle( { code: 'success' } );
+
+ await expect( screen.findByRole( 'button', { name: 'Close' } ) ).resolves.toBeInTheDocument();
+ } );
+
+ it( 'tells the store who owns the connection now', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByRole( 'link', { name: /Transfer ownership to another admin/ } )
+ );
+ await user.selectOptions(
+ await screen.findByRole( 'combobox', { name: /New connection owner/ } ),
+ '7'
+ );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+ await user.click( screen.getByRole( 'button', { name: 'Transfer ownership' } ) );
+
+ await waitFor( () => expect( mockSetConnectionOwner ).toHaveBeenCalledWith( 7 ) );
+
+ // The task is finished, so the exit closes the dialog rather than going back.
+ expect( screen.queryByRole( 'button', { name: 'Back' } ) ).not.toBeInTheDocument();
+ await user.click( await screen.findByRole( 'button', { name: 'Close' } ) );
+
+ expect( testProps.onClose ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ // Two controls reading "Back" would be ambiguous: the step's moves a step, the
+ // dialog's leaves the flow entirely.
+ it( 'does not offer two different Backs on the confirm step', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByRole( 'link', { name: /Transfer ownership to another admin/ } )
+ );
+ await user.selectOptions(
+ await screen.findByRole( 'combobox', { name: /New connection owner/ } ),
+ '7'
+ );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+
+ // The step's Back moves a step; the dialog's exit closes the dialog.
+ expect( screen.getAllByRole( 'button', { name: 'Back' } ) ).toHaveLength( 1 );
+ expect( screen.getByRole( 'button', { name: 'Cancel' } ) ).toBeInTheDocument();
+ } );
+
+ // Swapping views unmounts whatever had focus, which would otherwise leave a
+ // keyboard user on the body, outside the dialog.
+ it( 'keeps focus inside the dialog when the view changes', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByRole( 'link', { name: /Transfer ownership to another admin/ } )
+ );
+ await expect(
+ screen.findByRole( 'combobox', { name: /New connection owner/ } )
+ ).resolves.toBeInTheDocument();
+
+ expect(
+ screen.getByRole( 'heading', { name: 'Transfer connection ownership' } )
+ ).toHaveFocus();
+ } );
+
+ it( 'leaves the dialog when the chooser is dismissed', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByRole( 'link', { name: /Transfer ownership to another admin/ } )
+ );
+ await expect(
+ screen.findByRole( 'combobox', { name: /New connection owner/ } )
+ ).resolves.toBeInTheDocument();
+ await user.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
+
+ expect( testProps.onClose ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ // Reopening has to start on the actions, not on whatever step was last seen.
+ it( 'forgets the step once the dialog has closed', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render( );
+
+ await user.click(
+ screen.getByRole( 'link', { name: /Transfer ownership to another admin/ } )
+ );
+ await expect(
+ screen.findByRole( 'combobox', { name: /New connection owner/ } )
+ ).resolves.toBeInTheDocument();
+
+ rerender( );
+ rerender( );
+
+ await expect(
+ screen.findByRole( 'link', { name: /Disconnect Jetpack/ } )
+ ).resolves.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'combobox', { name: /New connection owner/ } )
+ ).not.toBeInTheDocument();
+ } );
+
it( 'hides "Disconnect Jetpack" on a WoA site even for an admin', () => {
mockIsWoASite.mockReturnValue( true );
render( );
diff --git a/projects/js-packages/connection/components/owner-disconnect-dialog/index.tsx b/projects/js-packages/connection/components/owner-disconnect-dialog/index.tsx
index 0ea04a90359f..04417002c7ab 100644
--- a/projects/js-packages/connection/components/owner-disconnect-dialog/index.tsx
+++ b/projects/js-packages/connection/components/owner-disconnect-dialog/index.tsx
@@ -3,8 +3,6 @@
*/
import jetpackAnalytics from '@automattic/jetpack-analytics';
import restApi from '@automattic/jetpack-api';
-import { getRedirectUrl } from '@automattic/jetpack-components';
-import { getScriptData } from '@automattic/jetpack-script-data';
import { Modal } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useCallback, useState } from 'react';
@@ -12,6 +10,7 @@ import useRestApiInit from '../../hooks/use-rest-api-init';
import DisconnectActionFooter from '../shared/disconnect-action-footer';
import ManageConnectionActionCard from '../shared/manage-connection-action-card';
import './style.scss';
+import type { MouseEvent } from 'react';
interface OwnerDisconnectDialogProps {
/** Whether the dialog is open. */
@@ -26,6 +25,8 @@ interface OwnerDisconnectDialogProps {
onDisconnected?: () => void;
/** Callback after user is unlinked. */
onUnlinked?: () => void;
+ /** Opens the ownership transfer flow. Omit to hide that action. */
+ onTransferOwnership?: () => void;
}
/**
@@ -41,6 +42,7 @@ const OwnerDisconnectDialog = ( {
apiNonce,
onDisconnected,
onUnlinked,
+ onTransferOwnership,
}: OwnerDisconnectDialogProps ) => {
// Add state for disconnect status and error
const [ isDisconnecting, setIsDisconnecting ] = useState( false );
@@ -53,6 +55,14 @@ const OwnerDisconnectDialog = ( {
// Initialize the REST API
useRestApiInit( apiRoot, apiNonce );
+ const handleTransferClick = useCallback(
+ ( e?: MouseEvent< HTMLElement > ) => {
+ e?.preventDefault();
+ onTransferOwnership?.();
+ },
+ [ onTransferOwnership ]
+ );
+
const handleStayConnected = useCallback( () => {
onClose();
}, [ onClose ] );
@@ -110,23 +120,17 @@ const OwnerDisconnectDialog = ( {
{ __(
- 'Disconnecting the owner account will remove the Jetpack connection for all users on this site. The site will remain connected.',
+ 'Disconnecting the owner account without choosing a new owner will remove the Jetpack connection for all users on this site. The site will remain connected.',
'jetpack-connection-js'
) }
-
-
+ { onTransferOwnership && (
+
+ ) }
{
} );
describe( 'when open', () => {
+ const renderOpen = ( props = {} ) =>
+ render( );
+
it( 'renders the labelled Modal', () => {
- render( );
+ renderOpen();
// Modal aria.labelledby points at the heading; assert both resolve to the same label.
expect( screen.getByRole( 'dialog', { name: dialogName } ) ).toBeInTheDocument();
expect(
@@ -56,24 +59,34 @@ describe( 'OwnerDisconnectDialog', () => {
} );
it( 'seeds the REST API with the passed root and nonce', () => {
- render( );
+ renderOpen();
expect( mockSetApiRoot ).toHaveBeenCalledWith( testProps.apiRoot );
expect( mockSetApiNonce ).toHaveBeenCalledWith( testProps.apiNonce );
} );
- it( 'renders both action cards as links', () => {
- render( );
- expect(
+ it( 'hands the user to the transfer flow rather than running its own', async () => {
+ const onTransferOwnership = jest.fn();
+ const user = userEvent.setup();
+ renderOpen( { onTransferOwnership } );
+
+ await user.click(
screen.getByRole( 'link', { name: /Transfer ownership to another admin/ } )
- ).toBeInTheDocument();
+ );
+
+ expect( onTransferOwnership ).toHaveBeenCalledTimes( 1 );
+ expect( screen.queryByRole( 'combobox' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'hides the transfer action when no flow is wired up', () => {
+ renderOpen();
expect(
- screen.getByRole( 'link', { name: /View other connected accounts/ } )
- ).toBeInTheDocument();
+ screen.queryByRole( 'link', { name: /Transfer ownership to another admin/ } )
+ ).not.toBeInTheDocument();
} );
it( 'calls onClose when "Stay connected" is clicked', async () => {
const user = userEvent.setup();
- render( );
+ renderOpen();
await user.click( screen.getByRole( 'button', { name: 'Stay connected' } ) );
expect( testProps.onClose ).toHaveBeenCalledTimes( 1 );
} );
diff --git a/projects/js-packages/connection/components/transfer-connection-ownership/candidate-identity.tsx b/projects/js-packages/connection/components/transfer-connection-ownership/candidate-identity.tsx
new file mode 100644
index 000000000000..aa18c11c049b
--- /dev/null
+++ b/projects/js-packages/connection/components/transfer-connection-ownership/candidate-identity.tsx
@@ -0,0 +1,85 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { Stack, Text } from '@wordpress/ui';
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+export interface AvatarBadgeProps {
+ /** Name the badge stands for. */
+ name: string;
+ /** Whether this is the person taking ownership over. */
+ isIncoming?: boolean;
+}
+
+/**
+ * Round initials badge for one person.
+ *
+ * @param {AvatarBadgeProps} props - Component props.
+ * @return {import('react').ReactNode} The AvatarBadge component.
+ */
+export const AvatarBadge = ( { name, isIncoming }: AvatarBadgeProps ) => {
+ const initials = name
+ .split( /\s+/ )
+ .slice( 0, 2 )
+ .map( part => part.charAt( 0 ) )
+ .join( '' )
+ .toUpperCase();
+
+ return (
+
+ { initials }
+
+ );
+};
+
+export interface CandidateIdentityProps {
+ /** Display name. */
+ displayName: string;
+ /** Login, shown under the name. */
+ login: string;
+ /** Email address, shown beside the login. */
+ email?: string;
+}
+
+/**
+ * Initials, display name and login for one person.
+ *
+ * @param {CandidateIdentityProps} props - Component props.
+ * @return {import('react').ReactNode} The CandidateIdentity component.
+ */
+const CandidateIdentity = ( { displayName, login, email }: CandidateIdentityProps ) => (
+
+
+
+ }>{ displayName }
+
+ { email
+ ? sprintf(
+ /* translators: %1$s: user login. %2$s: user email address. */
+ __( '%1$s (%2$s)', 'jetpack-connection-js' ),
+ login,
+ email
+ )
+ : login }
+
+
+
+);
+
+export default CandidateIdentity;
diff --git a/projects/js-packages/connection/components/transfer-connection-ownership/candidate-list.tsx b/projects/js-packages/connection/components/transfer-connection-ownership/candidate-list.tsx
new file mode 100644
index 000000000000..4467deb097ef
--- /dev/null
+++ b/projects/js-packages/connection/components/transfer-connection-ownership/candidate-list.tsx
@@ -0,0 +1,165 @@
+/**
+ * External dependencies
+ */
+import { getRedirectUrl } from '@automattic/jetpack-components';
+import { SelectControl } from '@wordpress/components';
+import { createInterpolateElement } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+// SelectControl comes from @wordpress/components, not @wordpress/ui: the latter is
+// bundled rather than loaded from WordPress, and its select drags in Base UI and
+// Floating UI — roughly 220KB on every page that loads this script.
+import { Link, Notice, Stack, Text } from '@wordpress/ui';
+import { useCallback, useMemo } from 'react';
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import type { ConnectionOwnerCandidate } from './use-ownership-transfer';
+
+export interface OwnerCandidateListProps {
+ /** The administrators ownership could be handed to, or null while loading. */
+ candidates: ConnectionOwnerCandidate[] | null;
+ /** The currently chosen candidate. */
+ selectedId: number | null;
+ /** Called with the chosen candidate's ID. */
+ onSelect: ( id: number ) => void;
+ /** An error to show below the control, if any. */
+ error?: string;
+}
+
+/**
+ * Choose which administrator takes over the connection.
+ *
+ * @param {OwnerCandidateListProps} props - Component props.
+ * @return {import('react').ReactNode} The OwnerCandidateList component.
+ */
+const OwnerCandidateList = ( {
+ candidates,
+ selectedId,
+ onSelect,
+ error,
+}: OwnerCandidateListProps ) => {
+ const options = useMemo(
+ () =>
+ ( candidates ?? [] ).map( candidate => ( {
+ label: candidate.email
+ ? `${ candidate.displayName } (${ candidate.email })`
+ : candidate.displayName,
+ value: String( candidate.id ),
+ } ) ),
+ [ candidates ]
+ );
+
+ const handleChange = useCallback(
+ ( value: string ) => {
+ if ( value ) {
+ onSelect( Number( value ) );
+ }
+ },
+ [ onSelect ]
+ );
+
+ const isLoading = null === candidates;
+ const isEmpty = ! isLoading && 0 === candidates.length && ! error;
+
+ if ( isEmpty ) {
+ return (
+
+
+
+ { __( 'No other connected administrators', 'jetpack-connection-js' ) }
+
+
+ { __(
+ 'Ownership can only go to an administrator who has connected their WordPress.com account.',
+ 'jetpack-connection-js'
+ ) }
+
+
+ }>
+ { createInterpolateElement(
+ __(
+ 'Ask another administrator to connect, then come back here to transfer ownership. How users connect',
+ 'jetpack-connection-js'
+ ),
+ {
+ link: (
+
+ ),
+ }
+ ) }
+
+
+ );
+ }
+
+ // Separate statements, not a ternary: the minifier folds two `__()` calls in one
+ // expression into a single call with a non-literal argument, which i18n-check rejects.
+ let placeholder: string = __( 'Select an administrator', 'jetpack-connection-js' );
+ if ( isLoading ) {
+ placeholder = __( 'Loading administrators…', 'jetpack-connection-js' );
+ }
+
+ return (
+
+ }>
+ { __(
+ "Choose a connected administrator to take over this site's connection to WordPress.com.",
+ 'jetpack-connection-js'
+ ) }
+
+
+
+
+ } className="jp-connection__transfer-ownership__hint">
+ { createInterpolateElement(
+ __(
+ "Don't see someone? They need to be an administrator and connect their WordPress.com account first.",
+ 'jetpack-connection-js'
+ ),
+ {
+ link: (
+
+ ),
+ }
+ ) }
+
+
+ { /* A Notice rather than styled text: it arrives after an async failure, and
+ Notice announces itself so the user is not left waiting on nothing. */ }
+ { error && (
+
+ { error }
+
+ ) }
+
+ );
+};
+
+export default OwnerCandidateList;
diff --git a/projects/js-packages/connection/components/transfer-connection-ownership/confirm-step.tsx b/projects/js-packages/connection/components/transfer-connection-ownership/confirm-step.tsx
new file mode 100644
index 000000000000..f846b8fd3569
--- /dev/null
+++ b/projects/js-packages/connection/components/transfer-connection-ownership/confirm-step.tsx
@@ -0,0 +1,132 @@
+/**
+ * External dependencies
+ */
+import { getRedirectUrl } from '@automattic/jetpack-components';
+import { getScriptData } from '@automattic/jetpack-script-data';
+import { createInterpolateElement } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { Link, Notice, Stack, Text } from '@wordpress/ui';
+/**
+ * Internal dependencies
+ */
+import { AvatarBadge } from './candidate-identity';
+import './style.scss';
+import type { ConnectionOwnerCandidate } from './use-ownership-transfer';
+
+export interface TransferConfirmStepProps {
+ /** The administrator about to become the owner. */
+ candidate: ConnectionOwnerCandidate;
+ /** Whether the current owner is also this connection's protected owner. */
+ isProtectedOwner?: boolean;
+ /** A failed transfer's message, if any. */
+ error?: string;
+}
+
+/**
+ * What changes once the transfer goes through, shown before it is committed.
+ *
+ * @param {TransferConfirmStepProps} props - Component props.
+ * @return {import('react').ReactNode} The TransferConfirmStep component.
+ */
+const TransferConfirmStep = ( {
+ candidate,
+ isProtectedOwner,
+ error,
+}: TransferConfirmStepProps ) => {
+ const currentOwnerName = getScriptData()?.user?.current_user?.display_name ?? '';
+
+ return (
+
+
+ { currentOwnerName && (
+ <>
+
+
+ →
+
+ >
+ ) }
+
+
+ { createInterpolateElement(
+ /* translators: is replaced with the display name of the administrator taking over. */
+ __( ' will become the connection owner.', 'jetpack-connection-js' ),
+ { name: { candidate.displayName } }
+ ) }
+
+
+
+
+
+ { sprintf(
+ /* translators: %s: display name of the administrator taking over. */
+ __(
+ '%s will own the data WordPress.com stores for this site.',
+ 'jetpack-connection-js'
+ ),
+ candidate.displayName
+ ) }
+
+
{ __( "You'll stay connected as a regular user.", 'jetpack-connection-js' ) }
+ { isProtectedOwner && (
+
+ { sprintf(
+ /* translators: %s: display name of the administrator taking over. */
+ __( '%s needs to confirm protected ownership.', 'jetpack-connection-js' ),
+ candidate.displayName
+ ) }
+
+ ) }
+
+
+ { isProtectedOwner && (
+
+
+ { __( "You're the protected owner of this connection", 'jetpack-connection-js' ) }
+
+
+ { sprintf(
+ /* translators: %s: display name of the administrator taking over. */
+ __(
+ 'After the transfer, features that need a protected owner stop working until %s confirms they are the new protected owner.',
+ 'jetpack-connection-js'
+ ),
+ candidate.displayName
+ ) }
+
+
+ ) }
+
+
+
+ { createInterpolateElement(
+ /* translators: is replaced with the display name of the administrator taking over. */
+ __(
+ ' also becomes the main point of contact for support about that data. Learn about the connection owner',
+ 'jetpack-connection-js'
+ ),
+ {
+ name: { candidate.displayName },
+ link: ,
+ }
+ ) }
+
+
+
+ { /* A Notice rather than styled text: it arrives after an async failure, and
+ Notice announces itself so the user is not left waiting on nothing. */ }
+ { error && (
+
+ { error }
+
+ ) }
+
+ );
+};
+
+export default TransferConfirmStep;
diff --git a/projects/js-packages/connection/components/transfer-connection-ownership/done-step.tsx b/projects/js-packages/connection/components/transfer-connection-ownership/done-step.tsx
new file mode 100644
index 000000000000..cfaf8a3c2bc2
--- /dev/null
+++ b/projects/js-packages/connection/components/transfer-connection-ownership/done-step.tsx
@@ -0,0 +1,47 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { Notice, Stack } from '@wordpress/ui';
+/**
+ * Internal dependencies
+ */
+import CandidateIdentity from './candidate-identity';
+import './style.scss';
+import type { ConnectionOwnerCandidate } from './use-ownership-transfer';
+
+export interface TransferDoneStepProps {
+ /** The administrator who now owns the connection. */
+ candidate: ConnectionOwnerCandidate;
+}
+
+/**
+ * Confirmation that WordPress.com accepted the new owner.
+ *
+ * @param {TransferDoneStepProps} props - Component props.
+ * @return {import('react').ReactNode} The TransferDoneStep component.
+ */
+const TransferDoneStep = ( { candidate }: TransferDoneStepProps ) => (
+
+
+
+
+
+ { sprintf(
+ /* translators: %s: display name of the new connection owner. */
+ __(
+ "%s is now the connection owner. You're still connected as a regular user.",
+ 'jetpack-connection-js'
+ ),
+ candidate.displayName
+ ) }
+
+
+
+);
+
+export default TransferDoneStep;
diff --git a/projects/js-packages/connection/components/transfer-connection-ownership/index.tsx b/projects/js-packages/connection/components/transfer-connection-ownership/index.tsx
new file mode 100644
index 000000000000..aaa78a2a6011
--- /dev/null
+++ b/projects/js-packages/connection/components/transfer-connection-ownership/index.tsx
@@ -0,0 +1,130 @@
+/**
+ * External dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+import { Button, Stack } from '@wordpress/ui';
+import { useEffect } from 'react';
+/**
+ * Internal dependencies
+ */
+import { STORE_ID } from '../../state/store';
+import OwnerCandidateList from './candidate-list';
+import TransferConfirmStep from './confirm-step';
+import TransferDoneStep from './done-step';
+import useOwnershipTransfer from './use-ownership-transfer';
+
+export type { ConnectionOwnerCandidate } from './use-ownership-transfer';
+
+type StoreSelector = ( storeId: string ) => Record< string, ( ...args: unknown[] ) => unknown >;
+
+export interface TransferConnectionOwnershipProps {
+ /** API root URL. */
+ apiRoot: string;
+ /** API nonce. */
+ apiNonce: string;
+ /** Called as soon as WordPress.com accepts the new owner. */
+ onTransferred?: ( newOwnerId: number ) => void;
+ /** Called while a transfer is in flight, so a host can hold its own exits shut. */
+ onBusyChange?: ( isBusy: boolean ) => void;
+}
+
+/**
+ * Hand this site's WordPress.com connection to another connected administrator.
+ *
+ * Carries the whole choose → confirm → done flow and its own actions.
+ *
+ * @param {TransferConnectionOwnershipProps} props - Component props.
+ * @return {import('react').ReactNode} The TransferConnectionOwnership component.
+ */
+const TransferConnectionOwnership = ( {
+ apiRoot,
+ apiNonce,
+ onTransferred,
+ onBusyChange,
+}: TransferConnectionOwnershipProps ) => {
+ const {
+ candidates,
+ selectedId,
+ setSelectedId,
+ selectedCandidate,
+ step,
+ confirm,
+ back,
+ transfer,
+ isTransferring,
+ error,
+ } = useOwnershipTransfer( { apiRoot, apiNonce, onTransferred } );
+
+ const hasCandidates = !! candidates?.length;
+
+ useEffect( () => {
+ onBusyChange?.( isTransferring );
+ }, [ isTransferring, onBusyChange ] );
+ // True only of the connection owner, who is the person transferring: hence "you're".
+ const isProtectedOwner = useSelect(
+ ( select: StoreSelector ) => !! select( STORE_ID ).getHasProtectedOwner(),
+ []
+ );
+
+ if ( 'done' === step && selectedCandidate ) {
+ return (
+
+
+
+ );
+ }
+
+ if ( 'confirm' === step && selectedCandidate ) {
+ // "anyway" acknowledges the warning above it on a protected-owner site.
+ let confirmLabel: string = __( 'Transfer ownership', 'jetpack-connection-js' );
+ if ( isProtectedOwner ) {
+ confirmLabel = __( 'Transfer anyway', 'jetpack-connection-js' );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ { hasCandidates && (
+
+
+
+ ) }
+
+ );
+};
+
+export default TransferConnectionOwnership;
diff --git a/projects/js-packages/connection/components/transfer-connection-ownership/style.scss b/projects/js-packages/connection/components/transfer-connection-ownership/style.scss
new file mode 100644
index 000000000000..ef6518f996dd
--- /dev/null
+++ b/projects/js-packages/connection/components/transfer-connection-ownership/style.scss
@@ -0,0 +1,62 @@
+// Keep `flex-shrink: 0`: the dialogs lay this out in a `height: 100%` box
+// beside a footer, and without it the button rides up over the text above.
+.jp-connection__transfer-ownership-form {
+ flex-shrink: 0;
+}
+
+.jp-connection__transfer-ownership {
+ flex-shrink: 0;
+ text-align: start;
+
+ // @wordpress/ui's scale starts at 13px, which reads as fine print beside
+ // the 16px the surrounding dialog uses. Size the step from Jetpack's
+ // token instead, and keep the smaller pieces relative to it.
+ font-size: var(--font-body, 16px);
+
+ p {
+ font-size: inherit;
+ }
+}
+
+.jp-connection__transfer-ownership__avatar {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ inline-size: 38px;
+ block-size: 38px;
+ border-radius: 50%;
+ background-color: var(--jp-black, #000);
+ color: var(--jp-white, #fff);
+ font-size: 0.875em;
+ font-weight: 600;
+
+ // Not a status colour: red, yellow and green mean something else here.
+ &.is-incoming {
+ background-color: var(--jp-pink, #c9356e);
+ }
+}
+
+.jp-connection__transfer-ownership__who {
+ min-inline-size: 0;
+}
+
+.jp-connection__transfer-ownership__meta {
+ color: var(--jp-gray-50, #646970);
+ font-size: 0.875em;
+}
+
+.jp-connection__transfer-ownership__hint {
+ font-size: 0.875em;
+ color: var(--jp-gray-50, #646970);
+}
+
+.jp-connection__transfer-ownership__outcomes {
+ margin: 0;
+ padding-inline-start: 20px;
+ list-style: disc;
+}
+
+.jp-connection__transfer-ownership__arrow {
+ color: var(--jp-gray-50, #646970);
+}
diff --git a/projects/js-packages/connection/components/transfer-connection-ownership/test/component.tsx b/projects/js-packages/connection/components/transfer-connection-ownership/test/component.tsx
new file mode 100644
index 000000000000..fdc3f0260cac
--- /dev/null
+++ b/projects/js-packages/connection/components/transfer-connection-ownership/test/component.tsx
@@ -0,0 +1,241 @@
+import { jest } from '@jest/globals';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+// ESM test: static jest.mock does not work under --experimental-vm-modules, so mock the module
+// with unstable_mockModule (must run before the dynamic import of the component below).
+const mockFetchCandidates = jest.fn< () => Promise< unknown > >();
+const mockSetConnectionOwner = jest.fn< ( id: number ) => Promise< unknown > >();
+
+jest.unstable_mockModule( '@automattic/jetpack-api', () => ( {
+ __esModule: true,
+ default: {
+ setApiRoot: jest.fn(),
+ setApiNonce: jest.fn(),
+ fetchConnectionOwnerCandidates: mockFetchCandidates,
+ setConnectionOwner: mockSetConnectionOwner,
+ },
+} ) );
+
+window.JP_CONNECTION_INITIAL_STATE = {
+ hasProtectedOwner: false,
+} as typeof window.JP_CONNECTION_INITIAL_STATE;
+
+const { default: TransferConnectionOwnership } = await import( '../index' );
+
+describe( 'TransferConnectionOwnership', () => {
+ const props = {
+ apiRoot: 'https://example.org/wp-json/',
+ apiNonce: 'test-nonce',
+ };
+
+ const candidates = [
+ {
+ id: 7,
+ login: 'kazz',
+ displayName: 'Kazz',
+ email: 'kazz@example.com',
+ },
+ {
+ id: 9,
+ login: 'alexm',
+ displayName: 'Alex Moreno',
+ email: 'alex@example.com',
+ },
+ ];
+
+ // `checkStatus` throws an Error with the REST body on `response`, and its own
+ // "… (Status 400)" text on `message`. Rejecting with the body alone would let a
+ // component reading `err.code` pass while showing the raw server string in the browser.
+ const apiError = ( code: string, message: string ) => {
+ const error = new Error( `${ message } (Status 400)` );
+ Object.assign( error, { name: 'ApiError', response: { code, message } } );
+ return error;
+ };
+
+ const chooseKazz = async ( user: ReturnType< typeof userEvent.setup > ) => {
+ await user.selectOptions(
+ await screen.findByRole( 'combobox', { name: /New connection owner/ } ),
+ '7'
+ );
+ };
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockFetchCandidates.mockResolvedValue( candidates );
+ mockSetConnectionOwner.mockResolvedValue( { code: 'success' } );
+ } );
+
+ it( 'lists the administrators who could take over', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click( await screen.findByRole( 'combobox', { name: /New connection owner/ } ) );
+
+ await expect(
+ screen.findByRole( 'option', { name: 'Kazz (kazz@example.com)' } )
+ ).resolves.toBeInTheDocument();
+ expect(
+ screen.getByRole( 'option', { name: 'Alex Moreno (alex@example.com)' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'chooses the only administrator there is', async () => {
+ mockFetchCandidates.mockResolvedValue( [ candidates[ 0 ] ] );
+ render( );
+
+ await expect(
+ screen.findByRole( 'button', { name: 'Continue' } )
+ ).resolves.not.toHaveAttribute( 'aria-disabled', 'true' );
+ expect( screen.getByRole( 'combobox', { name: /New connection owner/ } ) ).toHaveTextContent(
+ 'Kazz'
+ );
+ } );
+
+ it( 'cannot continue until somebody is chosen', async () => {
+ render( );
+ await expect(
+ screen.findByRole( 'combobox', { name: /New connection owner/ } )
+ ).resolves.toBeInTheDocument();
+
+ // @wordpress/ui keeps disabled buttons focusable, so it marks them with aria-disabled.
+ expect( screen.getByRole( 'button', { name: 'Continue' } ) ).toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
+ } );
+
+ it( 'confirms who is taking over before committing the transfer', async () => {
+ const user = userEvent.setup();
+ render( );
+ await chooseKazz( user );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+
+ await expect(
+ screen.findByText( /will become the connection owner/ )
+ ).resolves.toBeInTheDocument();
+ expect( mockSetConnectionOwner ).not.toHaveBeenCalled();
+ } );
+
+ it( 'goes back to the chooser without transferring', async () => {
+ const user = userEvent.setup();
+ render( );
+ await chooseKazz( user );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+ await user.click( screen.getByRole( 'button', { name: 'Back' } ) );
+
+ await expect(
+ screen.findByRole( 'combobox', { name: /New connection owner/ } )
+ ).resolves.toBeInTheDocument();
+ expect( mockSetConnectionOwner ).not.toHaveBeenCalled();
+ } );
+
+ it( 'reports the transfer as soon as it lands', async () => {
+ const onTransferred = jest.fn();
+ const user = userEvent.setup();
+ render( );
+
+ await chooseKazz( user );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+ await user.click( screen.getByRole( 'button', { name: 'Transfer ownership' } ) );
+
+ await waitFor( () => expect( mockSetConnectionOwner ).toHaveBeenCalledWith( 7 ) );
+ await waitFor( () => expect( onTransferred ).toHaveBeenCalledWith( 7 ) );
+
+ // findAllBy: Notice also announces itself into the a11y-speak live region.
+ await expect( screen.findAllByText( /is now the connection owner/ ) ).resolves.not.toHaveLength(
+ 0
+ );
+ // Leaving is the surrounding surface's business, so the step offers no way out.
+ expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'leaves the protected owner warning out when it does not apply', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await chooseKazz( user );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+
+ await expect(
+ screen.findByRole( 'button', { name: 'Transfer ownership' } )
+ ).resolves.toBeInTheDocument();
+ expect( screen.queryByText( /protected owner of this connection/ ) ).not.toBeInTheDocument();
+ } );
+
+ it.each( [
+ [ 'new_owner_not_connected', /no longer connected to WordPress\.com/ ],
+ [ 'new_owner_not_admin', /no longer an administrator/ ],
+ [ 'new_owner_is_existing_owner', /already owns this connection/ ],
+ [ 'ownership_locked', /locked and cannot be transferred/ ],
+ ] )( 'says why the transfer was refused: %s', async ( code, expected ) => {
+ mockSetConnectionOwner.mockRejectedValue( apiError( code, 'Raw server text' ) );
+ const user = userEvent.setup();
+ render( );
+ await chooseKazz( user );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+ await user.click( screen.getByRole( 'button', { name: 'Transfer ownership' } ) );
+
+ // findAllBy: the error Notice also announces itself into the a11y-speak region,
+ // which is the point of it being a Notice.
+ await expect( screen.findAllByText( expected ) ).resolves.not.toHaveLength( 0 );
+ } );
+
+ it( 'falls back to what the server said when the code is one we do not map', async () => {
+ mockSetConnectionOwner.mockRejectedValue(
+ apiError( 'error_setting_new_owner', 'Could not confirm new owner.' )
+ );
+ const user = userEvent.setup();
+ render( );
+ await chooseKazz( user );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+ await user.click( screen.getByRole( 'button', { name: 'Transfer ownership' } ) );
+
+ await expect(
+ screen.findAllByText( 'Could not confirm new owner.' )
+ ).resolves.not.toHaveLength( 0 );
+ } );
+
+ // The confirm step interpolates an element into a translated string; a name spliced
+ // into that string rather than passed as a child could break the parse.
+ it( 'renders a display name containing markup literally', async () => {
+ mockFetchCandidates.mockResolvedValue( [
+ { id: 7, login: 'kazz', displayName: 'Kazz', email: 'kazz@example.com' },
+ ] );
+ const user = userEvent.setup();
+ render( );
+
+ await chooseKazz( user );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+
+ // Both the handover line and the support notice name the new owner.
+ await expect( screen.findAllByText( 'Kazz' ) ).resolves.toHaveLength( 2 );
+ expect( screen.getByText( /will become the connection owner/ ) ).toBeInTheDocument();
+ } );
+
+ it( 'says so when the candidates cannot be loaded', async () => {
+ mockFetchCandidates.mockRejectedValue( new Error( 'Network down' ) );
+ render( );
+
+ await expect(
+ screen.findAllByText( /Could not load the administrators/ )
+ ).resolves.not.toHaveLength( 0 );
+ // Not the empty state: nobody has established that there is no one to choose.
+ expect( screen.queryByText( 'No other connected administrators' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'explains the empty case rather than offering an action nobody can take', async () => {
+ mockFetchCandidates.mockResolvedValue( [] );
+ render( );
+
+ await expect(
+ screen.findByText( 'No other connected administrators' )
+ ).resolves.toBeInTheDocument();
+ expect( screen.queryByRole( 'combobox' ) ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', { name: 'Transfer ownership' } )
+ ).not.toBeInTheDocument();
+ // The chooser prompt would be pointing at a list that is not there.
+ expect( screen.queryByText( /Choose a connected administrator/ ) ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/projects/js-packages/connection/components/transfer-connection-ownership/test/protected-owner.tsx b/projects/js-packages/connection/components/transfer-connection-ownership/test/protected-owner.tsx
new file mode 100644
index 000000000000..ced89a26b60e
--- /dev/null
+++ b/projects/js-packages/connection/components/transfer-connection-ownership/test/protected-owner.tsx
@@ -0,0 +1,48 @@
+import { jest } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+// ESM test: static jest.mock does not work under --experimental-vm-modules, so mock the module
+// with unstable_mockModule (must run before the dynamic import of the component below).
+jest.unstable_mockModule( '@automattic/jetpack-api', () => ( {
+ __esModule: true,
+ default: {
+ setApiRoot: jest.fn(),
+ setApiNonce: jest.fn(),
+ fetchConnectionOwnerCandidates: () =>
+ Promise.resolve( [
+ { id: 7, login: 'kazz', displayName: 'Kazz', email: 'kazz@example.com' },
+ ] ),
+ setConnectionOwner: jest.fn(),
+ },
+} ) );
+
+window.JP_CONNECTION_INITIAL_STATE = {
+ hasProtectedOwner: true,
+} as typeof window.JP_CONNECTION_INITIAL_STATE;
+
+const { default: TransferConnectionOwnership } = await import( '../index' );
+
+describe( 'TransferConnectionOwnership on a protected-owner site', () => {
+ const props = {
+ apiRoot: 'https://example.org/wp-json/',
+ apiNonce: 'test-nonce',
+ };
+
+ it( 'warns what the transfer costs before they commit', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.selectOptions(
+ await screen.findByRole( 'combobox', { name: /New connection owner/ } ),
+ '7'
+ );
+ await user.click( screen.getByRole( 'button', { name: 'Continue' } ) );
+
+ await expect(
+ screen.findByText( "You're the protected owner of this connection" )
+ ).resolves.toBeInTheDocument();
+ expect( screen.getByText( /needs to confirm protected ownership/ ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'button', { name: 'Transfer anyway' } ) ).toBeInTheDocument();
+ } );
+} );
diff --git a/projects/js-packages/connection/components/transfer-connection-ownership/types.ts b/projects/js-packages/connection/components/transfer-connection-ownership/types.ts
new file mode 100644
index 000000000000..f4b1d8623d58
--- /dev/null
+++ b/projects/js-packages/connection/components/transfer-connection-ownership/types.ts
@@ -0,0 +1,11 @@
+/** A connected administrator the connection could be handed to. */
+export interface ConnectionOwnerCandidate {
+ /** Local user ID. */
+ id: number;
+ /** Local user login. */
+ login: string;
+ /** Local display name. */
+ displayName: string;
+ /** Local email address. */
+ email: string;
+}
diff --git a/projects/js-packages/connection/components/transfer-connection-ownership/use-ownership-transfer.ts b/projects/js-packages/connection/components/transfer-connection-ownership/use-ownership-transfer.ts
new file mode 100644
index 000000000000..4a2acb619a07
--- /dev/null
+++ b/projects/js-packages/connection/components/transfer-connection-ownership/use-ownership-transfer.ts
@@ -0,0 +1,202 @@
+/**
+ * External dependencies
+ */
+import restApi from '@automattic/jetpack-api';
+import { useDispatch } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import useRestApiInit from '../../hooks/use-rest-api-init';
+import { STORE_ID } from '../../state/store';
+import type { ConnectionOwnerCandidate } from './types';
+
+/** What `checkStatus` throws: an Error carrying the REST body it could not return. */
+interface ApiError {
+ message?: string;
+ response?: { code?: string; message?: string };
+}
+
+/**
+ * Turn a failed transfer into something the user can act on.
+ *
+ * All four are reachable: the list is built when the dialog opens, so a candidate can
+ * stop qualifying while it is on screen.
+ *
+ * The refusal code is on the thrown Error's `response`, not the Error itself: the API
+ * client puts the REST body there and keeps `message` for its own "… (Status 400)" text.
+ *
+ * @param {ApiError} err - The rejection from the API client.
+ * @return {string} Message to show.
+ */
+function transferErrorMessage( err: ApiError ): string {
+ const code = err?.response?.code ?? '';
+ const message = err?.response?.message ?? err?.message ?? '';
+
+ switch ( code ) {
+ case 'ownership_locked':
+ return __(
+ 'Ownership of this connection is locked and cannot be transferred.',
+ 'jetpack-connection-js'
+ );
+ case 'new_owner_not_admin':
+ return __(
+ 'That user is no longer an administrator, so they cannot take over the connection.',
+ 'jetpack-connection-js'
+ );
+ case 'new_owner_not_connected':
+ return __(
+ 'That user is no longer connected to WordPress.com. Ask them to reconnect, or choose someone else.',
+ 'jetpack-connection-js'
+ );
+ case 'new_owner_is_existing_owner':
+ return __( 'That user already owns this connection.', 'jetpack-connection-js' );
+ default:
+ return (
+ message ||
+ __(
+ 'The connection owner could not be changed. Please try again.',
+ 'jetpack-connection-js'
+ )
+ );
+ }
+}
+
+/** Where the user is in choose → confirm → done. */
+export type TransferStep = 'choose' | 'confirm' | 'done';
+
+export interface UseOwnershipTransferArgs {
+ /** API root URL. */
+ apiRoot: string;
+ /** API nonce. */
+ apiNonce: string;
+ /** Called as soon as WordPress.com accepts the new owner. */
+ onTransferred?: ( newOwnerId: number ) => void;
+}
+
+export type { ConnectionOwnerCandidate };
+
+/**
+ * Candidate list, selection and the step the user is on.
+ *
+ * @param {UseOwnershipTransferArgs} args - Hook arguments.
+ * @return The candidates, the current selection, and the transfer action.
+ */
+export default function useOwnershipTransfer( {
+ apiRoot,
+ apiNonce,
+ onTransferred,
+}: UseOwnershipTransferArgs ) {
+ const [ candidates, setCandidates ] = useState< ConnectionOwnerCandidate[] | null >( null );
+ const [ selectedId, setSelectedId ] = useState< number | null >( null );
+ const [ isTransferring, setIsTransferring ] = useState( false );
+ const [ step, setStep ] = useState< TransferStep >( 'choose' );
+ const [ error, setError ] = useState( '' );
+ const { setConnectionOwner } = useDispatch( STORE_ID );
+ const isMounted = useRef( true );
+
+ useRestApiInit( apiRoot, apiNonce );
+
+ useEffect( () => {
+ isMounted.current = true;
+
+ return () => {
+ isMounted.current = false;
+ };
+ }, [] );
+
+ useEffect( () => {
+ let ignore = false;
+
+ restApi
+ .fetchConnectionOwnerCandidates()
+ .then( ( result: unknown ) => {
+ if ( ignore ) {
+ return;
+ }
+
+ const list = Array.isArray( result ) ? ( result as ConnectionOwnerCandidate[] ) : [];
+
+ setCandidates( list );
+
+ // Nothing to choose between, so choose it.
+ if ( 1 === list.length ) {
+ setSelectedId( list[ 0 ].id );
+ }
+ } )
+ .catch( () => {
+ if ( ! ignore ) {
+ setCandidates( [] );
+ setError(
+ __(
+ 'Could not load the administrators who can take over this connection.',
+ 'jetpack-connection-js'
+ )
+ );
+ }
+ } );
+
+ // The component can close while the request is in flight.
+ return () => {
+ ignore = true;
+ };
+ }, [] );
+
+ const confirm = useCallback( () => {
+ setError( '' );
+ setStep( 'confirm' );
+ }, [] );
+
+ const back = useCallback( () => {
+ setError( '' );
+ setStep( 'choose' );
+ }, [] );
+
+ const selectedCandidate = candidates?.find( c => c.id === selectedId ) ?? null;
+
+ const transfer = useCallback( () => {
+ if ( ! selectedCandidate ) {
+ return;
+ }
+
+ const { id, displayName } = selectedCandidate;
+
+ setIsTransferring( true );
+ setError( '' );
+
+ restApi
+ .setConnectionOwner( id )
+ .then( () => {
+ // Tell the store who owns the connection now, so every surface reading it
+ // stops describing an owner the user no longer is.
+ setConnectionOwner( { id, displayName } );
+
+ if ( ! isMounted.current ) {
+ return;
+ }
+
+ setIsTransferring( false );
+ setStep( 'done' );
+ onTransferred?.( id );
+ } )
+ .catch( ( err: unknown ) => {
+ if ( ! isMounted.current ) {
+ return;
+ }
+
+ setError( transferErrorMessage( err as ApiError ) );
+ setIsTransferring( false );
+ } );
+ }, [ selectedCandidate, onTransferred, setConnectionOwner ] );
+
+ return {
+ candidates,
+ selectedId,
+ setSelectedId,
+ selectedCandidate,
+ step,
+ confirm,
+ back,
+ transfer,
+ isTransferring,
+ error,
+ };
+}
diff --git a/projects/js-packages/connection/index.jsx b/projects/js-packages/connection/index.jsx
index acbb9e19eaf9..bbaba2cb8adf 100644
--- a/projects/js-packages/connection/index.jsx
+++ b/projects/js-packages/connection/index.jsx
@@ -32,6 +32,8 @@ export { default as DisconnectDialog } from './components/disconnect-dialog';
export { default as DisconnectCard } from './components/disconnect-card';
export { default as useConnection } from './components/use-connection';
export { default as ManageConnectionDialog } from './components/manage-connection-dialog';
+export { default as TransferConnectionOwnership } from './components/transfer-connection-ownership';
+export * from './components/transfer-connection-ownership/types.ts';
export {
default as ProtectedOwnerConfirmation,
PROTECTED_OWNER_CLAIMED_BY_OTHER,
diff --git a/projects/js-packages/connection/state/actions.jsx b/projects/js-packages/connection/state/actions.jsx
index 841a432c12ec..7d68c8b06f5c 100644
--- a/projects/js-packages/connection/state/actions.jsx
+++ b/projects/js-packages/connection/state/actions.jsx
@@ -16,6 +16,23 @@ const SET_CONNECTED_PLUGINS = 'SET_CONNECTED_PLUGINS';
const REFRESH_CONNECTED_PLUGINS = 'REFRESH_CONNECTED_PLUGINS';
const SET_CONNECTION_ERRORS = 'SET_CONNECTION_ERRORS';
const SET_IS_OFFLINE_MODE = 'SET_IS_OFFLINE_MODE';
+const SET_CONNECTION_OWNER = 'SET_CONNECTION_OWNER';
+
+/**
+ * Record that ownership of the connection has moved.
+ *
+ * Patched from the transfer rather than refetched: nothing else in the user connection
+ * data turns on ownership — the permissions it reports are plain capability checks — so
+ * a request would only confirm what the caller already knows.
+ *
+ * @param {object} owner - The new owner.
+ * @param {number} owner.id - Their local user ID.
+ * @param {string} owner.displayName - Their display name.
+ * @return {object} The action.
+ */
+const setConnectionOwner = owner => {
+ return { type: SET_CONNECTION_OWNER, owner };
+};
const setConnectionStatus = connectionStatus => {
return { type: SET_CONNECTION_STATUS, connectionStatus };
@@ -137,6 +154,7 @@ const refreshConnectedPlugins =
};
const actions = {
+ setConnectionOwner,
setConnectionStatus,
setConnectionStatusIsFetching,
fetchConnectionStatus,
@@ -173,5 +191,6 @@ export {
REFRESH_CONNECTED_PLUGINS,
SET_CONNECTION_ERRORS,
SET_IS_OFFLINE_MODE,
+ SET_CONNECTION_OWNER,
actions as default,
};
diff --git a/projects/js-packages/connection/state/reducers.jsx b/projects/js-packages/connection/state/reducers.jsx
index 8978238ab8f7..1580d67f0b85 100644
--- a/projects/js-packages/connection/state/reducers.jsx
+++ b/projects/js-packages/connection/state/reducers.jsx
@@ -11,6 +11,7 @@ import {
SET_CONNECTED_PLUGINS,
SET_CONNECTION_ERRORS,
SET_IS_OFFLINE_MODE,
+ SET_CONNECTION_OWNER,
} from './actions';
const connectionStatus = ( state = {}, action ) => {
@@ -82,6 +83,13 @@ const authorizationUrl = ( state, action ) => {
const userConnectionData = ( state, action ) => {
switch ( action.type ) {
+ case SET_CONNECTION_OWNER:
+ // Whoever transferred is no longer the owner: handing it to yourself is refused.
+ return {
+ ...state,
+ currentUser: { ...state?.currentUser, isMaster: false },
+ connectionOwner: action.owner.displayName,
+ };
default:
return state;
}
@@ -89,6 +97,8 @@ const userConnectionData = ( state, action ) => {
const connectionOwner = ( state = null, action ) => {
switch ( action.type ) {
+ case SET_CONNECTION_OWNER:
+ return { id: action.owner.id, displayName: action.owner.displayName };
default:
return state;
}
@@ -102,7 +112,14 @@ const connectionOwner = ( state = null, action ) => {
* `null` is the initial value on purpose. The server withholds all three from a viewer who
* cannot manage the connection, and "cannot say" must not read as "no".
*/
-const hasProtectedOwner = ( state = null ) => state;
+const hasProtectedOwner = ( state = null, action ) => {
+ switch ( action.type ) {
+ case SET_CONNECTION_OWNER:
+ return null;
+ default:
+ return state;
+ }
+};
const requiresProtectedOwner = ( state = null ) => state;
diff --git a/projects/js-packages/connection/state/test/reducers-connection-owner.jsx b/projects/js-packages/connection/state/test/reducers-connection-owner.jsx
new file mode 100644
index 000000000000..c7c3055db605
--- /dev/null
+++ b/projects/js-packages/connection/state/test/reducers-connection-owner.jsx
@@ -0,0 +1,64 @@
+import { createReduxStore, dispatch, register, select } from '@wordpress/data';
+import actions from '../actions';
+import reducers from '../reducers';
+import selectors from '../selectors';
+
+/**
+ * Register a store seeded as the package seeds it at load.
+ *
+ * @param {object} initialState - What PHP put on the page.
+ * @param {string} storeId - A unique id, since a store cannot be registered twice.
+ * @return {object} The store id.
+ */
+const storeWith = ( initialState, storeId ) => {
+ register( createReduxStore( storeId, { reducer: reducers, actions, selectors, initialState } ) );
+
+ return storeId;
+};
+
+describe( 'setConnectionOwner', () => {
+ const initialState = {
+ connectionOwner: { id: 1, displayName: 'Original Owner' },
+ userConnectionData: {
+ currentUser: { id: 1, isMaster: true, isConnected: true },
+ connectionOwner: 'Original Owner',
+ },
+ };
+
+ it( 'records the new owner for surfaces reading the connection owner', () => {
+ const id = storeWith( initialState, 'test/owner-record' );
+
+ dispatch( id ).setConnectionOwner( { id: 7, displayName: 'Kazz' } );
+
+ expect( select( id ).getConnectionOwner() ).toEqual( { id: 7, displayName: 'Kazz' } );
+ } );
+
+ // isMaster is what the UI gates owner-only actions on, so it has to move too.
+ it( 'stops reporting the current user as the owner', () => {
+ const id = storeWith( initialState, 'test/owner-is-master' );
+
+ dispatch( id ).setConnectionOwner( { id: 7, displayName: 'Kazz' } );
+
+ const data = select( id ).getUserConnectionData();
+ expect( data.currentUser.isMaster ).toBe( false );
+ expect( data.connectionOwner ).toBe( 'Kazz' );
+ } );
+
+ it( 'stops answering whether the owner is the protected owner', () => {
+ const id = storeWith( { ...initialState, hasProtectedOwner: true }, 'test/owner-protected' );
+
+ dispatch( id ).setConnectionOwner( { id: 7, displayName: 'Kazz' } );
+
+ expect( select( id ).getHasProtectedOwner() ).toBeNull();
+ } );
+
+ it( 'leaves the rest of the user connection data alone', () => {
+ const id = storeWith( initialState, 'test/owner-untouched' );
+
+ dispatch( id ).setConnectionOwner( { id: 7, displayName: 'Kazz' } );
+
+ const { currentUser } = select( id ).getUserConnectionData();
+ expect( currentUser.id ).toBe( 1 );
+ expect( currentUser.isConnected ).toBe( true );
+ } );
+} );
diff --git a/projects/packages/connection/changelog/add-cnx-ownership-transfer b/projects/packages/connection/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..daaa9313c90f
--- /dev/null
+++ b/projects/packages/connection/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add a REST endpoint listing the connected administrators who could take over the connection.
diff --git a/projects/packages/connection/src/class-rest-connector.php b/projects/packages/connection/src/class-rest-connector.php
index 5eeb2213a39e..84ced277738e 100644
--- a/projects/packages/connection/src/class-rest-connector.php
+++ b/projects/packages/connection/src/class-rest-connector.php
@@ -359,6 +359,17 @@ public function __construct( Manager $connection ) {
)
);
+ // Users who could take over the connection, for the owner change above.
+ register_rest_route(
+ 'jetpack/v4',
+ '/connection/owner/candidates',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( static::class, 'get_connection_owner_candidates' ),
+ 'permission_callback' => array( static::class, 'get_connection_owner_candidates_permission_check' ),
+ )
+ );
+
// Confirm the current user as the protected owner. Not the connection-owner change above.
register_rest_route(
'jetpack/v4',
@@ -1109,6 +1120,64 @@ public static function set_connection_owner_permission_check() {
return new WP_Error( 'invalid_user_permission_set_connection_owner', self::get_user_permissions_error_msg(), array( 'status' => rest_authorization_required_code() ) );
}
+ /**
+ * Users this connection's ownership could be handed to.
+ *
+ * Filtered by what `Manager::update_connection_owner()` requires of a new owner, so the
+ * list cannot offer someone the change would then refuse.
+ *
+ * @since $$next-version$$
+ *
+ * @return \WP_REST_Response
+ */
+ public static function get_connection_owner_candidates() {
+ $connection = new Manager();
+ $owner_id = (int) $connection->get_connection_owner_id();
+ $admin_cap = ( new Roles() )->translate_role_to_cap( 'administrator' );
+ $candidates = array();
+
+ if ( ! $admin_cap ) {
+ return rest_ensure_response( $candidates );
+ }
+
+ foreach ( $connection->get_connected_users( $admin_cap ) as $user ) {
+ if ( (int) $user->ID === $owner_id ) {
+ continue;
+ }
+
+ $candidates[] = array(
+ 'id' => (int) $user->ID,
+ 'login' => $user->user_login,
+ 'displayName' => $user->display_name,
+ 'email' => $user->user_email,
+ );
+ }
+
+ return rest_ensure_response( $candidates );
+ }
+
+ /**
+ * Whether the current user may list the users ownership could be handed to.
+ *
+ * Same capability as changing the owner: seeing the candidates and acting on them go
+ * together, and the list carries logins and email addresses.
+ *
+ * @since $$next-version$$
+ *
+ * @return true|WP_Error
+ */
+ public static function get_connection_owner_candidates_permission_check() {
+ if ( current_user_can( 'jetpack_disconnect' ) ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'invalid_user_permission_list_connection_owner_candidates',
+ self::get_user_permissions_error_msg(),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
/**
* Confirm the current user as the protected owner.
*
diff --git a/projects/packages/connection/tests/php/REST_Endpoints_Test.php b/projects/packages/connection/tests/php/REST_Endpoints_Test.php
index c6c2f60db2e8..26b52baaac63 100644
--- a/projects/packages/connection/tests/php/REST_Endpoints_Test.php
+++ b/projects/packages/connection/tests/php/REST_Endpoints_Test.php
@@ -967,6 +967,128 @@ public function test_update_connection_owner_success() {
$this->assertEquals( self::$secondary_user_id, Jetpack_Options::get_option( 'master_user' ), 'Connection owner should be updated.' );
}
+ /* ── connection/owner/candidates ───────────────────────────── */
+
+ /**
+ * Dispatch the candidates endpoint and return its data.
+ *
+ * @return array
+ */
+ private function get_owner_candidates() {
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/jetpack/v4/connection/owner/candidates' ) );
+
+ $this->assertSame( 200, $response->get_status() );
+
+ return $response->get_data();
+ }
+
+ /**
+ * Connected administrators other than the current owner can take the connection over.
+ */
+ public function test_owner_candidates_lists_connected_admins_except_the_owner() {
+ Jetpack_Options::update_option( 'id', self::BLOG_ID );
+ Jetpack_Options::update_option( 'blog_token', 'blogkey.private' );
+ Jetpack_Options::update_option( 'master_user', self::$user_id );
+ Jetpack_Options::update_option(
+ 'user_tokens',
+ array(
+ self::$user_id => 'ownerkey.private.' . self::$user_id,
+ self::$secondary_user_id => 'secondkey.private.' . self::$secondary_user_id,
+ )
+ );
+
+ $candidates = $this->get_owner_candidates();
+
+ $this->assertCount( 1, $candidates, 'The current owner is not a candidate for their own role.' );
+ $this->assertSame( self::$secondary_user_id, $candidates[0]['id'] );
+ $this->assertArrayHasKey( 'login', $candidates[0] );
+ $this->assertArrayHasKey( 'displayName', $candidates[0] );
+ $this->assertArrayHasKey( 'email', $candidates[0] );
+ }
+
+ /**
+ * A connected editor cannot be handed the connection, so it is not offered.
+ *
+ * Matches update_connection_owner(), which refuses a new owner who is not an administrator.
+ */
+ public function test_owner_candidates_skips_connected_non_admins() {
+ Jetpack_Options::update_option( 'id', self::BLOG_ID );
+ Jetpack_Options::update_option( 'blog_token', 'blogkey.private' );
+ Jetpack_Options::update_option( 'master_user', self::$user_id );
+ Jetpack_Options::update_option(
+ 'user_tokens',
+ array(
+ self::$user_id => 'ownerkey.private.' . self::$user_id,
+ self::$non_admin_user_id => 'editorkey.private.' . self::$non_admin_user_id,
+ )
+ );
+
+ $this->assertSame( array(), $this->get_owner_candidates() );
+ }
+
+ /**
+ * An administrator who has not linked an account cannot be handed the connection either.
+ */
+ public function test_owner_candidates_skips_admins_without_a_token() {
+ Jetpack_Options::update_option( 'id', self::BLOG_ID );
+ Jetpack_Options::update_option( 'blog_token', 'blogkey.private' );
+ Jetpack_Options::update_option( 'master_user', self::$user_id );
+ Jetpack_Options::update_option(
+ 'user_tokens',
+ array( self::$user_id => 'ownerkey.private.' . self::$user_id )
+ );
+
+ $this->assertSame( array(), $this->get_owner_candidates() );
+ }
+
+ /**
+ * A logged-out request must not reach the list at all.
+ */
+ public function test_owner_candidates_rejects_a_logged_out_request() {
+ wp_set_current_user( 0 );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/jetpack/v4/connection/owner/candidates' ) );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'invalid_user_permission_list_connection_owner_candidates', $response->get_data()['code'] );
+ }
+
+ /**
+ * Any administrator may read the list, not only the current owner.
+ *
+ * They are the ones offered the transfer once ownership moves to them.
+ */
+ public function test_owner_candidates_allows_an_admin_who_is_not_the_owner() {
+ $secondary = get_user_by( 'id', self::$secondary_user_id );
+ $secondary->add_cap( 'jetpack_disconnect' );
+ wp_set_current_user( self::$secondary_user_id );
+
+ \Jetpack_Options::update_option(
+ 'user_tokens',
+ array(
+ self::$user_id => 'ownerkey.private.' . self::$user_id,
+ self::$secondary_user_id => 'secondkey.private.' . self::$secondary_user_id,
+ )
+ );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/jetpack/v4/connection/owner/candidates' ) );
+
+ $this->assertSame( 200, $response->get_status() );
+ $secondary->remove_cap( 'jetpack_disconnect' );
+ }
+
+ /**
+ * The list carries logins and email addresses, so it needs the same capability as the change.
+ */
+ public function test_owner_candidates_requires_the_disconnect_capability() {
+ wp_set_current_user( self::$non_admin_user_id );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/jetpack/v4/connection/owner/candidates' ) );
+
+ $this->assertSame( 403, $response->get_status() );
+ $this->assertSame( 'invalid_user_permission_list_connection_owner_candidates', $response->get_data()['code'] );
+ }
+
/**
* A connected administrator who is not the connection owner may confirm a protected owner.
*/
diff --git a/projects/plugins/backup/changelog/add-cnx-ownership-transfer b/projects/plugins/backup/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..610ebab42184
--- /dev/null
+++ b/projects/plugins/backup/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Connection: Transfer connection ownership to another administrator without leaving your site.
diff --git a/projects/plugins/boost/changelog/add-cnx-ownership-transfer b/projects/plugins/boost/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..610ebab42184
--- /dev/null
+++ b/projects/plugins/boost/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Connection: Transfer connection ownership to another administrator without leaving your site.
diff --git a/projects/plugins/jetpack/changelog/add-cnx-ownership-transfer b/projects/plugins/jetpack/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..2824677e0ed4
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Connection: Transfer connection ownership to another administrator without leaving your site.
diff --git a/projects/plugins/protect/changelog/add-cnx-ownership-transfer b/projects/plugins/protect/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..610ebab42184
--- /dev/null
+++ b/projects/plugins/protect/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Connection: Transfer connection ownership to another administrator without leaving your site.
diff --git a/projects/plugins/search/changelog/add-cnx-ownership-transfer b/projects/plugins/search/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..610ebab42184
--- /dev/null
+++ b/projects/plugins/search/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Connection: Transfer connection ownership to another administrator without leaving your site.
diff --git a/projects/plugins/social/changelog/add-cnx-ownership-transfer b/projects/plugins/social/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..610ebab42184
--- /dev/null
+++ b/projects/plugins/social/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Connection: Transfer connection ownership to another administrator without leaving your site.
diff --git a/projects/plugins/stats/changelog/add-cnx-ownership-transfer b/projects/plugins/stats/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..610ebab42184
--- /dev/null
+++ b/projects/plugins/stats/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Connection: Transfer connection ownership to another administrator without leaving your site.
diff --git a/projects/plugins/videopress/changelog/add-cnx-ownership-transfer b/projects/plugins/videopress/changelog/add-cnx-ownership-transfer
new file mode 100644
index 000000000000..610ebab42184
--- /dev/null
+++ b/projects/plugins/videopress/changelog/add-cnx-ownership-transfer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Connection: Transfer connection ownership to another administrator without leaving your site.
From 0d3df8b5b837aa65d2bb6951a7416c18cf9bd894 Mon Sep 17 00:00:00 2001
From: Xavier Lozano Carreras <8511199+xavier-lc@users.noreply.github.com>
Date: Tue, 13 Oct 2026 13:09:05 +0200
Subject: [PATCH 03/57] My Jetpack: Show the highest-priority global notice
first (#53400)
---
.../_inc/context/notices/noticeContext.tsx | 29 +++++-----
.../notices/test/notice-context.test.tsx | 53 +++++++++++++++++++
...fix-my-jetpack-notice-priority-stale-state | 4 ++
...fix-my-jetpack-notice-priority-stale-state | 4 ++
...fix-my-jetpack-notice-priority-stale-state | 4 ++
...fix-my-jetpack-notice-priority-stale-state | 4 ++
...fix-my-jetpack-notice-priority-stale-state | 4 ++
...fix-my-jetpack-notice-priority-stale-state | 4 ++
...fix-my-jetpack-notice-priority-stale-state | 4 ++
...fix-my-jetpack-notice-priority-stale-state | 4 ++
...fix-my-jetpack-notice-priority-stale-state | 4 ++
11 files changed, 106 insertions(+), 12 deletions(-)
create mode 100644 projects/packages/my-jetpack/_inc/context/notices/test/notice-context.test.tsx
create mode 100644 projects/packages/my-jetpack/changelog/fix-my-jetpack-notice-priority-stale-state
create mode 100644 projects/plugins/backup/changelog/fix-my-jetpack-notice-priority-stale-state
create mode 100644 projects/plugins/boost/changelog/fix-my-jetpack-notice-priority-stale-state
create mode 100644 projects/plugins/jetpack/changelog/fix-my-jetpack-notice-priority-stale-state
create mode 100644 projects/plugins/protect/changelog/fix-my-jetpack-notice-priority-stale-state
create mode 100644 projects/plugins/search/changelog/fix-my-jetpack-notice-priority-stale-state
create mode 100644 projects/plugins/social/changelog/fix-my-jetpack-notice-priority-stale-state
create mode 100644 projects/plugins/stats/changelog/fix-my-jetpack-notice-priority-stale-state
create mode 100644 projects/plugins/videopress/changelog/fix-my-jetpack-notice-priority-stale-state
diff --git a/projects/packages/my-jetpack/_inc/context/notices/noticeContext.tsx b/projects/packages/my-jetpack/_inc/context/notices/noticeContext.tsx
index 073e9f54f650..8a4826ff03d8 100644
--- a/projects/packages/my-jetpack/_inc/context/notices/noticeContext.tsx
+++ b/projects/packages/my-jetpack/_inc/context/notices/noticeContext.tsx
@@ -28,19 +28,24 @@ const NoticeContextProvider = ( { children } ) => {
const setNotice = useCallback(
// If onClose is not provided in the "notice", and close button is not hidden, use the custom onClose function
( notice: Notice, onClose?: NoticeOptions[ 'onClose' ] ) => {
- // Only update notice if there is not already a notice or the new notice has a higher priority
- if ( ! currentNotice.message || notice.options.priority > currentNotice.options.priority ) {
- const newOptions = {
- ...notice.options,
- onClose:
- notice.options?.onClose || ( ! notice.options?.hideCloseButton ? onClose : undefined ),
- };
-
- resetNotice();
- setCurrentNotice( { ...notice, options: newOptions } );
- }
+ const newOptions = {
+ ...notice.options,
+ onClose:
+ notice.options?.onClose || ( ! notice.options?.hideCloseButton ? onClose : undefined ),
+ };
+
+ // Replace the notice only if none is showing or the new one has a higher priority, so on a tie
+ // the watcher that runs first keeps it. Read the queued state, since watchers call this several
+ // times in one effect pass.
+ setCurrentNotice( prev =>
+ ! prev.message || notice.options.priority > prev.options.priority
+ ? { ...notice, options: newOptions }
+ : prev
+ );
},
- [ currentNotice.message, currentNotice.options.priority, resetNotice ]
+ // Changing identity on each notice change re-runs the watchers, e.g. to show the next notice after a close.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [ currentNotice.message, currentNotice.options.priority ]
);
return (
diff --git a/projects/packages/my-jetpack/_inc/context/notices/test/notice-context.test.tsx b/projects/packages/my-jetpack/_inc/context/notices/test/notice-context.test.tsx
new file mode 100644
index 000000000000..9dbbcb20fcf2
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/context/notices/test/notice-context.test.tsx
@@ -0,0 +1,53 @@
+import { render } from '@testing-library/react';
+import { useContext, useEffect } from 'react';
+import { NOTICE_PRIORITY_HIGH, NOTICE_PRIORITY_MEDIUM } from '../../constants';
+import NoticeContextProvider, { NoticeContext } from '../noticeContext';
+
+describe( 'NoticeContextProvider', () => {
+ it.each( [
+ [
+ 'keeps the higher-priority notice when a lower one follows',
+ NOTICE_PRIORITY_MEDIUM,
+ [ 'First' ],
+ ],
+ [
+ 'keeps the first notice when an equal-priority one follows',
+ NOTICE_PRIORITY_HIGH,
+ [ 'First' ],
+ ],
+ ] )( '%s in the same effect pass', ( _, secondPriority, expected ) => {
+ const rendered: string[] = [];
+
+ const Watchers = () => {
+ const { setNotice } = useContext( NoticeContext );
+ useEffect( () => {
+ setNotice( {
+ message: 'First',
+ options: { level: 'error', priority: NOTICE_PRIORITY_HIGH },
+ } );
+ setNotice( {
+ message: 'Second',
+ options: { level: 'error', priority: secondPriority },
+ } );
+ }, [ setNotice ] );
+ return null;
+ };
+
+ const CurrentNotice = () => {
+ const { currentNotice } = useContext( NoticeContext );
+ if ( currentNotice.message ) {
+ rendered.push( String( currentNotice.message ) );
+ }
+ return null;
+ };
+
+ render(
+
+
+
+
+ );
+
+ expect( rendered ).toEqual( expected );
+ } );
+} );
diff --git a/projects/packages/my-jetpack/changelog/fix-my-jetpack-notice-priority-stale-state b/projects/packages/my-jetpack/changelog/fix-my-jetpack-notice-priority-stale-state
new file mode 100644
index 000000000000..31b55cc0c157
--- /dev/null
+++ b/projects/packages/my-jetpack/changelog/fix-my-jetpack-notice-priority-stale-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Notices: Show the highest-priority notice first when several are set at once.
diff --git a/projects/plugins/backup/changelog/fix-my-jetpack-notice-priority-stale-state b/projects/plugins/backup/changelog/fix-my-jetpack-notice-priority-stale-state
new file mode 100644
index 000000000000..1d7ae1238d22
--- /dev/null
+++ b/projects/plugins/backup/changelog/fix-my-jetpack-notice-priority-stale-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+My Jetpack: Show the highest-priority notice first when several are set at once.
diff --git a/projects/plugins/boost/changelog/fix-my-jetpack-notice-priority-stale-state b/projects/plugins/boost/changelog/fix-my-jetpack-notice-priority-stale-state
new file mode 100644
index 000000000000..1d7ae1238d22
--- /dev/null
+++ b/projects/plugins/boost/changelog/fix-my-jetpack-notice-priority-stale-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+My Jetpack: Show the highest-priority notice first when several are set at once.
diff --git a/projects/plugins/jetpack/changelog/fix-my-jetpack-notice-priority-stale-state b/projects/plugins/jetpack/changelog/fix-my-jetpack-notice-priority-stale-state
new file mode 100644
index 000000000000..be46cc23d333
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/fix-my-jetpack-notice-priority-stale-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: bugfix
+
+My Jetpack: Show the highest-priority notice first when several are set at once.
diff --git a/projects/plugins/protect/changelog/fix-my-jetpack-notice-priority-stale-state b/projects/plugins/protect/changelog/fix-my-jetpack-notice-priority-stale-state
new file mode 100644
index 000000000000..1d7ae1238d22
--- /dev/null
+++ b/projects/plugins/protect/changelog/fix-my-jetpack-notice-priority-stale-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+My Jetpack: Show the highest-priority notice first when several are set at once.
diff --git a/projects/plugins/search/changelog/fix-my-jetpack-notice-priority-stale-state b/projects/plugins/search/changelog/fix-my-jetpack-notice-priority-stale-state
new file mode 100644
index 000000000000..1d7ae1238d22
--- /dev/null
+++ b/projects/plugins/search/changelog/fix-my-jetpack-notice-priority-stale-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+My Jetpack: Show the highest-priority notice first when several are set at once.
diff --git a/projects/plugins/social/changelog/fix-my-jetpack-notice-priority-stale-state b/projects/plugins/social/changelog/fix-my-jetpack-notice-priority-stale-state
new file mode 100644
index 000000000000..1d7ae1238d22
--- /dev/null
+++ b/projects/plugins/social/changelog/fix-my-jetpack-notice-priority-stale-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+My Jetpack: Show the highest-priority notice first when several are set at once.
diff --git a/projects/plugins/stats/changelog/fix-my-jetpack-notice-priority-stale-state b/projects/plugins/stats/changelog/fix-my-jetpack-notice-priority-stale-state
new file mode 100644
index 000000000000..1d7ae1238d22
--- /dev/null
+++ b/projects/plugins/stats/changelog/fix-my-jetpack-notice-priority-stale-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+My Jetpack: Show the highest-priority notice first when several are set at once.
diff --git a/projects/plugins/videopress/changelog/fix-my-jetpack-notice-priority-stale-state b/projects/plugins/videopress/changelog/fix-my-jetpack-notice-priority-stale-state
new file mode 100644
index 000000000000..1d7ae1238d22
--- /dev/null
+++ b/projects/plugins/videopress/changelog/fix-my-jetpack-notice-priority-stale-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+My Jetpack: Show the highest-priority notice first when several are set at once.
From dca51f19a28aa3e4876f67158281aba6a74f8091 Mon Sep 17 00:00:00 2001
From: Karen Attfield
Date: Tue, 13 Oct 2026 12:16:23 +0100
Subject: [PATCH 04/57] Connection: Build connectors card as a bundled ES
module (#53179)
* Build connectors card as a bundled ES module
# Conflicts:
* More thorough build state checks, and general cleanup
# Conflicts:
# projects/plugins/inspect/composer.lock
* Fix test type expectation
* Add test-js-coverage script
* Add additional clarification-adding comments in the connection webpack config file
* Add override block in babel.config.js
* Update composer.lock files
# Conflicts:
# projects/plugins/inspect/composer.lock
* Update .c8rc.json file to inclue src under include
* Switch approach to disable DependencyExtractionPlugin and use a custom plugin instead
* Update dependency-extraction-webpack-plugin to 6.57.0 to match trunk
* Remove failed build snackbar
* Connectors card: map all shared scripts and fail the build on any other unmapped import
* Fix connectors card return with a failed build and update tests and docs
* Connectors card: drop the unused jetpackConfig external, since connectorsCardExternal() now handles every import
---
pnpm-lock.yaml | 6 +
projects/packages/connection/.c8rc.json | 5 +
projects/packages/connection/.gitattributes | 9 +-
projects/packages/connection/babel.config.js | 15 ++
.../bin/validate-connectors-card-lib.js | 141 ++++++++++++
.../bin/validate-connectors-card.js | 15 ++
.../changelog/add-connectors-card-es-module | 4 +
projects/packages/connection/composer.json | 4 +
.../packages/connection/docs/connectors.md | 2 +-
projects/packages/connection/package.json | 7 +-
.../connectors/class-jetpack-connector.php | 60 ++++-
.../src/connectors/js/connectors-card.js | 44 ++--
.../tests/js/validate-connectors-card.test.js | 43 ++++
.../tests/php/Jetpack_Connector_Test.php | 179 +++++++++++++++
.../packages/connection/webpack.config.js | 210 ++++++++++++++++++
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/agents-manager/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
.../composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/backup/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/boost/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
.../classic-theme-helper-plugin/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/jetpack/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 5 +
.../plugins/mu-wpcom-plugin/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
.../paypal-payment-buttons/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
.../plugins/premium-analytics/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/protect/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/search/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/social/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 5 +
projects/plugins/starter-plugin/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/stats/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/videopress/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/wpcloud-sso/composer.lock | 8 +-
.../changelog/add-connectors-card-es-module | 4 +
projects/plugins/wpcomsh/composer.lock | 8 +-
49 files changed, 906 insertions(+), 44 deletions(-)
create mode 100644 projects/packages/connection/.c8rc.json
create mode 100644 projects/packages/connection/bin/validate-connectors-card-lib.js
create mode 100644 projects/packages/connection/bin/validate-connectors-card.js
create mode 100644 projects/packages/connection/changelog/add-connectors-card-es-module
create mode 100644 projects/packages/connection/tests/js/validate-connectors-card.test.js
create mode 100644 projects/plugins/agents-manager/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/automattic-for-agencies-client/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/backup/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/boost/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/classic-theme-helper-plugin/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/jetpack/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/mu-wpcom-plugin/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/paypal-payment-buttons/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/premium-analytics/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/protect/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/search/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/social/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/starter-plugin/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/stats/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/videopress/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/wpcloud-sso/changelog/add-connectors-card-es-module
create mode 100644 projects/plugins/wpcomsh/changelog/add-connectors-card-es-module
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 98c4b5d7eede..2120477c74f7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2463,6 +2463,12 @@ importers:
'@wordpress/browserslist-config':
specifier: 6.55.0
version: 6.55.0
+ '@wordpress/dependency-extraction-webpack-plugin':
+ specifier: 6.57.0
+ version: 6.57.0(webpack@5.111.1)
+ c8:
+ specifier: ^11.0.0
+ version: 11.0.0
glob:
specifier: 13.0.6
version: 13.0.6
diff --git a/projects/packages/connection/.c8rc.json b/projects/packages/connection/.c8rc.json
new file mode 100644
index 000000000000..d99de8e7a1e9
--- /dev/null
+++ b/projects/packages/connection/.c8rc.json
@@ -0,0 +1,5 @@
+{
+ "reporter": [ "json" ],
+ "all": true,
+ "include": [ "bin", "src" ]
+}
diff --git a/projects/packages/connection/.gitattributes b/projects/packages/connection/.gitattributes
index 1c18e8e8fa82..76988d0f87cf 100644
--- a/projects/packages/connection/.gitattributes
+++ b/projects/packages/connection/.gitattributes
@@ -7,7 +7,10 @@ docs/ export-ignore
# Files not needed in the production build.
# (see also entries in the monorepo root .gitattributes)
-src/js/** production-exclude
-/dist/*.css.map production-exclude
-/dist/*.js.map production-exclude
+src/js/** production-exclude
+/dist/*.css.map production-exclude
+/dist/*.js.map production-exclude
+/dist/connectors/*.js.map production-exclude
src/identity-crisis/_inc/** production-exclude
+src/connectors/js/** production-exclude
+/bin/** production-exclude
diff --git a/projects/packages/connection/babel.config.js b/projects/packages/connection/babel.config.js
index 91f65013b5c3..2e98b0ab29ee 100644
--- a/projects/packages/connection/babel.config.js
+++ b/projects/packages/connection/babel.config.js
@@ -5,6 +5,21 @@ const config = {
{ pluginReplaceTextdomain: { textdomain: 'jetpack-connection' } },
],
],
+ overrides: [
+ // The Connectors card is a script module, which can't depend on the classic wp-polyfill script.
+ {
+ test: /\/connectors\//,
+ presets: [
+ [
+ '@automattic/jetpack-webpack-config/babel/preset',
+ {
+ autoWpPolyfill: false,
+ pluginReplaceTextdomain: { textdomain: 'jetpack-connection' },
+ },
+ ],
+ ],
+ },
+ ],
};
module.exports = config;
diff --git a/projects/packages/connection/bin/validate-connectors-card-lib.js b/projects/packages/connection/bin/validate-connectors-card-lib.js
new file mode 100644
index 000000000000..43dd7141d7d8
--- /dev/null
+++ b/projects/packages/connection/bin/validate-connectors-card-lib.js
@@ -0,0 +1,141 @@
+/* global __dirname */
+/**
+ * Checks on the Connectors card's built script module, shared by the post-build CLI script and the tests.
+ */
+
+const { execFileSync } = require( 'child_process' );
+const { existsSync, readdirSync, readFileSync, statSync } = require( 'fs' );
+const path = require( 'path' );
+
+const CONNECTOR_CLASS_FILE = path.join(
+ __dirname,
+ '..',
+ 'src',
+ 'connectors',
+ 'class-jetpack-connector.php'
+);
+const SIZE_LIMIT = 60 * 1024;
+
+// Markers of a bundled React.
+const FORBIDDEN_MARKERS = [
+ [ '__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED', 'a bundled React' ],
+ [ 'react-stack-bottom-frame', 'a bundled React' ],
+ [ 'jsx-runtime', 'a bundled React JSX runtime' ],
+];
+
+/**
+ * Run a PHP snippet and decode the JSON it prints.
+ *
+ * @param {string} code - PHP code, without the opening tag. `$argv[1]` is `file`.
+ * @param {string} file - File path passed to the snippet.
+ * @return {*} Decoded value.
+ */
+function readPhpJson( code, file ) {
+ return JSON.parse( execFileSync( 'php', [ '-r', code, file ], { encoding: 'utf8' } ) );
+}
+
+/**
+ * Read a PHP asset file as JSON.
+ *
+ * @param {string} assetFile - Path to the `.asset.php` file.
+ * @return {object} Asset data.
+ */
+function readAsset( assetFile ) {
+ return readPhpJson( 'echo json_encode( require $argv[1] );', assetFile );
+}
+
+/**
+ * Read where the PHP loads the card from.
+ *
+ * @param {string} classFile - Path to class-jetpack-connector.php.
+ * @return {string} Module path without extension.
+ */
+function readModuleFile( classFile ) {
+ const moduleFile = readPhpJson(
+ 'require $argv[1]; echo json_encode( Automattic\\Jetpack\\Connection\\Jetpack_Connector::MODULE_FILE );',
+ classFile
+ );
+ return path.resolve( path.dirname( classFile ), moduleFile );
+}
+
+/**
+ * Check the card's build output.
+ *
+ * @param {object} build - Build output to check.
+ * @param {object} build.asset - Asset data.
+ * @param {string[]} build.jsFiles - JS files emitted for the entry.
+ * @param {string} build.bundle - Contents of the emitted JS.
+ * @param {number} build.size - Size of the emitted JS in bytes.
+ * @return {string[]} Problems found; empty when the build is valid.
+ */
+function checkConnectorsCard( { asset, jsFiles, bundle, size } ) {
+ if ( ! asset || typeof asset !== 'object' || Array.isArray( asset ) ) {
+ return [
+ `The asset file returned ${ JSON.stringify( asset ) }, expected an associative array.`,
+ ];
+ }
+
+ const errors = [];
+ const moduleDependencies = Array.isArray( asset.module_dependencies )
+ ? asset.module_dependencies
+ : [];
+
+ if ( asset.type !== 'module' ) {
+ errors.push( `Asset type is ${ JSON.stringify( asset.type ) }, expected "module".` );
+ }
+
+ if (
+ ! moduleDependencies.some(
+ dependency => dependency?.id === '@wordpress/connectors' && dependency.import === 'static'
+ )
+ ) {
+ errors.push( '@wordpress/connectors is not a static module dependency.' );
+ }
+
+ if ( jsFiles.length !== 1 ) {
+ errors.push( `Expected one JS file, found ${ jsFiles.length }: ${ jsFiles.join( ', ' ) }.` );
+ }
+
+ for ( const [ marker, description ] of FORBIDDEN_MARKERS ) {
+ if ( bundle.includes( marker ) ) {
+ errors.push( `The bundle contains ${ description } ("${ marker }").` );
+ }
+ }
+
+ if ( size > SIZE_LIMIT ) {
+ errors.push( `The bundle is ${ size } bytes, over the ${ SIZE_LIMIT } byte limit.` );
+ }
+
+ return errors;
+}
+
+/**
+ * Read the build output from where the PHP loads it, and check it.
+ *
+ * @param {string} [classFile] - Path to class-jetpack-connector.php.
+ * @return {string[]} Problems found; empty when the build is valid.
+ */
+function validateConnectorsCard( classFile = CONNECTOR_CLASS_FILE ) {
+ const moduleFile = readModuleFile( classFile );
+ const jsFile = `${ moduleFile }.js`;
+ const assetFile = `${ moduleFile }.asset.php`;
+
+ if ( ! existsSync( jsFile ) || ! existsSync( assetFile ) ) {
+ return [
+ `Build output not found at ${ jsFile }, where Jetpack_Connector::MODULE_FILE points.`,
+ ];
+ }
+
+ return checkConnectorsCard( {
+ asset: readAsset( assetFile ),
+ jsFiles: readdirSync( path.dirname( jsFile ) ).filter( file => /\.m?js$/.test( file ) ),
+ bundle: readFileSync( jsFile, 'utf8' ),
+ size: statSync( jsFile ).size,
+ } );
+}
+
+module.exports = {
+ checkConnectorsCard,
+ validateConnectorsCard,
+ SIZE_LIMIT,
+};
diff --git a/projects/packages/connection/bin/validate-connectors-card.js b/projects/packages/connection/bin/validate-connectors-card.js
new file mode 100644
index 000000000000..da703ea419bd
--- /dev/null
+++ b/projects/packages/connection/bin/validate-connectors-card.js
@@ -0,0 +1,15 @@
+#!/usr/bin/env node
+
+/**
+ * Fail the build if the Connectors card's script module is built in a shape the PHP can't load.
+ */
+
+const { validateConnectorsCard } = require( './validate-connectors-card-lib.js' );
+
+const errors = validateConnectorsCard();
+
+if ( errors.length > 0 ) {
+ throw new Error(
+ 'Connectors card build check failed:\n' + errors.map( error => ` - ${ error }` ).join( '\n' )
+ );
+}
diff --git a/projects/packages/connection/changelog/add-connectors-card-es-module b/projects/packages/connection/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..8360992a24b3
--- /dev/null
+++ b/projects/packages/connection/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connectors: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/packages/connection/composer.json b/projects/packages/connection/composer.json
index a5ce26e1f1ba..a999ae83dd84 100644
--- a/projects/packages/connection/composer.json
+++ b/projects/packages/connection/composer.json
@@ -48,6 +48,10 @@
"phpunit-select-config phpunit.#.xml.dist --colors=always"
],
"test-php": "@composer phpunit",
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": "pnpm run test-coverage",
"test-php-coverage": "php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
},
"repositories": [
diff --git a/projects/packages/connection/docs/connectors.md b/projects/packages/connection/docs/connectors.md
index d792ac08eb70..463f84461db6 100644
--- a/projects/packages/connection/docs/connectors.md
+++ b/projects/packages/connection/docs/connectors.md
@@ -11,7 +11,7 @@ The relevant class is `Automattic\Jetpack\Connection\Jetpack_Connector`.
`Jetpack_Connector::init()` is called automatically when you configure the connection package (it runs from `Manager::configure()`), so no extra setup is required in your plugin. Initialization wires up three things:
1. **Connector registration** — on `wp_connectors_init`, registers a `wordpress_com` connector (a `cloud_service` type with `authentication.method = none`) in the core registry.
-2. **Script module** — on the Connectors admin screen, enqueues the `@automattic/jetpack-connection-connectors` script module (and its stylesheet), which provides a custom render function for the card.
+2. **Script module** — on the Connectors admin screen, enqueues the `@automattic/jetpack-connection-connectors` script module (and its stylesheet), which provides a custom render function for the card. The module is built by webpack into `dist/connectors/`, and its `connectors-card.asset.php` lists the script modules it imports (`module_dependencies`) and the classic scripts it reads as globals (`dependencies`), which are enqueued beside it. If either built file is missing, the card is skipped.
3. **Auth error capture** — on `jetpack_client_authorize_error`, stores the error so it can be surfaced in the card after an authorization attempt.
## What the card shows
diff --git a/projects/packages/connection/package.json b/projects/packages/connection/package.json
index bba76c7d7141..04a23bdd4b9c 100644
--- a/projects/packages/connection/package.json
+++ b/projects/packages/connection/package.json
@@ -15,10 +15,13 @@
"scripts": {
"build": "pnpm run clean && pnpm run build-js",
"build-js": "webpack",
- "build-production": "pnpm run clean && pnpm run build-production-js && pnpm run validate",
+ "build-production": "pnpm run clean && pnpm run build-production-js && pnpm run validate && pnpm run validate-connectors-card",
"build-production-js": "NODE_ENV=production BABEL_ENV=production pnpm run build-js",
"clean": "rm -rf dist",
+ "test": "node --test 'tests/js/**/*.test.js'",
+ "test-coverage": "c8 --report-dir=\"$COVERAGE_DIR\" --temp-directory=\"$ARTIFACTS_DIR/v8\" pnpm run test",
"validate": "pnpm exec validate-es dist/",
+ "validate-connectors-card": "node bin/validate-connectors-card.js",
"watch": "pnpm run build && pnpm webpack watch"
},
"browserslist": [
@@ -36,6 +39,8 @@
"@babel/runtime": "7.29.7",
"@types/react": "18.3.31",
"@wordpress/browserslist-config": "6.55.0",
+ "@wordpress/dependency-extraction-webpack-plugin": "6.57.0",
+ "c8": "^11.0.0",
"glob": "13.0.6",
"react": "18.3.1",
"react-dom": "18.3.1",
diff --git a/projects/packages/connection/src/connectors/class-jetpack-connector.php b/projects/packages/connection/src/connectors/class-jetpack-connector.php
index 18cc736b9550..f8adc17b0ca8 100644
--- a/projects/packages/connection/src/connectors/class-jetpack-connector.php
+++ b/projects/packages/connection/src/connectors/class-jetpack-connector.php
@@ -38,6 +38,15 @@ class Jetpack_Connector {
*/
const MODULE_ID = '@automattic/jetpack-connection-connectors';
+ /**
+ * Path of the card's built script module relative to this file, without the extension.
+ *
+ * @since $$next-version$$
+ *
+ * @var string
+ */
+ const MODULE_FILE = '../../dist/connectors/connectors-card';
+
/**
* Screen ID assigned by WordPress to the Gutenberg plugin's connectors submenu page.
*
@@ -94,7 +103,10 @@ public static function register_connector( $registry ) {
/**
* Enqueue the connectors card script module on the Settings > Connectors page.
*
+ * Skips the card when its built files are missing.
+ *
* @since 8.2.0
+ * @since $$next-version$$ Loads the webpack build from dist/, with its dependencies and version from the asset file.
*/
public static function enqueue_script_module() {
$screen = get_current_screen();
@@ -107,6 +119,12 @@ public static function enqueue_script_module() {
return;
}
+ // The built files are missing in a checkout that hasn't run the build, or mid-deploy.
+ $asset = static::get_module_asset( __DIR__ . '/' . static::MODULE_FILE );
+ if ( null === $asset ) {
+ return;
+ }
+
$css_path = __DIR__ . '/css/connectors-card.css';
wp_enqueue_style(
'jetpack-connector-card',
@@ -115,21 +133,26 @@ public static function enqueue_script_module() {
(string) @filemtime( $css_path ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback to empty string if file is missing.
);
- $js_path = __DIR__ . '/js/connectors-card.js';
wp_register_script_module(
static::MODULE_ID,
- plugins_url( 'js/connectors-card.js', __FILE__ ),
- array(
- array(
- 'id' => '@wordpress/connectors',
- 'import' => 'static',
- ),
- ),
- (string) @filemtime( $js_path ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback to empty string if file is missing.
+ // Resolve the '..' so core finds translations under the file's real path, as Assets::register_script() does.
+ Assets::normalize_path( plugins_url( static::MODULE_FILE . '.js', __FILE__ ) ),
+ $asset['module_dependencies'] ?? array(),
+ $asset['version'] ?? false
);
+ // WP 7.0+; the Gutenberg plugin's Connectors screen can run on older cores.
+ if ( function_exists( 'wp_set_script_module_translations' ) ) {
+ wp_set_script_module_translations( static::MODULE_ID, 'jetpack-connection' );
+ }
wp_enqueue_script_module( static::MODULE_ID );
+ // A script module can't depend on classic scripts, so the card's globals are enqueued beside it.
// Assets::enqueue_script also loads the stylesheet registered with the handle.
+ foreach ( $asset['dependencies'] ?? array() as $handle ) {
+ Assets::enqueue_script( $handle );
+ }
+
+ // The card reads these dialogs from window.JetpackConnection, so the asset file does not list the script.
if ( static::should_enqueue_protected_owner_dialogs( new Manager() ) ) {
Assets::enqueue_script( 'jetpack-connection' );
}
@@ -140,6 +163,25 @@ public static function enqueue_script_module() {
);
}
+ /**
+ * Read the asset file the build emits beside the card's script module.
+ *
+ * @since $$next-version$$
+ *
+ * @param string $module_path Path of the built module, without the extension.
+ * @return array|null Asset data, or null if the module or its asset file is missing, or the asset is invalid.
+ */
+ private static function get_module_asset( $module_path ) {
+ $asset_path = $module_path . '.asset.php';
+ if ( ! file_exists( $module_path . '.js' ) || ! file_exists( $asset_path ) ) {
+ return null;
+ }
+
+ $asset = require $asset_path;
+
+ return is_array( $asset ) ? $asset : null;
+ }
+
/**
* Build the data passed to the script module via the script_module_data_ filter.
*
diff --git a/projects/packages/connection/src/connectors/js/connectors-card.js b/projects/packages/connection/src/connectors/js/connectors-card.js
index 66177329ae7c..d616326494f8 100644
--- a/projects/packages/connection/src/connectors/js/connectors-card.js
+++ b/projects/packages/connection/src/connectors/js/connectors-card.js
@@ -2,9 +2,10 @@
* Script module that registers a Jetpack connector card on the
* WP core Settings > Connectors page (WP 7.0+).
*
- * Loaded via wp_enqueue_script_module() with `@wordpress/connectors`
- * as a static dependency. Uses classic-script globals for element,
- * i18n, and components which are always loaded on admin pages.
+ * Built by webpack into dist/connectors/ and loaded via
+ * wp_enqueue_script_module() with `@wordpress/connectors` as a static
+ * dependency. Uses classic-script globals for element, i18n, and
+ * components which are always loaded on admin pages.
*
* Name, description, and logo are provided by the PHP registration
* in register_connector() and merged automatically by the store.
@@ -17,8 +18,10 @@
* @see Jetpack_Connector::enqueue_script_module()
*/
+// Namespace import: a missing named export would fail the whole module before the fallbacks run.
// eslint-disable-next-line import/no-unresolved -- resolved via WP import map at runtime.
-const connectors = await import( '@wordpress/connectors' );
+import * as connectors from '@wordpress/connectors';
+
const registerConnector =
connectors.__experimentalRegisterConnector || connectors.registerConnector;
const ConnectorItem = connectors.__experimentalConnectorItem || connectors.ConnectorItem;
@@ -817,7 +820,8 @@ function ConnectPrompt( { onConnect, isConnecting, isDisconnecting } ) {
)
: __(
'Your site is registered with WordPress.com. Connect your user account to unlock full functionality.',
- 'jetpack-connection'
+ 'jetpack-connection',
+ /* dummy arg to avoid bad minification */ 0
);
return createElement(
@@ -840,7 +844,7 @@ function ConnectPrompt( { onConnect, isConnecting, isDisconnecting } ) {
},
isConnecting
? __( 'Connecting…', 'jetpack-connection' )
- : __( 'Connect account', 'jetpack-connection' )
+ : __( 'Connect account', 'jetpack-connection', /* dummy arg to avoid bad minification */ 0 )
)
);
}
@@ -935,7 +939,11 @@ function SiteDetailsModal( { onClose } ) {
__( 'WordPress.com login (SSO)', 'jetpack-connection' ),
ssoStatus
? __( 'Enabled', 'jetpack-connection' )
- : __( 'Not enabled', 'jetpack-connection' )
+ : __(
+ 'Not enabled',
+ 'jetpack-connection',
+ /* dummy arg to avoid bad minification */ 0
+ )
)
: [] )
)
@@ -1161,7 +1169,8 @@ function IDCPanel() {
: // translators: %s: "site" or "store".
__(
'This %s is registered with WordPress.com at , but now loads at . Features that sync with WordPress.com are paused in Safe Mode until you resolve this.',
- 'jetpack-connection'
+ 'jetpack-connection',
+ /* dummy arg to avoid bad minification */ 0
);
const intro = createInterpolateElement( sprintf( introText, subjectNoun ), {
@@ -1355,7 +1364,11 @@ function IDCPanel() {
},
busyAction === 'safe-mode'
? __( 'Saving…', 'jetpack-connection' )
- : __( 'Not sure? Stay in Safe Mode', 'jetpack-connection' )
+ : __(
+ 'Not sure? Stay in Safe Mode',
+ 'jetpack-connection',
+ /* dummy arg to avoid bad minification */ 0
+ )
),
createElement(
Text,
@@ -1489,7 +1502,8 @@ function ExpandedDetails( { isConnecting = false, onConnect = null } ) {
)
: __(
'Are you sure you want to disconnect your WordPress.com account?',
- 'jetpack-connection'
+ 'jetpack-connection',
+ /* dummy arg to avoid bad minification */ 0
);
setPendingConfirm( {
@@ -1529,7 +1543,11 @@ function ExpandedDetails( { isConnecting = false, onConnect = null } ) {
},
isUnlinking
? __( 'Disconnecting…', 'jetpack-connection' )
- : __( 'Disconnect account', 'jetpack-connection' )
+ : __(
+ 'Disconnect account',
+ 'jetpack-connection',
+ /* dummy arg to avoid bad minification */ 0
+ )
),
} )
: null,
@@ -1744,7 +1762,7 @@ function JetpackConnectorCard( { name, label, description, logo, icon } ) {
};
let toggleLabel = isExpanded
? __( 'Close', 'jetpack-connection' )
- : __( 'Details', 'jetpack-connection' );
+ : __( 'Details', 'jetpack-connection', /* dummy arg to avoid bad minification */ 0 );
if ( isInSafeMode && ! isExpanded ) {
toggleProps.variant = 'primary';
toggleLabel = __( 'Resolve', 'jetpack-connection' );
@@ -1785,7 +1803,7 @@ function JetpackConnectorCard( { name, label, description, logo, icon } ) {
},
isConnecting
? __( 'Connecting…', 'jetpack-connection' )
- : __( 'Connect', 'jetpack-connection' )
+ : __( 'Connect', 'jetpack-connection', /* dummy arg to avoid bad minification */ 0 )
);
}
diff --git a/projects/packages/connection/tests/js/validate-connectors-card.test.js b/projects/packages/connection/tests/js/validate-connectors-card.test.js
new file mode 100644
index 000000000000..5ddcbcd1d9c6
--- /dev/null
+++ b/projects/packages/connection/tests/js/validate-connectors-card.test.js
@@ -0,0 +1,43 @@
+const assert = require( 'node:assert/strict' );
+const { describe, it } = require( 'node:test' );
+const { checkConnectorsCard, SIZE_LIMIT } = require( '../../bin/validate-connectors-card-lib.js' );
+
+const validBuild = {
+ asset: {
+ dependencies: [ 'jetpack-connection' ],
+ module_dependencies: [ { id: '@wordpress/connectors', import: 'static' } ],
+ version: 'abc',
+ type: 'module',
+ },
+ jsFiles: [ 'connectors-card.js' ],
+ bundle: 'import*as e from"@wordpress/connectors";',
+ size: 1000,
+};
+
+describe( 'checkConnectorsCard', () => {
+ const broken = [
+ [ 'an asset file that returns no array', { asset: null }, /associative array/ ],
+ [ 'a classic asset', { asset: { ...validBuild.asset, type: undefined } }, /type/ ],
+ [
+ 'a dynamic @wordpress/connectors import',
+ {
+ asset: {
+ ...validBuild.asset,
+ module_dependencies: [ { id: '@wordpress/connectors', import: 'dynamic' } ],
+ },
+ },
+ /not a static module dependency/,
+ ],
+ [ 'a split chunk', { jsFiles: [ 'connectors-card.js', '123.js' ] }, /one JS file/ ],
+ [ 'a bundled React', { bundle: 'react-stack-bottom-frame' }, /bundled React/ ],
+ [ 'an oversized bundle', { size: SIZE_LIMIT + 1 }, /limit/ ],
+ ];
+
+ for ( const [ name, override, expected ] of broken ) {
+ it( `rejects ${ name }`, () => {
+ const errors = checkConnectorsCard( { ...validBuild, ...override } );
+ assert.equal( errors.length, 1, errors.join( '\n' ) );
+ assert.match( errors[ 0 ], expected );
+ } );
+ }
+} );
diff --git a/projects/packages/connection/tests/php/Jetpack_Connector_Test.php b/projects/packages/connection/tests/php/Jetpack_Connector_Test.php
index 486710ac7757..5432a496eb25 100644
--- a/projects/packages/connection/tests/php/Jetpack_Connector_Test.php
+++ b/projects/packages/connection/tests/php/Jetpack_Connector_Test.php
@@ -9,6 +9,7 @@
use Automattic\Jetpack\Status\Cache as StatusCache;
use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use WorDBless\Options as WorDBless_Options;
use WorDBless\Users as WorDBless_Users;
@@ -108,6 +109,12 @@ public function tearDown(): void {
remove_all_filters( 'jetpack_is_in_safe_mode' );
remove_all_filters( 'jetpack_connection_requires_protected_owner' );
remove_all_filters( 'jetpack_connection_protected_owner_default_ui' );
+ remove_all_filters( 'script_module_data_' . Jetpack_Connector::MODULE_ID );
+
+ unset( $GLOBALS['current_screen'] );
+ wp_deregister_script_module( Jetpack_Connector::MODULE_ID );
+ $GLOBALS['wp_scripts'] = null;
+ $GLOBALS['wp_styles'] = null;
if ( null !== $this->caps_manager ) {
remove_filter( 'map_meta_cap', array( $this->caps_manager, 'jetpack_connection_custom_caps' ), 1 );
$this->caps_manager = null;
@@ -523,6 +530,116 @@ public function test_is_connectors_screen_rejects_other() {
$this->assertFalse( $this->call_is_connectors_screen( 'options-general' ) );
}
+ /* ── enqueue_script_module() ────────────────────────────────── */
+
+ /**
+ * Test that the card is registered from the build, with the dependencies and version from its asset file.
+ */
+ public function test_enqueue_registers_the_built_module_from_its_asset_file() {
+ wp_register_script( 'test-card-global', 'https://example.com/global.js', array(), '1', true );
+ $restore = $this->stub_module_build(
+ array(
+ 'dependencies' => array( 'test-card-global' ),
+ 'module_dependencies' => array(
+ array(
+ 'id' => '@wordpress/connectors',
+ 'import' => 'static',
+ ),
+ ),
+ 'version' => 'test-card-version',
+ 'type' => 'module',
+ )
+ );
+
+ try {
+ $this->enqueue_on_connectors_screen();
+ } finally {
+ $restore();
+ }
+
+ $module = wp_script_modules()->get_registered( Jetpack_Connector::MODULE_ID );
+ $this->assertNotNull( $module );
+ $this->assertStringEndsWith( 'dist/connectors/connectors-card.js', $module['src'] );
+ $this->assertSame( 'test-card-version', $module['version'] );
+ $this->assertSame( array( '@wordpress/connectors' ), array_column( $module['dependencies'], 'id' ) );
+ $this->assertTrue( wp_script_is( 'test-card-global', 'enqueued' ) );
+ }
+
+ /**
+ * Test that core looks up the card's translations under the package domain and the file's real path.
+ */
+ public function test_card_translations_are_looked_up_under_the_package_domain_and_real_path() {
+ $restore = $this->stub_module_build( array( 'version' => 'test-card-version' ) );
+ $lookup_file = '';
+ $lookup_domain = '';
+ $path = '';
+
+ $record_lookup = static function ( $file, $handle, $domain ) use ( &$lookup_file, &$lookup_domain ) {
+ if ( Jetpack_Connector::MODULE_ID === $handle && '' === $lookup_file ) {
+ $lookup_file = basename( (string) $file );
+ $lookup_domain = (string) $domain;
+ }
+ return $file;
+ };
+ $record_path = static function ( $relative, $src ) use ( &$path ) {
+ if ( str_contains( $src, 'connectors-card.js' ) && '' === $path ) {
+ $path = (string) $relative;
+ }
+ return $relative;
+ };
+ add_filter( 'load_script_translation_file', $record_lookup, 10, 3 );
+ add_filter( 'load_script_textdomain_relative_path', $record_path, 10, 2 );
+
+ try {
+ $this->enqueue_on_connectors_screen();
+ ob_start();
+ wp_script_modules()->print_script_module_translations();
+ ob_end_clean();
+ } finally {
+ $restore();
+ remove_filter( 'load_script_translation_file', $record_lookup );
+ remove_filter( 'load_script_textdomain_relative_path', $record_path );
+ }
+
+ $this->assertSame( 'jetpack-connection', $lookup_domain );
+ $this->assertStringStartsWith( 'jetpack-connection-', $lookup_file );
+ $this->assertStringEndsWith( 'dist/connectors/connectors-card.js', $path );
+ $this->assertStringNotContainsString( '..', $path );
+ }
+
+ /**
+ * Test that a missing build skips the card, instead of fataling on the asset file.
+ *
+ * @param array|null $asset Asset data, or null for no asset file.
+ * @param bool $with_js Whether the module's JS file exists.
+ * @dataProvider provide_incomplete_builds
+ */
+ #[DataProvider( 'provide_incomplete_builds' )]
+ public function test_enqueue_skips_the_card_when_the_build_is_missing( $asset, $with_js ) {
+ $restore = $this->stub_module_build( $asset, $with_js );
+
+ try {
+ $this->enqueue_on_connectors_screen();
+ } finally {
+ $restore();
+ }
+
+ $this->assertNull( wp_script_modules()->get_registered( Jetpack_Connector::MODULE_ID ) );
+ $this->assertFalse( wp_style_is( 'jetpack-connector-card', 'enqueued' ) );
+ }
+
+ /**
+ * Builds missing a file, as in a checkout that hasn't run the build or mid-deploy.
+ *
+ * @return array[]
+ */
+ public static function provide_incomplete_builds() {
+ return array(
+ 'no asset file' => array( null, true ),
+ 'no JS file' => array( array( 'version' => 'test-card-version' ), false ),
+ );
+ }
+
/* ── get_plugin_logo_url() ─────────────────────────────────── */
/**
@@ -1057,6 +1174,68 @@ public function test_get_connector_data_omits_auth_error_when_none() {
/* ── Helpers ───────────────────────────────────────────────── */
+ /**
+ * Enqueue the card as WordPress does on the core Connectors screen.
+ */
+ private function enqueue_on_connectors_screen() {
+ set_current_screen( 'options-connectors' );
+ Jetpack_Connector::enqueue_script_module();
+ }
+
+ /**
+ * Put fixture build files where the card's build goes, or remove them, restoring any real build afterwards.
+ *
+ * @param array|null $asset Asset data to write, or null for no asset file.
+ * @param bool $with_js Whether to write a module JS file, or remove it.
+ * @return callable Restores the previous state.
+ */
+ private function stub_module_build( $asset, $with_js = true ) {
+ $class_dir = dirname( ( new \ReflectionClass( Jetpack_Connector::class ) )->getFileName() );
+ $base = $class_dir . '/' . Jetpack_Connector::MODULE_FILE;
+ $dir = dirname( $base );
+ $files = array(
+ $base . '.asset.php' => null === $asset ? null : ' $with_js ? '' : null,
+ );
+
+ $originals = array();
+ foreach ( array_keys( $files ) as $file ) {
+ $originals[ $file ] = file_exists( $file ) ? file_get_contents( $file ) : null; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ }
+
+ $created_dirs = array();
+ foreach ( array( dirname( $dir ), $dir ) as $d ) {
+ if ( ! is_dir( $d ) ) {
+ mkdir( $d );
+ $created_dirs[] = $d;
+ }
+ }
+
+ foreach ( $files as $file => $contents ) {
+ if ( null !== $contents ) {
+ file_put_contents( $file, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ } elseif ( file_exists( $file ) ) {
+ unlink( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+ }
+ }
+
+ return static function () use ( $originals, $created_dirs ) {
+ foreach ( $originals as $file => $original ) {
+ if ( null !== $original ) {
+ file_put_contents( $file, $original ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ } elseif ( file_exists( $file ) ) {
+ unlink( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+ }
+ }
+ // Only directories this fixture created, and only while empty.
+ foreach ( array_reverse( $created_dirs ) as $d ) {
+ if ( 2 === count( scandir( $d ) ) ) {
+ rmdir( $d ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ }
+ }
+ };
+ }
+
/**
* Call the private is_connectors_screen() method via reflection.
*
diff --git a/projects/packages/connection/webpack.config.js b/projects/packages/connection/webpack.config.js
index 16a2433815e2..0426ddda27c1 100644
--- a/projects/packages/connection/webpack.config.js
+++ b/projects/packages/connection/webpack.config.js
@@ -1,7 +1,193 @@
const path = require( 'path' );
const jetpackWebpackConfig = require( '@automattic/jetpack-webpack-config/webpack' );
+const {
+ defaultRequestToExternal,
+ defaultRequestToHandle,
+} = require( '@wordpress/dependency-extraction-webpack-plugin/lib/util' );
const { glob } = require( 'glob' );
+// Module-only packages, loaded through the import map rather than as classic script globals.
+// defaultRequestToExternal() would map them to a `wp.*` global that core never defines.
+const AS_MODULE = new Set( [
+ '@wordpress/connectors',
+ '@wordpress/interactivity',
+ '@wordpress/interactivity-router',
+ '@wordpress/a11y',
+ '@wordpress/boot',
+ '@wordpress/route',
+ '@wordpress/widget-primitives',
+] );
+
+// Jetpack packages shipped as shared scripts; bundling one loads a second copy, and shared-stores would register its stores twice.
+const SHARED_SCRIPTS = {
+ '@automattic/jetpack-connection': {
+ external: 'var JetpackConnection',
+ handle: 'jetpack-connection',
+ },
+ '@automattic/jetpack-script-data': {
+ external: 'var JetpackScriptDataModule',
+ handle: 'jetpack-script-data',
+ },
+ '@automattic/jetpack-shared-stores': {
+ external: 'var JetpackSharedStores',
+ handle: 'jetpack-shared-stores',
+ },
+};
+
+// Package imports the Connectors card may bundle; any other unmapped package fails the build.
+const BUNDLE_ALLOWLIST = [];
+
+/**
+ * External for a request from the Connectors card, with the classic script handle it needs.
+ *
+ * Shared scripts mirror the wrapper's `defaultRequestMap`, which isn't exported and is only used by DependencyExtractionPlugin.
+ *
+ * @see projects/js-packages/webpack-config/src/webpack.js
+ * @see https://github.com/WordPress/gutenberg/blob/485f42ae8a1c58ceea18371a507fd4acfa86fbd8/packages/dependency-extraction-webpack-plugin/lib/util.js#L86-L109
+ *
+ * @param {string} request - Module request.
+ * @return {{external: string, handle?: string}|undefined} External with its type prefix, or undefined to bundle the request.
+ * @throws {Error} For a package import that is neither mapped nor in BUNDLE_ALLOWLIST.
+ */
+const connectorsCardExternal = request => {
+ // module-import: webpack picks `module` for a static import and `import` for an import() call.
+ if ( AS_MODULE.has( request ) ) {
+ return { external: `module-import ${ request }` };
+ }
+ if ( Object.hasOwn( SHARED_SCRIPTS, request ) ) {
+ return SHARED_SCRIPTS[ request ];
+ }
+ // Only the package root maps to the shared script.
+ const sharedPackage = Object.keys( SHARED_SCRIPTS ).find( pkg =>
+ request.startsWith( `${ pkg }/` )
+ );
+ if ( sharedPackage ) {
+ throw new Error(
+ `Import ${ request } from '${ sharedPackage }' instead, so it comes from the shared ${ SHARED_SCRIPTS[ sharedPackage ].handle } script.`
+ );
+ }
+ // defaultRequestToExternal() returns undefined both for unknown packages and for ones DEWP bundles on purpose.
+ const global = defaultRequestToExternal( request );
+ if ( ! global ) {
+ // The card's own files; Babel helpers arrive as absolute paths too (absoluteRuntime).
+ if (
+ request.startsWith( '.' ) ||
+ path.isAbsolute( request ) ||
+ BUNDLE_ALLOWLIST.some( prefix => request.startsWith( prefix ) )
+ ) {
+ return undefined;
+ }
+ throw new Error(
+ `The Connectors card would bundle ${ request }. Map it in connectorsCardExternal(), import it from '@automattic/jetpack-connection', or add it to BUNDLE_ALLOWLIST.`
+ );
+ }
+ return {
+ external: `var ${ [].concat( global ).join( '.' ) }`,
+ handle: defaultRequestToHandle( request ) ?? request,
+ };
+};
+
+/**
+ * Externalizes the Connectors card's imports and writes its asset file, in place of DependencyExtractionPlugin.
+ *
+ * Core needs classic script handles and script module IDs apart, which the plugin's module mode lists together.
+ * Adapted from PolyfillModulePlugin in packages/wp-build-polyfills/webpack.config.js.
+ */
+class ConnectorsCardAssetPlugin {
+ /**
+ * Apply the plugin.
+ *
+ * @param {import('webpack').Compiler} compiler - Webpack compiler.
+ */
+ apply( compiler ) {
+ const { webpack } = compiler;
+ const name = 'ConnectorsCardAssetPlugin';
+
+ // 1. Keep each import out of the bundle; the external's type prefix (module-import or var) says how it loads.
+ new webpack.ExternalsPlugin( 'import', ( { request }, callback ) => {
+ let external;
+ try {
+ external = connectorsCardExternal( request )?.external;
+ } catch ( error ) {
+ return callback( error );
+ }
+ callback( null, external );
+ } ).apply( compiler );
+
+ // 2. Once the bundle is minified, write the asset file beside each entry's JS.
+ compiler.hooks.thisCompilation.tap( name, compilation => {
+ compilation.hooks.processAssets.tap(
+ { name, stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE },
+ () => {
+ for ( const [ , entrypoint ] of compilation.entrypoints ) {
+ const chunk = entrypoint.getEntrypointChunk();
+ const jsFile = Array.from( chunk.files ).find( file => /\.m?js$/i.test( file ) );
+ if ( ! jsFile ) {
+ continue;
+ }
+ compilation.emitAsset(
+ jsFile.replace( /\.m?js$/i, '.asset.php' ),
+ new webpack.sources.RawSource( this.assetFile( compilation, chunk, jsFile ) )
+ );
+ }
+ }
+ );
+ } );
+ }
+
+ /**
+ * Build the asset file for an entry chunk from the external modules it uses.
+ *
+ * @param {import('webpack').Compilation} compilation - Webpack compilation.
+ * @param {import('webpack').Chunk} chunk - Entry chunk.
+ * @param {string} jsFile - Emitted JS file of the chunk.
+ * @return {string} PHP source of the asset file.
+ */
+ assetFile( compilation, chunk, jsFile ) {
+ const { webpack } = compilation.compiler;
+ const handles = new Set();
+ const modules = new Map();
+
+ for ( const module of compilation.chunkGraph.getChunkModulesIterable( chunk ) ) {
+ if ( ! ( module instanceof webpack.ExternalModule ) ) {
+ continue;
+ }
+ const { userRequest: request, externalType, dependencyMeta } = module;
+ // module-import externals are script modules; var externals are classic scripts.
+ if ( externalType === 'module-import' ) {
+ const kind = dependencyMeta?.externalType === 'import' ? 'dynamic' : 'static';
+ // A static import wins when the card imports the same module both ways.
+ if ( kind === 'static' || ! modules.has( request ) ) {
+ modules.set( request, kind );
+ }
+ continue;
+ }
+ const handle = connectorsCardExternal( request )?.handle;
+ if ( handle ) {
+ handles.add( handle );
+ }
+ }
+
+ const php = value => `'${ String( value ).replace( /[\\']/g, '\\$&' ) }'`;
+ const dependencies = [ ...handles ].sort().map( php );
+ const moduleDependencies = [ ...modules ]
+ .sort( ( [ a ], [ b ] ) => a.localeCompare( b ) )
+ .map( ( [ id, kind ] ) => `array('id' => ${ php( id ) }, 'import' => ${ php( kind ) })` );
+ const version = webpack.util
+ .createHash( 'xxhash64' )
+ .update( compilation.getAsset( jsFile ).source.buffer() )
+ .digest( 'hex' )
+ .slice( 0, 16 );
+
+ // Read by Jetpack_Connector::get_module_asset().
+ return ` array(${ dependencies.join(
+ ', '
+ ) }), 'module_dependencies' => array(${ moduleDependencies.join(
+ ', '
+ ) }), 'version' => ${ php( version ) }, 'type' => 'module');\n`;
+ }
+}
+
const ssoEntries = {};
// Add all js files in the src/sso directory.
for ( const file of glob.sync( './src/sso/*.js' ) ) {
@@ -109,4 +295,28 @@ module.exports = [
} ),
],
},
+ {
+ ...sharedConfig,
+ entry: {
+ 'connectors-card': './src/connectors/js/connectors-card.js',
+ },
+ output: {
+ ...jetpackWebpackConfig.output,
+ path: path.resolve( './dist/connectors' ),
+ module: true,
+ // Inlines any dynamic import() instead of emitting a lazy-loaded file, because the PHP
+ // registers and versions connectors-card.js only.
+ asyncChunks: false,
+ },
+ // Not sharedConfig.externals: every import goes through connectorsCardExternal().
+ externals: {},
+ plugins: [
+ ...jetpackWebpackConfig.StandardPlugins( {
+ DependencyExtractionPlugin: false,
+ // It only handles lazy-loaded chunks, and this entry has none.
+ I18nLoaderPlugin: false,
+ } ),
+ new ConnectorsCardAssetPlugin(),
+ ],
+ },
];
diff --git a/projects/plugins/agents-manager/changelog/add-connectors-card-es-module b/projects/plugins/agents-manager/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/agents-manager/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/agents-manager/composer.lock b/projects/plugins/agents-manager/composer.lock
index 5639fb547fed..6121c706e6f6 100644
--- a/projects/plugins/agents-manager/composer.lock
+++ b/projects/plugins/agents-manager/composer.lock
@@ -418,7 +418,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -488,6 +488,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/automattic-for-agencies-client/changelog/add-connectors-card-es-module b/projects/plugins/automattic-for-agencies-client/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/automattic-for-agencies-client/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/automattic-for-agencies-client/composer.lock b/projects/plugins/automattic-for-agencies-client/composer.lock
index bcb46f37cd12..c12c256276c5 100644
--- a/projects/plugins/automattic-for-agencies-client/composer.lock
+++ b/projects/plugins/automattic-for-agencies-client/composer.lock
@@ -414,7 +414,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -484,6 +484,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/backup/changelog/add-connectors-card-es-module b/projects/plugins/backup/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/backup/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/backup/composer.lock b/projects/plugins/backup/composer.lock
index de2ddbe52949..0cb6443b0f63 100644
--- a/projects/plugins/backup/composer.lock
+++ b/projects/plugins/backup/composer.lock
@@ -754,7 +754,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -824,6 +824,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/boost/changelog/add-connectors-card-es-module b/projects/plugins/boost/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/boost/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/boost/composer.lock b/projects/plugins/boost/composer.lock
index b4ba55dd14a7..91a6e4fc1173 100644
--- a/projects/plugins/boost/composer.lock
+++ b/projects/plugins/boost/composer.lock
@@ -609,7 +609,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -679,6 +679,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/classic-theme-helper-plugin/changelog/add-connectors-card-es-module b/projects/plugins/classic-theme-helper-plugin/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/classic-theme-helper-plugin/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/classic-theme-helper-plugin/composer.lock b/projects/plugins/classic-theme-helper-plugin/composer.lock
index 085a73a6dbeb..613d7e6c8583 100644
--- a/projects/plugins/classic-theme-helper-plugin/composer.lock
+++ b/projects/plugins/classic-theme-helper-plugin/composer.lock
@@ -599,7 +599,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -669,6 +669,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/jetpack/changelog/add-connectors-card-es-module b/projects/plugins/jetpack/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..b9fda09e02e1
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: bugfix
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock
index caa56335a85c..b9bf9f5075f8 100644
--- a/projects/plugins/jetpack/composer.lock
+++ b/projects/plugins/jetpack/composer.lock
@@ -1324,7 +1324,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -1394,6 +1394,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/mu-wpcom-plugin/changelog/add-connectors-card-es-module b/projects/plugins/mu-wpcom-plugin/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..47f5577b9214
--- /dev/null
+++ b/projects/plugins/mu-wpcom-plugin/changelog/add-connectors-card-es-module
@@ -0,0 +1,5 @@
+Significance: patch
+Type: changed
+Comment: Update composer.lock.
+
+
diff --git a/projects/plugins/mu-wpcom-plugin/composer.lock b/projects/plugins/mu-wpcom-plugin/composer.lock
index 6d139279c97e..db12f4ce2279 100644
--- a/projects/plugins/mu-wpcom-plugin/composer.lock
+++ b/projects/plugins/mu-wpcom-plugin/composer.lock
@@ -707,7 +707,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -777,6 +777,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/paypal-payment-buttons/changelog/add-connectors-card-es-module b/projects/plugins/paypal-payment-buttons/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/paypal-payment-buttons/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/paypal-payment-buttons/composer.lock b/projects/plugins/paypal-payment-buttons/composer.lock
index 039a2153f676..a5eb1664a5c0 100644
--- a/projects/plugins/paypal-payment-buttons/composer.lock
+++ b/projects/plugins/paypal-payment-buttons/composer.lock
@@ -399,7 +399,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -469,6 +469,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/premium-analytics/changelog/add-connectors-card-es-module b/projects/plugins/premium-analytics/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/premium-analytics/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/premium-analytics/composer.lock b/projects/plugins/premium-analytics/composer.lock
index 9331709fe60d..b481506ed0da 100644
--- a/projects/plugins/premium-analytics/composer.lock
+++ b/projects/plugins/premium-analytics/composer.lock
@@ -477,7 +477,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -547,6 +547,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/protect/changelog/add-connectors-card-es-module b/projects/plugins/protect/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/protect/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/protect/composer.lock b/projects/plugins/protect/composer.lock
index f8963cf10ec5..17448c4188bb 100644
--- a/projects/plugins/protect/composer.lock
+++ b/projects/plugins/protect/composer.lock
@@ -731,7 +731,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -801,6 +801,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/search/changelog/add-connectors-card-es-module b/projects/plugins/search/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/search/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/search/composer.lock b/projects/plugins/search/composer.lock
index 82bdd9ead0c6..63bd94a129ab 100644
--- a/projects/plugins/search/composer.lock
+++ b/projects/plugins/search/composer.lock
@@ -609,7 +609,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -679,6 +679,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/social/changelog/add-connectors-card-es-module b/projects/plugins/social/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/social/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/social/composer.lock b/projects/plugins/social/composer.lock
index 8ea95b6a934b..0c2a53c7444e 100644
--- a/projects/plugins/social/composer.lock
+++ b/projects/plugins/social/composer.lock
@@ -596,7 +596,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -666,6 +666,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/starter-plugin/changelog/add-connectors-card-es-module b/projects/plugins/starter-plugin/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..47f5577b9214
--- /dev/null
+++ b/projects/plugins/starter-plugin/changelog/add-connectors-card-es-module
@@ -0,0 +1,5 @@
+Significance: patch
+Type: changed
+Comment: Update composer.lock.
+
+
diff --git a/projects/plugins/starter-plugin/composer.lock b/projects/plugins/starter-plugin/composer.lock
index b207bfa9c26d..9e0fa1c0815b 100644
--- a/projects/plugins/starter-plugin/composer.lock
+++ b/projects/plugins/starter-plugin/composer.lock
@@ -533,7 +533,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -603,6 +603,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/stats/changelog/add-connectors-card-es-module b/projects/plugins/stats/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/stats/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/stats/composer.lock b/projects/plugins/stats/composer.lock
index ccc61a5731b7..9bde579f9f69 100644
--- a/projects/plugins/stats/composer.lock
+++ b/projects/plugins/stats/composer.lock
@@ -533,7 +533,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -603,6 +603,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/videopress/changelog/add-connectors-card-es-module b/projects/plugins/videopress/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/videopress/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/videopress/composer.lock b/projects/plugins/videopress/composer.lock
index b8dfb555ee7e..91e06190d88e 100644
--- a/projects/plugins/videopress/composer.lock
+++ b/projects/plugins/videopress/composer.lock
@@ -609,7 +609,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -679,6 +679,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/wpcloud-sso/changelog/add-connectors-card-es-module b/projects/plugins/wpcloud-sso/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/wpcloud-sso/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/wpcloud-sso/composer.lock b/projects/plugins/wpcloud-sso/composer.lock
index e57987e46205..feaa0f710ffa 100644
--- a/projects/plugins/wpcloud-sso/composer.lock
+++ b/projects/plugins/wpcloud-sso/composer.lock
@@ -414,7 +414,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "6bf10704aa6241cf7d03a212d94e12ef109a15fc"
+ "reference": "3e05d73855c8198cfdbd90828092ffa03a65e901"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -484,6 +484,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
diff --git a/projects/plugins/wpcomsh/changelog/add-connectors-card-es-module b/projects/plugins/wpcomsh/changelog/add-connectors-card-es-module
new file mode 100644
index 000000000000..4dd25e69d6c9
--- /dev/null
+++ b/projects/plugins/wpcomsh/changelog/add-connectors-card-es-module
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Connection: Translate the Connectors card's text on sites that don't use English.
diff --git a/projects/plugins/wpcomsh/composer.lock b/projects/plugins/wpcomsh/composer.lock
index 014c7f738291..e025af2a15da 100644
--- a/projects/plugins/wpcomsh/composer.lock
+++ b/projects/plugins/wpcomsh/composer.lock
@@ -894,7 +894,7 @@
"dist": {
"type": "path",
"url": "../../packages/connection",
- "reference": "c42317b099a1cb683a3274426034891b31c49e81"
+ "reference": "5e4721924c67bb30f92d641134127b55d4ecbb5d"
},
"require": {
"automattic/jetpack-a8c-mc-stats": "@dev",
@@ -964,6 +964,12 @@
"test-php": [
"@composer phpunit"
],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "test-js-coverage": [
+ "pnpm run test-coverage"
+ ],
"test-php-coverage": [
"php -dpcov.directory=. ./vendor/bin/phpunit-select-config phpunit.#.xml.dist --coverage-php \"$COVERAGE_DIR/php.cov\""
]
From 2ceede9d3c883b72e77dcf29e394b6bce376446f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?=
Date: Tue, 13 Oct 2026 12:17:38 +0100
Subject: [PATCH 05/57] WooCommerce Stats: move the columns widgets into the
package (#53477)
* add the Columns widget kind over the chart pieces
columns as figures of both periods in; series, legend, colors and states out
* draw the bar family widgets with Columns
the four builders go; the coupon Other sums the same coupons in both periods
* expose the Columns widget kind through the SDK
contract 1.10.0
* add the columns widgets to the package
six on the Columns kind over the package's own hooks; contract floor 1.10.0
* drop the Premium Analytics columns widgets
the WooCommerce Stats package builds them now
* move internal dep to the proper doc block
https://github.com/Automattic/jetpack/pull/53464#discussion_r4256360362
---------
Co-authored-by: Cursor
---
.../update-woocommerce-stats-columns-family | 4 +
.../src/components/columns/use-bar-styles.ts | 3 +-
.../packages/widgets-toolkit/src/index.ts | 9 +-
.../src/stories/mocks/data/coupons.ts | 2 -
.../src/stories/mocks/data/customers.ts | 2 -
.../stories/mocks/data/order-attribution.ts | 2 -
.../widgets-toolkit/src/widgets/README.md | 4 -
.../widgets-toolkit/src/widgets/index.ts | 7 -
.../widgets/revenue-by-customer-type/index.ts | 4 -
.../revenue-by-customer-type-widget.tsx | 101 -------
...evenue-by-customer-type-widget.stories.tsx | 107 -------
.../src/widgets/sales-by-coupon/index.ts | 1 -
.../sales-by-coupon-widget.tsx | 62 ----
.../sales-by-coupon-widget.stories.tsx | 107 -------
.../src/widgets/sales-by-device/index.ts | 1 -
.../sales-by-device-widget.tsx | 93 ------
.../sales-by-device-widget.stories.tsx | 107 -------
.../src/widgets/total-returns/index.ts | 1 -
.../stories/total-returns-widget.stories.tsx | 103 -------
.../total-returns/total-returns-widget.tsx | 80 -----
.../widgets/bookings-by-device/render.tsx | 54 ----
.../bookings-by-device-widget.stories.tsx | 262 -----------------
.../widgets/bookings-by-device/widget.ts | 19 --
.../render.tsx | 40 ---
...evenue-by-customer-type-widget.stories.tsx | 278 ------------------
.../widget.ts | 19 --
.../revenue-by-customer-type/render.tsx | 35 ---
...evenue-by-customer-type-widget.stories.tsx | 273 -----------------
.../revenue-by-customer-type/widget.ts | 16 -
.../sales-by-coupon-usage/package.json | 13 -
.../widgets/sales-by-coupon-usage/render.tsx | 34 ---
.../sales-by-coupon-usage-widget.stories.tsx | 264 -----------------
.../widgets/sales-by-coupon-usage/widget.json | 11 -
.../widgets/sales-by-coupon-usage/widget.ts | 16 -
.../widgets/sales-by-coupon/render.tsx | 34 ---
.../sales-by-coupon-widget.stories.tsx | 211 -------------
.../widgets/sales-by-coupon/widget.ts | 16 -
.../widgets/sales-by-device/render.tsx | 34 ---
.../sales-by-device-widget.stories.tsx | 261 ----------------
.../widgets/sales-by-device/widget.ts | 16 -
.../widgets/total-returns/render.tsx | 36 ---
.../stories/total-returns-widget.stories.tsx | 204 -------------
.../widgets/total-returns/widget.ts | 19 --
projects/packages/woocommerce-stats/README.md | 2 +-
.../update-woocommerce-stats-columns-family | 4 +
.../src/class-analytics-dashboard.php | 30 +-
.../components/revenue-by-customer-type.tsx | 90 ++++++
.../src/components/sales-by-device.tsx | 72 +++++
.../tests/php/Analytics_Dashboard_Test.php | 4 +
...alytics_Dashboard_Widget_Contract_Test.php | 3 +-
.../widgets/bookings-by-device/package.json | 5 +-
.../widgets/bookings-by-device/render.tsx | 35 +++
.../widgets/bookings-by-device/widget.json | 5 +-
.../widgets/bookings-by-device/widget.ts | 7 +
.../package.json | 5 +-
.../render.tsx | 35 +++
.../widget.json | 5 +-
.../widget.ts | 7 +
.../revenue-by-customer-type/package.json | 5 +-
.../revenue-by-customer-type/render.tsx | 32 ++
.../revenue-by-customer-type/widget.json | 5 +-
.../revenue-by-customer-type/widget.ts | 7 +
.../build-sales-by-coupon-columns.test.ts | 0
.../build-sales-by-coupon-columns.ts | 15 +-
.../widgets/sales-by-coupon/package.json | 5 +-
.../widgets/sales-by-coupon/render.tsx | 93 ++++++
.../widgets/sales-by-coupon/widget.json | 5 +-
.../widgets/sales-by-coupon/widget.ts | 7 +
.../widgets/sales-by-device/package.json | 5 +-
.../widgets/sales-by-device/render.tsx | 32 ++
.../widgets/sales-by-device/widget.json | 5 +-
.../widgets/sales-by-device/widget.ts | 7 +
.../widgets/total-returns/package.json | 5 +-
.../widgets/total-returns/render.tsx | 111 +++++++
.../widgets/total-returns/widget.json | 5 +-
.../widgets/total-returns/widget.ts | 7 +
76 files changed, 617 insertions(+), 3008 deletions(-)
create mode 100644 projects/packages/premium-analytics/changelog/update-woocommerce-stats-columns-family
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/index.ts
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/revenue-by-customer-type-widget.tsx
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/index.ts
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/sales-by-coupon-widget.tsx
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/stories/sales-by-coupon-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/index.ts
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/sales-by-device-widget.tsx
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/stories/sales-by-device-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/index.ts
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/stories/total-returns-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/total-returns-widget.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/bookings-by-device/render.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/bookings-by-device/stories/bookings-by-device-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/bookings-by-device/widget.ts
delete mode 100644 projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/render.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/stories/bookings-revenue-by-customer-type-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/widget.ts
delete mode 100644 projects/packages/premium-analytics/widgets/revenue-by-customer-type/render.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/revenue-by-customer-type/widget.ts
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-coupon-usage/package.json
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-coupon-usage/render.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-coupon-usage/stories/sales-by-coupon-usage-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-coupon-usage/widget.json
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-coupon-usage/widget.ts
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-coupon/render.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-coupon/stories/sales-by-coupon-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-coupon/widget.ts
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-device/render.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-device/stories/sales-by-device-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/sales-by-device/widget.ts
delete mode 100644 projects/packages/premium-analytics/widgets/total-returns/render.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/total-returns/stories/total-returns-widget.stories.tsx
delete mode 100644 projects/packages/premium-analytics/widgets/total-returns/widget.ts
create mode 100644 projects/packages/woocommerce-stats/changelog/update-woocommerce-stats-columns-family
create mode 100644 projects/packages/woocommerce-stats/src/components/revenue-by-customer-type.tsx
create mode 100644 projects/packages/woocommerce-stats/src/components/sales-by-device.tsx
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/bookings-by-device/package.json (51%)
create mode 100644 projects/packages/woocommerce-stats/widgets/bookings-by-device/render.tsx
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/bookings-by-device/widget.json (69%)
create mode 100644 projects/packages/woocommerce-stats/widgets/bookings-by-device/widget.ts
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/bookings-revenue-by-customer-type/package.json (53%)
create mode 100644 projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/render.tsx
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/bookings-revenue-by-customer-type/widget.json (71%)
create mode 100644 projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/widget.ts
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/revenue-by-customer-type/package.json (52%)
create mode 100644 projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/render.tsx
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/revenue-by-customer-type/widget.json (71%)
create mode 100644 projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/widget.ts
rename projects/packages/{premium-analytics/packages/widgets-toolkit/src => woocommerce-stats}/widgets/sales-by-coupon/__tests__/build-sales-by-coupon-columns.test.ts (100%)
rename projects/packages/{premium-analytics/packages/widgets-toolkit/src => woocommerce-stats}/widgets/sales-by-coupon/build-sales-by-coupon-columns.ts (71%)
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/sales-by-coupon/package.json (51%)
create mode 100644 projects/packages/woocommerce-stats/widgets/sales-by-coupon/render.tsx
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/sales-by-coupon/widget.json (69%)
create mode 100644 projects/packages/woocommerce-stats/widgets/sales-by-coupon/widget.ts
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/sales-by-device/package.json (51%)
create mode 100644 projects/packages/woocommerce-stats/widgets/sales-by-device/render.tsx
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/sales-by-device/widget.json (69%)
create mode 100644 projects/packages/woocommerce-stats/widgets/sales-by-device/widget.ts
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/total-returns/package.json (50%)
create mode 100644 projects/packages/woocommerce-stats/widgets/total-returns/render.tsx
rename projects/packages/{premium-analytics => woocommerce-stats}/widgets/total-returns/widget.json (68%)
create mode 100644 projects/packages/woocommerce-stats/widgets/total-returns/widget.ts
diff --git a/projects/packages/premium-analytics/changelog/update-woocommerce-stats-columns-family b/projects/packages/premium-analytics/changelog/update-woocommerce-stats-columns-family
new file mode 100644
index 000000000000..c9efea348add
--- /dev/null
+++ b/projects/packages/premium-analytics/changelog/update-woocommerce-stats-columns-family
@@ -0,0 +1,4 @@
+Significance: minor
+Type: removed
+
+Remove the WooCommerce columns widgets, now built by the WooCommerce Stats package.
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/columns/use-bar-styles.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/columns/use-bar-styles.ts
index 3b4d040beb6c..851c9e6f82a2 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/columns/use-bar-styles.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/columns/use-bar-styles.ts
@@ -3,11 +3,10 @@
*/
import { useGlobalChartsContext, type SeriesData } from '@jetpack-premium-analytics/externals';
import { useMemo } from 'react';
-import type { BarChartStyle } from '../chart-bar/bar-chart';
-
/**
* Internal dependencies
*/
+import type { BarChartStyle } from '../chart-bar/bar-chart';
/**
* Maps each series to the color the theme provider resolves for it.
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts
index eb12f59d1865..5b0851caff3a 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts
@@ -354,14 +354,7 @@ export {
/**
* Widget components
*/
-export {
- BookingsRevenueByCustomerTypeWidget,
- RevenueByCustomerTypeWidget,
- SalesByCouponWidget,
- TotalReturnsWidget,
- VisitorsByLocationWidget,
- SalesByDeviceWidget,
-} from './widgets';
+export { VisitorsByLocationWidget } from './widgets';
/**
* Types
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/coupons.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/coupons.ts
index 84fb00b4cd0d..fc1691541582 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/coupons.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/coupons.ts
@@ -1,8 +1,6 @@
/**
* Mock data for Coupons endpoint
*
- * Used by: SalesByCouponWidget
- *
* Response structure matches:
* - summary: CouponsDataSummary
* - data: CouponsDataItem[]
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/customers.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/customers.ts
index 275063689e9f..104e9deaed68 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/customers.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/customers.ts
@@ -1,8 +1,6 @@
/**
* Mock data for Customers New vs Returning endpoint
*
- * Used by: RevenueByCustomerTypeWidget, BookingsRevenueByCustomerTypeWidget
- *
* Response structure matches:
* - summary: CustomersNewReturningSummary
* - data: CustomersNewReturningItem[]
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/order-attribution.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/order-attribution.ts
index 23e2ce4a8071..29c536672d6f 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/order-attribution.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/order-attribution.ts
@@ -1,7 +1,5 @@
/**
* Mock data for order attribution endpoint
- * Used by: SalesByDeviceWidget
- *
* API format: /jetpack-premium-analytics/v1/proxy/v2/analytics/reports/order-attribution/:view/summary
* Values are strings (the sanitizer converts them to numbers)
*/
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/README.md
index c8e3e58898cb..79ae27965e2d 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/README.md
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/README.md
@@ -6,10 +6,6 @@ Dashboard widget components for Jetpack Premium Analytics.
| Widget | Chart Component | Description |
| ------------------------------ | ----------------------------------------------- | ------------------------------------------------- |
-| `RevenueByCustomerTypeWidget` | `Columns` | Revenue breakdown by customer type |
-| `SalesByCouponWidget` | `Columns` | Coupon sales for all product types |
-| `SalesByDeviceWidget` | `Columns` | Sales breakdown by device type |
-| `TotalReturnsWidget` | `Columns` | Returns/refunds for all product types |
## Chart Components
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts
index ed63d7e70e7d..4fe6eec7ad8f 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts
+++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts
@@ -1,8 +1 @@
-export { SalesByCouponWidget } from './sales-by-coupon';
-export {
- RevenueByCustomerTypeWidget,
- BookingsRevenueByCustomerTypeWidget,
-} from './revenue-by-customer-type';
-export { SalesByDeviceWidget } from './sales-by-device';
-export { TotalReturnsWidget } from './total-returns';
export { VisitorsByLocationWidget } from './visitors-by-location';
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/index.ts
deleted file mode 100644
index 5e8082e1ab2d..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export {
- RevenueByCustomerTypeWidget,
- BookingsRevenueByCustomerTypeWidget,
-} from './revenue-by-customer-type-widget';
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/revenue-by-customer-type-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/revenue-by-customer-type-widget.tsx
deleted file mode 100644
index c6d2be50f5f3..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/revenue-by-customer-type-widget.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * External dependencies
- */
-import { useReportCustomers, type FilterCondition } from '@jetpack-premium-analytics/data';
-import { customer } from '@jetpack-premium-analytics/icons';
-import { __ } from '@wordpress/i18n';
-import { useMemo } from 'react';
-/**
- * Internal dependencies
- */
-import { Columns, type ColumnInput } from '../../components/columns';
-import { useWidgetRootContext } from '../../components/widget-root';
-import { BOOKINGS_FILTER } from '../../helpers';
-
-const CURRENCY_FORMAT = { type: 'currency' as const, options: { useMultipliers: true } };
-
-type CustomerTypeRevenueWidgetProps = {
- /**
- * Optional product type filter to apply when fetching customer data.
- * If not provided, will show data for all product types.
- *
- * @see BOOKINGS_FILTER for booking products (booking, bookable-event, bookable-service)
- */
- filter?: FilterCondition;
-};
-
-/**
- * Revenue from returning customers against new ones, one column each per period.
- *
- * Must be used within a WidgetRoot which provides reportParams via context.
- */
-function CustomerTypeRevenueWidget( { filter }: CustomerTypeRevenueWidgetProps ) {
- const { reportParams } = useWidgetRootContext();
-
- const { primary, comparison, hasComparison, isLoading, isFetching, hasData, isError, refetch } =
- useReportCustomers( {
- ...reportParams,
- filters: filter ? [ filter ] : undefined,
- } );
- const summary = primary.data?.summary;
- const previous = comparison.data?.summary;
-
- // The categories are fixed, so the skeleton knows its two columns before the data arrives.
- const columns = useMemo< ColumnInput[] >(
- () => [
- {
- label: __( 'Returning', 'jetpack-premium-analytics-pkg' ),
- value: summary?.returning_customer_sales ?? 0,
- previousValue: previous?.returning_customer_sales,
- },
- {
- label: __( 'New', 'jetpack-premium-analytics-pkg' ),
- value: summary?.new_customer_sales ?? 0,
- previousValue: previous?.new_customer_sales,
- },
- ],
- [ summary, previous ]
- );
-
- return (
-
- );
-}
-
-/**
- * Displays customer revenue data for all product types.
- * No product type filtering applied.
- */
-export function RevenueByCustomerTypeWidget() {
- return ;
-}
-
-/**
- * Displays customer revenue data for booking products only.
- * Filters to: booking, bookable-event, and bookable-service product types.
- */
-export function BookingsRevenueByCustomerTypeWidget() {
- return ;
-}
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx
deleted file mode 100644
index 3ade516c05e9..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { WidgetRootContext } from '../../../components/widget-root/context';
-import { withWidgetRoot } from '../../../stories/with-widget-root';
-import { RevenueByCustomerTypeWidget } from '../revenue-by-customer-type-widget';
-import type { Meta, StoryObj, Decorator } from '@storybook/react';
-
-const meta: Meta< typeof RevenueByCustomerTypeWidget > = {
- title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/RevenueByCustomerType',
- component: RevenueByCustomerTypeWidget,
- tags: [ 'autodocs' ],
- parameters: {
- docs: {
- description: {
- component:
- 'Displays revenue comparison between new and returning customers using a bar chart. Shows data for all product types with comparison support.',
- },
- },
- },
- decorators: [
- withWidgetRoot(),
- Story => (
-
-
-
- ),
- ],
-};
-
-export default meta;
-
-type Story = StoryObj< typeof RevenueByCustomerTypeWidget >;
-
-/**
- * Default state with mock data (no comparison)
- */
-export const Default: Story = {};
-
-/**
- * With comparison period enabled
- */
-export const WithComparison: Story = {
- decorators: [
- Story => (
-
-
-
- ),
- ],
-};
-
-/*
- * Container Size Stories
- *
- * These stories demonstrate how the widget adapts to different container sizes.
- * Uses extreme data (long labels, large values) with comparison enabled.
- * Breakpoints aligned with Tailwind container query defaults (ARC-464).
- *
- * @see https://linear.app/a8c/issue/ARC-464/design-system-wide-responsiveness
- * @see https://linear.app/a8c/issue/WOOA7S-869
- */
-
-/**
- * Creates a decorator that wraps the story in a fixed-size container
- * with comparison enabled and extreme data.
- */
-const createSizeDecorator = ( width: string ): Decorator => {
- return Story => (
-
-
-
-
-
- );
-};
-
-/**
- * Extra extra small widgets (256px / xxs breakpoint)
- * Tests compact layout with comparison enabled.
- */
-export const SizeXXSmall: Story = {
- decorators: [ createSizeDecorator( '256px' ) ],
-};
-
-/**
- * Medium container (448px / md breakpoint)
- * Tests standard tile size with comparison.
- */
-export const SizeMedium: Story = {
- decorators: [ createSizeDecorator( '448px' ) ],
-};
-
-/**
- * Large container (576px / xl breakpoint)
- * Tests expanded layout with full data visibility.
- */
-export const SizeLarge: Story = {
- decorators: [ createSizeDecorator( '576px' ) ],
-};
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/index.ts
deleted file mode 100644
index 231977195cfc..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { SalesByCouponWidget } from './sales-by-coupon-widget';
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/sales-by-coupon-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/sales-by-coupon-widget.tsx
deleted file mode 100644
index d71e789d38fa..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/sales-by-coupon-widget.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * External dependencies
- */
-import { useReportCoupons } from '@jetpack-premium-analytics/data';
-import { coupon } from '@jetpack-premium-analytics/icons';
-import { __ } from '@wordpress/i18n';
-import { useMemo } from 'react';
-/**
- * Internal dependencies
- */
-import { Columns } from '../../components/columns';
-import { useWidgetRootContext } from '../../components/widget-root';
-import { buildSalesByCouponColumns } from './build-sales-by-coupon-columns';
-
-const CURRENCY_FORMAT = { type: 'currency' as const, options: { useMultipliers: true } };
-
-/** Top coupons charted individually; the rest are summed into an "Other" column. */
-const TOP_COUPON_SEGMENTS = 3;
-
-/**
- * Sales of the top coupons and of the rest as Other, one column each per period.
- *
- * Must be used within a WidgetRoot which provides reportParams via context.
- */
-export function SalesByCouponWidget() {
- const { reportParams } = useWidgetRootContext();
-
- const { primary, comparison, hasComparison, isLoading, isFetching, hasData, isError, refetch } =
- useReportCoupons( reportParams );
-
- const columns = useMemo(
- () =>
- buildSalesByCouponColumns( primary.data?.data, comparison.data?.data, TOP_COUPON_SEGMENTS ),
- [ primary.data, comparison.data ]
- );
-
- return (
-
- );
-}
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/stories/sales-by-coupon-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/stories/sales-by-coupon-widget.stories.tsx
deleted file mode 100644
index d7b9bf619a55..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/stories/sales-by-coupon-widget.stories.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { WidgetRootContext } from '../../../components/widget-root/context';
-import { withWidgetRoot } from '../../../stories/with-widget-root';
-import { SalesByCouponWidget } from '../sales-by-coupon-widget';
-import type { Meta, StoryObj, Decorator } from '@storybook/react';
-
-const meta: Meta< typeof SalesByCouponWidget > = {
- title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/SalesByCoupon',
- component: SalesByCouponWidget,
- tags: [ 'autodocs' ],
- parameters: {
- docs: {
- description: {
- component:
- 'Displays revenue distribution by coupon using a bar chart. Shows top 3 coupons plus "Other" segment with comparison support.',
- },
- },
- },
- decorators: [
- withWidgetRoot(),
- Story => (
-
-
-
- ),
- ],
-};
-
-export default meta;
-
-type Story = StoryObj< typeof SalesByCouponWidget >;
-
-/**
- * Default state with mock data (no comparison)
- */
-export const Default: Story = {};
-
-/**
- * With comparison period enabled
- */
-export const WithComparison: Story = {
- decorators: [
- Story => (
-
-
-
- ),
- ],
-};
-
-/*
- * Container Size Stories
- *
- * These stories demonstrate how the widget adapts to different container sizes.
- * Uses extreme data (long labels, large values) with comparison enabled.
- * Breakpoints aligned with Tailwind container query defaults (ARC-464).
- *
- * @see https://linear.app/a8c/issue/ARC-464/design-system-wide-responsiveness
- * @see https://linear.app/a8c/issue/WOOA7S-869
- */
-
-/**
- * Creates a decorator that wraps the story in a fixed-size container
- * with comparison enabled and extreme data.
- */
-const createSizeDecorator = ( width: string ): Decorator => {
- return Story => (
-
-
-
-
-
- );
-};
-
-/**
- * Extra extra small widgets (256px / xxs breakpoint)
- * Tests compact layout with comparison enabled.
- */
-export const SizeXXSmall: Story = {
- decorators: [ createSizeDecorator( '256px' ) ],
-};
-
-/**
- * Medium container (448px / md breakpoint)
- * Tests standard tile size with comparison.
- */
-export const SizeMedium: Story = {
- decorators: [ createSizeDecorator( '448px' ) ],
-};
-
-/**
- * Large container (576px / xl breakpoint)
- * Tests expanded layout with full data visibility.
- */
-export const SizeLarge: Story = {
- decorators: [ createSizeDecorator( '576px' ) ],
-};
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/index.ts
deleted file mode 100644
index 59df68b00606..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { SalesByDeviceWidget } from './sales-by-device-widget';
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/sales-by-device-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/sales-by-device-widget.tsx
deleted file mode 100644
index 288cc0cef1a3..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/sales-by-device-widget.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * External dependencies
- */
-import { useReportOrderAttribution, type FilterCondition } from '@jetpack-premium-analytics/data';
-import { device } from '@jetpack-premium-analytics/icons';
-import { __ } from '@wordpress/i18n';
-import { useMemo } from 'react';
-/**
- * Internal dependencies
- */
-import { Columns, type ColumnInput } from '../../components/columns';
-import { useWidgetRootContext } from '../../components/widget-root';
-
-const CURRENCY_FORMAT = { type: 'currency' as const, options: { useMultipliers: true } };
-
-type SalesByDeviceWidgetProps = {
- /**
- * Optional product type filter to apply when fetching order attribution data.
- *
- * When provided, filters results to specific product types (e.g., bookings only).
- * When omitted, shows data for all product types.
- */
- filter?: FilterCondition;
-
- emptyStateText?: string;
-
- errorText?: string;
-};
-
-/**
- * Sales by device type (Desktop, Mobile, Tablet), one column each per period.
- *
- * Must render within a WidgetRoot, which provides reportParams via context.
- */
-export function SalesByDeviceWidget( {
- filter,
- emptyStateText,
- errorText,
-}: SalesByDeviceWidgetProps ) {
- const { reportParams } = useWidgetRootContext();
-
- const paramsWithView = useMemo(
- () => ( {
- ...reportParams,
- view: 'device' as const,
- ...( filter && { filters: [ filter ] } ),
- } ),
- [ reportParams, filter ]
- );
-
- const { primary, hasComparison, isLoading, isFetching, hasData, isError, refetch } =
- useReportOrderAttribution( paramsWithView );
-
- // The attribution report carries both periods in one response.
- const columns = useMemo< ColumnInput[] >(
- () =>
- ( primary.data?.data ?? [] ).map( item => ( {
- label: item.item || __( 'Unassigned', 'jetpack-premium-analytics-pkg' ),
- value: item.current_period?.value ?? 0,
- previousValue: item.previous_period?.value,
- } ) ),
- [ primary.data ]
- );
-
- return (
-
- );
-}
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/stories/sales-by-device-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/stories/sales-by-device-widget.stories.tsx
deleted file mode 100644
index 43c3cf13c876..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/stories/sales-by-device-widget.stories.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { WidgetRootContext } from '../../../components/widget-root/context';
-import { withWidgetRoot } from '../../../stories/with-widget-root';
-import { SalesByDeviceWidget } from '../sales-by-device-widget';
-import type { Meta, StoryObj, Decorator } from '@storybook/react';
-
-const meta: Meta< typeof SalesByDeviceWidget > = {
- title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/SalesByDevice',
- component: SalesByDeviceWidget,
- tags: [ 'autodocs' ],
- parameters: {
- docs: {
- description: {
- component:
- 'Displays sales attribution data by device type (Desktop, Mobile, Tablet) using a bar chart with comparison support.',
- },
- },
- },
- decorators: [
- withWidgetRoot(),
- Story => (
-
-
-
- ),
- ],
-};
-
-export default meta;
-
-type Story = StoryObj< typeof SalesByDeviceWidget >;
-
-/**
- * Default state with mock data (no comparison)
- */
-export const Default: Story = {};
-
-/**
- * With comparison period enabled
- */
-export const WithComparison: Story = {
- decorators: [
- Story => (
-
-
-
- ),
- ],
-};
-
-/*
- * Container Size Stories
- *
- * These stories demonstrate how the widget adapts to different container sizes.
- * Uses extreme data (long labels, large values) with comparison enabled.
- * Breakpoints aligned with Tailwind container query defaults (ARC-464).
- *
- * @see https://linear.app/a8c/issue/ARC-464/design-system-wide-responsiveness
- * @see https://linear.app/a8c/issue/WOOA7S-869
- */
-
-/**
- * Creates a decorator that wraps the story in a fixed-size container
- * with comparison enabled and extreme data.
- */
-const createSizeDecorator = ( width: string ): Decorator => {
- return Story => (
-
-
-
-
-
- );
-};
-
-/**
- * Extra extra small widgets (256px / xxs breakpoint)
- * Tests compact layout with comparison enabled.
- */
-export const SizeXXSmall: Story = {
- decorators: [ createSizeDecorator( '256px' ) ],
-};
-
-/**
- * Medium container (448px / md breakpoint)
- * Tests standard tile size with comparison.
- */
-export const SizeMedium: Story = {
- decorators: [ createSizeDecorator( '448px' ) ],
-};
-
-/**
- * Large container (576px / xl breakpoint)
- * Tests expanded layout with full data visibility.
- */
-export const SizeLarge: Story = {
- decorators: [ createSizeDecorator( '576px' ) ],
-};
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/index.ts
deleted file mode 100644
index ea562eb4bb17..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { TotalReturnsWidget } from './total-returns-widget';
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/stories/total-returns-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/stories/total-returns-widget.stories.tsx
deleted file mode 100644
index 5b2bacb1368c..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/stories/total-returns-widget.stories.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { WidgetRootContext } from '../../../components/widget-root/context';
-import { withWidgetRoot } from '../../../stories/with-widget-root';
-import { TotalReturnsWidget } from '../total-returns-widget';
-import type { Meta, StoryObj, Decorator } from '@storybook/react';
-
-const meta: Meta< typeof TotalReturnsWidget > = {
- title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/TotalReturns',
- component: TotalReturnsWidget,
- tags: [ 'autodocs' ],
- parameters: {
- docs: {
- description: {
- component:
- 'Displays refunds vs total sales as a bar chart. Shows the proportion of returns compared to total revenue.',
- },
- },
- },
- decorators: [
- withWidgetRoot(),
- Story => (
-
-
-
- ),
- ],
-};
-
-export default meta;
-
-type Story = StoryObj< typeof TotalReturnsWidget >;
-
-/**
- * Default state with mock data (no comparison)
- */
-export const Default: Story = {};
-
-/**
- * With comparison period enabled
- */
-export const WithComparison: Story = {
- decorators: [
- Story => (
-
-
-
- ),
- ],
-};
-
-/*
- * Container Size Stories
- *
- * These stories demonstrate how the widget adapts to different container sizes.
- * Breakpoints aligned with Tailwind container query defaults (ARC-464).
- */
-
-/**
- * Creates a decorator that wraps the story in a fixed-size container
- * with comparison enabled.
- */
-const createSizeDecorator = ( width: string ): Decorator => {
- return Story => (
-
-
-
-
-
- );
-};
-
-/**
- * Extra extra small widgets (256px / xxs breakpoint)
- * Tests compact layout with comparison enabled.
- */
-export const SizeXXSmall: Story = {
- decorators: [ createSizeDecorator( '256px' ) ],
-};
-
-/**
- * Medium container (448px / md breakpoint)
- * Tests standard tile size with comparison.
- */
-export const SizeMedium: Story = {
- decorators: [ createSizeDecorator( '448px' ) ],
-};
-
-/**
- * Large container (576px / xl breakpoint)
- * Tests expanded layout with full data visibility.
- */
-export const SizeLarge: Story = {
- decorators: [ createSizeDecorator( '576px' ) ],
-};
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/total-returns-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/total-returns-widget.tsx
deleted file mode 100644
index d9c7a0d0df6d..000000000000
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/total-returns-widget.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * External dependencies
- */
-import { useReportOrders, type ReportDataMap } from '@jetpack-premium-analytics/data';
-import { paymentReturn } from '@jetpack-premium-analytics/icons';
-import { __ } from '@wordpress/i18n';
-import { useMemo } from 'react';
-/**
- * Internal dependencies
- */
-import { Columns, type ColumnInput } from '../../components/columns';
-import { useWidgetRootContext } from '../../components/widget-root';
-
-const CURRENCY_FORMAT = { type: 'currency' as const, options: { useMultipliers: true } };
-
-type OrdersSummary = Pick< ReportDataMap[ 'orders' ][ 'summary' ], 'total_sales' | 'refunds' >;
-
-const salesNetOfRefunds = ( summary: OrdersSummary ) =>
- Math.max( 0, summary.total_sales - summary.refunds );
-
-/**
- * Refunds beside the sales net of them, one column each per period. A period pair without a
- * refund reads as empty rather than as sales labelled returns.
- *
- * Must be used within a WidgetRoot which provides reportParams via context.
- */
-export function TotalReturnsWidget() {
- const { reportParams } = useWidgetRootContext();
-
- const { primary, comparison, hasComparison, isLoading, isFetching, hasData, isError, refetch } =
- useReportOrders( reportParams );
- const summary = primary.data?.summary;
- const previous = comparison.data?.summary;
-
- const columns = useMemo< ColumnInput[] >( () => {
- if ( summary && ! summary.refunds && ! previous?.refunds ) {
- return [];
- }
-
- // The categories are fixed, so the skeleton knows its two columns before the data arrives.
- return [
- {
- label: __( 'Total sales', 'jetpack-premium-analytics-pkg' ),
- value: summary ? salesNetOfRefunds( summary ) : 0,
- previousValue: previous ? salesNetOfRefunds( previous ) : undefined,
- },
- {
- label: __( 'Refunds', 'jetpack-premium-analytics-pkg' ),
- value: summary?.refunds ?? 0,
- previousValue: previous?.refunds,
- },
- ];
- }, [ summary, previous ] );
-
- return (
-
- );
-}
diff --git a/projects/packages/premium-analytics/widgets/bookings-by-device/render.tsx b/projects/packages/premium-analytics/widgets/bookings-by-device/render.tsx
deleted file mode 100644
index 9e1150880174..000000000000
--- a/projects/packages/premium-analytics/widgets/bookings-by-device/render.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * External dependencies
- */
-import {
- BOOKINGS_FILTER,
- SalesByDeviceWidget,
- WidgetRoot,
- type ReportParamsFieldAttributes,
-} from '@jetpack-premium-analytics/widgets-toolkit';
-import { __ } from '@wordpress/i18n';
-/**
- * Internal dependencies
- */
-import type { BookingsByDeviceAttributes } from './widget';
-import type { WidgetRenderProps } from '@wordpress/widget-primitives';
-import type { ComponentProps } from 'react';
-
-// Report params are usually URL-driven (WidgetRoot's fallback), but callers may
-// also pass them via `attributes`. Compose the render-only shape to cover both.
-type BookingsByDeviceRenderAttributes = BookingsByDeviceAttributes &
- Partial< ReportParamsFieldAttributes >;
-
-type BookingsByDeviceWidgetProps = WidgetRenderProps< BookingsByDeviceRenderAttributes > & {
- setError?: ComponentProps< typeof WidgetRoot >[ 'setError' ];
-};
-
-function BookingsByDeviceWidget() {
- return (
-
- );
-}
-
-/**
- * Thin composition over the widgets-toolkit: WidgetRoot provides the query
- * client, chart theme, and resolved report params; SalesByDeviceWidget fetches
- * the filtered bookings attribution report and renders the device breakdown.
- */
-export default function BookingsByDeviceRender( {
- attributes = {},
- setError,
-}: BookingsByDeviceWidgetProps ) {
- return (
-
-
-
- );
-}
diff --git a/projects/packages/premium-analytics/widgets/bookings-by-device/stories/bookings-by-device-widget.stories.tsx b/projects/packages/premium-analytics/widgets/bookings-by-device/stories/bookings-by-device-widget.stories.tsx
deleted file mode 100644
index 36a117c65d81..000000000000
--- a/projects/packages/premium-analytics/widgets/bookings-by-device/stories/bookings-by-device-widget.stories.tsx
+++ /dev/null
@@ -1,262 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { SELECTABLE_PRESETS, type SelectablePresetId } from '@jetpack-premium-analytics/datetime';
-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 { withWidgetCanvas } from '../../stories/with-widget-canvas';
-import {
- registerReportMocks,
- setReportMockState,
-} from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks';
-import BookingsByDeviceRender 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 BOOKINGS_BY_DEVICE_RENDER_MODULE = 'storybook/bookings-by-device';
-const DEFAULT_PRESET = 'last-30-days' satisfies SelectablePresetId;
-const PRESET_OPTIONS = SELECTABLE_PRESETS;
-
-type BookingsByDeviceRenderProps = ComponentProps< typeof BookingsByDeviceRender >;
-
-interface BookingsByDeviceStoryControls {
- withComparison: boolean;
- preset: SelectablePresetId;
-}
-
-type BookingsByDeviceStoryProps = BookingsByDeviceRenderProps & BookingsByDeviceStoryControls;
-
-interface BookingsByDeviceDashboardStoryProps
- extends WidgetDashboardWithWidgetControls, BookingsByDeviceStoryControls {}
-
-function getBookingsByDeviceAttributes(
- withComparison = false,
- preset: SelectablePresetId = DEFAULT_PRESET
-): BookingsByDeviceRenderProps[ 'attributes' ] {
- return {
- reportParams: getDefaultQueryParams( withComparison, preset ),
- };
-}
-
-function getDefaultQueryParamsSource( {
- withComparison,
- preset,
-}: Partial< BookingsByDeviceStoryControls > ) {
- const hasComparison = Boolean( withComparison );
- const storyPreset = preset ?? DEFAULT_PRESET;
-
- if ( ! hasComparison && storyPreset === DEFAULT_PRESET ) {
- return 'getDefaultQueryParams()';
- }
-
- if ( hasComparison && storyPreset === DEFAULT_PRESET ) {
- return 'getDefaultQueryParams( true )';
- }
-
- return `getDefaultQueryParams( ${ hasComparison ? 'true' : 'false' }, '${ storyPreset }' )`;
-}
-
-function getBookingsByDeviceSource( args: Partial< BookingsByDeviceStoryControls > ) {
- return `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-
-`;
-}
-
-function renderBookingsByDevice( { withComparison, preset }: BookingsByDeviceStoryControls ) {
- return (
-
- );
-}
-
-// Distinct preset → own query-cache entry; see forceStatsMockState.
-function renderBookingsByDeviceOnPreset( preset: SelectablePresetId ) {
- return ;
-}
-
-function BookingsByDeviceDashboardStory( {
- withComparison,
- preset,
- ...dashboardStoryArgs
-}: BookingsByDeviceDashboardStoryProps ) {
- return (
- > }
- attributes={ getBookingsByDeviceAttributes( withComparison, preset ) }
- />
- );
-}
-
-const meta = {
- title: 'Packages/Premium Analytics/Widgets/BookingsByDevice',
- component: BookingsByDeviceRender,
- tags: [ 'autodocs' ],
- argTypes: {
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- description: {
- component: 'Dashboard widget that displays which devices customers use to make bookings.',
- },
- },
- },
-} satisfies Meta< BookingsByDeviceStoryProps >;
-
-export default meta;
-
-type Story = StoryObj< BookingsByDeviceStoryControls >;
-type DashboardStory = StoryObj< BookingsByDeviceDashboardStoryProps >;
-
-/**
- * Default state for the current report period.
- */
-export const Default: Story = {
- render: renderBookingsByDevice,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: false,
- },
- decorators: [ withWidgetCanvas ],
- parameters: {
- docs: {
- source: {
- transform: (
- _source: string,
- storyContext: { args: Partial< BookingsByDeviceStoryControls > }
- ) => getBookingsByDeviceSource( storyContext.args ),
- },
- },
- },
-};
-
-/**
- * Comparison period enabled, showing period-over-period change and sparkline data.
- */
-export const WithComparison: Story = {
- render: renderBookingsByDevice,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- decorators: [ withWidgetCanvas ],
- parameters: {
- docs: {
- source: {
- transform: (
- _source: string,
- storyContext: { args: Partial< BookingsByDeviceStoryControls > }
- ) => getBookingsByDeviceSource( storyContext.args ),
- },
- },
- },
-};
-
-/**
- * First load: the filtered order-attribution report 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: () => renderBookingsByDeviceOnPreset( 'last-90-days' ),
- // Off the shared autodocs page — path-keyed override; see forceStatsMockState.
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'order-attribution-by-product/device/summary', 'loading' );
- return () => setReportMockState( 'order-attribution-by-product/device/summary', null );
- },
-};
-
-/**
- * The filtered order-attribution report 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 Error: Story = {
- render: () => renderBookingsByDeviceOnPreset( 'last-7-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'order-attribution-by-product/device/summary', 'error' );
- return () => setReportMockState( 'order-attribution-by-product/device/summary', null );
- },
-};
-
-/**
- * Resolved with no order-attribution rows: the widget shows its empty state (the
- * neutral device glyph and "No booking data in this period.").
- */
-export const Empty: Story = {
- render: () => renderBookingsByDeviceOnPreset( 'last-365-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'order-attribution-by-product/device/summary', 'empty' );
- return () => setReportMockState( 'order-attribution-by-product/device/summary', null );
- },
-};
-
-/**
- * Renders the widget through the shared dashboard harness.
- */
-export const WidgetDashboardWithWidget: DashboardStory = {
- render: args => ,
- args: {
- ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- argTypes: {
- ...widgetDashboardWithWidgetArgTypes,
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- source: {
- code: `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-
-`,
- },
- },
- },
-};
diff --git a/projects/packages/premium-analytics/widgets/bookings-by-device/widget.ts b/projects/packages/premium-analytics/widgets/bookings-by-device/widget.ts
deleted file mode 100644
index 9191673545ef..000000000000
--- a/projects/packages/premium-analytics/widgets/bookings-by-device/widget.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * The widget has no user-configurable attributes. Report params still reach it
- * through WidgetRoot: the dashboard date range, or `attributes.reportParams`
- * when a host injects them (e.g. Storybook and dashboard previews).
- */
-export type BookingsByDeviceAttributes = Record< never, never >;
-
-/**
- * Widget type definition.
- *
- * Ported from `woocommerce-analytics/bookings-by-device` in
- * woocommerce/woocommerce-analytics (next-woocommerce-analytics).
- *
- * Report params intentionally come from the analytics dashboard's global
- * date-range state for now. Adding widget-level overrides needs a host-level
- * control registry so analytics dashboards can hide the field while other
- * dashboards can opt in.
- */
-export default {};
diff --git a/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/render.tsx b/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/render.tsx
deleted file mode 100644
index 78bc6144abe4..000000000000
--- a/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/render.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * External dependencies
- */
-import {
- BookingsRevenueByCustomerTypeWidget,
- WidgetRoot,
- type ReportParamsFieldAttributes,
-} from '@jetpack-premium-analytics/widgets-toolkit';
-/**
- * Internal dependencies
- */
-import type { BookingsRevenueByCustomerTypeAttributes } from './widget';
-import type { WidgetRenderProps } from '@wordpress/widget-primitives';
-import type { ComponentProps } from 'react';
-
-// Report params are usually URL-driven (WidgetRoot's fallback), but callers may
-// also pass them via `attributes`. Compose the render-only shape to cover both.
-type BookingsRevenueByCustomerTypeRenderAttributes = BookingsRevenueByCustomerTypeAttributes &
- Partial< ReportParamsFieldAttributes >;
-
-type BookingsRevenueByCustomerTypeWidgetProps =
- WidgetRenderProps< BookingsRevenueByCustomerTypeRenderAttributes > & {
- setError?: ComponentProps< typeof WidgetRoot >[ 'setError' ];
- };
-
-/**
- * Thin composition over the widgets-toolkit: WidgetRoot provides the query
- * client, chart theme, and resolved report params; BookingsRevenueByCustomerTypeWidget
- * fetches the bookings customers report and renders the revenue breakdown.
- */
-export default function BookingsRevenueByCustomerTypeRender( {
- attributes = {},
- setError,
-}: BookingsRevenueByCustomerTypeWidgetProps ) {
- return (
-
-
-
- );
-}
diff --git a/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/stories/bookings-revenue-by-customer-type-widget.stories.tsx b/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/stories/bookings-revenue-by-customer-type-widget.stories.tsx
deleted file mode 100644
index e07d64b8e987..000000000000
--- a/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/stories/bookings-revenue-by-customer-type-widget.stories.tsx
+++ /dev/null
@@ -1,278 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { SELECTABLE_PRESETS, type SelectablePresetId } from '@jetpack-premium-analytics/datetime';
-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 { withWidgetCanvas } from '../../stories/with-widget-canvas';
-import {
- registerReportMocks,
- setReportMockState,
-} from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks';
-import BookingsRevenueByCustomerTypeRender 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 BOOKINGS_REVENUE_BY_CUSTOMER_TYPE_RENDER_MODULE =
- 'storybook/bookings-revenue-by-customer-type';
-const DEFAULT_PRESET = 'last-30-days' satisfies SelectablePresetId;
-const PRESET_OPTIONS = SELECTABLE_PRESETS;
-
-type BookingsRevenueByCustomerTypeRenderProps = ComponentProps<
- typeof BookingsRevenueByCustomerTypeRender
->;
-
-interface BookingsRevenueByCustomerTypeStoryControls {
- withComparison: boolean;
- preset: SelectablePresetId;
-}
-
-type BookingsRevenueByCustomerTypeStoryProps = BookingsRevenueByCustomerTypeRenderProps &
- BookingsRevenueByCustomerTypeStoryControls;
-
-interface BookingsRevenueByCustomerTypeDashboardStoryProps
- extends WidgetDashboardWithWidgetControls, BookingsRevenueByCustomerTypeStoryControls {}
-
-function getBookingsRevenueByCustomerTypeAttributes(
- withComparison = false,
- preset: SelectablePresetId = DEFAULT_PRESET
-): BookingsRevenueByCustomerTypeRenderProps[ 'attributes' ] {
- return {
- reportParams: getDefaultQueryParams( withComparison, preset ),
- };
-}
-
-function getDefaultQueryParamsSource( {
- withComparison,
- preset,
-}: Partial< BookingsRevenueByCustomerTypeStoryControls > ) {
- const hasComparison = Boolean( withComparison );
- const storyPreset = preset ?? DEFAULT_PRESET;
-
- if ( ! hasComparison && storyPreset === DEFAULT_PRESET ) {
- return 'getDefaultQueryParams()';
- }
-
- if ( hasComparison && storyPreset === DEFAULT_PRESET ) {
- return 'getDefaultQueryParams( true )';
- }
-
- return `getDefaultQueryParams( ${ hasComparison ? 'true' : 'false' }, '${ storyPreset }' )`;
-}
-
-function getBookingsRevenueByCustomerTypeSource(
- args: Partial< BookingsRevenueByCustomerTypeStoryControls >
-) {
- return `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-
-`;
-}
-
-function renderBookingsRevenueByCustomerType( {
- withComparison,
- preset,
-}: BookingsRevenueByCustomerTypeStoryControls ) {
- return (
-
- );
-}
-
-// Distinct preset → own query-cache entry; see forceStatsMockState.
-function renderBookingsRevenueByCustomerTypeOnPreset( preset: SelectablePresetId ) {
- return (
-
- );
-}
-
-function BookingsRevenueByCustomerTypeDashboardStory( {
- withComparison,
- preset,
- ...dashboardStoryArgs
-}: BookingsRevenueByCustomerTypeDashboardStoryProps ) {
- return (
- >
- }
- attributes={ getBookingsRevenueByCustomerTypeAttributes( withComparison, preset ) }
- />
- );
-}
-
-const meta = {
- title: 'Packages/Premium Analytics/Widgets/BookingsRevenueByCustomerType',
- component: BookingsRevenueByCustomerTypeRender,
- tags: [ 'autodocs' ],
- argTypes: {
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- description: {
- component:
- 'Dashboard widget that displays booking revenue from new customers versus returning customers.',
- },
- },
- },
-} satisfies Meta< BookingsRevenueByCustomerTypeStoryProps >;
-
-export default meta;
-
-type Story = StoryObj< BookingsRevenueByCustomerTypeStoryControls >;
-type DashboardStory = StoryObj< BookingsRevenueByCustomerTypeDashboardStoryProps >;
-
-/**
- * Default state for the current report period.
- */
-export const Default: Story = {
- render: renderBookingsRevenueByCustomerType,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: false,
- },
- decorators: [ withWidgetCanvas ],
- parameters: {
- docs: {
- source: {
- transform: (
- _source: string,
- storyContext: { args: Partial< BookingsRevenueByCustomerTypeStoryControls > }
- ) => getBookingsRevenueByCustomerTypeSource( storyContext.args ),
- },
- },
- },
-};
-
-/**
- * Comparison period enabled, showing period-over-period revenue changes.
- */
-export const WithComparison: Story = {
- render: renderBookingsRevenueByCustomerType,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- decorators: [ withWidgetCanvas ],
- parameters: {
- docs: {
- source: {
- transform: (
- _source: string,
- storyContext: { args: Partial< BookingsRevenueByCustomerTypeStoryControls > }
- ) => getBookingsRevenueByCustomerTypeSource( storyContext.args ),
- },
- },
- },
-};
-
-/**
- * First load: the filtered customers report 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: () => renderBookingsRevenueByCustomerTypeOnPreset( 'last-90-days' ),
- // Off the shared autodocs page — path-keyed override; see forceStatsMockState.
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'customers/new-returning', 'loading' );
- return () => setReportMockState( 'customers/new-returning', null );
- },
-};
-
-/**
- * The filtered customers report 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 Error: Story = {
- render: () => renderBookingsRevenueByCustomerTypeOnPreset( 'last-7-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'customers/new-returning', 'error' );
- return () => setReportMockState( 'customers/new-returning', null );
- },
-};
-
-/**
- * The filtered customers report resolved with no booking revenue in the period:
- * the widget shows its empty state.
- */
-export const Empty: Story = {
- render: () => renderBookingsRevenueByCustomerTypeOnPreset( 'last-365-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'customers/new-returning', 'empty' );
- return () => setReportMockState( 'customers/new-returning', null );
- },
-};
-
-/**
- * Renders the widget through the shared dashboard harness.
- */
-export const WidgetDashboardWithWidget: DashboardStory = {
- render: args => ,
- args: {
- ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- argTypes: {
- ...widgetDashboardWithWidgetArgTypes,
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- source: {
- code: `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-
-`,
- },
- },
- },
-};
diff --git a/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/widget.ts b/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/widget.ts
deleted file mode 100644
index 5146cd461315..000000000000
--- a/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/widget.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * The widget has no user-configurable attributes. Report params still reach it
- * through WidgetRoot: the dashboard date range, or `attributes.reportParams`
- * when a host injects them (e.g. Storybook and dashboard previews).
- */
-export type BookingsRevenueByCustomerTypeAttributes = Record< never, never >;
-
-/**
- * Widget type definition.
- *
- * Ported from `woocommerce-analytics/bookings-revenue-by-customer-type` in
- * woocommerce/woocommerce-analytics (next-woocommerce-analytics).
- *
- * Report params intentionally come from the analytics dashboard's global
- * date-range state for now. Adding widget-level overrides needs a host-level
- * control registry so analytics dashboards can hide the field while other
- * dashboards can opt in.
- */
-export default {};
diff --git a/projects/packages/premium-analytics/widgets/revenue-by-customer-type/render.tsx b/projects/packages/premium-analytics/widgets/revenue-by-customer-type/render.tsx
deleted file mode 100644
index cd8d33dd7265..000000000000
--- a/projects/packages/premium-analytics/widgets/revenue-by-customer-type/render.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * External dependencies
- */
-import {
- RevenueByCustomerTypeWidget,
- WidgetRoot,
- type ReportParamsFieldAttributes,
-} from '@jetpack-premium-analytics/widgets-toolkit';
-/**
- * Internal dependencies
- */
-import type { RevenueByCustomerTypeAttributes } from './widget';
-import type { WidgetRenderProps } from '@wordpress/widget-primitives';
-import type { ComponentProps } from 'react';
-
-// Report params are usually URL-driven (WidgetRoot's fallback), but callers may
-// also pass them via `attributes`. Compose the render-only shape to cover both.
-type RevenueByCustomerTypeRenderAttributes = RevenueByCustomerTypeAttributes &
- Partial< ReportParamsFieldAttributes >;
-
-type RevenueByCustomerTypeWidgetProps =
- WidgetRenderProps< RevenueByCustomerTypeRenderAttributes > & {
- setError?: ComponentProps< typeof WidgetRoot >[ 'setError' ];
- };
-
-export default function RevenueByCustomerTypeRender( {
- attributes = {},
- setError,
-}: RevenueByCustomerTypeWidgetProps ) {
- return (
-
-
-
- );
-}
diff --git a/projects/packages/premium-analytics/widgets/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx b/projects/packages/premium-analytics/widgets/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx
deleted file mode 100644
index e95edd123e41..000000000000
--- a/projects/packages/premium-analytics/widgets/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx
+++ /dev/null
@@ -1,273 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { SELECTABLE_PRESETS, type SelectablePresetId } from '@jetpack-premium-analytics/datetime';
-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 { withWidgetCanvas } from '../../stories/with-widget-canvas';
-import {
- registerReportMocks,
- setReportMockState,
-} from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks';
-import RevenueByCustomerTypeRender 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 REVENUE_BY_CUSTOMER_TYPE_RENDER_MODULE = 'storybook/revenue-by-customer-type';
-const DEFAULT_PRESET = 'last-30-days' satisfies SelectablePresetId;
-const PRESET_OPTIONS = SELECTABLE_PRESETS;
-
-type RevenueByCustomerTypeRenderProps = ComponentProps< typeof RevenueByCustomerTypeRender >;
-
-interface RevenueByCustomerTypeStoryControls {
- withComparison: boolean;
- preset: SelectablePresetId;
-}
-
-type RevenueByCustomerTypeStoryProps = RevenueByCustomerTypeRenderProps &
- RevenueByCustomerTypeStoryControls;
-
-interface RevenueByCustomerTypeDashboardStoryProps
- extends WidgetDashboardWithWidgetControls, RevenueByCustomerTypeStoryControls {}
-
-function getRevenueByCustomerTypeAttributes(
- withComparison = false,
- preset: SelectablePresetId = DEFAULT_PRESET
-): RevenueByCustomerTypeRenderProps[ 'attributes' ] {
- return {
- reportParams: getDefaultQueryParams( withComparison, preset ),
- };
-}
-
-function getDefaultQueryParamsSource( {
- withComparison,
- preset,
-}: Partial< RevenueByCustomerTypeStoryControls > ) {
- const hasComparison = Boolean( withComparison );
- const storyPreset = preset ?? DEFAULT_PRESET;
-
- if ( ! hasComparison && storyPreset === DEFAULT_PRESET ) {
- return 'getDefaultQueryParams()';
- }
-
- if ( hasComparison && storyPreset === DEFAULT_PRESET ) {
- return 'getDefaultQueryParams( true )';
- }
-
- return `getDefaultQueryParams( ${ hasComparison ? 'true' : 'false' }, '${ storyPreset }' )`;
-}
-
-function getRevenueByCustomerTypeSource( args: Partial< RevenueByCustomerTypeStoryControls > ) {
- return `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-
-`;
-}
-
-function renderRevenueByCustomerType( {
- withComparison,
- preset,
-}: RevenueByCustomerTypeStoryControls ) {
- return (
-
- );
-}
-
-// Distinct preset → own query-cache entry; see forceStatsMockState.
-function renderRevenueByCustomerTypeOnPreset( preset: SelectablePresetId ) {
- return (
-
- );
-}
-
-function RevenueByCustomerTypeDashboardStory( {
- withComparison,
- preset,
- ...dashboardStoryArgs
-}: RevenueByCustomerTypeDashboardStoryProps ) {
- return (
- >
- }
- attributes={ getRevenueByCustomerTypeAttributes( withComparison, preset ) }
- />
- );
-}
-
-const meta = {
- title: 'Packages/Premium Analytics/Widgets/RevenueByCustomerType',
- component: RevenueByCustomerTypeRender,
- tags: [ 'autodocs' ],
- argTypes: {
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- description: {
- component:
- 'Dashboard widget that displays new and returning customer revenue for the selected period.',
- },
- },
- },
-} satisfies Meta< RevenueByCustomerTypeStoryProps >;
-
-export default meta;
-
-type Story = StoryObj< RevenueByCustomerTypeStoryControls >;
-type DashboardStory = StoryObj< RevenueByCustomerTypeDashboardStoryProps >;
-
-/**
- * Default state for the current report period.
- */
-export const Default: Story = {
- render: renderRevenueByCustomerType,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: false,
- },
- decorators: [ withWidgetCanvas ],
- parameters: {
- docs: {
- source: {
- transform: (
- _source: string,
- storyContext: { args: Partial< RevenueByCustomerTypeStoryControls > }
- ) => getRevenueByCustomerTypeSource( storyContext.args ),
- },
- },
- },
-};
-
-/**
- * Comparison period enabled, showing period-over-period revenue changes.
- */
-export const WithComparison: Story = {
- render: renderRevenueByCustomerType,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- decorators: [ withWidgetCanvas ],
- parameters: {
- docs: {
- source: {
- transform: (
- _source: string,
- storyContext: { args: Partial< RevenueByCustomerTypeStoryControls > }
- ) => getRevenueByCustomerTypeSource( storyContext.args ),
- },
- },
- },
-};
-
-/**
- * First load: the customers report 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: () => renderRevenueByCustomerTypeOnPreset( 'last-90-days' ),
- // Off the shared autodocs page — path-keyed override; see forceStatsMockState.
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'customers/new-returning', 'loading' );
- return () => setReportMockState( 'customers/new-returning', null );
- },
-};
-
-/**
- * The customers report 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 Error: Story = {
- render: () => renderRevenueByCustomerTypeOnPreset( 'last-7-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'customers/new-returning', 'error' );
- return () => setReportMockState( 'customers/new-returning', null );
- },
-};
-
-/**
- * The customers report resolved with no revenue in the period: the widget shows
- * its empty state.
- */
-export const Empty: Story = {
- render: () => renderRevenueByCustomerTypeOnPreset( 'last-365-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'customers/new-returning', 'empty' );
- return () => setReportMockState( 'customers/new-returning', null );
- },
-};
-
-/**
- * Renders the widget through the shared dashboard harness.
- */
-export const WidgetDashboardWithWidget: DashboardStory = {
- render: args => ,
- args: {
- ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- argTypes: {
- ...widgetDashboardWithWidgetArgTypes,
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- source: {
- code: `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-
-`,
- },
- },
- },
-};
diff --git a/projects/packages/premium-analytics/widgets/revenue-by-customer-type/widget.ts b/projects/packages/premium-analytics/widgets/revenue-by-customer-type/widget.ts
deleted file mode 100644
index 6d99908aebfa..000000000000
--- a/projects/packages/premium-analytics/widgets/revenue-by-customer-type/widget.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * No user-configurable attributes; report params still reach the widget through
- * WidgetRoot (dashboard date range, or `attributes.reportParams` from a host).
- */
-export type RevenueByCustomerTypeAttributes = Record< never, never >;
-
-/**
- * Ported from `woocommerce-analytics/revenue-by-customer-type` in
- * woocommerce/woocommerce-analytics (next-woocommerce-analytics).
- *
- * Report params intentionally come from the analytics dashboard's global
- * date-range state for now. Adding widget-level overrides needs a host-level
- * control registry so analytics dashboards can hide the field while other
- * dashboards can opt in.
- */
-export default {};
diff --git a/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/package.json b/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/package.json
deleted file mode 100644
index 514117ed86b9..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/package.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "name": "@automattic/jetpack-premium-analytics-widget-sales-by-coupon-usage",
- "version": "0.1.0-alpha",
- "private": true,
- "type": "module",
- "dependencies": {
- "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit",
- "@wordpress/i18n": "^6.9.0",
- "@wordpress/icons": "^16.0.0",
- "@wordpress/widget-primitives": "0.7.0",
- "react": "18.3.1"
- }
-}
diff --git a/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/render.tsx b/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/render.tsx
deleted file mode 100644
index 5e13ffb00f29..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/render.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * External dependencies
- */
-import {
- SalesByCouponWidget,
- WidgetRoot,
- type ReportParamsFieldAttributes,
-} from '@jetpack-premium-analytics/widgets-toolkit';
-/**
- * Internal dependencies
- */
-import type { SalesByCouponUsageAttributes } from './widget';
-import type { WidgetRenderProps } from '@wordpress/widget-primitives';
-import type { ComponentProps } from 'react';
-
-// Report params are usually URL-driven (WidgetRoot's fallback), but callers may
-// also pass them via `attributes`. Compose the render-only shape to cover both.
-type SalesByCouponUsageRenderAttributes = SalesByCouponUsageAttributes &
- Partial< ReportParamsFieldAttributes >;
-
-type SalesByCouponUsageWidgetProps = WidgetRenderProps< SalesByCouponUsageRenderAttributes > & {
- setError?: ComponentProps< typeof WidgetRoot >[ 'setError' ];
-};
-
-export default function SalesByCouponUsageRender( {
- attributes = {},
- setError,
-}: SalesByCouponUsageWidgetProps ) {
- return (
-
-
-
- );
-}
diff --git a/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/stories/sales-by-coupon-usage-widget.stories.tsx b/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/stories/sales-by-coupon-usage-widget.stories.tsx
deleted file mode 100644
index b204388e6bb5..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/stories/sales-by-coupon-usage-widget.stories.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { SELECTABLE_PRESETS, type SelectablePresetId } from '@jetpack-premium-analytics/datetime';
-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 { withWidgetCanvas } from '../../stories/with-widget-canvas';
-import {
- registerReportMocks,
- setReportMockState,
-} from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks';
-import SalesByCouponUsageRender 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 SALES_BY_COUPON_USAGE_RENDER_MODULE = 'storybook/sales-by-coupon-usage';
-const DEFAULT_PRESET = 'last-30-days' satisfies SelectablePresetId;
-const PRESET_OPTIONS = SELECTABLE_PRESETS;
-
-type SalesByCouponUsageWidgetProps = ComponentProps< typeof SalesByCouponUsageRender >;
-
-interface SalesByCouponUsageStoryControls {
- withComparison: boolean;
- preset: SelectablePresetId;
-}
-
-type SalesByCouponUsageStoryProps = SalesByCouponUsageWidgetProps & SalesByCouponUsageStoryControls;
-
-interface SalesByCouponUsageDashboardStoryProps
- extends WidgetDashboardWithWidgetControls, SalesByCouponUsageStoryControls {}
-
-function getSalesByCouponUsageAttributes(
- withComparison = false,
- preset: SelectablePresetId = DEFAULT_PRESET
-): SalesByCouponUsageWidgetProps[ 'attributes' ] {
- return {
- reportParams: getDefaultQueryParams( withComparison, preset ),
- };
-}
-
-function getDefaultQueryParamsSource( {
- withComparison,
- preset,
-}: Partial< SalesByCouponUsageStoryControls > ) {
- const hasComparison = Boolean( withComparison );
- const storyPreset = preset ?? DEFAULT_PRESET;
-
- if ( ! hasComparison && storyPreset === DEFAULT_PRESET ) {
- return 'getDefaultQueryParams()';
- }
-
- if ( hasComparison && storyPreset === DEFAULT_PRESET ) {
- return 'getDefaultQueryParams( true )';
- }
-
- return `getDefaultQueryParams( ${ hasComparison ? 'true' : 'false' }, '${ storyPreset }' )`;
-}
-
-function getSalesByCouponUsageSource( args: Partial< SalesByCouponUsageStoryControls > ) {
- return `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-
-`;
-}
-
-function renderSalesByCouponUsage( { withComparison, preset }: SalesByCouponUsageStoryControls ) {
- return (
-
- );
-}
-
-// Distinct preset → own query-cache entry; see forceStatsMockState.
-function renderSalesByCouponUsageOnPreset( preset: SelectablePresetId ) {
- return (
-
- );
-}
-
-function SalesByCouponUsageDashboardStory( {
- withComparison,
- preset,
- ...dashboardStoryArgs
-}: SalesByCouponUsageDashboardStoryProps ) {
- return (
- > }
- attributes={ getSalesByCouponUsageAttributes( withComparison, preset ) }
- />
- );
-}
-
-const meta = {
- title: 'Packages/Premium Analytics/Widgets/SalesByCouponUsage',
- component: SalesByCouponUsageRender,
- tags: [ 'autodocs' ],
- argTypes: {
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- description: {
- component:
- 'Dashboard widget that displays the coupon sales breakdown for the selected period.',
- },
- },
- },
-} satisfies Meta< SalesByCouponUsageStoryProps >;
-
-export default meta;
-
-type Story = StoryObj< SalesByCouponUsageStoryControls >;
-type DashboardStory = StoryObj< SalesByCouponUsageDashboardStoryProps >;
-
-/**
- * Default state for the current report period.
- */
-export const Default: Story = {
- render: renderSalesByCouponUsage,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: false,
- },
- decorators: [ withWidgetCanvas ],
- parameters: {
- docs: {
- source: {
- transform: (
- _source: string,
- storyContext: { args: Partial< SalesByCouponUsageStoryControls > }
- ) => getSalesByCouponUsageSource( storyContext.args ),
- },
- },
- },
-};
-
-/**
- * Comparison period enabled, showing period-over-period coupon sales data.
- */
-export const WithComparison: Story = {
- render: renderSalesByCouponUsage,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- decorators: [ withWidgetCanvas ],
- parameters: {
- docs: {
- source: {
- transform: (
- _source: string,
- storyContext: { args: Partial< SalesByCouponUsageStoryControls > }
- ) => getSalesByCouponUsageSource( storyContext.args ),
- },
- },
- },
-};
-
-/**
- * First load: the coupons report 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: () => renderSalesByCouponUsageOnPreset( 'last-90-days' ),
- // Off the shared autodocs page — path-keyed override; see forceStatsMockState.
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'coupons/', 'loading' );
- return () => setReportMockState( 'coupons/', null );
- },
-};
-
-/**
- * The coupons report 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 Error: Story = {
- render: () => renderSalesByCouponUsageOnPreset( 'last-7-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'coupons/', 'error' );
- return () => setReportMockState( 'coupons/', null );
- },
-};
-
-/**
- * Resolved with no coupon rows: the widget shows its empty state (the neutral
- * coupon glyph and "No coupon sales in this period.").
- */
-export const Empty: Story = {
- render: () => renderSalesByCouponUsageOnPreset( 'last-365-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'coupons/', 'empty' );
- return () => setReportMockState( 'coupons/', null );
- },
-};
-
-/**
- * Renders the widget through the shared dashboard harness.
- */
-export const WidgetDashboardWithWidget: DashboardStory = {
- render: args => ,
- args: {
- ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- argTypes: {
- ...widgetDashboardWithWidgetArgTypes,
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- source: {
- code: `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-
-`,
- },
- },
- },
-};
diff --git a/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/widget.json b/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/widget.json
deleted file mode 100644
index 54cf39890098..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/widget.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "name": "jpa/sales-by-coupon-usage",
- "icon": "jpa/chart-bar",
- "title": "Sales by coupon usage",
- "description": "Sales by coupon usage over the selected time period.",
- "help": {
- "content": "Compare sales with and without coupons."
- },
- "category": "coupons",
- "presentation": "framed"
-}
diff --git a/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/widget.ts b/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/widget.ts
deleted file mode 100644
index 3750ff61d848..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-coupon-usage/widget.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * No user-configurable attributes; report params still reach the widget through
- * WidgetRoot (dashboard date range, or `attributes.reportParams` from a host).
- */
-export type SalesByCouponUsageAttributes = Record< never, never >;
-
-/**
- * Ported from `woocommerce-analytics/sales-by-coupon-usage` in
- * woocommerce/woocommerce-analytics (next-woocommerce-analytics).
- *
- * Report params intentionally come from the analytics dashboard's global
- * date-range state for now. Adding widget-level overrides needs a host-level
- * control registry so analytics dashboards can hide the field while other
- * dashboards can opt in.
- */
-export default {};
diff --git a/projects/packages/premium-analytics/widgets/sales-by-coupon/render.tsx b/projects/packages/premium-analytics/widgets/sales-by-coupon/render.tsx
deleted file mode 100644
index ce7d666c7a08..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-coupon/render.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * External dependencies
- */
-import {
- SalesByCouponWidget,
- WidgetRoot,
- type ReportParamsFieldAttributes,
-} from '@jetpack-premium-analytics/widgets-toolkit';
-/**
- * Internal dependencies
- */
-import type { SalesByCouponAttributes } from './widget';
-import type { WidgetRenderProps } from '@wordpress/widget-primitives';
-import type { ComponentProps } from 'react';
-
-// Report params are usually URL-driven (WidgetRoot's fallback), but callers may
-// also pass them via `attributes`. Compose the render-only shape to cover both.
-type SalesByCouponRenderAttributes = SalesByCouponAttributes &
- Partial< ReportParamsFieldAttributes >;
-
-type SalesByCouponWidgetProps = WidgetRenderProps< SalesByCouponRenderAttributes > & {
- setError?: ComponentProps< typeof WidgetRoot >[ 'setError' ];
-};
-
-export default function SalesByCouponRender( {
- attributes = {},
- setError,
-}: SalesByCouponWidgetProps ) {
- return (
-
-
-
- );
-}
diff --git a/projects/packages/premium-analytics/widgets/sales-by-coupon/stories/sales-by-coupon-widget.stories.tsx b/projects/packages/premium-analytics/widgets/sales-by-coupon/stories/sales-by-coupon-widget.stories.tsx
deleted file mode 100644
index 967b93d9ad2b..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-coupon/stories/sales-by-coupon-widget.stories.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { SELECTABLE_PRESETS, type SelectablePresetId } from '@jetpack-premium-analytics/datetime';
-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 { withWidgetCanvas } from '../../stories/with-widget-canvas';
-import {
- registerReportMocks,
- setReportMockState,
-} from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks';
-import SalesByCouponRender 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 SALES_BY_COUPON_RENDER_MODULE = 'storybook/sales-by-coupon';
-const DEFAULT_PRESET = 'last-30-days' satisfies SelectablePresetId;
-const PRESET_OPTIONS = SELECTABLE_PRESETS;
-
-type SalesByCouponWidgetProps = ComponentProps< typeof SalesByCouponRender >;
-const setStoryError: SalesByCouponWidgetProps[ 'setError' ] = () => undefined;
-
-interface SalesByCouponStoryControls {
- /**
- * Whether comparison report params are enabled.
- */
- withComparison: boolean;
- /**
- * Date-range preset used for report params.
- */
- preset: SelectablePresetId;
-}
-
-type SalesByCouponStoryProps = SalesByCouponWidgetProps & SalesByCouponStoryControls;
-
-interface SalesByCouponDashboardStoryProps
- extends WidgetDashboardWithWidgetControls, SalesByCouponStoryControls {}
-
-function getSalesByCouponAttributes(
- withComparison = false,
- preset: SelectablePresetId = DEFAULT_PRESET
-): SalesByCouponWidgetProps[ 'attributes' ] {
- return {
- reportParams: getDefaultQueryParams( withComparison, preset ),
- };
-}
-
-function renderSalesByCoupon( { withComparison, preset }: SalesByCouponStoryControls ) {
- return (
-
- );
-}
-
-// Distinct preset → own query-cache entry; see forceStatsMockState.
-function renderSalesByCouponOnPreset( preset: SelectablePresetId ) {
- return (
-
- );
-}
-
-function SalesByCouponDashboardStory( {
- withComparison,
- preset,
- ...dashboardStoryArgs
-}: SalesByCouponDashboardStoryProps ) {
- return (
- > }
- attributes={ getSalesByCouponAttributes( withComparison, preset ) }
- />
- );
-}
-
-const meta = {
- title: 'Packages/Premium Analytics/Widgets/SalesByCoupon',
- component: SalesByCouponRender,
- tags: [ 'autodocs' ],
- argTypes: {
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- description: {
- component:
- 'Dashboard widget that displays top coupon codes by order revenue for the selected period.',
- },
- },
- },
-} satisfies Meta< SalesByCouponStoryProps >;
-
-export default meta;
-
-type Story = StoryObj< SalesByCouponStoryControls >;
-type DashboardStory = StoryObj< SalesByCouponDashboardStoryProps >;
-
-/**
- * Default state for the current report period.
- */
-export const Default: Story = {
- render: renderSalesByCoupon,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: false,
- },
- decorators: [ withWidgetCanvas ],
-};
-
-/**
- * Comparison period enabled, showing period-over-period coupon revenue.
- */
-export const WithComparison: Story = {
- render: renderSalesByCoupon,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- decorators: [ withWidgetCanvas ],
-};
-
-/**
- * First load: the coupons report 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: () => renderSalesByCouponOnPreset( 'last-90-days' ),
- // Off the shared autodocs page — path-keyed override; see forceStatsMockState.
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'coupons/', 'loading' );
- return () => setReportMockState( 'coupons/', null );
- },
-};
-
-/**
- * The coupons report 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 Error: Story = {
- render: () => renderSalesByCouponOnPreset( 'last-7-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'coupons/', 'error' );
- return () => setReportMockState( 'coupons/', null );
- },
-};
-
-/**
- * Resolved with no coupon rows: the widget shows its empty state (the neutral
- * coupon glyph and "No coupon sales in this period.").
- */
-export const Empty: Story = {
- render: () => renderSalesByCouponOnPreset( 'last-365-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'coupons/', 'empty' );
- return () => setReportMockState( 'coupons/', null );
- },
-};
-
-/**
- * Renders the widget through the shared dashboard harness.
- */
-export const WidgetDashboardWithWidget: DashboardStory = {
- render: args => ,
- args: {
- ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- argTypes: {
- ...widgetDashboardWithWidgetArgTypes,
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
-};
diff --git a/projects/packages/premium-analytics/widgets/sales-by-coupon/widget.ts b/projects/packages/premium-analytics/widgets/sales-by-coupon/widget.ts
deleted file mode 100644
index a73bc624a8d9..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-coupon/widget.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * No user-configurable attributes; report params still reach the widget through
- * WidgetRoot (dashboard date range, or `attributes.reportParams` from a host).
- */
-export type SalesByCouponAttributes = Record< never, never >;
-
-/**
- * Ported from `woocommerce-analytics/sales-by-coupon` in
- * woocommerce/woocommerce-analytics (next-woocommerce-analytics).
- *
- * Report params intentionally come from the analytics dashboard's global
- * date-range state for now. Adding widget-level overrides needs a host-level
- * control registry so analytics dashboards can hide the field while other
- * dashboards can opt in.
- */
-export default {};
diff --git a/projects/packages/premium-analytics/widgets/sales-by-device/render.tsx b/projects/packages/premium-analytics/widgets/sales-by-device/render.tsx
deleted file mode 100644
index 50f5aafeccc5..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-device/render.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * External dependencies
- */
-import {
- SalesByDeviceWidget,
- WidgetRoot,
- type ReportParamsFieldAttributes,
-} from '@jetpack-premium-analytics/widgets-toolkit';
-/**
- * Internal dependencies
- */
-import type { SalesByDeviceAttributes } from './widget';
-import type { WidgetRenderProps } from '@wordpress/widget-primitives';
-import type { ComponentProps } from 'react';
-
-// Report params are usually URL-driven (WidgetRoot's fallback), but callers may
-// also pass them via `attributes`. Compose the render-only shape to cover both.
-type SalesByDeviceRenderAttributes = SalesByDeviceAttributes &
- Partial< ReportParamsFieldAttributes >;
-
-type SalesByDeviceWidgetProps = WidgetRenderProps< SalesByDeviceRenderAttributes > & {
- setError?: ComponentProps< typeof WidgetRoot >[ 'setError' ];
-};
-
-export default function SalesByDeviceRender( {
- attributes = {},
- setError,
-}: SalesByDeviceWidgetProps ) {
- return (
-
-
-
- );
-}
diff --git a/projects/packages/premium-analytics/widgets/sales-by-device/stories/sales-by-device-widget.stories.tsx b/projects/packages/premium-analytics/widgets/sales-by-device/stories/sales-by-device-widget.stories.tsx
deleted file mode 100644
index 9d4d0e723b17..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-device/stories/sales-by-device-widget.stories.tsx
+++ /dev/null
@@ -1,261 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { SELECTABLE_PRESETS, type SelectablePresetId } from '@jetpack-premium-analytics/datetime';
-import {
- registerReportMocks,
- setReportMockState,
-} from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks';
-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 { withWidgetCanvas } from '../../stories/with-widget-canvas';
-import SalesByDeviceRender 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 SALES_BY_DEVICE_RENDER_MODULE = 'storybook/sales-by-device';
-const DEFAULT_PRESET = 'last-30-days' satisfies SelectablePresetId;
-const PRESET_OPTIONS = SELECTABLE_PRESETS;
-
-type SalesByDeviceWidgetProps = ComponentProps< typeof SalesByDeviceRender >;
-
-interface SalesByDeviceStoryControls {
- withComparison: boolean;
- preset: SelectablePresetId;
-}
-
-type SalesByDeviceStoryProps = SalesByDeviceWidgetProps & SalesByDeviceStoryControls;
-
-interface SalesByDeviceDashboardStoryProps
- extends WidgetDashboardWithWidgetControls, SalesByDeviceStoryControls {}
-
-function getSalesByDeviceAttributes(
- withComparison = false,
- preset: SelectablePresetId = DEFAULT_PRESET
-): SalesByDeviceWidgetProps[ 'attributes' ] {
- return {
- reportParams: getDefaultQueryParams( withComparison, preset ),
- };
-}
-
-function getDefaultQueryParamsSource( {
- withComparison,
- preset,
-}: Partial< SalesByDeviceStoryControls > ) {
- const hasComparison = Boolean( withComparison );
- const storyPreset = preset ?? DEFAULT_PRESET;
-
- if ( ! hasComparison && storyPreset === DEFAULT_PRESET ) {
- return 'getDefaultQueryParams()';
- }
-
- if ( hasComparison && storyPreset === DEFAULT_PRESET ) {
- return 'getDefaultQueryParams( true )';
- }
-
- return `getDefaultQueryParams( ${ hasComparison ? 'true' : 'false' }, '${ storyPreset }' )`;
-}
-
-function getSalesByDeviceSource( args: Partial< SalesByDeviceStoryControls > ) {
- return `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-
-`;
-}
-
-function renderSalesByDevice( { withComparison, preset }: SalesByDeviceStoryControls ) {
- return (
-
- );
-}
-
-// Distinct preset → own query-cache entry; see forceStatsMockState.
-function renderSalesByDeviceOnPreset( preset: SelectablePresetId ) {
- return ;
-}
-
-function SalesByDeviceDashboardStory( {
- withComparison,
- preset,
- ...dashboardStoryArgs
-}: SalesByDeviceDashboardStoryProps ) {
- return (
- > }
- attributes={ getSalesByDeviceAttributes( withComparison, preset ) }
- />
- );
-}
-
-const meta = {
- title: 'Packages/Premium Analytics/Widgets/SalesByDevice',
- component: SalesByDeviceRender,
- tags: [ 'autodocs' ],
- argTypes: {
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- description: {
- component:
- 'The "Sales by device" widget. Fetches the order-attribution report and displays the sales breakdown by device type for the selected period.',
- },
- },
- },
-} satisfies Meta< SalesByDeviceStoryProps >;
-
-export default meta;
-
-type Story = StoryObj< SalesByDeviceStoryControls >;
-type DashboardStory = StoryObj< SalesByDeviceDashboardStoryProps >;
-
-/**
- * Default state for the current report period.
- */
-export const Default: Story = {
- render: renderSalesByDevice,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: false,
- },
- decorators: [ withWidgetCanvas ],
- parameters: {
- docs: {
- source: {
- transform: (
- _source: string,
- storyContext: { args: Partial< SalesByDeviceStoryControls > }
- ) => getSalesByDeviceSource( storyContext.args ),
- },
- },
- },
-};
-
-/**
- * Comparison period enabled, showing period-over-period sales breakdown.
- */
-export const WithComparison: Story = {
- render: renderSalesByDevice,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- decorators: [ withWidgetCanvas ],
- parameters: {
- docs: {
- source: {
- transform: (
- _source: string,
- storyContext: { args: Partial< SalesByDeviceStoryControls > }
- ) => getSalesByDeviceSource( storyContext.args ),
- },
- },
- },
-};
-
-/**
- * First load: the order-attribution report 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: () => renderSalesByDeviceOnPreset( 'last-90-days' ),
- // Off the shared autodocs page — path-keyed override; see forceStatsMockState.
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'order-attribution/device/summary', 'loading' );
- return () => setReportMockState( 'order-attribution/device/summary', null );
- },
-};
-
-/**
- * The order-attribution report 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 Error: Story = {
- render: () => renderSalesByDeviceOnPreset( 'last-7-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'order-attribution/device/summary', 'error' );
- return () => setReportMockState( 'order-attribution/device/summary', null );
- },
-};
-
-/**
- * Resolved with no order-attribution rows: the widget shows its empty state (the
- * neutral device glyph and "No sales data in this period.").
- */
-export const Empty: Story = {
- render: () => renderSalesByDeviceOnPreset( 'last-365-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'order-attribution/device/summary', 'empty' );
- return () => setReportMockState( 'order-attribution/device/summary', null );
- },
-};
-
-/**
- * Renders the widget through the shared dashboard harness.
- */
-export const WidgetDashboardWithWidget: DashboardStory = {
- render: args => ,
- args: {
- ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- argTypes: {
- ...widgetDashboardWithWidgetArgTypes,
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- source: {
- code: `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-
-`,
- },
- },
- },
-};
diff --git a/projects/packages/premium-analytics/widgets/sales-by-device/widget.ts b/projects/packages/premium-analytics/widgets/sales-by-device/widget.ts
deleted file mode 100644
index d9ffa974b5a7..000000000000
--- a/projects/packages/premium-analytics/widgets/sales-by-device/widget.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * No user-configurable attributes; report params still reach the widget through
- * WidgetRoot (dashboard date range, or `attributes.reportParams` from a host).
- */
-export type SalesByDeviceAttributes = Record< never, never >;
-
-/**
- * Ported from `woocommerce-analytics/sales-by-device` in
- * woocommerce/woocommerce-analytics (next-woocommerce-analytics).
- *
- * Report params intentionally come from the analytics dashboard's global
- * date-range state for now. Adding widget-level overrides needs a host-level
- * control registry so analytics dashboards can hide the field while other
- * dashboards can opt in.
- */
-export default {};
diff --git a/projects/packages/premium-analytics/widgets/total-returns/render.tsx b/projects/packages/premium-analytics/widgets/total-returns/render.tsx
deleted file mode 100644
index 65d8249c8662..000000000000
--- a/projects/packages/premium-analytics/widgets/total-returns/render.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * External dependencies
- */
-import {
- TotalReturnsWidget,
- WidgetRoot,
- type ReportParamsFieldAttributes,
-} from '@jetpack-premium-analytics/widgets-toolkit';
-/**
- * Internal dependencies
- */
-import type { TotalReturnsAttributes } from './widget';
-import type { WidgetRenderProps } from '@wordpress/widget-primitives';
-import type { ComponentProps } from 'react';
-
-// Report params are usually URL-driven (WidgetRoot's fallback), but callers may
-// also pass them via `attributes`. Compose the render-only shape to cover both.
-type TotalReturnsRenderAttributes = TotalReturnsAttributes & Partial< ReportParamsFieldAttributes >;
-
-type TotalReturnsWidgetProps = WidgetRenderProps< TotalReturnsRenderAttributes > & {
- setError?: ComponentProps< typeof WidgetRoot >[ 'setError' ];
-};
-
-/**
- * Refunds and net sales for the selected period.
- */
-export default function TotalReturnsRender( {
- attributes = {},
- setError,
-}: TotalReturnsWidgetProps ) {
- return (
-
-
-
- );
-}
diff --git a/projects/packages/premium-analytics/widgets/total-returns/stories/total-returns-widget.stories.tsx b/projects/packages/premium-analytics/widgets/total-returns/stories/total-returns-widget.stories.tsx
deleted file mode 100644
index ecacde705070..000000000000
--- a/projects/packages/premium-analytics/widgets/total-returns/stories/total-returns-widget.stories.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-import { getDefaultQueryParams } from '@jetpack-premium-analytics/data';
-import { SELECTABLE_PRESETS, type SelectablePresetId } from '@jetpack-premium-analytics/datetime';
-import {
- registerReportMocks,
- setReportMockState,
-} from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks';
-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 { withWidgetCanvas } from '../../stories/with-widget-canvas';
-import TotalReturnsRender 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 TOTAL_RETURNS_RENDER_MODULE = 'storybook/total-returns';
-const DEFAULT_PRESET = 'last-30-days' satisfies SelectablePresetId;
-const PRESET_OPTIONS = SELECTABLE_PRESETS;
-
-type TotalReturnsWidgetProps = ComponentProps< typeof TotalReturnsRender >;
-const noopSetError: TotalReturnsWidgetProps[ 'setError' ] = () => undefined;
-
-interface TotalReturnsStoryControls {
- withComparison: boolean;
- preset: SelectablePresetId;
-}
-
-type TotalReturnsStoryProps = TotalReturnsWidgetProps & TotalReturnsStoryControls;
-
-interface TotalReturnsDashboardStoryProps
- extends WidgetDashboardWithWidgetControls, TotalReturnsStoryControls {}
-
-function getTotalReturnsAttributes(
- withComparison = false,
- preset: SelectablePresetId = DEFAULT_PRESET
-): TotalReturnsWidgetProps[ 'attributes' ] {
- return {
- reportParams: getDefaultQueryParams( withComparison, preset ),
- };
-}
-
-function renderTotalReturns( { withComparison, preset }: TotalReturnsStoryControls ) {
- return (
-
- );
-}
-
-// Distinct preset → own query-cache entry; see forceStatsMockState.
-function renderTotalReturnsOnPreset( preset: SelectablePresetId ) {
- return (
-
- );
-}
-
-function TotalReturnsDashboardStory( {
- withComparison,
- preset,
- ...dashboardStoryArgs
-}: TotalReturnsDashboardStoryProps ) {
- return (
- > }
- attributes={ getTotalReturnsAttributes( withComparison, preset ) }
- />
- );
-}
-
-const meta = {
- title: 'Packages/Premium Analytics/Widgets/TotalReturns',
- component: TotalReturnsRender,
- tags: [ 'autodocs' ],
- argTypes: {
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
- parameters: {
- docs: {
- description: {
- component: 'Dashboard widget that displays refunds and net sales for the selected period.',
- },
- },
- },
-} satisfies Meta< TotalReturnsStoryProps >;
-
-export default meta;
-
-type Story = StoryObj< TotalReturnsStoryControls >;
-type DashboardStory = StoryObj< TotalReturnsDashboardStoryProps >;
-
-/**
- * Default state for the current report period.
- */
-export const Default: Story = {
- render: renderTotalReturns,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: false,
- },
- decorators: [ withWidgetCanvas ],
-};
-
-/**
- * Comparison period enabled, showing period-over-period change data.
- */
-export const WithComparison: Story = {
- render: renderTotalReturns,
- args: {
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- decorators: [ withWidgetCanvas ],
-};
-
-/**
- * First load: the orders report 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: () => renderTotalReturnsOnPreset( 'last-90-days' ),
- // Off the shared autodocs page — path-keyed override; see forceStatsMockState.
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'orders/by-date', 'loading' );
- return () => setReportMockState( 'orders/by-date', null );
- },
-};
-
-/**
- * The orders report 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 Error: Story = {
- render: () => renderTotalReturnsOnPreset( 'last-7-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'orders/by-date', 'error' );
- return () => setReportMockState( 'orders/by-date', null );
- },
-};
-
-/**
- * Resolved with no refunds in the period: the widget shows its empty state (the
- * neutral payment-return glyph and "No returns in this period.").
- */
-export const Empty: Story = {
- render: () => renderTotalReturnsOnPreset( 'last-365-days' ),
- tags: [ '!autodocs' ],
- decorators: [ withWidgetCanvas ],
- beforeEach: () => {
- setReportMockState( 'orders/by-date', 'empty' );
- return () => setReportMockState( 'orders/by-date', null );
- },
-};
-
-/**
- * Renders the widget through the shared dashboard harness.
- */
-export const WidgetDashboardWithWidget: DashboardStory = {
- render: args => ,
- args: {
- ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
- preset: DEFAULT_PRESET,
- withComparison: true,
- },
- argTypes: {
- ...widgetDashboardWithWidgetArgTypes,
- preset: {
- control: 'select',
- options: PRESET_OPTIONS,
- description: 'Date-range preset used to generate the widget report params.',
- },
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
- },
-};
diff --git a/projects/packages/premium-analytics/widgets/total-returns/widget.ts b/projects/packages/premium-analytics/widgets/total-returns/widget.ts
deleted file mode 100644
index cb5e35dbb4e2..000000000000
--- a/projects/packages/premium-analytics/widgets/total-returns/widget.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * The widget has no user-configurable attributes. Report params still reach it
- * through WidgetRoot: the dashboard date range, or `attributes.reportParams`
- * when a host injects them (e.g. Storybook and dashboard previews).
- */
-export type TotalReturnsAttributes = Record< never, never >;
-
-/**
- * Widget type definition.
- *
- * Ported from `woocommerce-analytics/total-returns` in
- * woocommerce/woocommerce-analytics (next-woocommerce-analytics).
- *
- * Report params intentionally come from the analytics dashboard's global
- * date-range state for now. Adding widget-level overrides needs a host-level
- * control registry so analytics dashboards can hide the field while other
- * dashboards can opt in.
- */
-export default {};
diff --git a/projects/packages/woocommerce-stats/README.md b/projects/packages/woocommerce-stats/README.md
index 360500ca47de..49fe462ae590 100644
--- a/projects/packages/woocommerce-stats/README.md
+++ b/projects/packages/woocommerce-stats/README.md
@@ -8,7 +8,7 @@ It hands the dashboard the store currency too: `premium_analytics.store_currency
The widgets read those reports with the client in `src/reports/`: one hook per report, built on `useReport` from the dashboard SDK, so every query runs in the dashboard's query client.
-The widget types live in `widgets/`. wp-build builds them, and the package registers them from the build manifest when the dashboard's widget contract is 1.9 or newer. They draw with the kinds the dashboard SDK exports, so the charts are the dashboard's own. The time series family is here: net, total and gross sales, orders, average order value, average items per order, bookings and visitors over time. So are the breakdowns: new vs returning customer, payment status, orders fulfillment, coupon usage over time, bookings by status and sessions by device; the leaderboards: top performing products and bookings, and sales by UTM source, channel and campaign; Store performance, every store metric as a tab over one chart; and the conversion funnel, for every product and for bookings.
+The widget types live in `widgets/`. wp-build builds them, and the package registers them from the build manifest when the dashboard's widget contract is 1.10 or newer. They draw with the kinds the dashboard SDK exports, so the charts are the dashboard's own. The time series family is here: net, total and gross sales, orders, average order value, average items per order, bookings and visitors over time. So are the breakdowns: new vs returning customer, payment status, orders fulfillment, coupon usage over time, bookings by status and sessions by device; the leaderboards: top performing products and bookings, and sales by UTM source, channel and campaign; Store performance, every store metric as a tab over one chart; the conversion funnel, for every product and for bookings; and the columns: revenue by customer type, sales by device, total returns and sales by coupon, with the bookings variants of the first two.
The package decides nothing about who gets the section. The plugin that bundles it, Jetpack or the standalone Premium Analytics plugin, calls `Analytics_Dashboard::init()`. Availability stays on the section itself: WooCommerce active, the store-reports capability, and the store-section flag.
diff --git a/projects/packages/woocommerce-stats/changelog/update-woocommerce-stats-columns-family b/projects/packages/woocommerce-stats/changelog/update-woocommerce-stats-columns-family
new file mode 100644
index 000000000000..70a02bad9b50
--- /dev/null
+++ b/projects/packages/woocommerce-stats/changelog/update-woocommerce-stats-columns-family
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add the columns widgets: revenue by customer type, sales by device, total returns and sales by coupon, with the bookings variants of the first two; the store ones are placed on the WooCommerce tab by default.
diff --git a/projects/packages/woocommerce-stats/src/class-analytics-dashboard.php b/projects/packages/woocommerce-stats/src/class-analytics-dashboard.php
index 7e13d543217d..de37f60ccb7f 100644
--- a/projects/packages/woocommerce-stats/src/class-analytics-dashboard.php
+++ b/projects/packages/woocommerce-stats/src/class-analytics-dashboard.php
@@ -47,11 +47,21 @@ class Analytics_Dashboard {
const COUPON_USAGE_OVER_TIME_TYPE = 'woocommerce-analytics/coupon-usage-over-time';
const BOOKINGS_BY_STATUS_TYPE = 'woocommerce-analytics/bookings-by-status';
const SESSIONS_BY_DEVICE_TYPE = 'woocommerce-analytics/sessions-by-device';
- const TOP_PERFORMING_PRODUCTS_TYPE = 'woocommerce-analytics/top-performing-products';
- const TOP_PERFORMING_BOOKINGS_TYPE = 'woocommerce-analytics/top-performing-bookings';
- const SALES_BY_UTM_SOURCE_TYPE = 'woocommerce-analytics/sales-by-utm-source';
- const SALES_BY_UTM_CHANNEL_TYPE = 'woocommerce-analytics/sales-by-utm-channel';
- const SALES_BY_UTM_CAMPAIGN_TYPE = 'woocommerce-analytics/sales-by-utm-campaign';
+
+ /**
+ * The columns family, with the bookings variants of the first two.
+ */
+ const REVENUE_BY_CUSTOMER_TYPE_TYPE = 'woocommerce-analytics/revenue-by-customer-type';
+ const BOOKINGS_REVENUE_BY_CUSTOMER_TYPE_TYPE = 'woocommerce-analytics/bookings-revenue-by-customer-type';
+ const SALES_BY_DEVICE_TYPE = 'woocommerce-analytics/sales-by-device';
+ const BOOKINGS_BY_DEVICE_TYPE = 'woocommerce-analytics/bookings-by-device';
+ const TOTAL_RETURNS_TYPE = 'woocommerce-analytics/total-returns';
+ const SALES_BY_COUPON_TYPE = 'woocommerce-analytics/sales-by-coupon';
+ const TOP_PERFORMING_PRODUCTS_TYPE = 'woocommerce-analytics/top-performing-products';
+ const TOP_PERFORMING_BOOKINGS_TYPE = 'woocommerce-analytics/top-performing-bookings';
+ const SALES_BY_UTM_SOURCE_TYPE = 'woocommerce-analytics/sales-by-utm-source';
+ const SALES_BY_UTM_CHANNEL_TYPE = 'woocommerce-analytics/sales-by-utm-channel';
+ const SALES_BY_UTM_CAMPAIGN_TYPE = 'woocommerce-analytics/sales-by-utm-campaign';
/**
* The conversion funnel, for every product and for bookings.
@@ -71,10 +81,10 @@ class Analytics_Dashboard {
const TEXTDOMAIN = 'jetpack-woocommerce-stats-pkg';
/**
- * Lowest widget contract the build works against: the conversion rate widgets draw `Funnel`,
- * which the dashboard ships from 1.9.0.
+ * Lowest widget contract the build works against: the columns family draws `Columns`, which the
+ * dashboard ships from 1.10.0.
*/
- const MIN_WIDGET_API_VERSION = '1.9.0';
+ const MIN_WIDGET_API_VERSION = '1.10.0';
/**
* Widget categories the package builds but holds back from the tab for now: the bookings
@@ -162,6 +172,10 @@ public static function get_default_layout() {
get_dashboard_default_widget_instance( 'default-sales-by-utm-source-widget-instance', self::SALES_BY_UTM_SOURCE_TYPE, 15, 1, 2 ),
get_dashboard_default_widget_instance( 'default-sales-by-utm-channel-widget-instance', self::SALES_BY_UTM_CHANNEL_TYPE, 16, 1, 2 ),
get_dashboard_default_widget_instance( 'default-sales-by-utm-campaign-widget-instance', self::SALES_BY_UTM_CAMPAIGN_TYPE, 17, 1, 2 ),
+ get_dashboard_default_widget_instance( 'default-revenue-by-customer-type-widget-instance', self::REVENUE_BY_CUSTOMER_TYPE_TYPE, 18, 1, 2 ),
+ get_dashboard_default_widget_instance( 'default-sales-by-device-widget-instance', self::SALES_BY_DEVICE_TYPE, 19, 1, 2 ),
+ get_dashboard_default_widget_instance( 'default-total-returns-widget-instance', self::TOTAL_RETURNS_TYPE, 20, 1, 2 ),
+ get_dashboard_default_widget_instance( 'default-sales-by-coupon-widget-instance', self::SALES_BY_COUPON_TYPE, 21, 1, 2 ),
);
}
diff --git a/projects/packages/woocommerce-stats/src/components/revenue-by-customer-type.tsx b/projects/packages/woocommerce-stats/src/components/revenue-by-customer-type.tsx
new file mode 100644
index 000000000000..8f2cc578d837
--- /dev/null
+++ b/projects/packages/woocommerce-stats/src/components/revenue-by-customer-type.tsx
@@ -0,0 +1,90 @@
+/**
+ * External dependencies
+ */
+import {
+ Columns,
+ describeError,
+ useWidgetRootContext,
+ type ColumnInput,
+ type DataFormat,
+} from '@automattic/jetpack-premium-analytics-sdk';
+import { __ } from '@wordpress/i18n';
+import { useMemo } from 'react';
+/**
+ * Internal dependencies
+ */
+import { useReportCustomers, type FilterCondition } from '../reports';
+
+const CURRENCY_FORMAT: DataFormat = { type: 'currency', options: { useMultipliers: true } };
+
+type RevenueByCustomerTypeProps = {
+ /** The product types the revenue counts; every type when omitted. */
+ filters?: FilterCondition[];
+};
+
+/**
+ * Revenue from returning customers against new ones, one column each per period, read under
+ * the widget root for its report params.
+ *
+ * @param {RevenueByCustomerTypeProps} props - The component props.
+ * @return {JSX.Element} The columns, or the loading, error or empty state of their report.
+ */
+export function RevenueByCustomerType( { filters }: RevenueByCustomerTypeProps ) {
+ const { reportParams } = useWidgetRootContext();
+ const params = useMemo(
+ () => ( filters ? { ...reportParams, filters } : reportParams ),
+ [ reportParams, filters ]
+ );
+ const {
+ primary,
+ comparison,
+ hasComparison,
+ isLoading,
+ isFetching,
+ hasData,
+ isError,
+ error,
+ refetch,
+ } = useReportCustomers( params );
+ const summary = primary.data?.summary;
+ const previous = comparison.data?.summary;
+
+ // The categories are fixed, so the skeleton knows its two columns before the data arrives.
+ const columns = useMemo< ColumnInput[] >(
+ () => [
+ {
+ label: __( 'Returning', 'jetpack-woocommerce-stats-pkg' ),
+ value: summary?.returning_customer_sales ?? 0,
+ previousValue: previous?.returning_customer_sales,
+ },
+ {
+ label: __( 'New', 'jetpack-woocommerce-stats-pkg' ),
+ value: summary?.new_customer_sales ?? 0,
+ previousValue: previous?.new_customer_sales,
+ },
+ ],
+ [ summary, previous ]
+ );
+
+ return (
+
+ );
+}
diff --git a/projects/packages/woocommerce-stats/src/components/sales-by-device.tsx b/projects/packages/woocommerce-stats/src/components/sales-by-device.tsx
new file mode 100644
index 000000000000..75c0581c4838
--- /dev/null
+++ b/projects/packages/woocommerce-stats/src/components/sales-by-device.tsx
@@ -0,0 +1,72 @@
+/**
+ * External dependencies
+ */
+import {
+ Columns,
+ describeError,
+ useWidgetRootContext,
+ type ColumnInput,
+ type DataFormat,
+} from '@automattic/jetpack-premium-analytics-sdk';
+import { __ } from '@wordpress/i18n';
+import { useMemo } from 'react';
+/**
+ * Internal dependencies
+ */
+import { useReportOrderAttribution, type FilterCondition } from '../reports';
+
+const CURRENCY_FORMAT: DataFormat = { type: 'currency', options: { useMultipliers: true } };
+
+type SalesByDeviceProps = {
+ /** The product types the sales count; every type when omitted. */
+ filters?: FilterCondition[];
+};
+
+/**
+ * Sales by device type, one column each per period, read under the widget root for its report
+ * params. The attribution report carries both periods in one response.
+ *
+ * @param {SalesByDeviceProps} props - The component props.
+ * @return {JSX.Element} The columns, or the loading, error or empty state of their report.
+ */
+export function SalesByDevice( { filters }: SalesByDeviceProps ) {
+ const { reportParams } = useWidgetRootContext();
+ const params = useMemo(
+ () => ( { ...reportParams, view: 'device' as const, ...( filters && { filters } ) } ),
+ [ reportParams, filters ]
+ );
+ const { primary, hasComparison, isLoading, isFetching, hasData, isError, error, refetch } =
+ useReportOrderAttribution( params );
+
+ const columns = useMemo< ColumnInput[] >(
+ () =>
+ ( primary.data?.data ?? [] ).map( item => ( {
+ label: item.item || __( 'Unassigned', 'jetpack-woocommerce-stats-pkg' ),
+ value: item.current_period?.value ?? 0,
+ previousValue: item.previous_period?.value,
+ } ) ),
+ [ primary.data ]
+ );
+
+ return (
+
+ );
+}
diff --git a/projects/packages/woocommerce-stats/tests/php/Analytics_Dashboard_Test.php b/projects/packages/woocommerce-stats/tests/php/Analytics_Dashboard_Test.php
index c8f80cba533f..23763170c5f3 100644
--- a/projects/packages/woocommerce-stats/tests/php/Analytics_Dashboard_Test.php
+++ b/projects/packages/woocommerce-stats/tests/php/Analytics_Dashboard_Test.php
@@ -123,6 +123,10 @@ public function test_registers_the_woocommerce_section() {
Analytics_Dashboard::SALES_BY_UTM_SOURCE_TYPE,
Analytics_Dashboard::SALES_BY_UTM_CHANNEL_TYPE,
Analytics_Dashboard::SALES_BY_UTM_CAMPAIGN_TYPE,
+ Analytics_Dashboard::REVENUE_BY_CUSTOMER_TYPE_TYPE,
+ Analytics_Dashboard::SALES_BY_DEVICE_TYPE,
+ Analytics_Dashboard::TOTAL_RETURNS_TYPE,
+ Analytics_Dashboard::SALES_BY_COUPON_TYPE,
),
array_column( $section->get_default_layout(), 'type' )
);
diff --git a/projects/packages/woocommerce-stats/tests/php/Analytics_Dashboard_Widget_Contract_Test.php b/projects/packages/woocommerce-stats/tests/php/Analytics_Dashboard_Widget_Contract_Test.php
index a44ef85a9fef..23a40d031bce 100644
--- a/projects/packages/woocommerce-stats/tests/php/Analytics_Dashboard_Widget_Contract_Test.php
+++ b/projects/packages/woocommerce-stats/tests/php/Analytics_Dashboard_Widget_Contract_Test.php
@@ -15,7 +15,7 @@
use WorDBless\BaseTestCase;
/**
- * The widgets draw `Funnel`, which the dashboard ships from 1.9.0, so they register on that major from there on.
+ * The widgets draw `Columns`, which the dashboard ships from 1.10.0, so they register on that major from there on.
*
* Each case runs in its own process: the contract version is a constant.
*
@@ -59,6 +59,7 @@ public static function unsupported_contract_versions() {
'the contract before the bars variant' => array( '1.6.0' ),
'the contract before the half-ring variant' => array( '1.7.0' ),
'the contract before Funnel' => array( '1.8.0' ),
+ 'the contract before Columns' => array( '1.9.0' ),
'the next major' => array( '2.0.0' ),
);
}
diff --git a/projects/packages/premium-analytics/widgets/bookings-by-device/package.json b/projects/packages/woocommerce-stats/widgets/bookings-by-device/package.json
similarity index 51%
rename from projects/packages/premium-analytics/widgets/bookings-by-device/package.json
rename to projects/packages/woocommerce-stats/widgets/bookings-by-device/package.json
index a6f35ee14c3e..c32c931797df 100644
--- a/projects/packages/premium-analytics/widgets/bookings-by-device/package.json
+++ b/projects/packages/woocommerce-stats/widgets/bookings-by-device/package.json
@@ -1,12 +1,11 @@
{
- "name": "@automattic/jetpack-premium-analytics-widget-bookings-by-device",
+ "name": "@automattic/jetpack-woocommerce-stats-widget-bookings-by-device",
"version": "0.1.0-alpha",
"private": true,
"type": "module",
"dependencies": {
- "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit",
+ "@automattic/jetpack-premium-analytics-sdk": "workspace:*",
"@wordpress/i18n": "^6.9.0",
- "@wordpress/icons": "^16.0.0",
"@wordpress/widget-primitives": "0.7.0",
"react": "18.3.1"
}
diff --git a/projects/packages/woocommerce-stats/widgets/bookings-by-device/render.tsx b/projects/packages/woocommerce-stats/widgets/bookings-by-device/render.tsx
new file mode 100644
index 000000000000..a5b1ea60e7c9
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/bookings-by-device/render.tsx
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import {
+ WidgetRoot,
+ type ReportParamsFieldAttributes,
+} from '@automattic/jetpack-premium-analytics-sdk';
+/**
+ * Internal dependencies
+ */
+import { SalesByDevice } from '../../src/components/sales-by-device';
+import { BOOKINGS_FILTER } from '../../src/reports';
+import type { BookingsByDeviceAttributes } from './widget';
+import type { WidgetRenderProps } from '@wordpress/widget-primitives';
+
+type BookingsByDeviceRenderAttributes = BookingsByDeviceAttributes &
+ Partial< ReportParamsFieldAttributes >;
+
+const FILTERS = [ BOOKINGS_FILTER ];
+
+/**
+ * The Bookings by device widget.
+ *
+ * @param {WidgetRenderProps< BookingsByDeviceRenderAttributes >} props - The props the host passes.
+ * @return {JSX.Element} The widget.
+ */
+export default function BookingsByDeviceRender( {
+ attributes = {},
+}: WidgetRenderProps< BookingsByDeviceRenderAttributes > ) {
+ return (
+
+
+
+ );
+}
diff --git a/projects/packages/premium-analytics/widgets/bookings-by-device/widget.json b/projects/packages/woocommerce-stats/widgets/bookings-by-device/widget.json
similarity index 69%
rename from projects/packages/premium-analytics/widgets/bookings-by-device/widget.json
rename to projects/packages/woocommerce-stats/widgets/bookings-by-device/widget.json
index e3ce624afdec..4ae0d797dfb9 100644
--- a/projects/packages/premium-analytics/widgets/bookings-by-device/widget.json
+++ b/projects/packages/woocommerce-stats/widgets/bookings-by-device/widget.json
@@ -1,5 +1,5 @@
{
- "name": "jpa/bookings-by-device",
+ "name": "woocommerce-analytics/bookings-by-device",
"icon": "jpa/chart-bar",
"title": "Bookings by device",
"description": "See which devices your customers are using to make bookings in your store.",
@@ -7,5 +7,6 @@
"content": "See which devices your customers are using to make bookings in your store."
},
"category": "bookings",
- "presentation": "framed"
+ "presentation": "framed",
+ "textdomain": "jetpack-woocommerce-stats-pkg"
}
diff --git a/projects/packages/woocommerce-stats/widgets/bookings-by-device/widget.ts b/projects/packages/woocommerce-stats/widgets/bookings-by-device/widget.ts
new file mode 100644
index 000000000000..d2e335780039
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/bookings-by-device/widget.ts
@@ -0,0 +1,7 @@
+/**
+ * No configurable attributes; the empty record allows host-provided fields, such as the report
+ * params.
+ */
+export type BookingsByDeviceAttributes = Record< never, never >;
+
+export default {};
diff --git a/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/package.json b/projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/package.json
similarity index 53%
rename from projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/package.json
rename to projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/package.json
index 7cba17b7ba2e..b68aac728151 100644
--- a/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/package.json
+++ b/projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/package.json
@@ -1,12 +1,11 @@
{
- "name": "@automattic/jetpack-premium-analytics-widget-bookings-revenue-by-customer-type",
+ "name": "@automattic/jetpack-woocommerce-stats-widget-bookings-revenue-by-customer-type",
"version": "0.1.0-alpha",
"private": true,
"type": "module",
"dependencies": {
- "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit",
+ "@automattic/jetpack-premium-analytics-sdk": "workspace:*",
"@wordpress/i18n": "^6.9.0",
- "@wordpress/icons": "^16.0.0",
"@wordpress/widget-primitives": "0.7.0",
"react": "18.3.1"
}
diff --git a/projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/render.tsx b/projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/render.tsx
new file mode 100644
index 000000000000..ac411a000244
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/render.tsx
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import {
+ WidgetRoot,
+ type ReportParamsFieldAttributes,
+} from '@automattic/jetpack-premium-analytics-sdk';
+/**
+ * Internal dependencies
+ */
+import { RevenueByCustomerType } from '../../src/components/revenue-by-customer-type';
+import { BOOKINGS_FILTER } from '../../src/reports';
+import type { BookingsRevenueByCustomerTypeAttributes } from './widget';
+import type { WidgetRenderProps } from '@wordpress/widget-primitives';
+
+type BookingsRevenueByCustomerTypeRenderAttributes = BookingsRevenueByCustomerTypeAttributes &
+ Partial< ReportParamsFieldAttributes >;
+
+const FILTERS = [ BOOKINGS_FILTER ];
+
+/**
+ * The Bookings revenue by customer type widget.
+ *
+ * @param {WidgetRenderProps< BookingsRevenueByCustomerTypeRenderAttributes >} props - The props the host passes.
+ * @return {JSX.Element} The widget.
+ */
+export default function BookingsRevenueByCustomerTypeRender( {
+ attributes = {},
+}: WidgetRenderProps< BookingsRevenueByCustomerTypeRenderAttributes > ) {
+ return (
+
+
+
+ );
+}
diff --git a/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/widget.json b/projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/widget.json
similarity index 71%
rename from projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/widget.json
rename to projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/widget.json
index 9de55314b4a6..832b440cd478 100644
--- a/projects/packages/premium-analytics/widgets/bookings-revenue-by-customer-type/widget.json
+++ b/projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/widget.json
@@ -1,5 +1,5 @@
{
- "name": "jpa/bookings-revenue-by-customer-type",
+ "name": "woocommerce-analytics/bookings-revenue-by-customer-type",
"icon": "jpa/chart-bar",
"title": "Bookings revenue by customer type",
"description": "Revenue breakdown from new customers vs returning customers for bookings over the selected time period.",
@@ -7,5 +7,6 @@
"content": "Revenue breakdown from new customers vs returning customers for bookings over the selected time period."
},
"category": "bookings",
- "presentation": "framed"
+ "presentation": "framed",
+ "textdomain": "jetpack-woocommerce-stats-pkg"
}
diff --git a/projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/widget.ts b/projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/widget.ts
new file mode 100644
index 000000000000..948a1bf31f8a
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/bookings-revenue-by-customer-type/widget.ts
@@ -0,0 +1,7 @@
+/**
+ * No configurable attributes; the empty record allows host-provided fields, such as the report
+ * params.
+ */
+export type BookingsRevenueByCustomerTypeAttributes = Record< never, never >;
+
+export default {};
diff --git a/projects/packages/premium-analytics/widgets/revenue-by-customer-type/package.json b/projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/package.json
similarity index 52%
rename from projects/packages/premium-analytics/widgets/revenue-by-customer-type/package.json
rename to projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/package.json
index 5dc392cc4744..6c98af6a87f4 100644
--- a/projects/packages/premium-analytics/widgets/revenue-by-customer-type/package.json
+++ b/projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/package.json
@@ -1,12 +1,11 @@
{
- "name": "@automattic/jetpack-premium-analytics-widget-revenue-by-customer-type",
+ "name": "@automattic/jetpack-woocommerce-stats-widget-revenue-by-customer-type",
"version": "0.1.0-alpha",
"private": true,
"type": "module",
"dependencies": {
- "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit",
+ "@automattic/jetpack-premium-analytics-sdk": "workspace:*",
"@wordpress/i18n": "^6.9.0",
- "@wordpress/icons": "^16.0.0",
"@wordpress/widget-primitives": "0.7.0",
"react": "18.3.1"
}
diff --git a/projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/render.tsx b/projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/render.tsx
new file mode 100644
index 000000000000..046202f98f0d
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/render.tsx
@@ -0,0 +1,32 @@
+/**
+ * External dependencies
+ */
+import {
+ WidgetRoot,
+ type ReportParamsFieldAttributes,
+} from '@automattic/jetpack-premium-analytics-sdk';
+/**
+ * Internal dependencies
+ */
+import { RevenueByCustomerType } from '../../src/components/revenue-by-customer-type';
+import type { RevenueByCustomerTypeAttributes } from './widget';
+import type { WidgetRenderProps } from '@wordpress/widget-primitives';
+
+type RevenueByCustomerTypeRenderAttributes = RevenueByCustomerTypeAttributes &
+ Partial< ReportParamsFieldAttributes >;
+
+/**
+ * The Revenue by customer type widget.
+ *
+ * @param {WidgetRenderProps< RevenueByCustomerTypeRenderAttributes >} props - The props the host passes.
+ * @return {JSX.Element} The widget.
+ */
+export default function RevenueByCustomerTypeRender( {
+ attributes = {},
+}: WidgetRenderProps< RevenueByCustomerTypeRenderAttributes > ) {
+ return (
+
+
+
+ );
+}
diff --git a/projects/packages/premium-analytics/widgets/revenue-by-customer-type/widget.json b/projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/widget.json
similarity index 71%
rename from projects/packages/premium-analytics/widgets/revenue-by-customer-type/widget.json
rename to projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/widget.json
index 2d8e64b8855f..9052d06a30f6 100644
--- a/projects/packages/premium-analytics/widgets/revenue-by-customer-type/widget.json
+++ b/projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/widget.json
@@ -1,5 +1,5 @@
{
- "name": "jpa/revenue-by-customer-type",
+ "name": "woocommerce-analytics/revenue-by-customer-type",
"icon": "jpa/chart-bar",
"title": "Revenue by customer type",
"description": "Shows the breakdown of order revenue from new and returning customers over the selected time period.",
@@ -7,5 +7,6 @@
"content": "Revenue breakdown from new customers vs returning customers over the selected time period."
},
"category": "store",
- "presentation": "framed"
+ "presentation": "framed",
+ "textdomain": "jetpack-woocommerce-stats-pkg"
}
diff --git a/projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/widget.ts b/projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/widget.ts
new file mode 100644
index 000000000000..3d897c2adbff
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/revenue-by-customer-type/widget.ts
@@ -0,0 +1,7 @@
+/**
+ * No configurable attributes; the empty record allows host-provided fields, such as the report
+ * params.
+ */
+export type RevenueByCustomerTypeAttributes = Record< never, never >;
+
+export default {};
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/__tests__/build-sales-by-coupon-columns.test.ts b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/__tests__/build-sales-by-coupon-columns.test.ts
similarity index 100%
rename from projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/__tests__/build-sales-by-coupon-columns.test.ts
rename to projects/packages/woocommerce-stats/widgets/sales-by-coupon/__tests__/build-sales-by-coupon-columns.test.ts
diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/build-sales-by-coupon-columns.ts b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/build-sales-by-coupon-columns.ts
similarity index 71%
rename from projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/build-sales-by-coupon-columns.ts
rename to projects/packages/woocommerce-stats/widgets/sales-by-coupon/build-sales-by-coupon-columns.ts
index 5791dfe214e9..97d2c253f4f9 100644
--- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/build-sales-by-coupon-columns.ts
+++ b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/build-sales-by-coupon-columns.ts
@@ -2,10 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
-/**
- * Internal dependencies
- */
-import type { ColumnInput } from '../../components/columns';
+import type { ColumnInput } from '@automattic/jetpack-premium-analytics-sdk';
type CouponRow = {
coupon_code: string;
@@ -19,10 +16,10 @@ const sum = ( figures: readonly number[] ) =>
* The top coupons as columns, the rest summed into Other; the comparison figures follow the
* same coupons, so Other compares like for like.
*
- * @param items - The period's coupons, ranked.
- * @param comparisonItems - The comparison period's coupons; undefined while the comparison is off.
- * @param top - How many coupons get a column of their own.
- * @return The columns, or none without coupons.
+ * @param {CouponRow[]|undefined} items - The period's coupons, ranked.
+ * @param {CouponRow[]|undefined} comparisonItems - The comparison period's coupons; undefined while the comparison is off.
+ * @param {number} top - How many coupons get a column of their own.
+ * @return {ColumnInput[]} The columns, or none without coupons.
*/
export function buildSalesByCouponColumns(
items: readonly CouponRow[] | undefined,
@@ -48,7 +45,7 @@ export function buildSalesByCouponColumns(
const rest = items.slice( top );
if ( rest.length ) {
columns.push( {
- label: __( 'Other', 'jetpack-premium-analytics-pkg' ),
+ label: __( 'Other', 'jetpack-woocommerce-stats-pkg' ),
value: sum( rest.map( item => item.total_sales ) ),
previousValue: comparisonItems
? sum( rest.map( item => previousOf( item.coupon_code ) ?? 0 ) )
diff --git a/projects/packages/premium-analytics/widgets/sales-by-coupon/package.json b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/package.json
similarity index 51%
rename from projects/packages/premium-analytics/widgets/sales-by-coupon/package.json
rename to projects/packages/woocommerce-stats/widgets/sales-by-coupon/package.json
index 909fa78d2e07..da09e580e098 100644
--- a/projects/packages/premium-analytics/widgets/sales-by-coupon/package.json
+++ b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/package.json
@@ -1,12 +1,11 @@
{
- "name": "@automattic/jetpack-premium-analytics-widget-sales-by-coupon",
+ "name": "@automattic/jetpack-woocommerce-stats-widget-sales-by-coupon",
"version": "0.1.0-alpha",
"private": true,
"type": "module",
"dependencies": {
- "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit",
+ "@automattic/jetpack-premium-analytics-sdk": "workspace:*",
"@wordpress/i18n": "^6.9.0",
- "@wordpress/icons": "^16.0.0",
"@wordpress/widget-primitives": "0.7.0",
"react": "18.3.1"
}
diff --git a/projects/packages/woocommerce-stats/widgets/sales-by-coupon/render.tsx b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/render.tsx
new file mode 100644
index 000000000000..08ee116fd0c0
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/render.tsx
@@ -0,0 +1,93 @@
+/**
+ * External dependencies
+ */
+import {
+ Columns,
+ describeError,
+ useWidgetRootContext,
+ WidgetRoot,
+ type DataFormat,
+ type ReportParamsFieldAttributes,
+} from '@automattic/jetpack-premium-analytics-sdk';
+import { __ } from '@wordpress/i18n';
+import { useMemo } from 'react';
+/**
+ * Internal dependencies
+ */
+import { useReportCoupons } from '../../src/reports';
+import { buildSalesByCouponColumns } from './build-sales-by-coupon-columns';
+import type { SalesByCouponAttributes } from './widget';
+import type { WidgetRenderProps } from '@wordpress/widget-primitives';
+
+type SalesByCouponRenderAttributes = SalesByCouponAttributes &
+ Partial< ReportParamsFieldAttributes >;
+
+const CURRENCY_FORMAT: DataFormat = { type: 'currency', options: { useMultipliers: true } };
+
+/** Top coupons charted individually; the rest are summed into an "Other" column. */
+const TOP_COUPON_SEGMENTS = 3;
+
+/**
+ * Sales of the top coupons and of the rest as Other, one column each per period, read under the
+ * widget root for its report params.
+ *
+ * @return {JSX.Element} The columns, or the loading, error or empty state of their report.
+ */
+function SalesByCoupon() {
+ const { reportParams } = useWidgetRootContext();
+ const {
+ primary,
+ comparison,
+ hasComparison,
+ isLoading,
+ isFetching,
+ hasData,
+ isError,
+ error,
+ refetch,
+ } = useReportCoupons( reportParams );
+
+ const columns = useMemo(
+ () =>
+ buildSalesByCouponColumns( primary.data?.data, comparison.data?.data, TOP_COUPON_SEGMENTS ),
+ [ primary.data, comparison.data ]
+ );
+
+ return (
+
+ );
+}
+
+/**
+ * The Sales by coupon widget.
+ *
+ * @param {WidgetRenderProps< SalesByCouponRenderAttributes >} props - The props the host passes.
+ * @return {JSX.Element} The widget.
+ */
+export default function SalesByCouponRender( {
+ attributes = {},
+}: WidgetRenderProps< SalesByCouponRenderAttributes > ) {
+ return (
+
+
+
+ );
+}
diff --git a/projects/packages/premium-analytics/widgets/sales-by-coupon/widget.json b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/widget.json
similarity index 69%
rename from projects/packages/premium-analytics/widgets/sales-by-coupon/widget.json
rename to projects/packages/woocommerce-stats/widgets/sales-by-coupon/widget.json
index 112645947568..1098cdac9d74 100644
--- a/projects/packages/premium-analytics/widgets/sales-by-coupon/widget.json
+++ b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/widget.json
@@ -1,5 +1,5 @@
{
- "name": "jpa/sales-by-coupon",
+ "name": "woocommerce-analytics/sales-by-coupon",
"icon": "jpa/chart-bar",
"title": "Sales by coupon",
"description": "Shows the top coupon codes by order revenue over the selected time period.",
@@ -7,5 +7,6 @@
"content": "Revenue from physical product sales using coupons over the selected time period."
},
"category": "store",
- "presentation": "framed"
+ "presentation": "framed",
+ "textdomain": "jetpack-woocommerce-stats-pkg"
}
diff --git a/projects/packages/woocommerce-stats/widgets/sales-by-coupon/widget.ts b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/widget.ts
new file mode 100644
index 000000000000..a32bebdb83fa
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/sales-by-coupon/widget.ts
@@ -0,0 +1,7 @@
+/**
+ * No configurable attributes; the empty record allows host-provided fields, such as the report
+ * params.
+ */
+export type SalesByCouponAttributes = Record< never, never >;
+
+export default {};
diff --git a/projects/packages/premium-analytics/widgets/sales-by-device/package.json b/projects/packages/woocommerce-stats/widgets/sales-by-device/package.json
similarity index 51%
rename from projects/packages/premium-analytics/widgets/sales-by-device/package.json
rename to projects/packages/woocommerce-stats/widgets/sales-by-device/package.json
index 75e5b515f665..4114ce2ba95c 100644
--- a/projects/packages/premium-analytics/widgets/sales-by-device/package.json
+++ b/projects/packages/woocommerce-stats/widgets/sales-by-device/package.json
@@ -1,12 +1,11 @@
{
- "name": "@automattic/jetpack-premium-analytics-widget-sales-by-device",
+ "name": "@automattic/jetpack-woocommerce-stats-widget-sales-by-device",
"version": "0.1.0-alpha",
"private": true,
"type": "module",
"dependencies": {
- "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit",
+ "@automattic/jetpack-premium-analytics-sdk": "workspace:*",
"@wordpress/i18n": "^6.9.0",
- "@wordpress/icons": "^16.0.0",
"@wordpress/widget-primitives": "0.7.0",
"react": "18.3.1"
}
diff --git a/projects/packages/woocommerce-stats/widgets/sales-by-device/render.tsx b/projects/packages/woocommerce-stats/widgets/sales-by-device/render.tsx
new file mode 100644
index 000000000000..06ce06143b90
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/sales-by-device/render.tsx
@@ -0,0 +1,32 @@
+/**
+ * External dependencies
+ */
+import {
+ WidgetRoot,
+ type ReportParamsFieldAttributes,
+} from '@automattic/jetpack-premium-analytics-sdk';
+/**
+ * Internal dependencies
+ */
+import { SalesByDevice } from '../../src/components/sales-by-device';
+import type { SalesByDeviceAttributes } from './widget';
+import type { WidgetRenderProps } from '@wordpress/widget-primitives';
+
+type SalesByDeviceRenderAttributes = SalesByDeviceAttributes &
+ Partial< ReportParamsFieldAttributes >;
+
+/**
+ * The Sales by device widget.
+ *
+ * @param {WidgetRenderProps< SalesByDeviceRenderAttributes >} props - The props the host passes.
+ * @return {JSX.Element} The widget.
+ */
+export default function SalesByDeviceRender( {
+ attributes = {},
+}: WidgetRenderProps< SalesByDeviceRenderAttributes > ) {
+ return (
+
+
+
+ );
+}
diff --git a/projects/packages/premium-analytics/widgets/sales-by-device/widget.json b/projects/packages/woocommerce-stats/widgets/sales-by-device/widget.json
similarity index 69%
rename from projects/packages/premium-analytics/widgets/sales-by-device/widget.json
rename to projects/packages/woocommerce-stats/widgets/sales-by-device/widget.json
index cc8997f7466a..f7fb8cd0d325 100644
--- a/projects/packages/premium-analytics/widgets/sales-by-device/widget.json
+++ b/projects/packages/woocommerce-stats/widgets/sales-by-device/widget.json
@@ -1,5 +1,5 @@
{
- "name": "jpa/sales-by-device",
+ "name": "woocommerce-analytics/sales-by-device",
"icon": "jpa/chart-bar",
"title": "Sales by device",
"description": "Shows the sales breakdown by device type over the selected time period.",
@@ -7,5 +7,6 @@
"content": "See which devices your customers are using to make purchases in your store."
},
"category": "store",
- "presentation": "framed"
+ "presentation": "framed",
+ "textdomain": "jetpack-woocommerce-stats-pkg"
}
diff --git a/projects/packages/woocommerce-stats/widgets/sales-by-device/widget.ts b/projects/packages/woocommerce-stats/widgets/sales-by-device/widget.ts
new file mode 100644
index 000000000000..9a4aab7176a9
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/sales-by-device/widget.ts
@@ -0,0 +1,7 @@
+/**
+ * No configurable attributes; the empty record allows host-provided fields, such as the report
+ * params.
+ */
+export type SalesByDeviceAttributes = Record< never, never >;
+
+export default {};
diff --git a/projects/packages/premium-analytics/widgets/total-returns/package.json b/projects/packages/woocommerce-stats/widgets/total-returns/package.json
similarity index 50%
rename from projects/packages/premium-analytics/widgets/total-returns/package.json
rename to projects/packages/woocommerce-stats/widgets/total-returns/package.json
index b2d02b6ba4bf..e12ba0388198 100644
--- a/projects/packages/premium-analytics/widgets/total-returns/package.json
+++ b/projects/packages/woocommerce-stats/widgets/total-returns/package.json
@@ -1,12 +1,11 @@
{
- "name": "@automattic/jetpack-premium-analytics-widget-total-returns",
+ "name": "@automattic/jetpack-woocommerce-stats-widget-total-returns",
"version": "0.1.0-alpha",
"private": true,
"type": "module",
"dependencies": {
- "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit",
+ "@automattic/jetpack-premium-analytics-sdk": "workspace:*",
"@wordpress/i18n": "^6.9.0",
- "@wordpress/icons": "^16.0.0",
"@wordpress/widget-primitives": "0.7.0",
"react": "18.3.1"
}
diff --git a/projects/packages/woocommerce-stats/widgets/total-returns/render.tsx b/projects/packages/woocommerce-stats/widgets/total-returns/render.tsx
new file mode 100644
index 000000000000..d6abaf0cefc0
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/total-returns/render.tsx
@@ -0,0 +1,111 @@
+/**
+ * External dependencies
+ */
+import {
+ Columns,
+ describeError,
+ useWidgetRootContext,
+ WidgetRoot,
+ type ColumnInput,
+ type DataFormat,
+ type ReportParamsFieldAttributes,
+} from '@automattic/jetpack-premium-analytics-sdk';
+import { __ } from '@wordpress/i18n';
+import { useMemo } from 'react';
+/**
+ * Internal dependencies
+ */
+import { useReportOrders } from '../../src/reports';
+import type { TotalReturnsAttributes } from './widget';
+import type { WidgetRenderProps } from '@wordpress/widget-primitives';
+
+type TotalReturnsRenderAttributes = TotalReturnsAttributes & Partial< ReportParamsFieldAttributes >;
+
+const CURRENCY_FORMAT: DataFormat = { type: 'currency', options: { useMultipliers: true } };
+
+type OrdersSummary = { total_sales?: number; refunds?: number };
+
+const salesNetOfRefunds = ( summary: OrdersSummary ) =>
+ Math.max( 0, ( summary.total_sales ?? 0 ) - ( summary.refunds ?? 0 ) );
+
+/**
+ * Refunds beside the sales net of them, one column each per period, read under the widget root
+ * for its report params. A period pair without a refund reads as empty rather than as sales
+ * labelled returns.
+ *
+ * @return {JSX.Element} The columns, or the loading, error or empty state of their report.
+ */
+function TotalReturns() {
+ const { reportParams } = useWidgetRootContext();
+ const {
+ primary,
+ comparison,
+ hasComparison,
+ isLoading,
+ isFetching,
+ hasData,
+ isError,
+ error,
+ refetch,
+ } = useReportOrders( reportParams );
+ const summary = primary.data?.summary;
+ const previous = comparison.data?.summary;
+
+ const columns = useMemo< ColumnInput[] >( () => {
+ if ( summary && ! summary.refunds && ! previous?.refunds ) {
+ return [];
+ }
+
+ // The categories are fixed, so the skeleton knows its two columns before the data arrives.
+ return [
+ {
+ label: __( 'Total sales', 'jetpack-woocommerce-stats-pkg' ),
+ value: summary ? salesNetOfRefunds( summary ) : 0,
+ previousValue: previous ? salesNetOfRefunds( previous ) : undefined,
+ },
+ {
+ label: __( 'Refunds', 'jetpack-woocommerce-stats-pkg' ),
+ value: summary?.refunds ?? 0,
+ previousValue: previous?.refunds,
+ },
+ ];
+ }, [ summary, previous ] );
+
+ return (
+
+ );
+}
+
+/**
+ * The Total returns widget.
+ *
+ * @param {WidgetRenderProps< TotalReturnsRenderAttributes >} props - The props the host passes.
+ * @return {JSX.Element} The widget.
+ */
+export default function TotalReturnsRender( {
+ attributes = {},
+}: WidgetRenderProps< TotalReturnsRenderAttributes > ) {
+ return (
+
+
+
+ );
+}
diff --git a/projects/packages/premium-analytics/widgets/total-returns/widget.json b/projects/packages/woocommerce-stats/widgets/total-returns/widget.json
similarity index 68%
rename from projects/packages/premium-analytics/widgets/total-returns/widget.json
rename to projects/packages/woocommerce-stats/widgets/total-returns/widget.json
index 4122530b6439..e3e00ca03ad6 100644
--- a/projects/packages/premium-analytics/widgets/total-returns/widget.json
+++ b/projects/packages/woocommerce-stats/widgets/total-returns/widget.json
@@ -1,5 +1,5 @@
{
- "name": "jpa/total-returns",
+ "name": "woocommerce-analytics/total-returns",
"icon": "jpa/chart-bar",
"title": "Total returns",
"description": "Shows refunds and net sales over the selected time period.",
@@ -7,5 +7,6 @@
"content": "Total value of returns issued for physical products over the selected time period."
},
"category": "store",
- "presentation": "framed"
+ "presentation": "framed",
+ "textdomain": "jetpack-woocommerce-stats-pkg"
}
diff --git a/projects/packages/woocommerce-stats/widgets/total-returns/widget.ts b/projects/packages/woocommerce-stats/widgets/total-returns/widget.ts
new file mode 100644
index 000000000000..ee0411a19870
--- /dev/null
+++ b/projects/packages/woocommerce-stats/widgets/total-returns/widget.ts
@@ -0,0 +1,7 @@
+/**
+ * No configurable attributes; the empty record allows host-provided fields, such as the report
+ * params.
+ */
+export type TotalReturnsAttributes = Record< never, never >;
+
+export default {};
From 9da92dfaa0b49f0593d9f5e6d3287c05a6b066cf Mon Sep 17 00:00:00 2001
From: emilyaudela <36699353+emilyaudela@users.noreply.github.com>
Date: Tue, 13 Oct 2026 14:24:54 +0200
Subject: [PATCH 06/57] Podcast: load the dashboard's JavaScript translations
(#53042)
* Podcast: load the dashboard's JavaScript translations
The wp-build Podcast dashboard shipped with its strings in English on
non-English sites: nothing downloads its JS translation catalog at boot.
This applies the same approach #50762 used for the other wp-build dashboards.
- Add a boot `init` package that calls loadI18nCatalogs( 'jetpack-podcast' ).
- Register it on the dashboard page and stamp the text domain / write the i18n
manifest after wp-build.
- Enqueue wp-jp-i18n-loader on the Podcast page.
- Register the init script module directly when `wp_default_scripts` has
already fired. The Podcast build is loaded on admin_menu, after that hook, so
the generated registrar never ran and the init module was missing from the
import map ("Failed to resolve module specifier").
Co-Authored-By: Claude Sonnet 5.5
* Podcast: suppress the Phan undeclared-function error for the generated registrar
jetpack_podcast_register_script_modules() is defined by the generated
build/modules.php, which Phan doesn't analyze. The call is already guarded by
function_exists().
Co-Authored-By: Claude Sonnet 5.5
* Podcast: cover the translation loader changes with tests
Moves the late script-module registration into a small method that takes the
registrar as a parameter, so it can be tested without the generated build code
(and no longer needs a Phan suppression). Adds tests for the loader enqueue and
the registration.
Co-Authored-By: Claude Sonnet 5.5
* Add changelog entry for plugins/jetpack
* Refactor to follow the pattern from #50762 for consistency
---------
Co-authored-by: Claude Sonnet 5.5
Co-authored-by: Daniel Lind
---
pnpm-lock.yaml | 3 +++
.../fix-podcast-dashboard-js-translations | 4 +++
projects/packages/podcast/package.json | 11 ++++++--
.../podcast/packages/init/package.json | 12 +++++++++
.../podcast/packages/init/src/index.ts | 8 ++++++
.../packages/podcast/src/class-admin-page.php | 20 ++++++++++++++
.../podcast/tests/php/Admin_Page_Test.php | 27 +++++++++++++++++++
.../fix-podcast-dashboard-js-translations | 4 +++
8 files changed, 87 insertions(+), 2 deletions(-)
create mode 100644 projects/packages/podcast/changelog/fix-podcast-dashboard-js-translations
create mode 100644 projects/packages/podcast/packages/init/package.json
create mode 100644 projects/packages/podcast/packages/init/src/index.ts
create mode 100644 projects/plugins/jetpack/changelog/fix-podcast-dashboard-js-translations
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2120477c74f7..613702c3b774 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3951,6 +3951,9 @@ importers:
'@automattic/number-formatters':
specifier: workspace:*
version: link:../../js-packages/number-formatters
+ '@jetpack-podcast/init':
+ specifier: link:packages/init
+ version: link:packages/init
'@wordpress/api-fetch':
specifier: 7.55.0
version: 7.55.0
diff --git a/projects/packages/podcast/changelog/fix-podcast-dashboard-js-translations b/projects/packages/podcast/changelog/fix-podcast-dashboard-js-translations
new file mode 100644
index 000000000000..cefb91950256
--- /dev/null
+++ b/projects/packages/podcast/changelog/fix-podcast-dashboard-js-translations
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Load the dashboard's JavaScript translation catalogs so its strings are translated.
diff --git a/projects/packages/podcast/package.json b/projects/packages/podcast/package.json
index f8186d88c52e..475263227358 100644
--- a/projects/packages/podcast/package.json
+++ b/projects/packages/podcast/package.json
@@ -16,9 +16,10 @@
"author": "Automattic",
"type": "module",
"scripts": {
- "build": "pnpm run clean && concurrently 'pnpm:build:blocks' 'pnpm:build:wp-build' && pnpm run build:boot-asset",
+ "build": "pnpm run clean && concurrently 'pnpm:build:blocks' 'pnpm:build:wp-build' && pnpm run build:stamp-textdomains && pnpm run build:boot-asset",
"build:blocks": "webpack --config ./webpack.config.blocks.js",
"build:boot-asset": "provide-boot-asset-file",
+ "build:stamp-textdomains": "stamp-textdomains",
"build:strip-unminified-prod": "strip-unminified-prod",
"build:wp-build": "wp-build",
"build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build && pnpm run build:strip-unminified-prod",
@@ -38,6 +39,7 @@
"@automattic/jetpack-shared-extension-utils": "workspace:*",
"@automattic/jetpack-webpack-config": "workspace:*",
"@automattic/number-formatters": "workspace:*",
+ "@jetpack-podcast/init": "link:packages/init",
"@wordpress/api-fetch": "7.55.0",
"@wordpress/block-editor": "17.1.0",
"@wordpress/blocks": "16.0.0",
@@ -90,7 +92,12 @@
"packageNamespace": "jetpack-podcast",
"handlePrefix": "jetpack-podcast",
"pages": [
- "jetpack-podcast-dashboard"
+ {
+ "id": "jetpack-podcast-dashboard",
+ "init": [
+ "@jetpack-podcast/init"
+ ]
+ }
]
}
}
diff --git a/projects/packages/podcast/packages/init/package.json b/projects/packages/podcast/packages/init/package.json
new file mode 100644
index 000000000000..5efaf0ae09f4
--- /dev/null
+++ b/projects/packages/podcast/packages/init/package.json
@@ -0,0 +1,12 @@
+{
+ "private": true,
+ "name": "@automattic/jetpack-podcast-init",
+ "version": "0.1.0",
+ "type": "module",
+ "wpScript": true,
+ "module": "build-module/index.mjs",
+ "wpScriptModuleExports": "./build-module/index.mjs",
+ "dependencies": {
+ "@automattic/jetpack-wp-build-polyfills": "workspace:*"
+ }
+}
diff --git a/projects/packages/podcast/packages/init/src/index.ts b/projects/packages/podcast/packages/init/src/index.ts
new file mode 100644
index 000000000000..07e590cd94d8
--- /dev/null
+++ b/projects/packages/podcast/packages/init/src/index.ts
@@ -0,0 +1,8 @@
+import { loadI18nCatalogs } from '@automattic/jetpack-wp-build-polyfills/src/js/load-i18n-catalogs';
+
+/**
+ * Load Podcast's translation catalogs before the dashboard renders.
+ */
+export async function init(): Promise< void > {
+ await loadI18nCatalogs( 'jetpack-podcast', import.meta.url );
+}
diff --git a/projects/packages/podcast/src/class-admin-page.php b/projects/packages/podcast/src/class-admin-page.php
index 338829951615..b8e5af31c30b 100644
--- a/projects/packages/podcast/src/class-admin-page.php
+++ b/projects/packages/podcast/src/class-admin-page.php
@@ -126,6 +126,19 @@ public static function admin_init() {
// MediaUpload (cover-image-control) reads wp.media.view — only defined after this runs.
add_action( 'admin_enqueue_scripts', 'wp_enqueue_media' );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_tracks_transport' ) );
+ add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_i18n_loader' ) );
+ }
+
+ /**
+ * Enqueue the JS translation loader so the dashboard's init module can download its catalogs.
+ *
+ * The loader is registered on every admin page by jetpack-assets but only enqueued when
+ * something depends on it; the esbuild bundles don't pull it in.
+ */
+ public static function enqueue_i18n_loader() {
+ if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
+ wp_enqueue_script( 'wp-jp-i18n-loader' );
+ }
}
/**
@@ -269,6 +282,13 @@ private static function load_wp_build() {
require_once $build_index;
+ // wp-build hooks module registration to wp_default_scripts, which has
+ // already fired by admin_menu — call it directly or the init module
+ // never reaches the import map.
+ if ( function_exists( 'jetpack_podcast_register_script_modules' ) ) {
+ jetpack_podcast_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
+ }
+
WP_Build_Polyfills::register(
'jetpack-podcast',
array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
diff --git a/projects/packages/podcast/tests/php/Admin_Page_Test.php b/projects/packages/podcast/tests/php/Admin_Page_Test.php
index 2a4a4be3a18a..cc5e00c41ff7 100644
--- a/projects/packages/podcast/tests/php/Admin_Page_Test.php
+++ b/projects/packages/podcast/tests/php/Admin_Page_Test.php
@@ -37,6 +37,11 @@ protected function setUp(): void {
protected function tearDown(): void {
Constants::clear_constants();
remove_all_actions( 'load-jetpack_page_' . Admin_Page::ADMIN_PAGE_SLUG );
+ remove_action( 'admin_enqueue_scripts', 'wp_enqueue_media' );
+ remove_action( 'admin_enqueue_scripts', array( Admin_Page::class, 'enqueue_tracks_transport' ) );
+ remove_action( 'admin_enqueue_scripts', array( Admin_Page::class, 'enqueue_i18n_loader' ) );
+ wp_dequeue_script( 'wp-jp-i18n-loader' );
+ wp_deregister_script( 'wp-jp-i18n-loader' );
remove_action( 'admin_enqueue_scripts', array( Admin_Page::class, 'alias_screen_id_for_wp_build' ) );
remove_action( 'admin_enqueue_scripts', array( Admin_Page::class, 'restore_screen_id_after_wp_build' ) );
remove_filter( 'jetpack_admin_js_script_data', array( Admin_Page::class, 'inject_podcast_script_data' ) );
@@ -308,4 +313,26 @@ private function enter_podcast_admin_request(): void {
set_current_screen( 'jetpack_page_jetpack-podcast' );
$_GET['page'] = Admin_Page::ADMIN_PAGE_SLUG;
}
+
+ public function test_admin_init_queues_the_i18n_loader_enqueue() {
+ Admin_Page::admin_init();
+
+ $this->assertSame( 10, has_action( 'admin_enqueue_scripts', array( Admin_Page::class, 'enqueue_i18n_loader' ) ) );
+ }
+
+ public function test_enqueue_i18n_loader_enqueues_the_loader_when_it_is_registered() {
+ wp_register_script( 'wp-jp-i18n-loader', false, array(), '1.0', true );
+
+ Admin_Page::enqueue_i18n_loader();
+
+ $this->assertTrue( wp_script_is( 'wp-jp-i18n-loader', 'enqueued' ) );
+ }
+
+ public function test_enqueue_i18n_loader_does_nothing_when_the_loader_is_not_registered() {
+ wp_deregister_script( 'wp-jp-i18n-loader' );
+
+ Admin_Page::enqueue_i18n_loader();
+
+ $this->assertFalse( wp_script_is( 'wp-jp-i18n-loader', 'enqueued' ) );
+ }
}
diff --git a/projects/plugins/jetpack/changelog/fix-podcast-dashboard-js-translations b/projects/plugins/jetpack/changelog/fix-podcast-dashboard-js-translations
new file mode 100644
index 000000000000..3d204479ec31
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/fix-podcast-dashboard-js-translations
@@ -0,0 +1,4 @@
+Significance: patch
+Type: bugfix
+
+Podcast: Translate the dashboard's interface strings.
From d9f7bad7c01ab049bb63cd579d10997bdf529ca9 Mon Sep 17 00:00:00 2001
From: Jarda Snajdr
Date: Tue, 13 Oct 2026 14:37:38 +0200
Subject: [PATCH 07/57] jetpack-mu-wpcom: Sync React 19 incompatible plugins
with Gutenberg (#53539)
Adds the plugins introduced in WordPress/gutenberg#84607 to the React 19 incompatible plugins list:
- astra-pro-sites
- echo-rewards
- forminator
- iron-security
- stackable-ultimate-gutenberg-blocks
---
.../changelog/sync-react-19-incompatible-plugins | 4 ++++
.../packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php | 5 +++++
2 files changed, 9 insertions(+)
create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/sync-react-19-incompatible-plugins
diff --git a/projects/packages/jetpack-mu-wpcom/changelog/sync-react-19-incompatible-plugins b/projects/packages/jetpack-mu-wpcom/changelog/sync-react-19-incompatible-plugins
new file mode 100644
index 000000000000..c76852c56ce9
--- /dev/null
+++ b/projects/packages/jetpack-mu-wpcom/changelog/sync-react-19-incompatible-plugins
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Gutenberg: Sync the list of React 19 incompatible plugins with the Gutenberg plugin.
diff --git a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php
index 2af744af0464..28de58da70c1 100644
--- a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php
+++ b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php
@@ -48,6 +48,11 @@ class Jetpack_Mu_Wpcom {
'meetinghub/meetinghub.php' => null,
'mail-mint/mail-mint.php' => null,
'wp-letsencrypt-ssl-pro/wp-letsencrypt.php' => null,
+ 'astra-pro-sites/astra-pro-sites.php' => null,
+ 'echo-rewards/echo-rewards.php' => null,
+ 'forminator/forminator.php' => null,
+ 'iron-security/iron-security.php' => null,
+ 'stackable-ultimate-gutenberg-blocks/plugin.php' => null,
);
/**
From ce8a68929766dd8b5b97163718f692749a57c317 Mon Sep 17 00:00:00 2001
From: Automattic Bot
Date: Tue, 13 Oct 2026 14:49:03 +0200
Subject: [PATCH 08/57] phan: Update custom stubs (#53517)
Co-authored-by: matticbot
---
.phan/stubs/phpunit-stubs.php | 18 ++++++++++++++++--
.phan/stubs/woocommerce-internal-stubs.php | 2 +-
.phan/stubs/woocommerce-payments-stubs.php | 2 +-
3 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/.phan/stubs/phpunit-stubs.php b/.phan/stubs/phpunit-stubs.php
index 6de724c10011..387df6c0515a 100644
--- a/.phan/stubs/phpunit-stubs.php
+++ b/.phan/stubs/phpunit-stubs.php
@@ -1,6 +1,6 @@
$parameters
+ * @param array $parameters
* @throws \PHPUnit\Framework\Exception
*/
public function __construct(array $parameters)
@@ -25730,6 +25730,20 @@ public function download(string $url): false|string
final class PhpProcessException extends \RuntimeException implements \PHPUnit\Util\Exception
{
}
+/**
+ * @no-named-arguments
+ * @internal
+ */
+final class ExtensionSettings
+{
+ /**
+ * @param \Closure(): list $parameters
+ * @return list
+ */
+ public static function forChildProcess(\Closure $parameters): array
+ {
+ }
+}
/**
* @immutable
* @no-named-arguments
diff --git a/.phan/stubs/woocommerce-internal-stubs.php b/.phan/stubs/woocommerce-internal-stubs.php
index b7b2e37da74a..49320b65fa00 100644
--- a/.phan/stubs/woocommerce-internal-stubs.php
+++ b/.phan/stubs/woocommerce-internal-stubs.php
@@ -1,6 +1,6 @@
Date: Tue, 13 Oct 2026 20:35:27 +0700
Subject: [PATCH 09/57] Shortcodes: Use apply_shortcodes() instead of
do_shortcode() (#53543)
WordPress 5.4 added apply_shortcodes() as the preferred name for do_shortcode(). Jetpack now requires WordPress 7.0, so the alias is always available.
Calls whose result is echoed directly are left on do_shortcode(): WPCS lists do_shortcode() as an escaping function but not apply_shortcodes(), so switching those lines would fail WordPress.Security.EscapeOutput.OutputNotEscaped.
Co-authored-by: Fery Yundara Putera <22367469+daefery@users.noreply.github.com>
---
.../changelog/update-apply-shortcodes-jetpack-plugin | 4 ++++
.../json-endpoints/class.wpcom-json-api-render-endpoint.php | 2 +-
.../plugins/jetpack/modules/shortcodes/presentations.php | 4 ++--
projects/plugins/jetpack/modules/shortcodes/quiz.php | 2 +-
projects/plugins/jetpack/modules/shortcodes/recipe.php | 6 +++---
5 files changed, 11 insertions(+), 7 deletions(-)
create mode 100644 projects/plugins/jetpack/changelog/update-apply-shortcodes-jetpack-plugin
diff --git a/projects/plugins/jetpack/changelog/update-apply-shortcodes-jetpack-plugin b/projects/plugins/jetpack/changelog/update-apply-shortcodes-jetpack-plugin
new file mode 100644
index 000000000000..0b2f899cfc16
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/update-apply-shortcodes-jetpack-plugin
@@ -0,0 +1,4 @@
+Significance: patch
+Type: other
+
+Shortcodes, widgets: Use apply_shortcodes() instead of do_shortcode().
diff --git a/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-endpoint.php b/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-endpoint.php
index 6309cad2ecaf..bd5ae017f35e 100644
--- a/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-endpoint.php
+++ b/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-endpoint.php
@@ -154,7 +154,7 @@ public function get_version( $this_scripts_version, $default_version ) {
* @param string $shortcode - the shortcode.
*/
public function do_shortcode( $shortcode ) {
- return do_shortcode( $shortcode );
+ return apply_shortcodes( $shortcode );
}
/**
diff --git a/projects/plugins/jetpack/modules/shortcodes/presentations.php b/projects/plugins/jetpack/modules/shortcodes/presentations.php
index d8d20bafc4ce..4f965b5cf010 100644
--- a/projects/plugins/jetpack/modules/shortcodes/presentations.php
+++ b/projects/plugins/jetpack/modules/shortcodes/presentations.php
@@ -268,7 +268,7 @@ public function presentation_shortcode( $atts, $content = '' ) {
$out .= '
{ /* A closed panel renders no children, so open it when an option needs
fixing - otherwise the error is invisible on a saved button. */ }
diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss
index 7439be9ff33b..c101b80b3701 100644
--- a/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss
+++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/editor.scss
@@ -582,35 +582,6 @@
}
}
-// Image upload field in the create/edit form.
-.jetpack-paypal-payment-buttons__image-field {
- margin-bottom: 16px;
-}
-
-.jetpack-paypal-payment-buttons__image-preview {
-
- img {
- display: block;
- max-width: 100%;
- max-height: 200px;
- width: auto;
- height: auto;
- border-radius: 4px;
- border: 1px solid #e0e0e0;
- margin-bottom: 8px;
- }
-}
-
-.jetpack-paypal-payment-buttons__image-actions {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.jetpack-paypal-payment-buttons__upload-button {
- margin-bottom: 4px;
-}
-
// ---------------------------------------------------------------
// PayPal Button Preview Component
// ---------------------------------------------------------------
diff --git a/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx
index ad38e2276fe4..b1be5879f3ad 100644
--- a/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx
+++ b/projects/packages/paypal-payments/tests/js/paypal-payment-buttons-block-tests/edit.test.jsx
@@ -150,10 +150,6 @@ jest.mock( '../../../src/paypal-payment-buttons/utils/toast', () => ( {
toast: ( ...args ) => mockToast( ...args ),
} ) );
-// What the media library hands back. jsdom has none, so the MediaUpload mock
-// passes this to onSelect.
-const mockSelectedMedia = { url: 'https://example.com/chosen.png', id: 42 };
-
// Mock WordPress block-editor.
jest.mock( '@wordpress/block-editor', () => ( {
store: { name: 'core/block-editor' },
@@ -229,10 +225,6 @@ jest.mock( '@wordpress/block-editor', () => ( {
{ values || 'radius' }
),
- // open() calls onSelect straight away so the block's handler runs.
- MediaUpload: ( { onSelect, render: renderProp } ) =>
- renderProp( { open: () => onSelect( mockSelectedMedia ) } ),
- MediaUploadCheck: ( { children } ) => <>{ children }>,
// Like TextControl, className and help sit on the BaseControl wrapper, not the input.
// URLInput takes no onBlur - the form catches that on a wrapper of its own. The testid
// differs from `control-` so a test can tell a URL field from a text field. Ids by
@@ -2559,7 +2551,6 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => {
expect( screen.getByLabelText( 'Price' ) ).toBeInTheDocument();
expect( screen.getByLabelText( 'Currency' ) ).toBeInTheDocument();
expect( screen.getByLabelText( /Description/ ) ).toBeInTheDocument();
- expect( screen.getByText( 'Product Image (optional)' ) ).toBeInTheDocument();
} );
it( 'records no wizard events on a connected site', async () => {
@@ -6566,58 +6557,6 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => {
'jetpack-paypal-payment-buttons__has-error'
);
} );
-
- it( 'saves the image the merchant chooses', async () => {
- const user = userEvent.setup();
- renderForm( {} );
-
- await user.click( await screen.findByText( 'Upload Image' ) );
-
- expect( setAttributes ).toHaveBeenCalledWith( {
- imageUrl: 'https://example.com/chosen.png',
- imageId: 42,
- } );
- } );
-
- it( 'saves the replacement image', async () => {
- const user = userEvent.setup();
- renderForm( { imageUrl: 'https://example.com/previous.png', imageId: 7 } );
-
- await user.click( await screen.findByText( 'Replace' ) );
-
- expect( setAttributes ).toHaveBeenCalledWith( {
- imageUrl: 'https://example.com/chosen.png',
- imageId: 42,
- } );
- } );
-
- it( 'clears the image the merchant removes', async () => {
- const user = userEvent.setup();
- renderForm( { imageUrl: 'https://example.com/previous.png', imageId: 7 } );
- await formIsUp();
-
- // Customer notes and option groups have Remove buttons of their own.
- await user.click( details().getByText( 'Remove' ) );
-
- // toStrictEqual, because an assertion that only calls for undefined
- // values would be met by setAttributes( {} ) too.
- expect( setAttributes.mock.lastCall[ 0 ] ).toStrictEqual( {
- imageUrl: undefined,
- imageId: undefined,
- } );
- } );
-
- // The image stays on the site, so any address works.
- it( 'shows an http image without a warning', async () => {
- renderForm( { imageUrl: 'http://example.com/previous.png', imageId: 7 } );
- await formIsUp();
-
- expect( details().getByRole( 'img', { name: 'Test Widget' } ) ).toHaveAttribute(
- 'src',
- 'http://example.com/previous.png'
- );
- expect( details().queryByTestId( 'notice' ) ).not.toBeInTheDocument();
- } );
} );
// A key of validationErrors has to reach both the save gate and a control's `help`,
diff --git a/projects/plugins/paypal-payment-buttons/changelog/remove-wooptp-588-paypal-image-support b/projects/plugins/paypal-payment-buttons/changelog/remove-wooptp-588-paypal-image-support
new file mode 100644
index 000000000000..a834389931e0
--- /dev/null
+++ b/projects/plugins/paypal-payment-buttons/changelog/remove-wooptp-588-paypal-image-support
@@ -0,0 +1,4 @@
+Significance: patch
+Type: removed
+
+Remove the product image field from the block editor form until PayPal payment links can carry an image; blocks that already hold one keep showing it.
From 0c737b99e8f754fa2c3d0d6d32db74770944e1c4 Mon Sep 17 00:00:00 2001
From: Richard Ortiz
Date: Tue, 13 Oct 2026 17:05:47 +0200
Subject: [PATCH 12/57] Plugins: turn the Marketplace tab on, and let it work
on the Simple site Add Plugins screen (#53169)
* mu-wpcom: let the Marketplace cards work where core's install functions are missing
Simple sites draw the Add Plugins screen from their own page, without wp-admin/includes/plugin-install.php and under core's screen id. The cards no longer call install_plugin_install_status() when it is absent, and the details modal recognises that screen.
* mu-wpcom: let Simple sites route Marketplace purchases and supply store prices
Two filters for the Simple Add Plugins screen in wpcom: wpcom_marketplace_checkout_url, so a purchase checkout cannot complete goes through Calypso, and wpcom_marketplace_store_products, since the in-process API client on Simple cannot reach the v1.1 products endpoint the cards are priced from.
* mu-wpcom: add a changelog entry for the Simple Add Plugins screen
* mu-wpcom: keep a Marketplace plugin's saving on its price's line
Core caps the strip's left column at 180px for ratings, and a price with
its saving needs up to 192px, so the saving wrapped on most cards. That
row only shares its line with "Last Updated", so give the price whatever
that does not need, and never less than core's 180px.
* mu-wpcom: say a Marketplace plugin needs an upgrade where the plan lacks it
A site whose plan does not include purchased plugins can only buy one with
a plan in the same checkout, which a Simple site's checkout URL filter now
adds. "Purchase" read as if the plugin were all it paid for. Its button
says "Upgrade and purchase" there instead, and wraps onto two lines in
core's 120px button column rather than running over the card's name.
* mu-wpcom: test the Marketplace button's upgrade label
Covers both labels. The test runs in its own process: Brain Monkey's stub
of wpcom_site_has_feature() would otherwise stay defined for the rest of
the run and relabel every later button.
* mu-wpcom: turn the Marketplace tab on for every site
The wpcom-plugins-marketplace-tab flag now defaults to on, so the tab and
its cards show on the Add Plugins screen of every Atomic site, and of every
Simple site once WordPress.com's Simple screen is in. The flag stays as an
off switch: per site from Tools > Feature Flags (a8c), or for every site by
setting the default back.
Tests that checked the tab stays out with the flag off now turn it off
themselves instead of relying on the default, and the tab tests register
the flag again, since the feature flags tests reset the registry.
* mu-wpcom: one changelog entry for the Marketplace tab
The four entries described parts of one change: the tab going live on every site, Simple included.
---
...nal-premium-and-business-overtake-the-core | 4 +
.../class-marketplace-catalog.php | 18 ++-
.../wpcom-plugins/css/marketplace-tab.css | 32 +++++
.../wpcom-plugins/wpcom-marketplace-cards.php | 4 +-
.../wpcom-plugins/wpcom-marketplace-tab.php | 55 ++++++++-
.../Wpcom_Marketplace_Cards_Test.php | 2 +
.../Wpcom_Marketplace_Tab_Test.php | 111 +++++++++++++++++-
.../Wpcom_Plugins_Banner_Test.php | 4 +-
.../changelog/dotpar-19-marketplace-tab-on | 4 +
9 files changed, 220 insertions(+), 14 deletions(-)
create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/dotpar-19-simple-personal-premium-and-business-overtake-the-core
create mode 100644 projects/plugins/wpcomsh/changelog/dotpar-19-marketplace-tab-on
diff --git a/projects/packages/jetpack-mu-wpcom/changelog/dotpar-19-simple-personal-premium-and-business-overtake-the-core b/projects/packages/jetpack-mu-wpcom/changelog/dotpar-19-simple-personal-premium-and-business-overtake-the-core
new file mode 100644
index 000000000000..559053cd4fc5
--- /dev/null
+++ b/projects/packages/jetpack-mu-wpcom/changelog/dotpar-19-simple-personal-premium-and-business-overtake-the-core
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Plugins: Show a Marketplace tab of WordPress.com partner and premium plugins first on the Add Plugins screen, on Atomic and Simple sites.
diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/class-marketplace-catalog.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/class-marketplace-catalog.php
index d5bd918335fe..ebe6212f8fd3 100644
--- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/class-marketplace-catalog.php
+++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/class-marketplace-catalog.php
@@ -422,10 +422,24 @@ public static function to_card( array $product ) {
* @return array Keyed by product id.
*/
private static function fetch_store_products() {
+ /**
+ * Filters the store catalog before it is requested from WordPress.com.
+ *
+ * Simple sites answer it from the store itself: their in-process API client cannot
+ * reach the v1.1 products endpoint.
+ *
+ * @since $$next-version$$
+ *
+ * @param array|null $response Products keyed by store slug, as /rest/v1.1/products returns them, or null to request them.
+ */
+ $response = apply_filters( 'wpcom_marketplace_store_products', null );
+
// Site-scoped first, so prices come back in the site's own currency. Calypso
// reads the same two paths in the same order, for the same reason.
- $blog_id = self::blog_id();
- $response = $blog_id > 0 ? self::request( '/sites/' . $blog_id . '/products', '1.1', 'rest' ) : null;
+ if ( ! is_array( $response ) ) {
+ $blog_id = self::blog_id();
+ $response = $blog_id > 0 ? self::request( '/sites/' . $blog_id . '/products', '1.1', 'rest' ) : null;
+ }
if ( ! is_array( $response ) ) {
$response = self::request( '/products', '1.1', 'rest' );
diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/css/marketplace-tab.css b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/css/marketplace-tab.css
index e815db001cdb..6200e8125c32 100644
--- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/css/marketplace-tab.css
+++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/css/marketplace-tab.css
@@ -74,6 +74,38 @@
min-block-size: 18px;
}
+/*
+ * Room for the price and its saving on one line. Core keeps 180px for
+ * ratings, but this row only shares its line with "Last Updated", which
+ * needs at most 190px.
+ */
+.plugin-card .plugin-card-bottom .wpcom-marketplace-card__headline {
+ max-inline-size: max(180px, 100% - 190px);
+}
+
+.plugin-card .plugin-card-bottom .wpcom-marketplace-card__headline + .column-updated {
+ inline-size: min(190px, 100% - 180px);
+}
+
+/*
+ * "Upgrade and purchase" is wider than the 120px core keeps beside each card's
+ * name for its buttons, so it wraps onto two lines, centred, at the widths
+ * where core puts them there.
+ */
+@media (min-width: 1101px), (min-width: 481px) and (max-width: 781px) {
+
+ .plugin-card .plugin-action-buttons .button.wpcom-marketplace-card__upgrade {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ block-size: auto;
+ padding-block: 4px;
+ line-height: 1.4;
+ text-align: center;
+ white-space: normal;
+ }
+}
+
/* Core's "required" heading, hidden until the card script swaps in ours. */
.plugin-card:has(.wpcom-marketplace-strip[data-requires-label]) .plugin-dependencies-explainer-text {
visibility: hidden;
diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/wpcom-marketplace-cards.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/wpcom-marketplace-cards.php
index 1c1299853d45..d4870b491a81 100644
--- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/wpcom-marketplace-cards.php
+++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/wpcom-marketplace-cards.php
@@ -180,7 +180,7 @@ function wpcom_marketplace_card_description_markup( $description, $plugin ) {
return $description;
}
- $installed = 'install' !== install_plugin_install_status( $plugin )['status'];
+ $installed = wpcom_marketplace_is_installed( $plugin );
$referral = Marketplace_Catalog::is_referral( $plugin );
// Checkout installs everything a product needs, so its card says so rather than core's "required".
@@ -302,7 +302,7 @@ function wpcom_marketplace_remember_dependency( $result, $action, $args ) {
*/
function wpcom_marketplace_card_strip( array $plugin ) {
// Calypso drops the price once a plugin is installed.
- $rows = 'install' === install_plugin_install_status( $plugin )['status']
+ $rows = ! wpcom_marketplace_is_installed( $plugin )
? wpcom_marketplace_price_rows( $plugin )
: array(
'headline' => '',
diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/wpcom-marketplace-tab.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/wpcom-marketplace-tab.php
index 7ef65f4e1471..8edf412c3a32 100644
--- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/wpcom-marketplace-tab.php
+++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-plugins/wpcom-marketplace-tab.php
@@ -30,7 +30,8 @@ function wpcom_marketplace_register_flag() {
Feature_Flags::register(
WPCOM_MARKETPLACE_TAB_FLAG,
array(
- 'default' => false,
+ // On for every site. Kept as an off switch, per site or for all, until the tab has settled.
+ 'default' => true,
'description' => 'Show WordPress.com partner and premium plugins as a Marketplace tab on the Add Plugins screen.',
'owner' => 'jetpack-mu-wpcom',
)
@@ -152,10 +153,31 @@ function wpcom_marketplace_table_args( $args ) {
/**
* Whether this request is the Add Plugins screen, the details modal included.
*
+ * Simple sites draw that screen from their own page, which calls itself core's screen.
+ *
* @return bool
*/
function wpcom_marketplace_on_plugin_install_screen() {
- return isset( $GLOBALS['pagenow'] ) && 'plugin-install.php' === $GLOBALS['pagenow'];
+ if ( isset( $GLOBALS['pagenow'] ) && 'plugin-install.php' === $GLOBALS['pagenow'] ) {
+ return true;
+ }
+
+ $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
+
+ return null !== $screen && 'plugin-install' === $screen->id;
+}
+
+/**
+ * Whether a plugin is on this site already.
+ *
+ * Simple sites draw the Add Plugins screen without core's plugin install functions, and have
+ * nothing installed.
+ *
+ * @param array $plugin Plugin data.
+ * @return bool
+ */
+function wpcom_marketplace_is_installed( array $plugin ) {
+ return function_exists( 'install_plugin_install_status' ) && 'install' !== install_plugin_install_status( $plugin )['status'];
}
/**
@@ -267,10 +289,9 @@ function wpcom_marketplace_price_rows( array $card ) {
* @return string Button markup.
*/
function wpcom_marketplace_card_button( array $card, $back_url = '' ) {
- $name = (string) ( $card['name'] ?? $card['slug'] ?? '' );
- $status = install_plugin_install_status( $card );
+ $name = (string) ( $card['name'] ?? $card['slug'] ?? '' );
- if ( 'install' !== $status['status'] ) {
+ if ( wpcom_marketplace_is_installed( $card ) ) {
return function_exists( 'wp_get_plugin_action_button' )
? wp_get_plugin_action_button( $name, $card, true, true )
: '';
@@ -307,6 +328,19 @@ function wpcom_marketplace_card_button( array $card, $back_url = '' ) {
$checkout = Marketplace_Catalog::checkout_url( $card, WPCOM_MARKETPLACE_TERM, '' === $back_url ? wpcom_marketplace_tab_url() : $back_url );
+ /**
+ * Filters where a Marketplace card's Purchase button leads.
+ *
+ * Simple sites add the plan the purchase needs, or send it through Calypso when the purchase
+ * cannot go ahead.
+ *
+ * @since $$next-version$$
+ *
+ * @param string $checkout Checkout URL, or empty when there is no store product.
+ * @param array $card The product's card.
+ */
+ $checkout = (string) apply_filters( 'wpcom_marketplace_checkout_url', $checkout, $card );
+
// Without a store product there is nothing to buy, so fall back to the product page.
if ( '' === $checkout ) {
return sprintf(
@@ -318,6 +352,17 @@ function wpcom_marketplace_card_button( array $card, $back_url = '' ) {
);
}
+ // A site whose plan does not include it buys one in the same checkout, through the filter above.
+ if ( function_exists( 'wpcom_site_has_feature' ) && ! wpcom_site_has_feature( 'install-purchased-plugins' ) ) {
+ return sprintf(
+ '%s',
+ esc_url( $checkout ),
+ /* translators: %s: Plugin name. */
+ esc_attr( sprintf( __( 'Upgrade your plan and purchase %s', 'jetpack-mu-wpcom' ), $name ) ),
+ esc_html__( 'Upgrade and purchase', 'jetpack-mu-wpcom' )
+ );
+ }
+
return sprintf(
'%s',
esc_url( $checkout ),
diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Marketplace_Cards_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Marketplace_Cards_Test.php
index aff748a7ce79..460f858a298c 100644
--- a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Marketplace_Cards_Test.php
+++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Marketplace_Cards_Test.php
@@ -115,7 +115,9 @@ public function test_it_hooks_in_on_the_add_plugins_screen_only() {
$screen = WP_Screen::get( 'plugin-install' );
+ add_filter( self::FLAG_FILTER, '__return_false' );
wpcom_marketplace_cards_start( $screen );
+ remove_filter( self::FLAG_FILTER, '__return_false' );
$this->assertFalse( has_filter( 'plugins_api_result', 'wpcom_marketplace_splice_search_results' ), 'Flag off.' );
add_filter( self::FLAG_FILTER, '__return_true' );
diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Marketplace_Tab_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Marketplace_Tab_Test.php
index 3c1b1be3c4c8..c6828fa0224f 100644
--- a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Marketplace_Tab_Test.php
+++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Marketplace_Tab_Test.php
@@ -7,6 +7,9 @@
use Automattic\Jetpack\Jetpack_Mu_Wpcom;
use Automattic\Jetpack\Jetpack_Mu_Wpcom\Marketplace_Catalog;
+use Brain\Monkey\Functions;
+use PHPUnit\Framework\Attributes\PreserveGlobalState;
+use PHPUnit\Framework\Attributes\RunInSeparateProcess;
require_once Jetpack_Mu_Wpcom::PKG_DIR . 'src/features/wpcom-plugins/wpcom-marketplace-tab.php';
@@ -78,6 +81,16 @@ private function priced_card() {
return $catalog['gravityforms'];
}
+ /**
+ * Registers the flag again: the feature flags tests reset the registry it joined on load.
+ *
+ * @return void
+ */
+ public function set_up() {
+ parent::set_up();
+ wpcom_marketplace_register_flag();
+ }
+
/**
* Turn the tab on for the duration of a test.
*
@@ -264,11 +277,11 @@ public function test_tab_goes_first_without_a_featured_anchor() {
}
/**
- * With the flag off nothing about the screen changes.
+ * Every site gets the tab unless the flag is turned off.
*/
- public function test_tab_is_absent_by_default() {
+ public function test_tab_is_shown_by_default() {
$this->assertSame(
- array( 'featured' ),
+ array( WPCOM_MARKETPLACE_TAB, 'featured' ),
array_keys( wpcom_marketplace_add_tab( array( 'featured' => 'Featured' ) ) )
);
}
@@ -325,7 +338,10 @@ public function test_plugins_api_reports_an_empty_catalog() {
* Core has no query for a tab it does not know, so the tab asks for the whole catalog.
*/
public function test_table_args_ask_for_the_whole_catalog_on_one_page() {
- $this->assertFalse( wpcom_marketplace_table_args( false ), 'Flag off.' );
+ add_filter( self::FLAG_FILTER, '__return_false' );
+ $off = wpcom_marketplace_table_args( false );
+ remove_filter( self::FLAG_FILTER, '__return_false' );
+ $this->assertFalse( $off, 'Flag off.' );
$this->enable_tab();
$this->seed_catalog( array( 'gravityforms' => $this->priced_card() ) );
@@ -369,6 +385,23 @@ public function test_details_are_served_on_the_plugin_install_screen() {
$this->assertSame( 'gravityforms', $result->slug );
}
+ /**
+ * Simple sites draw the screen from plugins.php, under core's screen id.
+ */
+ public function test_details_are_served_on_the_simple_screen() {
+ require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php';
+ $this->enable_tab();
+ $this->seed_catalog( array( 'gravityforms' => Marketplace_Catalog::to_card( self::PRODUCT ) ) );
+
+ $GLOBALS['pagenow'] = 'plugins.php';
+ $GLOBALS['current_screen'] = WP_Screen::get( 'plugin-install' );
+ $result = wpcom_marketplace_serve_plugins_api( false, 'plugin_information', (object) array( 'slug' => 'gravityforms' ) );
+ unset( $GLOBALS['pagenow'], $GLOBALS['current_screen'] );
+
+ $this->assertIsObject( $result );
+ $this->assertSame( 'gravityforms', $result->slug );
+ }
+
/**
* Other admin screens call plugins_api( 'plugin_information' ) too, and none of
* them should pay for a catalog read.
@@ -730,6 +763,33 @@ public function test_pricing_skips_unknown_variations() {
$this->assertArrayNotHasKey( 'monthly', $catalog['gravityforms']['wpcom_pricing'] );
}
+ /**
+ * Simple sites supply the store catalog, since their API client cannot request it.
+ */
+ public function test_the_store_catalog_can_be_supplied() {
+ $supplied = static function () {
+ return array(
+ 'gravityforms_yearly' => array(
+ 'product_id' => 2509,
+ 'cost_display' => '$132.00',
+ 'cost' => 132,
+ ),
+ );
+ };
+ $fetch = new ReflectionMethod( Marketplace_Catalog::class, 'fetch_store_products' );
+ // @todo Remove this call once we no longer need to support PHP <8.1.
+ if ( PHP_VERSION_ID < 80100 ) {
+ $fetch->setAccessible( true );
+ }
+
+ add_filter( 'wpcom_marketplace_store_products', $supplied );
+ $store = $fetch->invoke( null );
+ remove_filter( 'wpcom_marketplace_store_products', $supplied );
+
+ $this->assertSame( 'gravityforms_yearly', $store[2509]['slug'] );
+ $this->assertSame( '$132.00', $store[2509]['price'] );
+ }
+
/**
* The button goes straight to checkout, which is what the Calypso product page's
* own button does. Landing there is the whole point of skipping that page.
@@ -784,6 +844,49 @@ public function test_button_sends_checkout_back_to_the_tab() {
$this->assertStringContainsString( rawurlencode( 'plugin-install.php' ), $button );
}
+ /**
+ * Simple sends a purchase that checkout alone cannot complete through Calypso.
+ */
+ public function test_purchase_can_be_sent_elsewhere() {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
+ $calypso = static function () {
+ return 'https://wordpress.com/plugins/gravityforms/example.org';
+ };
+
+ add_filter( 'wpcom_marketplace_checkout_url', $calypso );
+ $button = wpcom_marketplace_card_button( $this->priced_card() );
+ remove_filter( 'wpcom_marketplace_checkout_url', $calypso );
+
+ $this->assertStringContainsString( 'href="https://wordpress.com/plugins/gravityforms/example.org"', $button );
+ }
+
+ /**
+ * A site whose plan lacks purchased plugins buys a plan with the plugin, so the button says so.
+ *
+ * Separate: the stubbed function would otherwise outlive the test and relabel every later button.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ #[RunInSeparateProcess]
+ #[PreserveGlobalState( false )]
+ public function test_button_says_when_the_purchase_needs_a_plan_too() {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
+
+ Functions\when( 'wpcom_site_has_feature' )->justReturn( true );
+ $purchase = wpcom_marketplace_card_button( $this->priced_card() );
+
+ Functions\when( 'wpcom_site_has_feature' )->justReturn( false );
+ $upgrade = wpcom_marketplace_card_button( $this->priced_card() );
+
+ $this->assertStringContainsString( '>Purchase', $purchase );
+ $this->assertStringNotContainsString( 'wpcom-marketplace-card__upgrade', $purchase );
+ $this->assertStringContainsString( '>Upgrade and purchase', $upgrade );
+ $this->assertStringContainsString( 'wpcom-marketplace-card__upgrade', $upgrade );
+ }
+
/**
* No store product means nothing to buy.
*/
diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Plugins_Banner_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Plugins_Banner_Test.php
index 3e1bebf290db..26484d7ffd2c 100644
--- a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Plugins_Banner_Test.php
+++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-plugins/Wpcom_Plugins_Banner_Test.php
@@ -48,14 +48,16 @@ public function test_the_marketplace_tab_replaces_the_banner() {
}
/**
- * Until the tab ships, the banner stays.
+ * Turning the tab off brings the banner back.
*/
public function test_the_banner_stays_while_the_marketplace_tab_is_off() {
if ( ! file_exists( Jetpack_Mu_Wpcom::BASE_DIR . 'build/wpcom-plugins-banner/wpcom-plugins-banner.asset.php' ) ) {
$this->markTestSkipped( 'The banner is enqueued from its build, which this run does not have.' );
}
+ add_filter( self::FLAG_FILTER, '__return_false' );
wpcom_plugins_show_banner();
+ remove_filter( self::FLAG_FILTER, '__return_false' );
$this->assertTrue( wp_script_is( 'wpcom-plugins-banner', 'enqueued' ) );
}
diff --git a/projects/plugins/wpcomsh/changelog/dotpar-19-marketplace-tab-on b/projects/plugins/wpcomsh/changelog/dotpar-19-marketplace-tab-on
new file mode 100644
index 000000000000..66163905227e
--- /dev/null
+++ b/projects/plugins/wpcomsh/changelog/dotpar-19-marketplace-tab-on
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Plugins: Show a Marketplace tab of WordPress.com partner and premium plugins on the Add Plugins screen, first among the tabs.
From ac4666e67a39defc225cfc655cc01cbd6cc755f3 Mon Sep 17 00:00:00 2001
From: tbradsha <32492176+tbradsha@users.noreply.github.com>
Date: Tue, 13 Oct 2026 09:20:14 -0600
Subject: [PATCH 13/57] Tools: Update release tool changelog logic (#53512)
---
tools/release-plugin.sh | 20 +++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/tools/release-plugin.sh b/tools/release-plugin.sh
index 530e5fcd7a0d..2c7a71969609 100755
--- a/tools/release-plugin.sh
+++ b/tools/release-plugin.sh
@@ -20,7 +20,6 @@ RELEASE_STEPS=(
'do_trunk_and_prelease_branch_prep'
'do_changelogs'
'do_readme'
- 'do_commit_changelog_and_readme'
'do_push_and_build'
'do_packagist_check'
'do_create_release_branches'
@@ -194,9 +193,16 @@ function do_changelogs {
tools/changelogger-release.sh "${ARGS[@]}"
done
+ git add --all
+ git commit -m "Aggregate raw changelogs and bump versions"
+
# When it completes, wait for user to edit anything they want, then push key to continue.
read -r -s -p $'Edit and save all the changelog entries you want in a separate terminal or in your text editor of choice.\nCheck for consistency between the different entries, and keep in mind that your plugin changelog will be used in the plugin readme file.\n\nOnce you are happy with your work, press enter to continue the release process.'
echo ""
+
+ if ! git diff --quiet; then
+ git commit -am "Edit changelogs"
+ fi
}
function do_readme {
@@ -218,19 +224,15 @@ function do_readme {
esac
pnpm jetpack release "$PLUGIN" readme "${ARGS[@]}"
done
-}
-
-function do_commit_changelog_and_readme {
- verify_prerelease_branch
- yellow "Committing changes."
- git add --all
- git commit -am "Changelog and readme.txt edits."
# If we're releasing Jetpack and it's a beta, amend the readme.txt
if [[ -v PROJECTS["plugins/jetpack"] && "${PROJECTS[plugins/jetpack]}" == *-beta ]]; then
yellow "Releasing a beta for Jetpack, amending the readme.txt"
pnpm jetpack changelog squash plugins/jetpack readme
- git commit -am "Amend readme.txt"
+ fi
+
+ if ! git diff --quiet; then
+ git commit -am "Update readme.txt"
fi
}
From fe0258c3ea8e7f4324826b432d9d3e25850c45de Mon Sep 17 00:00:00 2001
From: Christian Gastrell
Date: Tue, 13 Oct 2026 12:42:41 -0300
Subject: [PATCH 14/57] Backup: flip the folder chevrons and back arrows in
right-to-left languages (#53485)
* Backup: flip directional icons in RTL
Closed-folder chevrons and the Back to overview arrows now follow isRTL().
Co-Authored-By: Claude Sonnet 5.5
* Backup: Cover the back arrow in LTR and name its direction correctly
Co-Authored-By: Claude Sonnet 5.5
---------
Co-authored-by: Claude Sonnet 5.5
---
.../changelog/fix-backup-rtl-direction-icons | 5 ++
.../components/file-browser/index.tsx | 8 +++-
.../components/invalid-rewind-id/index.tsx | 6 +--
.../backup/src/dashboard/screens/download.tsx | 5 +-
.../backup/src/dashboard/screens/restore.tsx | 5 +-
.../backup/tests/file-browser-icons.test.tsx | 23 ++++++++-
.../backup/tests/invalid-rewind-id.test.tsx | 47 +++++++++++++++++++
7 files changed, 89 insertions(+), 10 deletions(-)
create mode 100644 projects/packages/backup/changelog/fix-backup-rtl-direction-icons
diff --git a/projects/packages/backup/changelog/fix-backup-rtl-direction-icons b/projects/packages/backup/changelog/fix-backup-rtl-direction-icons
new file mode 100644
index 000000000000..cd023cb75b28
--- /dev/null
+++ b/projects/packages/backup/changelog/fix-backup-rtl-direction-icons
@@ -0,0 +1,5 @@
+Significance: patch
+Type: fixed
+Comment: Flip directional icons in right-to-left languages in the modernized dashboard, which is behind a feature flag.
+
+
diff --git a/projects/packages/backup/src/dashboard/components/file-browser/index.tsx b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx
index f9949e83e3b7..fe839dca8b96 100644
--- a/projects/packages/backup/src/dashboard/components/file-browser/index.tsx
+++ b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx
@@ -1,12 +1,13 @@
import { CheckboxControl } from '@wordpress/components';
import { useMergeRefs } from '@wordpress/compose';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from '@wordpress/element';
-import { __, _n, sprintf } from '@wordpress/i18n';
+import { __, _n, isRTL, sprintf } from '@wordpress/i18n';
// The upstream names don't describe what they draw: `file` is a folder
// glyph, `page` is a document one, and there is no `folder` export.
// `tests/file-browser-icons.test.tsx` holds these aliases in place.
import {
Icon,
+ chevronLeft,
chevronRight,
chevronDown,
file as folderIcon,
@@ -588,6 +589,9 @@ type NodeRowProps = {
onRegisterChildren: ( path: string, children: FileNode[] ) => void;
};
+// The closed-folder chevron points toward the inline end, so it flips in RTL.
+const closedChevron = () => ( isRTL() ? chevronLeft : chevronRight );
+
/**
* Recursive row inside the file-browser tree. Folders own their own expand state; while a folder is open, `useFileTree` keeps its children resolved (re-collapsing and re-opening re-issues the fetch).
*
@@ -731,7 +735,7 @@ function NodeRow( {
) }
onClick={ handleToggleOpen }
>
-
+ { node.name }
diff --git a/projects/packages/backup/src/dashboard/components/invalid-rewind-id/index.tsx b/projects/packages/backup/src/dashboard/components/invalid-rewind-id/index.tsx
index 270d10851138..0209af8bcae3 100644
--- a/projects/packages/backup/src/dashboard/components/invalid-rewind-id/index.tsx
+++ b/projects/packages/backup/src/dashboard/components/invalid-rewind-id/index.tsx
@@ -1,5 +1,5 @@
-import { __ } from '@wordpress/i18n';
-import { Icon, arrowLeft } from '@wordpress/icons';
+import { __, isRTL } from '@wordpress/i18n';
+import { Icon, arrowLeft, arrowRight } from '@wordpress/icons';
import { Link } from '@wordpress/route';
import { Card, Stack, Text } from '@wordpress/ui';
import DashboardLayout from '../dashboard-layout';
@@ -38,7 +38,7 @@ export default function InvalidRewindId( { prefix, title, body }: Props ) {
-
+
{ __( 'Back to overview', 'jetpack-backup-pkg' ) }
diff --git a/projects/packages/backup/src/dashboard/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx
index aa7a5b84af78..8734dea3de9a 100644
--- a/projects/packages/backup/src/dashboard/screens/download.tsx
+++ b/projects/packages/backup/src/dashboard/screens/download.tsx
@@ -1,12 +1,13 @@
import { ProgressBar, VisuallyHidden } from '@wordpress/components';
import { dateI18n } from '@wordpress/date';
import { useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
-import { __, sprintf } from '@wordpress/i18n';
+import { __, isRTL, sprintf } from '@wordpress/i18n';
import {
Icon,
check,
download as downloadIcon,
arrowLeft,
+ arrowRight,
error as errorIcon,
} from '@wordpress/icons';
import { Link, useParams, useSearch } from '@wordpress/route';
@@ -139,7 +140,7 @@ export default function DownloadScreen() {
-
+
{ __( 'Back to overview', 'jetpack-backup-pkg' ) }
diff --git a/projects/packages/backup/src/dashboard/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx
index 84fee0a2189a..5c16eed263ba 100644
--- a/projects/packages/backup/src/dashboard/screens/restore.tsx
+++ b/projects/packages/backup/src/dashboard/screens/restore.tsx
@@ -1,10 +1,11 @@
import { ProgressBar, VisuallyHidden } from '@wordpress/components';
import { dateI18n } from '@wordpress/date';
import { useCallback, useState } from '@wordpress/element';
-import { __, sprintf } from '@wordpress/i18n';
+import { __, isRTL, sprintf } from '@wordpress/i18n';
import {
Icon,
arrowLeft,
+ arrowRight,
backup as backupIcon,
caution,
cloudDownload,
@@ -103,7 +104,7 @@ export default function RestoreScreen() {