From efc60a554320b338379ce6c9aa51bf6353e52895 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Tue, 13 Oct 2026 16:16:47 +0530 Subject: [PATCH 01/57] WOOA7S-2294: Premium Analytics: Add Build URL to empty UTM widget (#53458) --- .../changelog/wooa7s-2294-utm-empty-builder | 4 + .../packages/externals/src/index.ts | 2 + .../__tests__/utm-insights.test.tsx | 14 + .../__tests__/utm-url-builder-dialog.test.tsx | 146 ++++++++++ .../widgets/utm-insights/package.json | 1 + .../widgets/utm-insights/render.tsx | 20 +- .../stories/utm-insights-widget.stories.tsx | 3 +- .../utm-url-builder-dialog.module.scss | 61 +++++ .../utm-insights/utm-url-builder-dialog.tsx | 252 ++++++++++++++++++ .../changelog/wooa7s-2294-utm-empty-builder | 4 + .../changelog/wooa7s-2294-utm-empty-builder | 4 + 11 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-2294-utm-empty-builder create mode 100644 projects/packages/premium-analytics/widgets/utm-insights/__tests__/utm-url-builder-dialog.test.tsx create mode 100644 projects/packages/premium-analytics/widgets/utm-insights/utm-url-builder-dialog.module.scss create mode 100644 projects/packages/premium-analytics/widgets/utm-insights/utm-url-builder-dialog.tsx create mode 100644 projects/plugins/jetpack/changelog/wooa7s-2294-utm-empty-builder create mode 100644 projects/plugins/premium-analytics/changelog/wooa7s-2294-utm-empty-builder diff --git a/projects/packages/premium-analytics/changelog/wooa7s-2294-utm-empty-builder b/projects/packages/premium-analytics/changelog/wooa7s-2294-utm-empty-builder new file mode 100644 index 000000000000..13fd400df997 --- /dev/null +++ b/projects/packages/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. diff --git a/projects/packages/premium-analytics/packages/externals/src/index.ts b/projects/packages/premium-analytics/packages/externals/src/index.ts index 9e13566f8f79..48d7e6002102 100644 --- a/projects/packages/premium-analytics/packages/externals/src/index.ts +++ b/projects/packages/premium-analytics/packages/externals/src/index.ts @@ -79,6 +79,7 @@ export { Icon, IconButton, Input, + InputControl, Link, Menu, Notice, @@ -93,6 +94,7 @@ export { Text, TextareaControl, Tooltip, + ValidatedInputControl, VisuallyHidden, } from '@wordpress/ui'; diff --git a/projects/packages/premium-analytics/widgets/utm-insights/__tests__/utm-insights.test.tsx b/projects/packages/premium-analytics/widgets/utm-insights/__tests__/utm-insights.test.tsx index fe5ed0ea6e84..bbac052c1d1e 100644 --- a/projects/packages/premium-analytics/widgets/utm-insights/__tests__/utm-insights.test.tsx +++ b/projects/packages/premium-analytics/widgets/utm-insights/__tests__/utm-insights.test.tsx @@ -40,6 +40,20 @@ afterEach( () => { } ); describe( 'UtmInsightsWidget', () => { + it( 'offers Build URL for an empty selection and keeps View all available', async () => { + const user = userEvent.setup(); + const { rerender } = render( ); + + expect( screen.getByRole( 'link', { name: 'View all' } ) ).toBeInTheDocument(); + await user.click( screen.getByRole( 'button', { name: 'Build URL' } ) ); + expect( screen.getByRole( 'dialog', { name: 'Build URL' } ) ).toBeInTheDocument(); + + await user.keyboard( '{Escape}' ); + mockRows = [ { label: 'newsletter / email', value: 18 } ]; + rerender( ); + expect( screen.queryByRole( 'button', { name: 'Build URL' } ) ).not.toBeInTheDocument(); + } ); + it.each( [ [ 'source-medium', {} ], [ 'campaign-source-medium', { utmDimension: 'utm_campaign,utm_source,utm_medium' } ], diff --git a/projects/packages/premium-analytics/widgets/utm-insights/__tests__/utm-url-builder-dialog.test.tsx b/projects/packages/premium-analytics/widgets/utm-insights/__tests__/utm-url-builder-dialog.test.tsx new file mode 100644 index 000000000000..0af124c8ccc3 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/utm-insights/__tests__/utm-url-builder-dialog.test.tsx @@ -0,0 +1,146 @@ +/** + * External dependencies + */ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +/** + * Internal dependencies + */ +import { UtmUrlBuilderDialog } from '../utm-url-builder-dialog'; + +const originalClipboard = Object.getOwnPropertyDescriptor( navigator, 'clipboard' ); + +beforeEach( () => { + jest.useFakeTimers(); +} ); + +afterEach( () => { + jest.useRealTimers(); + if ( originalClipboard ) { + Object.defineProperty( navigator, 'clipboard', originalClipboard ); + } else { + delete ( navigator as Navigator & { clipboard?: Clipboard } ).clipboard; + } +} ); + +it( 'copies encoded UTM values, keeps the destination query and fragment, and stays open', async () => { + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + const writeText = jest.fn().mockResolvedValue( undefined ); + Object.defineProperty( navigator, 'clipboard', { configurable: true, value: { writeText } } ); + const onClose = jest.fn(); + render( ); + + await user.type( + screen.getByRole( 'textbox', { name: 'Destination URL' } ), + 'https://example.com/landing?ref=email&utm_source=old#intro' + ); + await user.type( screen.getByRole( 'textbox', { name: 'UTM campaign' } ), 'spring sale' ); + await user.type( screen.getByRole( 'textbox', { name: 'UTM source' } ), 'newsletter' ); + await user.type( screen.getByRole( 'textbox', { name: 'UTM medium' } ), 'email & social' ); + await user.click( screen.getByRole( 'button', { name: 'Copy to clipboard' } ) ); + + const copied = new URL( writeText.mock.calls[ 0 ][ 0 ] ); + expect( copied.searchParams.get( 'ref' ) ).toBe( 'email' ); + expect( copied.searchParams.get( 'utm_campaign' ) ).toBe( 'spring sale' ); + expect( copied.searchParams.get( 'utm_source' ) ).toBe( 'newsletter' ); + expect( copied.searchParams.get( 'utm_medium' ) ).toBe( 'email & social' ); + expect( copied.hash ).toBe( '#intro' ); + await expect( screen.findByRole( 'button', { name: 'Copied!' } ) ).resolves.toBeInTheDocument(); + expect( onClose ).not.toHaveBeenCalled(); + expect( screen.getByRole( 'textbox', { name: 'UTM campaign' } ) ).toHaveValue( 'spring sale' ); +} ); + +it.each( [ + [ + 'the URL is edited', + ( user: ReturnType< typeof userEvent.setup > ) => + user.type( screen.getByRole( 'textbox', { name: 'UTM medium' } ), 'email' ), + ], + [ 'four seconds pass', async () => act( () => jest.advanceTimersByTime( 4000 ) ) ], +] )( 'stops confirming the copy once %s', async ( _reason, change ) => { + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + Object.defineProperty( navigator, 'clipboard', { + configurable: true, + value: { writeText: jest.fn().mockResolvedValue( undefined ) }, + } ); + render( ); + await user.type( + screen.getByRole( 'textbox', { name: 'Destination URL' } ), + 'https://example.com/' + ); + await user.type( screen.getByRole( 'textbox', { name: 'UTM source' } ), 'newsletter' ); + await user.click( screen.getByRole( 'button', { name: 'Copy to clipboard' } ) ); + await expect( screen.findByRole( 'button', { name: 'Copied!' } ) ).resolves.toBeInTheDocument(); + + await change( user ); + + expect( screen.getByRole( 'button', { name: 'Copy to clipboard' } ) ).toBeInTheDocument(); +} ); + +it( 'does not offer a share URL for an invalid destination or no UTM parameters', async () => { + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + const writeText = jest.fn().mockResolvedValue( undefined ); + Object.defineProperty( navigator, 'clipboard', { configurable: true, value: { writeText } } ); + const onClose = jest.fn(); + render( ); + const destination = screen.getByRole( 'textbox', { name: 'Destination URL' } ); + const source = screen.getByRole( 'textbox', { name: 'UTM source' } ); + const copy = screen.getByRole( 'button', { name: 'Copy to clipboard' } ); + + await user.type( destination, 'javascript:alert(1)' ); + await user.type( source, 'newsletter' ); + expect( copy ).toHaveAttribute( 'aria-disabled', 'true' ); + + await user.clear( destination ); + await user.type( destination, 'https://example.com/' ); + expect( copy ).toHaveAttribute( 'aria-disabled', 'false' ); + await user.clear( source ); + expect( copy ).toHaveAttribute( 'aria-disabled', 'true' ); + await user.type( source, ' ' ); + expect( copy ).toHaveAttribute( 'aria-disabled', 'true' ); + await user.click( copy ); + expect( writeText ).not.toHaveBeenCalled(); + expect( onClose ).not.toHaveBeenCalled(); +} ); + +it( 'drops a UTM value the destination carries when its field is left empty', async () => { + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + render( ); + + await user.type( + screen.getByRole( 'textbox', { name: 'Destination URL' } ), + 'https://example.com/?utm_medium=old' + ); + await user.type( screen.getByRole( 'textbox', { name: 'UTM source' } ), 'newsletter' ); + + const shared = new URL( screen.getByText( /utm_source=newsletter/ ).textContent ?? '' ); + expect( shared.searchParams.has( 'utm_medium' ) ).toBe( false ); +} ); + +it( 'asks for the destination when only UTM parameters are filled', async () => { + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + render( ); + + await user.type( screen.getByRole( 'textbox', { name: 'UTM source' } ), 'newsletter' ); + + expect( screen.getByText( 'Enter a destination URL to see the URL' ) ).toBeInTheDocument(); + expect( + screen.queryByText( 'Fill out campaign parameters to see the URL' ) + ).not.toBeInTheDocument(); +} ); + +it( 'asks for a protocol once the destination without one loses focus', async () => { + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); + render( ); + const destination = screen.getByRole( 'textbox', { name: 'Destination URL' } ); + const hint = 'Enter a full URL that starts with https:// or http://.'; + + await user.type( destination, 'example.com' ); + expect( screen.queryByText( hint ) ).not.toBeInTheDocument(); + await user.tab(); + expect( destination ).toHaveAccessibleDescription( hint ); + + await user.clear( destination ); + await user.type( destination, 'https://example.com' ); + expect( screen.queryByText( hint ) ).not.toBeInTheDocument(); +} ); diff --git a/projects/packages/premium-analytics/widgets/utm-insights/package.json b/projects/packages/premium-analytics/widgets/utm-insights/package.json index b0140022e738..e555cd281f6f 100644 --- a/projects/packages/premium-analytics/widgets/utm-insights/package.json +++ b/projects/packages/premium-analytics/widgets/utm-insights/package.json @@ -8,6 +8,7 @@ "@jetpack-premium-analytics/externals": "link:../../packages/externals", "@jetpack-premium-analytics/icons": "link:../../packages/icons", "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit", + "@wordpress/compose": "8.8.0", "@wordpress/element": "8.7.0", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^16.0.0", diff --git a/projects/packages/premium-analytics/widgets/utm-insights/render.tsx b/projects/packages/premium-analytics/widgets/utm-insights/render.tsx index c18aa3e326f3..9055f74772eb 100644 --- a/projects/packages/premium-analytics/widgets/utm-insights/render.tsx +++ b/projects/packages/premium-analytics/widgets/utm-insights/render.tsx @@ -1,7 +1,8 @@ /** - * WordPress dependencies + * External dependencies */ -import { useEffect, useMemo } from '@wordpress/element'; +import { search } from '@jetpack-premium-analytics/icons'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { WIDGET_ROW_LIMIT, @@ -32,6 +33,7 @@ import { */ import styles from './style.module.css'; import useUtmInsights from './use-utm-insights'; +import { UtmUrlBuilderDialog } from './utm-url-builder-dialog'; import { type UtmInsightsAttributes } from './widget'; /** * Types @@ -59,6 +61,8 @@ type UtmInsightsInnerProps = { function UtmInsightsInner( { utmDimension, showReportLink }: UtmInsightsInnerProps ) { const { reportParams } = useWidgetRootContext(); + const [ isBuilderOpen, setBuilderOpen ] = useState( false ); + const closeBuilder = useCallback( () => setBuilderOpen( false ), [] ); const reportSection = getUtmReportSection( utmDimension ); const { drillDownItem: selectedUtmLabel, @@ -172,6 +176,17 @@ function UtmInsightsInner( { utmDimension, showReportLink }: UtmInsightsInnerPro isFetching={ isFetching } isError={ isError } isEmpty={ data.length === 0 } + empty={ { + icon: search, + description: __( + 'You don’t have UTM visits for this period. Build a URL to start tracking a campaign.', + 'jetpack-premium-analytics-pkg' + ), + action: { + label: __( 'Build URL', 'jetpack-premium-analytics-pkg' ), + onClick: () => setBuilderOpen( true ), + }, + } } error={ describeError( error, { retryDescription: __( "We couldn't load UTM data. Please try again in a moment.", @@ -200,6 +215,7 @@ function UtmInsightsInner( { utmDimension, showReportLink }: UtmInsightsInnerPro /> ) } + { isBuilderOpen && } ); } diff --git a/projects/packages/premium-analytics/widgets/utm-insights/stories/utm-insights-widget.stories.tsx b/projects/packages/premium-analytics/widgets/utm-insights/stories/utm-insights-widget.stories.tsx index f67a1b698cc3..7527b340cf57 100644 --- a/projects/packages/premium-analytics/widgets/utm-insights/stories/utm-insights-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/utm-insights/stories/utm-insights-widget.stories.tsx @@ -142,8 +142,7 @@ export const ErrorRetryable: Story = { }; /** - * Resolved with no rows: the widget shows the generic empty state (the magnifier - * glyph and "We couldn’t find results for this time period."). + * Resolved with no rows: the widget offers Build URL and keeps View all available. */ export const Empty: Story = { render: () => renderUtmInsightsOnPreset( 'last-year' ), diff --git a/projects/packages/premium-analytics/widgets/utm-insights/utm-url-builder-dialog.module.scss b/projects/packages/premium-analytics/widgets/utm-insights/utm-url-builder-dialog.module.scss new file mode 100644 index 000000000000..ffba97191e3f --- /dev/null +++ b/projects/packages/premium-analytics/widgets/utm-insights/utm-url-builder-dialog.module.scss @@ -0,0 +1,61 @@ +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr); + gap: var(--wpds-dimension-gap-xl); +} + +.form { + display: grid; + align-content: start; + gap: var(--wpds-dimension-gap-md); + min-width: 0; +} + +.result { + display: grid; + gap: var(--wpds-dimension-gap-xs); + + .resultValue { + box-sizing: border-box; + display: block; + inline-size: 100%; + padding: var(--wpds-dimension-gap-sm); + border-radius: var(--wpds-border-radius-sm); + background: var(--wpds-color-background-surface-neutral-weak); + overflow-wrap: anywhere; + } +} + +.help { + display: grid; + align-content: start; + gap: var(--wpds-dimension-gap-md); + padding: var(--wpds-dimension-gap-lg); + border-radius: var(--wpds-border-radius-md); + background: var(--wpds-color-background-surface-neutral-weak); + + dl { + margin-block: 0; + } + + dt { + margin-block-start: var(--wpds-dimension-gap-md); + } + + dd { + margin-inline-start: 0; + margin-block-start: var(--wpds-dimension-gap-xs); + + em { + display: block; + color: var(--wpds-color-foreground-content-neutral-weak); + } + } +} + +@media ( max-width: 700px ) { + + .layout { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/projects/packages/premium-analytics/widgets/utm-insights/utm-url-builder-dialog.tsx b/projects/packages/premium-analytics/widgets/utm-insights/utm-url-builder-dialog.tsx new file mode 100644 index 000000000000..6d2489cb016c --- /dev/null +++ b/projects/packages/premium-analytics/widgets/utm-insights/utm-url-builder-dialog.tsx @@ -0,0 +1,252 @@ +/** + * External dependencies + */ +import { + Button, + Dialog, + InputControl, + Text, + ValidatedInputControl, +} from '@jetpack-premium-analytics/externals'; +import { useCopyToClipboard } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +/** + * Internal dependencies + */ +import styles from './utm-url-builder-dialog.module.scss'; + +const COPIED_LABEL_DURATION = 4000; + +type UtmValues = { + destination: string; + campaign: string; + source: string; + medium: string; +}; + +function parseDestination( destination: string ): URL | null { + const address = destination.trim(); + if ( ! /^https?:\/\//i.test( address ) ) { + return null; + } + + try { + return new URL( address ); + } catch { + return null; + } +} + +function buildTrackedUrl( + url: URL | null, + { campaign, source, medium }: Omit< UtmValues, 'destination' > +): string | null { + if ( ! url ) { + return null; + } + + const parameters = { + utm_campaign: campaign.trim(), + utm_source: source.trim(), + utm_medium: medium.trim(), + }; + if ( ! Object.values( parameters ).some( Boolean ) ) { + return null; + } + + for ( const [ key, value ] of Object.entries( parameters ) ) { + url.searchParams.delete( key ); + if ( value ) { + url.searchParams.set( key, value ); + } + } + + return url.toString(); +} + +export function UtmUrlBuilderDialog( { onClose }: { onClose: () => void } ) { + const [ values, setValues ] = useState< UtmValues >( { + destination: '', + campaign: '', + source: '', + medium: '', + } ); + const [ copiedUrl, setCopiedUrl ] = useState< string | null >( null ); + const copiedTimeout = useRef< ReturnType< typeof setTimeout > >(); + const destinationUrl = parseDestination( values.destination ); + const isDestinationInvalid = values.destination.trim() !== '' && ! destinationUrl; + const destinationValidity = useMemo( + () => + isDestinationInvalid + ? { + type: 'invalid' as const, + message: __( + 'Enter a full URL that starts with https:// or http://.', + 'jetpack-premium-analytics-pkg' + ), + } + : undefined, + [ isDestinationInvalid ] + ); + const trackedUrl = buildTrackedUrl( destinationUrl, values ); + const hasParameters = [ values.campaign, values.source, values.medium ].some( value => + value.trim() + ); + + const setField = useCallback( ( field: keyof UtmValues, value: string ) => { + setValues( current => ( { ...current, [ field ]: value } ) ); + }, [] ); + const setDestination = useCallback( + ( value: string ) => setField( 'destination', value ), + [ setField ] + ); + const setCampaign = useCallback( + ( value: string ) => setField( 'campaign', value ), + [ setField ] + ); + const setSource = useCallback( ( value: string ) => setField( 'source', value ), [ setField ] ); + const setMedium = useCallback( ( value: string ) => setField( 'medium', value ), [ setField ] ); + const handleOpenChange = useCallback( + ( open: boolean ) => { + if ( ! open ) { + onClose(); + } + }, + [ onClose ] + ); + + const copyRef = useCopyToClipboard< HTMLButtonElement >( trackedUrl ?? '', () => { + setCopiedUrl( trackedUrl ); + clearTimeout( copiedTimeout.current ); + copiedTimeout.current = setTimeout( () => setCopiedUrl( null ), COPIED_LABEL_DURATION ); + } ); + useEffect( () => () => clearTimeout( copiedTimeout.current ), [] ); + // Compared with the current URL, so an edit after a copy ends the confirmation. + const isCopied = copiedUrl !== null && copiedUrl === trackedUrl; + + return ( + + + + { __( 'Build URL', 'jetpack-premium-analytics-pkg' ) } + + + +
+
+ }> + { __( + 'Generate URLs to share and track UTM parameters.', + 'jetpack-premium-analytics-pkg' + ) } + + + + + +
+ }> + { __( 'Your URL to share', 'jetpack-premium-analytics-pkg' ) } + +
+ { 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 && ( <>
-

{ title }

+

+ { heading } +

{ __( 'At least one user must be connected for your Jetpack products to work properly.', 'jetpack-connection-js' ) } - { isCurrentUserAdmin && + { transferView && ( + + ) } + { ! transferView && + isCurrentUserAdmin && connectedUser.currentUser?.isConnected && connectedUser.currentUser?.isMaster && ( ) } - { connectedUser.currentUser?.isConnected && ( + { ! transferView && connectedUser.currentUser?.isConnected && ( <> { '' !== unlinkError && } ) } - { isCurrentUserAdmin && ! isWoASite() && ( + { ! transferView && isCurrentUserAdmin && ! isWoASite() && ( ) }
- +
@@ -299,9 +363,11 @@ interface HelpFooterProps { onClose: () => void; /** Whether the cancel button is disabled. */ disabled?: boolean; + /** Label for the dismiss button, which depends on where it takes the user. */ + dismissLabel: string; } -const HelpFooter = ( { onClose, disabled }: HelpFooterProps ) => { +const HelpFooter = ( { onClose, disabled, dismissLabel }: HelpFooterProps ) => { return (
@@ -315,7 +381,7 @@ const HelpFooter = ( { onClose, disabled }: HelpFooterProps ) => { className="jp-connection__manage-dialog__btn-dismiss" disabled={ disabled } > - { __( 'Cancel', 'jetpack-connection-js' ) } + { dismissLabel }
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 ( + + ); +}; + +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 .= '

'; } - $out .= do_shortcode( $content ); + $out .= apply_shortcodes( $content ); $out .= ''; @@ -392,7 +392,7 @@ public function slide_shortcode( $atts, $content = '' ) { ); $out .= '
'; - $out .= do_shortcode( $content ); + $out .= apply_shortcodes( $content ); $out .= '
'; return $out; diff --git a/projects/plugins/jetpack/modules/shortcodes/quiz.php b/projects/plugins/jetpack/modules/shortcodes/quiz.php index d0af5e62a28c..4392156fc552 100644 --- a/projects/plugins/jetpack/modules/shortcodes/quiz.php +++ b/projects/plugins/jetpack/modules/shortcodes/quiz.php @@ -254,7 +254,7 @@ private static function do_shortcode( $content ) { // Add internal parameter so it's only rendered when it has it. $content = preg_replace( '/\[(question|answer|wrong|explanation)\]/i', '[$1 quiz_item="true"]', $content ); - $content = do_shortcode( $content ); + $content = apply_shortcodes( $content ); $content = wp_kses( $content, array( diff --git a/projects/plugins/jetpack/modules/shortcodes/recipe.php b/projects/plugins/jetpack/modules/shortcodes/recipe.php index c79c2faa477e..6ffb67b0e1df 100644 --- a/projects/plugins/jetpack/modules/shortcodes/recipe.php +++ b/projects/plugins/jetpack/modules/shortcodes/recipe.php @@ -297,7 +297,7 @@ private static function recipe_shortcode_html( $atts, $content = '' ) { } // Print content between codes. - $html .= '
' . do_shortcode( $content ) . '
'; + $html .= '
' . apply_shortcodes( $content ) . '
'; // Close it up. $html .= ''; @@ -528,10 +528,10 @@ private static function output_list_content( $content, $type ) { $empty_p_pattern = '/(

)*\s*<\/p>/mi'; $html = preg_replace( $empty_p_pattern, '', $html ); } else { - $html .= do_shortcode( $content ); + $html .= apply_shortcodes( $content ); } } else { - $html .= do_shortcode( $content ); + $html .= apply_shortcodes( $content ); } // Return our formatted content. From dfe5a24627ee57b45397659af95ad30df0daf0b8 Mon Sep 17 00:00:00 2001 From: Dean Sas Date: Tue, 13 Oct 2026 15:15:49 +0100 Subject: [PATCH 10/57] Themes: share one WordPress.com themes data layer between the Marketplace tab and wpcomsh (#53511) The api, cache, mapper and a new tier reader move to jetpack-mu-wpcom, which both wpcomsh and WordPress.com Simple load; merging into core's tabs and symlink install stay in wpcomsh. Along the way: * The Marketplace tab lists the whole catalog below the Business plan, where the plan-based removal of WordPress.org themes used to empty the result it answered with. * A failed read of the theme list is no longer cached for a day, so the next request retries rather than every tab staying empty until the entry expires. * Entitlement by tier is answered in one place, leaving each surface its own choice about showing partner themes. --- .../dotcom-18877-dont-cache-failures | 4 + .../dotcom-18877-shared-themes-data-layer | 4 + .../wpcom-themes}/class-wpcom-themes-api.php | 11 +- .../class-wpcom-themes-cache.php | 13 +- .../class-wpcom-themes-mapper.php | 11 +- .../wpcom-themes/class-wpcom-themes-tiers.php | 62 +++++ .../wpcom-themes/wpcom-themes-tab.php | 230 +++++------------- .../wpcom-themes/Wpcom_Themes_Tab_Test.php | 46 ++-- .../wpcom-themes/Wpcom_Themes_Tiers_Test.php | 30 +++ .../dotcom-18877-dont-cache-failures | 4 + .../dotcom-18877-honest-dot-org-removal | 4 + .../tests/WpcomThemesPlanFilterTest.php | 86 +++++++ .../includes/class-wpcom-themes-service.php | 10 +- .../plugins/wpcomsh/wpcom-themes/themes.php | 51 +++- 14 files changed, 358 insertions(+), 208 deletions(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/dotcom-18877-dont-cache-failures create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/dotcom-18877-shared-themes-data-layer rename projects/{plugins/wpcomsh/wpcom-themes/includes => packages/jetpack-mu-wpcom/src/features/wpcom-themes}/class-wpcom-themes-api.php (91%) rename projects/{plugins/wpcomsh/wpcom-themes/includes => packages/jetpack-mu-wpcom/src/features/wpcom-themes}/class-wpcom-themes-cache.php (67%) rename projects/{plugins/wpcomsh/wpcom-themes/includes => packages/jetpack-mu-wpcom/src/features/wpcom-themes}/class-wpcom-themes-mapper.php (93%) create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-tiers.php create mode 100644 projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-themes/Wpcom_Themes_Tiers_Test.php create mode 100644 projects/plugins/wpcomsh/changelog/dotcom-18877-dont-cache-failures create mode 100644 projects/plugins/wpcomsh/changelog/dotcom-18877-honest-dot-org-removal create mode 100644 projects/plugins/wpcomsh/tests/WpcomThemesPlanFilterTest.php diff --git a/projects/packages/jetpack-mu-wpcom/changelog/dotcom-18877-dont-cache-failures b/projects/packages/jetpack-mu-wpcom/changelog/dotcom-18877-dont-cache-failures new file mode 100644 index 000000000000..a11b372c1e09 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/dotcom-18877-dont-cache-failures @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Themes: retry after a failed read of the WordPress.com theme list, rather than caching the failure for a day. diff --git a/projects/packages/jetpack-mu-wpcom/changelog/dotcom-18877-shared-themes-data-layer b/projects/packages/jetpack-mu-wpcom/changelog/dotcom-18877-shared-themes-data-layer new file mode 100644 index 000000000000..217052d167d0 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/dotcom-18877-shared-themes-data-layer @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Themes: share the WordPress.com themes API, cache, mapper and tier reader with wpcomsh rather than each keeping a copy. diff --git a/projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-api.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-api.php similarity index 91% rename from projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-api.php rename to projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-api.php index 0000bc51ad68..0cf9d020dff4 100644 --- a/projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-api.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-api.php @@ -3,7 +3,7 @@ * Class WPCom_Themes_Api. * Retrieves themes from the WordPress.com themes API. * - * @package wpcom-themes + * @package automattic/jetpack-mu-wpcom */ /** @@ -51,8 +51,13 @@ protected function fetch_themes( string $cache_key, array $params = array() ): a return $this->cache->run_cached( $cache_key, - fn() => $this->handle_request( $url )->themes ?? array() - ); + function () use ( $url ) { + $body = $this->handle_request( $url ); + + // Null, not an empty array: run_cached() stores anything else. + return isset( $body->themes ) && is_array( $body->themes ) ? $body->themes : null; + } + ) ?? array(); } /** diff --git a/projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-cache.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-cache.php similarity index 67% rename from projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-cache.php rename to projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-cache.php index d124299131cb..8e213352c729 100644 --- a/projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-cache.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-cache.php @@ -3,7 +3,7 @@ * Class WPCom_Themes_Cache. * Caches WPCom themes. * - * @package wpcom-themes + * @package automattic/jetpack-mu-wpcom */ /** @@ -29,8 +29,15 @@ class WPCom_Themes_Cache { public function run_cached( string $cache_key, callable $lambda, int $ttl = DAY_IN_SECONDS ) { $data = wp_cache_get( $cache_key, self::CACHE_GROUP ); - if ( false === $data || defined( 'IGNORE_CACHED_WPCOM_THEMES' ) ) { - $data = $lambda(); + if ( false !== $data && ! defined( 'IGNORE_CACHED_WPCOM_THEMES' ) ) { + return $data; + } + + $data = $lambda(); + + // Null means the read failed rather than came back empty, and a failure is not cached: a + // moment's trouble reaching WordPress.com would otherwise empty the theme lists for a day. + if ( null !== $data ) { wp_cache_set( $cache_key, $data, self::CACHE_GROUP, $ttl ); } diff --git a/projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-mapper.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-mapper.php similarity index 93% rename from projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-mapper.php rename to projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-mapper.php index 4243b072b082..d77747c83aa1 100644 --- a/projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-mapper.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-mapper.php @@ -3,7 +3,7 @@ * Class WPCom_Themes_Mapper. * Responsible for mapping theme objects between WPCom and WPOrg formats. * - * @package wpcom-themes + * @package automattic/jetpack-mu-wpcom */ /** @@ -46,7 +46,14 @@ public function map_wpcom_to_wporg( stdClass $wpcom_theme ): stdClass { $theme = new stdClass(); $theme->name = $wpcom_theme->name; $theme->slug = $wpcom_theme->id; - $theme->preview_url = $wpcom_theme->demo_uri . '?demo=true&iframe=true&theme_preview=true'; + $theme->preview_url = add_query_arg( + array( + 'demo' => 'true', + 'iframe' => 'true', + 'theme_preview' => 'true', + ), + $wpcom_theme->demo_uri + ); $theme->author = array( 'display_name' => $wpcom_theme->author ); $theme->screenshot_url = $wpcom_theme->screenshot; $theme->homepage = "https://wordpress.com/theme/$wpcom_theme->id"; diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-tiers.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-tiers.php new file mode 100644 index 000000000000..0e1e337781d0 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/class-wpcom-themes-tiers.php @@ -0,0 +1,62 @@ +theme_tier->slug ?? 'premium'; + } + + /** + * The capability name gating a tier. + * + * @param string $tier Tier slug. + * @return string + */ + public static function feature_for( string $tier ): string { + return $tier . '-themes'; + } + + /** + * Whether this site's plan includes a tier. + * + * Necessary but not sufficient for the partner tier, which every paid plan includes while each + * partner theme still needs its own subscription that no plan feature reports. + * + * @param string $tier Tier slug. + * @return bool True for the free tier, which needs no plan. For the rest, false when the + * platform cannot answer, so an unknown host offers nothing. + */ + public static function site_can_use( string $tier ): bool { + // There is no free-themes capability, so asking the platform would deny every free theme. + if ( self::FREE === $tier ) { + return true; + } + + return function_exists( 'wpcom_site_has_feature' ) + && (bool) wpcom_site_has_feature( self::feature_for( $tier ) ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/wpcom-themes-tab.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/wpcom-themes-tab.php index c3ce04a0b0b0..67e353de327f 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/wpcom-themes-tab.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/wpcom-themes-tab.php @@ -5,7 +5,6 @@ * @package automattic/jetpack-mu-wpcom */ -use Automattic\Jetpack\Connection\Client; use Automattic\Jetpack\Feature_Flags\Feature_Flags; use Automattic\Jetpack\Jetpack_Mu_Wpcom; @@ -24,18 +23,6 @@ */ const WPCOM_THEMES_TAB_PER_PAGE = 100; -/** - * Query args a theme's demo site reads to render as a preview, as the Calypso showcase passes them. - * - * Without these the demo site shows its own "get this theme on WordPress.com" promos inside the - * preview, and counts the view in the demo site's stats. - */ -const WPCOM_THEMES_TAB_PREVIEW_ARGS = array( - 'demo' => 'true', - 'iframe' => 'true', - 'theme_preview' => 'true', -); - /** * Registers the feature flag. * @@ -129,6 +116,52 @@ function wpcom_themes_tab_redirect_to_default_view() { exit; } add_action( 'load-theme-install.php', 'wpcom_themes_tab_redirect_to_default_view' ); +/** + * The shared WordPress.com themes reader, which wpcomsh also uses for core's own tabs. + * + * @return WPCom_Themes_Api + */ +function wpcom_themes_tab_api() { + static $api; + if ( ! $api ) { + $api = new WPCom_Themes_Api( new WPCom_Themes_Cache() ); + } + + return $api; +} + +/** + * The shared mapper, which shapes a WordPress.com theme the way core's grid reads it. + * + * @return WPCom_Themes_Mapper + */ +function wpcom_themes_tab_mapper() { + static $mapper; + if ( ! $mapper ) { + $mapper = new WPCom_Themes_Mapper(); + } + + return $mapper; +} + +/** + * Every theme the tab lists, in the order WordPress.com returns them. + * + * The shared reader fetches and caches the whole catalog, so the tab pages through it here rather + * than asking for a page at a time. + * + * @return array + */ +function wpcom_themes_tab_catalog() { + return array_values( + array_filter( + wpcom_themes_tab_api()->fetch_all_non_delisted_themes(), + static function ( $theme ) { + return is_object( $theme ) && ! empty( $theme->id ) && is_string( $theme->id ); + } + ) + ); +} /** * Answers the tab's query in place of the WordPress.org themes API. @@ -145,7 +178,7 @@ function wpcom_themes_tab_serve_themes_api( $result, $action, $args ) { // A `?theme=` preview link, loaded directly, looks its theme up by slug rather than by tab. if ( ! isset( $args->browse ) && isset( $args->theme ) && is_string( $args->theme ) ) { - $theme = wpcom_themes_tab_find_theme( $args->theme ); + $theme = wpcom_themes_tab_api()->fetch_theme( $args->theme ); return null === $theme ? $result : (object) array( 'info' => array( @@ -153,7 +186,7 @@ function wpcom_themes_tab_serve_themes_api( $result, $action, $args ) { 'pages' => 1, 'results' => 1, ), - 'themes' => array( wpcom_themes_tab_to_api_theme( $theme ) ), + 'themes' => array( wpcom_themes_tab_mapper()->map_wpcom_to_wporg( $theme ) ), ); } @@ -161,168 +194,21 @@ function wpcom_themes_tab_serve_themes_api( $result, $action, $args ) { return $result; } - $page = isset( $args->page ) ? max( 1, (int) $args->page ) : 1; - $catalog = wpcom_themes_tab_get_catalog( $page ); + $themes = wpcom_themes_tab_catalog(); + $page = isset( $args->page ) ? max( 1, (int) $args->page ) : 1; + $mapper = wpcom_themes_tab_mapper(); + + // Only the page core asked for is mapped: mapping reads each theme from disk to say whether it + // is installed. + $shown = array_slice( $themes, ( $page - 1 ) * WPCOM_THEMES_TAB_PER_PAGE, WPCOM_THEMES_TAB_PER_PAGE ); return (object) array( 'info' => array( 'page' => $page, - 'pages' => (int) ceil( $catalog['found'] / WPCOM_THEMES_TAB_PER_PAGE ), - 'results' => $catalog['found'], + 'pages' => (int) ceil( count( $themes ) / WPCOM_THEMES_TAB_PER_PAGE ), + 'results' => count( $themes ), ), - 'themes' => array_map( 'wpcom_themes_tab_to_api_theme', $catalog['themes'] ), + 'themes' => array_values( array_map( array( $mapper, 'map_wpcom_to_wporg' ), $shown ) ), ); } add_filter( 'themes_api', 'wpcom_themes_tab_serve_themes_api', 10, 3 ); - -/** - * Shapes a catalog theme the way wp_ajax_query_themes() and the `tmpl-theme` template read it. - * - * @param array $theme A theme from wpcom_themes_tab_get_catalog(). - * @return object - */ -function wpcom_themes_tab_to_api_theme( array $theme ) { - return (object) array( - 'slug' => $theme['slug'], - 'name' => $theme['name'], - 'version' => $theme['version'], - 'author' => array( 'display_name' => $theme['author'] ), - 'description' => $theme['description'], - 'screenshot_url' => $theme['screenshot_url'], - 'preview_url' => $theme['preview_url'], - 'rating' => 0, - 'num_ratings' => 0, - 'requires' => false, - 'requires_php' => false, - ); -} - -/** - * The transient caching one page of the catalog. - * - * @param int $page Page number. - * @return string - */ -function wpcom_themes_tab_cache_key( $page ) { - return 'wpcom_themes_tab_v2_' . $page; -} - -/** - * One page of the themes the tab lists, cached. - * - * @param int $page Page number. - * @return array{themes: array[], found: int} Empty when WordPress.com cannot be read. - */ -function wpcom_themes_tab_get_catalog( $page = 1 ) { - $cache_key = wpcom_themes_tab_cache_key( $page ); - $cached = get_transient( $cache_key ); - if ( is_array( $cached ) ) { - return $cached; - } - - $catalog = wpcom_themes_tab_fetch_catalog( $page ); - if ( null === $catalog ) { - // Cache the miss briefly, so an outage costs one request per five minutes, not one per view. - $catalog = array( - 'themes' => array(), - 'found' => 0, - ); - set_transient( $cache_key, $catalog, 5 * MINUTE_IN_SECONDS ); - - return $catalog; - } - - set_transient( $cache_key, $catalog, 6 * HOUR_IN_SECONDS ); - - return $catalog; -} - -/** - * A theme whose `?theme=` preview this tab answers, read from WordPress.com by slug. - * - * Every theme WordPress.com has is answered here, including ones WordPress.org also carries, so a - * preview shows our screenshot and demo rather than WordPress.org's copy of the same theme. - * - * @param string $slug Theme slug. - * @return array|null Null when WordPress.com does not have the theme. - */ -function wpcom_themes_tab_find_theme( $slug ) { - if ( ! method_exists( Client::class, 'wpcom_json_api_request_as_blog' ) ) { - return null; - } - - $response = Client::wpcom_json_api_request_as_blog( '/themes/' . rawurlencode( $slug ), '1.2' ); - if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { - return null; - } - - return wpcom_themes_tab_to_catalog_theme( json_decode( wp_remote_retrieve_body( $response ), true ) ); -} - -/** - * Reads one page of themes from `/wpcom/v2/themes`. - * - * @param int $page Page number. - * @return array{themes: array[], found: int}|null Null on any failure. - */ -function wpcom_themes_tab_fetch_catalog( $page ) { - if ( ! method_exists( Client::class, 'wpcom_json_api_request_as_blog' ) ) { - return null; - } - - $path = add_query_arg( - array( - 'number' => WPCOM_THEMES_TAB_PER_PAGE, - 'page' => $page, - ), - '/themes' - ); - - $response = Client::wpcom_json_api_request_as_blog( $path, '2', array(), null, 'wpcom' ); - if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { - return null; - } - - $body = json_decode( wp_remote_retrieve_body( $response ), true ); - if ( ! is_array( $body ) || ! is_array( $body['themes'] ?? null ) ) { - return null; - } - - $themes = array(); - foreach ( $body['themes'] as $theme ) { - $mapped = wpcom_themes_tab_to_catalog_theme( $theme ); - if ( null !== $mapped ) { - $themes[] = $mapped; - } - } - - return array( - 'themes' => $themes, - 'found' => (int) ( $body['found'] ?? count( $themes ) ), - ); -} - -/** - * Shapes one theme from `/themes` or `/themes/` into the array the tab works with. - * - * @param mixed $theme A theme from either endpoint. - * @return array|null Null when the theme has no usable slug. - */ -function wpcom_themes_tab_to_catalog_theme( $theme ) { - // `id` is the bare directory name; `stylesheet` can carry a `pub/` prefix for WordPress.com's own themes. - if ( ! is_array( $theme ) || empty( $theme['id'] ) || ! is_string( $theme['id'] ) ) { - return null; - } - - $demo_uri = (string) ( $theme['demo_uri'] ?? '' ); - - return array( - 'slug' => $theme['id'], - 'name' => (string) ( $theme['name'] ?? $theme['id'] ), - 'author' => (string) ( $theme['author'] ?? '' ), - 'description' => (string) ( $theme['description'] ?? '' ), - 'version' => (string) ( $theme['version'] ?? '' ), - 'screenshot_url' => (string) ( $theme['screenshot'] ?? '' ), - 'preview_url' => '' === $demo_uri ? '' : add_query_arg( WPCOM_THEMES_TAB_PREVIEW_ARGS, $demo_uri ), - ); -} diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-themes/Wpcom_Themes_Tab_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-themes/Wpcom_Themes_Tab_Test.php index b98058bb8059..77a06270e7dd 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-themes/Wpcom_Themes_Tab_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-themes/Wpcom_Themes_Tab_Test.php @@ -38,6 +38,10 @@ class Wpcom_Themes_Tab_Test extends \WorDBless\BaseTestCase { 'version' => '1.0', 'screenshot' => 'https://theme.files.wordpress.com/2023/02/stax-featured.jpg', 'demo_uri' => 'https://stax.organicthemes.com/', + 'block_theme' => true, + 'date_added' => '2023-02-01', + 'taxonomies' => array(), + 'theme_tier' => array( 'slug' => 'partner' ), ); /** @@ -77,8 +81,8 @@ public function tear_down() { Cache::clear(); wp_deregister_script( 'wpcom-themes-tab' ); - delete_transient( wpcom_themes_tab_cache_key( 1 ) ); - delete_transient( wpcom_themes_tab_cache_key( 2 ) ); + // The shared reader caches in the object cache. + wp_cache_flush(); \Jetpack_Options::delete_option( 'blog_token' ); \Jetpack_Options::delete_option( 'id' ); @@ -142,14 +146,13 @@ function ( $preempt, $args, $url ) use ( $code, $body ) { * @param int $found Total across all pages. * @return void */ - private function answer_with_themes( array $themes, $found ) { + private function answer_with_themes( array $themes, $found = null ) { + unset( $found ); + $this->answer_wpcom_with( 200, wp_json_encode( - array( - 'themes' => $themes, - 'found' => $found, - ), + array( 'body' => array( 'themes' => $themes ) ), JSON_UNESCAPED_SLASHES ) ); @@ -162,7 +165,7 @@ private function answer_with_themes( array $themes, $found ) { * @return void */ private function answer_with_theme( array $theme ) { - $this->answer_wpcom_with( 200, wp_json_encode( $theme, JSON_UNESCAPED_SLASHES ) ); + $this->answer_wpcom_with( 200, wp_json_encode( array( 'body' => $theme ), JSON_UNESCAPED_SLASHES ) ); } /** @@ -203,7 +206,7 @@ public function test_serves_themes_from_wpcom() { */ public function test_themes_have_the_fields_core_reads() { $this->enable_tab(); - $this->answer_with_themes( array( array( 'id' => 'sparse' ) ), 1 ); + $this->answer_with_themes( array( self::THEME ) ); $theme = $this->query( array( 'browse' => WPCOM_THEMES_TAB ) )->themes[0]; @@ -211,6 +214,8 @@ public function test_themes_have_the_fields_core_reads() { $this->assertObjectHasProperty( $field, $theme ); } $this->assertArrayHasKey( 'display_name', $theme->author ); + $this->assertObjectHasProperty( 'installed', $theme ); + $this->assertSame( 'partner', $theme->theme_tier ); } /** @@ -226,11 +231,15 @@ public function test_skips_themes_without_an_id() { } /** - * Scrolling the grid asks WordPress.com for the matching page. + * Scrolling the grid slices a catalog that was fetched once, rather than asking per page. */ - public function test_passes_the_page_through() { + public function test_pages_through_the_catalog_from_one_fetch() { $this->enable_tab(); - $this->answer_with_themes( array( self::THEME ), WPCOM_THEMES_TAB_PER_PAGE + 1 ); + $themes = array(); + for ( $i = 1; $i <= WPCOM_THEMES_TAB_PER_PAGE + 50; $i++ ) { + $themes[] = array_merge( self::THEME, array( 'id' => 'theme-' . $i ) ); + } + $this->answer_with_themes( $themes ); $result = $this->query( array( @@ -239,9 +248,12 @@ public function test_passes_the_page_through() { ) ); - $this->assertStringContainsString( 'page=2', $this->requests[0] ); + $this->assertCount( 50, $result->themes ); + $this->assertSame( 'theme-' . ( WPCOM_THEMES_TAB_PER_PAGE + 1 ), $result->themes[0]->slug ); + $this->assertSame( 2, $result->info['page'] ); $this->assertSame( 2, $result->info['pages'] ); - $this->assertSame( WPCOM_THEMES_TAB_PER_PAGE + 1, $result->info['results'] ); + $this->assertSame( WPCOM_THEMES_TAB_PER_PAGE + 50, $result->info['results'] ); + $this->assertCount( 1, $this->requests ); } /** @@ -259,9 +271,9 @@ public function test_caches_the_catalog() { } /** - * A failed read shows an empty tab, and is cached so an outage is not retried on every view. + * A failed read is not cached, so a moment's trouble does not empty the tab for a day. */ - public function test_failure_is_empty_and_cached() { + public function test_failure_is_empty_and_not_cached() { $this->enable_tab(); $this->answer_wpcom_with( 500, '{}' ); @@ -271,7 +283,7 @@ public function test_failure_is_empty_and_cached() { $this->assertSame( array(), $first->themes ); $this->assertSame( 0, $first->info['results'] ); $this->assertSame( array(), $second->themes ); - $this->assertCount( 1, $this->requests ); + $this->assertCount( 2, $this->requests ); } /** diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-themes/Wpcom_Themes_Tiers_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-themes/Wpcom_Themes_Tiers_Test.php new file mode 100644 index 000000000000..4fb8cac12a00 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-themes/Wpcom_Themes_Tiers_Test.php @@ -0,0 +1,30 @@ + (object) array( 'slug' => 'partner' ) ); + + $this->assertSame( 'partner', WPCom_Themes_Tiers::tier_of( $theme ) ); + $this->assertSame( 'premium', WPCom_Themes_Tiers::tier_of( (object) array() ) ); + } + + /** + * The capability name follows the tier slug, which is the convention wpcomsh relies on. + */ + public function test_capability_name_follows_the_tier() { + $this->assertSame( 'premium-themes', WPCom_Themes_Tiers::feature_for( 'premium' ) ); + $this->assertSame( 'partner-themes', WPCom_Themes_Tiers::feature_for( 'partner' ) ); + } +} diff --git a/projects/plugins/wpcomsh/changelog/dotcom-18877-dont-cache-failures b/projects/plugins/wpcomsh/changelog/dotcom-18877-dont-cache-failures new file mode 100644 index 000000000000..85e865f8dc17 --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/dotcom-18877-dont-cache-failures @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Themes: retry after a failed read of the WordPress.com theme list, rather than leaving the Add Themes tabs without WordPress.com themes for a day. diff --git a/projects/plugins/wpcomsh/changelog/dotcom-18877-honest-dot-org-removal b/projects/plugins/wpcomsh/changelog/dotcom-18877-honest-dot-org-removal new file mode 100644 index 000000000000..bfabbb997079 --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/dotcom-18877-honest-dot-org-removal @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Themes: when a plan does not include WordPress.org themes, remove those themes from the Add Themes results rather than emptying the results. diff --git a/projects/plugins/wpcomsh/tests/WpcomThemesPlanFilterTest.php b/projects/plugins/wpcomsh/tests/WpcomThemesPlanFilterTest.php new file mode 100644 index 000000000000..48659721e111 --- /dev/null +++ b/projects/plugins/wpcomsh/tests/WpcomThemesPlanFilterTest.php @@ -0,0 +1,86 @@ + array( 'results' => 2 ), + 'themes' => array( + (object) array( 'slug' => 'twentytwentyfive' ), + (object) array( + 'slug' => 'repose', + 'is_wpcom_theme' => true, + ), + ), + ); + } + + /** + * Only the WordPress.org themes go, so results from elsewhere survive. + * + * Emptying the list instead would also discard the Marketplace tab's themes, which reach this + * filter already in the result rather than being merged in afterwards. + */ + public function test_removes_dot_org_themes_and_keeps_ours() { + $result = wpcomsh_keep_only_wpcom_themes( $this->mixed_result() ); + + $this->assertCount( 1, $result->themes ); + $this->assertSame( 'repose', $result->themes[0]->slug ); + $this->assertSame( 1, $result->info['results'] ); + } + + /** + * A result made only of our themes keeps its total, which counts every page rather than this one. + * + * Replacing the total with this page's count made the Marketplace tab report 100 themes + * instead of the whole catalog. + */ + public function test_leaves_a_result_of_our_own_themes_alone() { + $result = wpcomsh_keep_only_wpcom_themes( + (object) array( + 'info' => array( 'results' => 335 ), + 'themes' => array( + (object) array( + 'slug' => 'repose', + 'is_wpcom_theme' => true, + ), + ), + ) + ); + + $this->assertCount( 1, $result->themes ); + $this->assertSame( 335, $result->info['results'] ); + } + + /** + * A theme_information result is one theme rather than a list, so there is nothing to filter. + */ + public function test_leaves_a_single_theme_result_alone() { + $theme = (object) array( + 'slug' => 'repose', + 'name' => 'Repose', + ); + + $result = wpcomsh_keep_only_wpcom_themes( $theme ); + + $this->assertFalse( isset( $result->themes ) ); + $this->assertSame( 'repose', $result->slug ); + } +} diff --git a/projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-service.php b/projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-service.php index a1e7f1033c45..ffed2de860b9 100644 --- a/projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-service.php +++ b/projects/plugins/wpcomsh/wpcom-themes/includes/class-wpcom-themes-service.php @@ -96,17 +96,19 @@ protected function map_and_filter_wpcom_themes( array $wpcom_themes ): array { } /** - * Checks if a WPCom theme has a valid tier from Atomic sites. + * Whether core's Add Themes tabs should offer a WPCom theme on this site. + * + * Partner themes need their own subscription rather than a plan, so these tabs leave them to + * the Marketplace tab, which lists them in order to sell them. * * @param stdClass $theme The theme to check. * * @return bool True if the theme has a valid tier, false otherwise. */ protected function has_valid_theme_tier( stdClass $theme ): bool { - $tier = $theme->theme_tier->slug ?? 'premium'; - $theme_tier_feature = $tier . '-themes'; + $tier = WPCom_Themes_Tiers::tier_of( $theme ); - return $tier !== 'partner' && ( $tier === 'free' || wpcom_site_has_feature( $theme_tier_feature ) ); + return 'partner' !== $tier && WPCom_Themes_Tiers::site_can_use( $tier ); } /** diff --git a/projects/plugins/wpcomsh/wpcom-themes/themes.php b/projects/plugins/wpcomsh/wpcom-themes/themes.php index 82dca9066bb8..7178c17bcae4 100644 --- a/projects/plugins/wpcomsh/wpcom-themes/themes.php +++ b/projects/plugins/wpcomsh/wpcom-themes/themes.php @@ -14,11 +14,8 @@ function wpcomsh_get_wpcom_themes_service_instance(): WPCom_Themes_Service { static $instance; if ( ! $instance ) { // Load dependencies. - require_once __DIR__ . '/includes/class-wpcom-themes-mapper.php'; require_once __DIR__ . '/includes/class-wpcom-themes-merger.php'; - require_once __DIR__ . '/includes/class-wpcom-themes-cache.php'; require_once __DIR__ . '/includes/class-wpcom-themes-service.php'; - require_once __DIR__ . '/includes/class-wpcom-themes-api.php'; // Resolve and Inject dependencies. $mapper = new WPCom_Themes_Mapper(); @@ -41,15 +38,55 @@ function wpcomsh_get_wpcom_themes_service_instance(): WPCom_Themes_Service { * @return mixed|stdClass */ function wpcomsh_remove_dot_org_themes_based_on_plan( $res ) { - if ( ! wpcom_site_has_feature( WPCOM_Features::COMMUNITY_THEMES ) ) { - $res->info['results'] = 0; - $res->themes = array(); + if ( wpcom_site_has_feature( WPCOM_Features::COMMUNITY_THEMES ) ) { + return $res; } - return $res; + return wpcomsh_keep_only_wpcom_themes( $res ); } add_filter( 'themes_api_result', 'wpcomsh_remove_dot_org_themes_based_on_plan', 0, 1 ); +/** + * Drops the themes that came from WordPress.org out of a themes API result. + * + * @param mixed $res The result object. + * + * @return mixed|stdClass + */ +function wpcomsh_keep_only_wpcom_themes( $res ) { + // A single theme's details, rather than a list to filter. + if ( ! isset( $res->themes ) || ! is_array( $res->themes ) ) { + return $res; + } + + $ours = array_values( + array_filter( + $res->themes, + function ( $theme ) { + return ! empty( $theme->is_wpcom_theme ); + } + ) + ); + + // Our own themes are already the ones this site may install, and the total counts every page + // rather than this one, so a result made of them is left as it is. + if ( count( $ours ) === count( $res->themes ) ) { + return $res; + } + + /* + * The rest came from WordPress.org, which this plan excludes. Clearing the total as well lets + * the filters that run next set it from their own list, which is what they expect. + */ + $res->themes = $ours; + + if ( isset( $res->info['results'] ) ) { + $res->info['results'] = count( $ours ); + } + + return $res; +} + /** * Process the themes API result and add the recommended WPCom themes to the Popular tab. * From 5620ebd4ecee6dbae8655b1adebae683acdece65 Mon Sep 17 00:00:00 2001 From: MILLER/F Date: Tue, 13 Oct 2026 16:40:22 +0200 Subject: [PATCH 11/57] PayPal Payment Buttons: Remove the product image field from the editor (#53405) The image lived on the site only; PayPal payment links cannot carry one yet. The field, its media-library imports and its styles go, while the attributes, the preview and the front-end render stay so blocks that already hold an image keep showing it. Co-authored-by: Claude Fable 5.1 --- .../remove-wooptp-588-paypal-image-support | 4 ++ .../docs/rest-api-reference.md | 2 +- .../edit-api-managed.jsx | 63 +------------------ .../src/paypal-payment-buttons/editor.scss | 29 --------- .../edit.test.jsx | 61 ------------------ .../remove-wooptp-588-paypal-image-support | 4 ++ 6 files changed, 10 insertions(+), 153 deletions(-) create mode 100644 projects/packages/paypal-payments/changelog/remove-wooptp-588-paypal-image-support create mode 100644 projects/plugins/paypal-payment-buttons/changelog/remove-wooptp-588-paypal-image-support diff --git a/projects/packages/paypal-payments/changelog/remove-wooptp-588-paypal-image-support b/projects/packages/paypal-payments/changelog/remove-wooptp-588-paypal-image-support new file mode 100644 index 000000000000..a834389931e0 --- /dev/null +++ b/projects/packages/paypal-payments/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. diff --git a/projects/packages/paypal-payments/docs/rest-api-reference.md b/projects/packages/paypal-payments/docs/rest-api-reference.md index 6e8aa26fa76b..116e1f1c6b5d 100644 --- a/projects/packages/paypal-payments/docs/rest-api-reference.md +++ b/projects/packages/paypal-payments/docs/rest-api-reference.md @@ -266,7 +266,7 @@ Deletion is permanent: PayPal has no pause, deactivate, or restore operation, an | `price` | string | — | Price value (e.g., `29.99`) | | `currencyCode` | string | `USD` | ISO currency code | | `productDescription` | string | — | Product description | -| `imageUrl` | string | — | Product image URL | +| `imageUrl` | string | — | Product image URL. No longer set from the editor; kept for blocks that already have one | | `returnUrl` | string | — | Post-payment redirect URL | | `scriptSrc` | string | — | Legacy: PayPal script URL | | `hostedButtonId` | string | — | Legacy: hosted button ID | diff --git a/projects/packages/paypal-payments/src/paypal-payment-buttons/edit-api-managed.jsx b/projects/packages/paypal-payments/src/paypal-payment-buttons/edit-api-managed.jsx index d33a36442e8e..705d6b9c81eb 100644 --- a/projects/packages/paypal-payments/src/paypal-payment-buttons/edit-api-managed.jsx +++ b/projects/packages/paypal-payments/src/paypal-payment-buttons/edit-api-managed.jsx @@ -11,16 +11,8 @@ import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; import apiFetch from '@wordpress/api-fetch'; // eslint-disable-line import/no-unresolved +import { BlockControls, InspectorControls, URLInput, useBlockProps } from '@wordpress/block-editor'; import { - BlockControls, - InspectorControls, - MediaUpload, - MediaUploadCheck, - URLInput, - useBlockProps, -} from '@wordpress/block-editor'; -import { - BaseControl, Button, CheckboxControl, CustomSelectControl, @@ -247,7 +239,6 @@ export default function ApiManagedEdit( { productDescription, productId, imageUrl, - imageId, returnUrl, variantsEnabled, variants, @@ -1194,58 +1185,6 @@ export default function ApiManagedEdit( { : undefined } /> - -

- - { __( 'Product Image (optional)', 'jetpack-paypal-payments' ) } - - { imageUrl ? ( -
- { -
- - - setAttributes( { imageUrl: media.url, imageId: media.id } ) - } - allowedTypes={ [ 'image' ] } - value={ imageId } - render={ ( { open } ) => ( - - ) } - /> - - -
-
- ) : ( - - setAttributes( { imageUrl: media.url, imageId: media.id } ) } - allowedTypes={ [ 'image' ] } - value={ imageId } - render={ ( { open } ) => ( - - ) } - /> - - ) } -
{ /* 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() {
- + { __( 'Back to overview', 'jetpack-backup-pkg' ) } diff --git a/projects/packages/backup/tests/file-browser-icons.test.tsx b/projects/packages/backup/tests/file-browser-icons.test.tsx index fd983a0dab82..e932e5676ba9 100644 --- a/projects/packages/backup/tests/file-browser-icons.test.tsx +++ b/projects/packages/backup/tests/file-browser-icons.test.tsx @@ -3,6 +3,12 @@ // tests cross-check the rows against the package's own artwork. const mockApiFetch = jest.fn(); +let mockIsRtl = false; + +jest.mock( '@wordpress/i18n', () => ( { + ...jest.requireActual( '@wordpress/i18n' ), + isRTL: () => mockIsRtl, +} ) ); jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, @@ -11,7 +17,7 @@ jest.mock( '@wordpress/api-fetch', () => ( { // Imports must come after the jest.mock factories above. import { render, screen } from '@testing-library/react'; -import { Icon, file, page } from '@wordpress/icons'; +import { Icon, chevronLeft, chevronRight, file, page } from '@wordpress/icons'; import FileBrowser, { EMPTY_FILE_SELECTION } from '../src/dashboard/components/file-browser'; import { queryClient } from '../src/dashboard/data/query-client'; import QueryClientProvider from '../src/dashboard/providers/query-client-provider'; @@ -74,6 +80,7 @@ async function renderRows(): Promise< { folderRow: HTMLElement; fileRow: HTMLEle } beforeEach( () => { + mockIsRtl = false; queryClient.clear(); queryClient.setDefaultOptions( { queries: { retry: false } } ); @@ -134,4 +141,18 @@ describe( 'file browser row icons', () => { expect( folderRow ).toHaveAttribute( 'aria-expanded', 'false' ); expect( fileRow ).not.toHaveAttribute( 'aria-expanded' ); } ); + + it.each( [ + [ 'LTR', false, chevronRight, chevronLeft ], + [ 'RTL', true, chevronLeft, chevronRight ], + ] )( + 'points a closed folder chevron toward the inline end in %s', + async ( _l, rtl, want, other ) => { + mockIsRtl = rtl; + const { folderRow } = await renderRows(); + + expect( shapesIn( folderRow ) ).toContain( shapeOf( want ) ); + expect( shapesIn( folderRow ) ).not.toContain( shapeOf( other ) ); + } + ); } ); diff --git a/projects/packages/backup/tests/invalid-rewind-id.test.tsx b/projects/packages/backup/tests/invalid-rewind-id.test.tsx index 1eba21c68434..d28059db501a 100644 --- a/projects/packages/backup/tests/invalid-rewind-id.test.tsx +++ b/projects/packages/backup/tests/invalid-rewind-id.test.tsx @@ -17,6 +17,12 @@ const mockApiFetch = jest.fn(); const mockParams = jest.fn< { rewindId: string }, [] >(); +let mockIsRtl = false; + +jest.mock( '@wordpress/i18n', () => ( { + ...jest.requireActual( '@wordpress/i18n' ), + isRTL: () => mockIsRtl, +} ) ); jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, @@ -32,6 +38,7 @@ jest.mock( '@wordpress/route', () => ( { // Imports must come after the jest.mock factories above. import { render, screen } from '@testing-library/react'; +import { Icon, arrowLeft, arrowRight } from '@wordpress/icons'; import { queryClient } from '../src/dashboard/data/query-client'; import QueryClientProvider from '../src/dashboard/providers/query-client-provider'; import DownloadScreen from '../src/dashboard/screens/download'; @@ -45,6 +52,7 @@ const CAPABILITIES = { hasBackupPlan: true, hasScan: false }; const VALID_ID = '1786644531.123'; beforeEach( () => { + mockIsRtl = false; queryClient.clear(); queryClient.setDefaultOptions( { queries: { retry: false } } ); @@ -107,6 +115,16 @@ describe( 'rewindIdToIso', () => { } ); } ); +/** + * Path data of the first under `root`, which identifies the drawn glyph. + * + * @param root - Element to scan. + * @return The `d` attribute, if any. + */ +function shapeOf( root: Element ): string | null | undefined { + return root.querySelectorAll( 'svg' )[ 0 ]?.querySelectorAll( 'path' )[ 0 ]?.getAttribute( 'd' ); +} + describe.each( [ { name: 'restore', @@ -177,4 +195,33 @@ describe.each( [ ).resolves.toBeInTheDocument(); expect( screen.queryByText( notFound ) ).not.toBeInTheDocument(); } ); + + it.each( [ + [ 'LTR', false, arrowLeft, arrowRight, 'a valid rewind id', VALID_ID ], + [ 'LTR', false, arrowLeft, arrowRight, 'an invalid rewind id', 'not-a-real-id' ], + [ 'RTL', true, arrowRight, arrowLeft, 'a valid rewind id', VALID_ID ], + [ 'RTL', true, arrowRight, arrowLeft, 'an invalid rewind id', 'not-a-real-id' ], + ] )( + 'points the back arrow toward the inline start in %s for %s', + async ( _dir, rtl, expected, other, _label, rewindId ) => { + const arrow = ( icon: typeof arrowLeft ) => { + const { container, unmount } = render( ); + const shape = shapeOf( container ); + unmount(); + return shape; + }; + mockIsRtl = rtl; + mockParams.mockReturnValue( { rewindId } ); + + render( + + + + ); + + const back = await screen.findByText( 'Back to overview' ); + expect( shapeOf( back ) ).toBe( arrow( expected ) ); + expect( shapeOf( back ) ).not.toBe( arrow( other ) ); + } + ); } ); From bb1fb5f1b947590f4af385e930a594f48d9c5657 Mon Sep 17 00:00:00 2001 From: Tony Arcangelini <33258733+arcangelini@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:48:28 +0200 Subject: [PATCH 15/57] Comments: Accept a stale nonce from the site's own page (#53532) * Comments: Accept a stale nonce from the site's own page * Comments: Check the Origin before the cookie swap * Comments: Match the full origin, drop the anonymous nonce swap * Comments: Ignore a default port in the origin match * Comments: Match scheme and port in the same-site check * Comments: Keep the Origin fallback to logged-in readers * Comments: Refuse a same-site check with no headers --- .../changelog/fix-comments-nonce-same-origin | 4 ++ .../packages/comments/src/class-tracks.php | 2 +- .../comments/src/form/class-comment-form.php | 31 +++----------- .../identity/checkpoint/class-checkpoint.php | 40 ++++++++++++++----- 4 files changed, 39 insertions(+), 38 deletions(-) create mode 100644 projects/packages/comments/changelog/fix-comments-nonce-same-origin diff --git a/projects/packages/comments/changelog/fix-comments-nonce-same-origin b/projects/packages/comments/changelog/fix-comments-nonce-same-origin new file mode 100644 index 000000000000..79bf1643467e --- /dev/null +++ b/projects/packages/comments/changelog/fix-comments-nonce-same-origin @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Let a logged-in reader post from a page left open past a day, or after logging in again elsewhere. diff --git a/projects/packages/comments/src/class-tracks.php b/projects/packages/comments/src/class-tracks.php index 1c49b8d9dfb5..f0ca5e9282d5 100644 --- a/projects/packages/comments/src/class-tracks.php +++ b/projects/packages/comments/src/class-tracks.php @@ -134,7 +134,7 @@ public static function record( $event_name, array $properties = array() ) { * Record a comment the form turned away, where a browser on this site sent it. Anything else is a bot, * and would cost an outbound request to record. * - * @param string $reason `nonce`, or the error code the sign-in was refused with. + * @param string $reason The error code the sign-in was refused with. * @return void */ public static function record_refusal( $reason ) { diff --git a/projects/packages/comments/src/form/class-comment-form.php b/projects/packages/comments/src/form/class-comment-form.php index 24d0c43de39e..6cc573a94591 100644 --- a/projects/packages/comments/src/form/class-comment-form.php +++ b/projects/packages/comments/src/form/class-comment-form.php @@ -498,7 +498,7 @@ public function enqueue_assets( $args = array() ) { } /** - * Require a logged-in reader's comment to arrive with a nonce this site issued. + * Require a logged-in reader's comment to arrive with a nonce made for them, or from this site's own origin. * * A logged-out reader's would be the same string for everybody, which a page cache * serves past its expiry, so their comments go through core's checks alone, as core's form does. @@ -518,31 +518,10 @@ public function verify_nonce( $comment_post_id = 0 ) { return; } - // A page cache can hand a logged-in reader a copy rendered for nobody, so - // the nonce they post is the anonymous one. wp_verify_nonce() reads the - // session token from the logged-in cookie, not the current user, so the - // cookie has to go too for the hash to match what a visitor was served. - if ( defined( 'LOGGED_IN_COOKIE' ) && isset( $_COOKIE[ LOGGED_IN_COOKIE ] ) ) { - $user_id = get_current_user_id(); - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Stashed and put back untouched. - $cookie = $_COOKIE[ LOGGED_IN_COOKIE ]; - - unset( $_COOKIE[ LOGGED_IN_COOKIE ] ); - wp_set_current_user( 0 ); - - $valid = (bool) wp_verify_nonce( $nonce, self::NONCE_ACTION ); - - $_COOKIE[ LOGGED_IN_COOKIE ] = $cookie; - wp_set_current_user( $user_id ); - - if ( $valid ) { - return; - } - } - - // A stale nonce is what a page cache costs real readers. - if ( '' !== $nonce ) { - Tracks::record_refusal( 'nonce' ); + // A forged post carries the other site's Origin, so this site's own covers a nonce that outlived + // its day or its login. Logged in only: a bot can send any Origin it likes. + if ( is_user_logged_in() && ! empty( $_SERVER['HTTP_ORIGIN'] ) && Checkpoint::is_same_site_request() ) { + return; } wp_die( diff --git a/projects/packages/comments/src/identity/checkpoint/class-checkpoint.php b/projects/packages/comments/src/identity/checkpoint/class-checkpoint.php index 700e0b06fa6a..3985f3efd635 100644 --- a/projects/packages/comments/src/identity/checkpoint/class-checkpoint.php +++ b/projects/packages/comments/src/identity/checkpoint/class-checkpoint.php @@ -275,21 +275,19 @@ public function admit( $comment_post_id = 0 ) { /** * Whether the browser says the request came from this site: the Origin or Referer - * host against the home and site hosts, or Sec-Fetch-Site alone when a proxy - * stripped both. Nothing at all is allowed through; another site's name is not. + * origin against the home and site origins, or Sec-Fetch-Site alone when a proxy + * stripped both. Nothing at all is refused: every browser that runs the sign-in flow sends Origin. * * @return bool */ public static function is_same_site_request() { - $hosts = array(); + $origins = array(); // Both: a page on the home host posts to wp-comments-post.php on the site host. foreach ( array( home_url(), site_url() ) as $url ) { - $host = wp_parse_url( $url, PHP_URL_HOST ); - - if ( is_string( $host ) ) { - $hosts[] = strtolower( $host ); - } + $origins[] = self::origin( $url ); + // A site still set to http:// but served over https posts from the https origin. + $origins[] = self::origin( set_url_scheme( $url, 'https' ) ); } foreach ( array( 'HTTP_ORIGIN', 'HTTP_REFERER' ) as $header ) { @@ -297,14 +295,34 @@ public static function is_same_site_request() { continue; } - $host = wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ), PHP_URL_HOST ); + $origin = self::origin( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) ); - return is_string( $host ) && in_array( strtolower( $host ), $hosts, true ); + return '' !== $origin && in_array( $origin, $origins, true ); } $site = isset( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) : ''; - return '' === $site || in_array( $site, array( 'same-origin', 'none' ), true ); + return in_array( $site, array( 'same-origin', 'none' ), true ); + } + + /** + * A URL's origin as a browser writes it: scheme, host and any port other than the default. + * + * @param string $url The URL. + * @return string The origin, or '' when the URL has none. + */ + private static function origin( $url ) { + $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); + $host = wp_parse_url( $url, PHP_URL_HOST ); + $port = wp_parse_url( $url, PHP_URL_PORT ); + + if ( ! $scheme || ! $host ) { + return ''; + } + + $port = in_array( "$scheme:$port", array( 'http:80', 'https:443' ), true ) ? '' : ( $port ? ":$port" : '' ); + + return strtolower( "$scheme://$host$port" ); } /** From 4c45f77af07ca61a655f30c1f4902ed009e84188 Mon Sep 17 00:00:00 2001 From: Tony Arcangelini <33258733+arcangelini@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:48:48 +0200 Subject: [PATCH 16/57] Comments: Keep the sign-in when a reader goes back and reposts (#53531) --- .../comments/changelog/fix-comments-spent-sign-in-code | 4 ++++ projects/packages/comments/src/form/comment-form.tsx | 10 ++++++++++ projects/packages/comments/src/modal/index.tsx | 3 +++ 3 files changed, 17 insertions(+) create mode 100644 projects/packages/comments/changelog/fix-comments-spent-sign-in-code diff --git a/projects/packages/comments/changelog/fix-comments-spent-sign-in-code b/projects/packages/comments/changelog/fix-comments-spent-sign-in-code new file mode 100644 index 000000000000..5f797076dd64 --- /dev/null +++ b/projects/packages/comments/changelog/fix-comments-spent-sign-in-code @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Keep a WordPress.com sign-in when a reader goes back and posts a comment again. diff --git a/projects/packages/comments/src/form/comment-form.tsx b/projects/packages/comments/src/form/comment-form.tsx index ee984d06d21c..5539c67ab4dc 100644 --- a/projects/packages/comments/src/form/comment-form.tsx +++ b/projects/packages/comments/src/form/comment-form.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import { useContext, useEffect } from 'preact/hooks'; import { Footer } from '../footer'; +import { readPassport } from '../shared/checkpoint'; import { markSubmitted, saveDraft } from '../shared/draft'; import { CommentSignals } from '../shared/state'; import { recordEvent } from '../shared/tracks'; @@ -135,6 +136,15 @@ export const CommentForm = ( { form }: { form: HTMLFormElement } ) => { const onPageShow = ( event: PageTransitionEvent ) => { if ( event.persisted ) { isPosting.value = false; + + // The post spent the sign-in code, so a repost needs the passport it earned. + const current = commenter.peek(); + if ( current.kind === 'wordpress' && current.code !== null ) { + const passport = readPassport(); + commenter.value = passport + ? { kind: 'wordpress', ...passport, code: null } + : { kind: 'unknown' }; + } } }; diff --git a/projects/packages/comments/src/modal/index.tsx b/projects/packages/comments/src/modal/index.tsx index 9bc091470de9..682663cc5cf6 100644 --- a/projects/packages/comments/src/modal/index.tsx +++ b/projects/packages/comments/src/modal/index.tsx @@ -157,6 +157,7 @@ export const Dialog = ( { internals }: { internals: ElementInternals } ) => { const logIn = async () => { setSignInStatus( 'pending' ); + const started = Date.now(); // Cancel closes the popup, which settles this as cancelled. const result = await signIn( opened => { @@ -175,6 +176,8 @@ export const Dialog = ( { internals }: { internals: ElementInternals } ) => { result: outcome, error: 'error' in result ? result.error : '', posting, + // An instant cancel is a popup cut off from this page, not a reader closing it. + duration_ms: Date.now() - started, } ); if ( 'error' in result ) { From a6793cb7b09a15dc3c5fd98669add041ecbfc4bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 13 Oct 2026 16:51:35 +0100 Subject: [PATCH 17/57] Premium Analytics: add a Locations widget kind over the chart pieces (#53533) * add the Locations widget kind over the chart pieces a ranking of places in; the list with flags, the map and the states out * draw Visitors by location with Locations the toggle only decides the country and the rows now * drop the deleted visitors-by-location suite from its test group The builder moved into the Locations kind, and the group still imported the old file. Co-authored-by: Cursor * center the Locations map with Stack The map cell was a flex box only to center the chart; Stack owns that alignment now. Co-authored-by: Cursor * export only the Locations names callers import The folder keeps the component and the row type. Props and builder results stay inside the kind, and the components barrel no longer re-exports it. Co-authored-by: Cursor * name the figure on the map and compare visible rows only Locations takes valueLabel for the map tooltip; a previous figure past maxRows no longer turns the comparison on --------- Co-authored-by: Cursor --- projects/packages/premium-analytics/AGENTS.md | 7 +- .../changelog/update-pa-locations-widget-kind | 4 + .../__tests__/build-geo-data.test.ts | 10 ++ .../locations-geo-chart/build-geo-data.ts | 8 +- .../locations-geo-chart.tsx | 7 +- .../__tests__/build-locations-data.test.ts | 62 +++++++ .../locations/__tests__/locations.test.tsx | 105 ++++++++++++ .../locations/build-locations-data.ts | 102 ++++++++++++ .../src/components/locations/index.ts | 2 + .../locations/locations.module.scss | 43 +++++ .../src/components/locations/locations.tsx | 136 ++++++++++++++++ .../locations/stories/locations.stories.tsx | 88 ++++++++++ .../build-visitors-by-location-data.test.ts | 147 ----------------- .../build-visitors-by-location-data.ts | 102 ------------ .../widgets-toolkit/src/helpers/index.ts | 6 - .../use-visitors-by-location.ts | 114 +++++-------- .../visitors-by-location-widget.module.scss | 86 ++-------- .../visitors-by-location-widget.tsx | 151 ++++++------------ .../widgets-toolkit-no-mocks-part3.test.tsx | 1 - 19 files changed, 668 insertions(+), 513 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/update-pa-locations-widget-kind create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/__tests__/build-locations-data.test.ts create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/__tests__/locations.test.tsx create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/build-locations-data.ts create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/index.ts create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/locations.module.scss create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/locations.tsx create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/stories/locations.stories.tsx delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-visitors-by-location-data.test.ts delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-visitors-by-location-data.ts diff --git a/projects/packages/premium-analytics/AGENTS.md b/projects/packages/premium-analytics/AGENTS.md index 1258c391dca4..454b5a8c40d2 100644 --- a/projects/packages/premium-analytics/AGENTS.md +++ b/projects/packages/premium-analytics/AGENTS.md @@ -714,7 +714,8 @@ no separate semi-circle kind. A funnel widget renders `` with its steps counts, and the hook's status; it owns the rate of the whole, the shares, the tooltip and the states. A columns widget renders `` with its columns, as figures of both periods, and the hook's status; it owns the series per period, the legend, the colors and the -states. Everything else renders its states through +states. A locations widget renders `` with its ranking and the hook's status; +it owns the list with flags, the map and the states. Everything else renders its states through `` from `@jetpack-premium-analytics/widgets-toolkit` rather than hand-rolling `if ( isError )` / empty branches or a `WidgetLoadingOverlay`. Map the data/view hook's result to its four signals. For Stats API errors, pass the raw `error` to the @@ -776,8 +777,8 @@ area. Notes: > `isLoading && data.length === 0`, and `LeaderboardChart`'s `emptyStateText`. They are being > migrated to `` — follow the contract above, not those widgets. For a leaderboard, > `` is that wrapper, `` for a breakdown, as a ring or a half ring, -> `` for a funnel, and `` for a few categories across the periods; the other -> widget kinds get theirs as follow-ups. +> `` for a funnel, `` for a few categories across the periods, and +> `` for places on a map; the other widget kinds get theirs as follow-ups. **Comparison data** diff --git a/projects/packages/premium-analytics/changelog/update-pa-locations-widget-kind b/projects/packages/premium-analytics/changelog/update-pa-locations-widget-kind new file mode 100644 index 000000000000..c495137c9d31 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/update-pa-locations-widget-kind @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Dashboard: add the Locations widget kind, which Visitors by location draws with. diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/__tests__/build-geo-data.test.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/__tests__/build-geo-data.test.ts index 30e422d4fdb5..938138e33237 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/__tests__/build-geo-data.test.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/__tests__/build-geo-data.test.ts @@ -33,6 +33,16 @@ describe( 'buildLocationsGeoChart', () => { ] ); } ); + it( 'names the figure as the caller says', () => { + const { data } = buildLocationsGeoChart( { + rows: [ row( 'Canada', 'CA', 'Canada', 12 ) ], + mode: 'country', + valueLabel: 'Store visitors', + } ); + + expect( data[ 0 ] ).toEqual( [ 'Country', 'Store visitors' ] ); + } ); + describe( 'region mode', () => { it( 'sums each country regions onto one map row', () => { const [ , ...rows ] = buildLocationsGeoChart( { diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/build-geo-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/build-geo-data.ts index 0d0fbbeb7468..2b89c44f99fd 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/build-geo-data.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/build-geo-data.ts @@ -37,6 +37,8 @@ export interface BuildLocationsGeoChartArgs { focusCountry?: LocationsGeoFocusCountry; /** Whether Google has a `provinces` map for the focused country. */ provinceMapSupported?: boolean; + /** What the figure is, as the tooltip names it. Defaults to Views. */ + valueLabel?: string; } export interface LocationsGeoChartConfig { @@ -130,6 +132,7 @@ function summarizeByCountry( rows: LocationsGeoRow[] ): [ string, CountrySummary * @param args.mode - Granularity of the rows. * @param args.focusCountry - The country the map is scoped to, if any. * @param args.provinceMapSupported - Whether Google has a provinces map for it. + * @param args.valueLabel - What the figure is, as the tooltip names it. * @return The GeoChart `data`, `region`, `resolution`, and `displayMode`. */ export function buildLocationsGeoChart( { @@ -137,6 +140,7 @@ export function buildLocationsGeoChart( { mode, focusCountry: requestedFocusCountry, provinceMapSupported = true, + valueLabel = __( 'Views', 'jetpack-premium-analytics-pkg' ), }: BuildLocationsGeoChartArgs ): LocationsGeoChartConfig { // Country mode plots the countries themselves, so scoping the map to one of // them would leave every other row with nowhere to sit. @@ -162,7 +166,7 @@ export function buildLocationsGeoChart( { { type: 'number', label: __( 'Latitude', 'jetpack-premium-analytics-pkg' ) }, { type: 'number', label: __( 'Longitude', 'jetpack-premium-analytics-pkg' ) }, { type: 'string', label: __( 'Location', 'jetpack-premium-analytics-pkg' ) }, - { type: 'number', label: __( 'Views', 'jetpack-premium-analytics-pkg' ) }, + { type: 'number', label: valueLabel }, ], ...rows.flatMap( ( row ): GoogleDataTableRow[] => row.coordinates @@ -179,7 +183,7 @@ export function buildLocationsGeoChart( { useProvinceMap ? __( 'Location', 'jetpack-premium-analytics-pkg' ) : __( 'Country', 'jetpack-premium-analytics-pkg' ), - __( 'Views', 'jetpack-premium-analytics-pkg' ), + valueLabel, ]; if ( useCountryFallbackMap && focusCountry ) { diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/locations-geo-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/locations-geo-chart.tsx index 8b9d17fba9b6..72c15bbec466 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/locations-geo-chart.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations-geo-chart/locations-geo-chart.tsx @@ -34,6 +34,8 @@ export interface LocationsGeoChartProps { focusCountry?: LocationsGeoFocusCountry; /** Debounce for the chart's own resize observer, in milliseconds. */ resizeDebounceTime?: number; + /** What the figure is, as the tooltip names it. Defaults to Views. */ + valueLabel?: string; } /** @@ -50,6 +52,7 @@ export function LocationsGeoChart( { mode, focusCountry, resizeDebounceTime = RESIZE_DEBOUNCE_MS, + valueLabel, }: LocationsGeoChartProps ) { const [ unsupportedProvinceMapCountries, setUnsupportedProvinceMapCountries ] = useState< Set< string > @@ -61,8 +64,8 @@ export function LocationsGeoChart( { ? ! unsupportedProvinceMapCountries.has( focusCountryCode ) : true; const { data, region, resolution, displayMode } = useMemo( - () => buildLocationsGeoChart( { rows, mode, focusCountry, provinceMapSupported } ), - [ focusCountry, mode, provinceMapSupported, rows ] + () => buildLocationsGeoChart( { rows, mode, focusCountry, provinceMapSupported, valueLabel } ), + [ focusCountry, mode, provinceMapSupported, rows, valueLabel ] ); const useProvinceMap = resolution === 'provinces'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/__tests__/build-locations-data.test.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/__tests__/build-locations-data.test.ts new file mode 100644 index 000000000000..7f71d28bacba --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/__tests__/build-locations-data.test.ts @@ -0,0 +1,62 @@ +/** + * Internal dependencies + */ +import { buildLocationsData, type LocationRowInput } from '../build-locations-data'; + +const COUNTRIES: LocationRowInput[] = [ + { id: 'us', label: 'United States', value: 1200, previousValue: 1000 }, + { id: 'ca', label: 'Canada', value: 300 }, +]; + +describe( 'buildLocationsData', () => { + it( 'places countries on the world map, each row under its own flag', () => { + const { geoRows, geoMode, focusCountry, rows, comparing } = buildLocationsData( COUNTRIES, { + hasComparison: true, + maxRows: 10, + } ); + + expect( geoMode ).toBe( 'country' ); + expect( focusCountry ).toBeUndefined(); + expect( geoRows.map( row => row.countryCode ) ).toEqual( [ 'US', 'CA' ] ); + expect( rows.map( row => row.media?.kind === 'flag' && row.media.country ) ).toEqual( [ + 'United States', + 'Canada', + ] ); + expect( comparing ).toBe( true ); + } ); + + it( 'places regions inside their country, every row under that flag', () => { + const { geoRows, geoMode, focusCountry, rows } = buildLocationsData( + [ { id: 'California', label: 'California', value: 500 } ], + { country: { code: 'US', name: 'United States' }, hasComparison: false, maxRows: 10 } + ); + + expect( geoMode ).toBe( 'region' ); + expect( focusCountry ).toEqual( { code: 'US', name: 'United States' } ); + expect( geoRows[ 0 ] ).toMatchObject( { countryCode: 'US', countryFull: 'United States' } ); + expect( rows[ 0 ].media ).toMatchObject( { kind: 'flag', country: 'United States' } ); + } ); + + it( 'compares only while some row carries a previous figure', () => { + const rows = COUNTRIES.map( ( { id, label, value } ) => ( { id, label, value } ) ); + + expect( buildLocationsData( rows, { hasComparison: true, maxRows: 10 } ).comparing ).toBe( + false + ); + } ); + + it( 'ignores a previous figure past the visible rows', () => { + const rows = [ + { id: 'us', label: 'United States', value: 1200 }, + { id: 'ca', label: 'Canada', value: 300 }, + { id: 'jp', label: 'Japan', value: 2, previousValue: 1 }, + ]; + + expect( buildLocationsData( rows, { hasComparison: true, maxRows: 2 } ).comparing ).toBe( + false + ); + expect( buildLocationsData( rows, { hasComparison: true, maxRows: 3 } ).comparing ).toBe( + true + ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/__tests__/locations.test.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/__tests__/locations.test.tsx new file mode 100644 index 000000000000..4a8b8195c0f3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/__tests__/locations.test.tsx @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import { GlobalChartsProvider } from '@jetpack-premium-analytics/externals'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +/** + * Internal dependencies + */ +import { Locations } from '../locations'; +import type { LocationsGeoChartProps } from '../../locations-geo-chart'; +import type { LocationRowInput } from '../build-locations-data'; +import type { ReactNode } from 'react'; + +// The map is Google's; the kind's job is what it hands it. +jest.mock( '../../locations-geo-chart', () => ( { + LocationsGeoChart: ( { mode, rows, valueLabel }: LocationsGeoChartProps ) => ( +
+ ), +} ) ); + +const ROWS: LocationRowInput[] = [ + { id: 'us', label: 'United States', value: 1200, previousValue: 1000 }, + { id: 'ca', label: 'Canada', value: 300, previousValue: 400 }, +]; + +const READY = { isLoading: false, isError: false }; + +const Providers = ( { children }: { children: ReactNode } ) => ( + { children } +); + +const renderLocations = ( ui: ReactNode ) => render( ui, { wrapper: Providers } ); + +describe( 'Locations', () => { + it( 'lists the rows and hands the map the same places, as countries or as regions', () => { + const { rerender } = renderLocations( + + ); + + expect( screen.getByText( 'United States' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Canada' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'geo-chart' ) ).toHaveAttribute( 'data-mode', 'country' ); + expect( screen.getByTestId( 'geo-chart' ) ).toHaveAttribute( 'data-rows', '2' ); + expect( screen.getByTestId( 'geo-chart' ) ).toHaveAttribute( 'data-value-label', 'Views' ); + + rerender( + + ); + + expect( screen.getByTestId( 'geo-chart' ) ).toHaveAttribute( 'data-mode', 'region' ); + } ); + + it( 'shows the deltas only while the comparison period is on', () => { + const { rerender } = renderLocations( + + ); + + expect( screen.queryByText( /20%/ ) ).not.toBeInTheDocument(); + + rerender( + + ); + + expect( screen.getByText( /20%/ ) ).toBeInTheDocument(); + } ); + + it( 'renders the empty state without rows', () => { + renderLocations( + + ); + + expect( screen.getByText( 'No location data in this period.' ) ).toBeInTheDocument(); + } ); + + it( 'offers a Retry bound to refetch when the widget passes no error of its own', async () => { + const refetch = jest.fn(); + renderLocations( + + ); + + expect( screen.getByRole( 'alert' ) ).toHaveTextContent( "We couldn't load this data." ); + await userEvent.click( screen.getByRole( 'button', { name: 'Retry' } ) ); + expect( refetch ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/build-locations-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/build-locations-data.ts new file mode 100644 index 000000000000..51f3ce47f01f --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/build-locations-data.ts @@ -0,0 +1,102 @@ +/** + * Internal dependencies + */ +import { flagUrl } from '../../helpers/flag-url'; +import type { LeaderboardRowInput } from '../leaderboard/build-leaderboard-chart-data'; +import type { + LocationsGeoFocusCountry, + LocationsGeoMode, + LocationsGeoRow, +} from '../locations-geo-chart'; + +/** + * One location of the ranking, in the widget's own terms. + */ +export type LocationRowInput = { + /** + * ISO country code on the world map; the region's name inside a country. + */ + id: string; + label: string; + /** + * Figure for the selected period. + */ + value: number; + /** + * Figure for the comparison period; `undefined` when the location has no match there. + */ + previousValue?: number; +}; + +/** + * The country the rows belong to, when they are its regions rather than countries. + */ +export type LocationsCountry = LocationsGeoFocusCountry; + +type LocationsDataOptions = { + /** + * The country the rows are regions of; omit for countries on the world map. + */ + country?: LocationsCountry; + /** + * Whether the comparison period is on. + */ + hasComparison: boolean; + /** + * Rows the list shows: a previous figure past them does not turn the comparison on. + */ + maxRows: number; +}; + +type LocationsDataResult = { + geoRows: LocationsGeoRow[]; + geoMode: LocationsGeoMode; + focusCountry?: LocationsCountry; + /** + * The ranking as the leaderboard builder takes it, a flag on every row. + */ + rows: LeaderboardRowInput[]; + /** + * Whether the comparison period is on and at least one visible row carries a previous figure. + */ + comparing: boolean; +}; + +/** + * Turns the ranking into the map's rows and the leaderboard's rows: countries on the world + * map, or the regions of one country, each row carrying the matching flag. + * + * @param rows - The ranking, in display order. + * @param options - The country the rows belong to, if any, the comparison flag and the visible row count. + * @return The map rows, the leaderboard rows and the comparison flag. + */ +export function buildLocationsData( + rows: readonly LocationRowInput[], + options: LocationsDataOptions +): LocationsDataResult { + const { country, hasComparison, maxRows } = options; + + return { + geoRows: rows.map( row => ( { + label: row.label, + value: row.value, + countryCode: country ? country.code : row.id.toUpperCase(), + countryFull: country ? country.name : row.label, + } ) ), + geoMode: country ? 'region' : 'country', + focusCountry: country, + rows: rows.map( row => ( { + id: row.id, + label: row.label, + value: row.value, + previousValue: row.previousValue, + media: { + kind: 'flag', + url: flagUrl( ( country ? country.code : row.id ).toLowerCase() ) ?? undefined, + country: country ? country.name : row.label, + }, + } ) ), + comparing: + hasComparison && rows.slice( 0, maxRows ).some( row => row.previousValue !== undefined ), + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/index.ts new file mode 100644 index 000000000000..a24f36f8b668 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/index.ts @@ -0,0 +1,2 @@ +export { Locations } from './locations'; +export type { LocationRowInput } from './build-locations-data'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/locations.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/locations.module.scss new file mode 100644 index 000000000000..0db2b98d86c5 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/locations.module.scss @@ -0,0 +1,43 @@ +.root { + // An inline-size container, so a narrow tile drops the map with CSS alone. + container-type: inline-size; + block-size: 100%; + min-block-size: 0; +} + +.container { + display: grid; + grid-template-columns: 280fr 400fr; + column-gap: var(--wpds-dimension-gap-lg); + align-items: stretch; + block-size: 100%; + min-block-size: 0; +} + +.list { + align-self: start; + min-inline-size: 0; + min-block-size: 0; + overflow: hidden; +} + +.map { + min-inline-size: 0; + block-size: 100%; + // Google redraws the SVG on a resize debounce; clip the stale one + // meanwhile. + overflow: hidden; +} + +// Between a one-column tile and a two-column one: the list alone fits the +// former. +@container (max-inline-size: 480px) { + + .container { + grid-template-columns: 1fr; + } + + .map { + display: none; + } +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/locations.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/locations.tsx new file mode 100644 index 000000000000..e356fe257a76 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/locations.tsx @@ -0,0 +1,136 @@ +/** + * External dependencies + */ +import { Stack } from '@jetpack-premium-analytics/externals'; +import { useMemo, type JSX } from 'react'; +/** + * Internal dependencies + */ +import { RESIZE_DEBOUNCE_MS } from '../../constants'; +import { WIDGET_ROW_LIMIT } from '../../constants/rows'; +import { LeaderboardChart } from '../chart-leaderboard/leaderboard-chart'; +import { LeaderboardSkeleton } from '../chart-leaderboard/leaderboard-skeleton'; +import { buildLeaderboardChartData } from '../leaderboard/build-leaderboard-chart-data'; +import { LocationsGeoChart } from '../locations-geo-chart'; +import { + WidgetState, + resolveWidgetStateError, + type WidgetStateEmpty, + type WidgetStateError, +} from '../widget-state'; +import { + buildLocationsData, + type LocationRowInput, + type LocationsCountry, +} from './build-locations-data'; +import styles from './locations.module.scss'; +import type { DataFormat, WidgetStatus } from '../../types'; + +type LocationsProps = { + /** + * The ranking, in display order: countries, or the regions of `country`. The map draws them + * all, the list the first `maxRows`. + */ + rows: readonly LocationRowInput[]; + /** + * Request state, from the widget's data hook. + */ + status: WidgetStatus; + /** + * Error copy and actions, as `describeError()` builds them. Omit for the generic + * message with a Retry bound to `status.refetch`. + */ + error?: WidgetStateError; + /** + * A widget's own empty state; omit for the generic one. + */ + empty?: WidgetStateEmpty; + /** + * Format of the figures. Defaults to compact integers. + */ + format?: DataFormat; + /** + * What the figure is, as the map tooltip names it: "Views", "Store visitors". + */ + valueLabel: string; + /** + * The country the rows are regions of. Omit for countries on the world map. + */ + country?: LocationsCountry; + /** + * Rows the list shows, and the skeleton's height. Defaults to the widget row limit. + */ + maxRows?: number; +}; + +const DEFAULT_FORMAT: DataFormat = { + type: 'number', + options: { useMultipliers: true, decimals: 0 }, +}; + +/** + * A locations widget body: the ranked list with a flag, share and delta per location beside + * the map that places them, and the loading, error and empty states. Renders inside + * `WidgetRoot`. + * + * @param {LocationsProps} props - The props for the Locations component. + * @return {JSX.Element} The Locations component. + */ +export function Locations( { + rows, + status, + error, + empty, + format = DEFAULT_FORMAT, + valueLabel, + country, + maxRows = WIDGET_ROW_LIMIT, +}: LocationsProps ): JSX.Element { + const { isLoading, isFetching, isError, refetch } = status; + const hasComparison = !! status.hasComparison; + + const data = useMemo( + () => buildLocationsData( rows, { country, hasComparison, maxRows } ), + [ rows, country, hasComparison, maxRows ] + ); + const listData = useMemo( + () => buildLeaderboardChartData( data.rows, { hasComparison: data.comparing, maxRows } ), + [ data, maxRows ] + ); + const errorState = useMemo( () => resolveWidgetStateError( error, refetch ), [ error, refetch ] ); + + return ( +
+ } + > +
+ + + + + +
+
+
+ ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/stories/locations.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/stories/locations.stories.tsx new file mode 100644 index 000000000000..340e7cec5962 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/locations/stories/locations.stories.tsx @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import { location } from '@jetpack-premium-analytics/icons'; +/** + * Internal dependencies + */ +import { WidgetCard } from '../../../stories/widget-card'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { Locations } from '../locations'; +import type { LocationRowInput } from '../build-locations-data'; +import type { Decorator, Meta, StoryObj } from '@storybook/react'; + +const COUNTRIES: LocationRowInput[] = [ + { id: 'us', label: 'United States', value: 12400, previousValue: 11000 }, + { id: 'gb', label: 'United Kingdom', value: 3100, previousValue: 3600 }, + { id: 'ca', label: 'Canada', value: 2050, previousValue: 1900 }, + { id: 'de', label: 'Germany', value: 980, previousValue: 1200 }, + { id: 'au', label: 'Australia', value: 640, previousValue: 500 }, +]; + +const STATES: LocationRowInput[] = [ + { id: 'California', label: 'California', value: 4200, previousValue: 3900 }, + { id: 'Texas', label: 'Texas', value: 2650, previousValue: 2800 }, + { id: 'New York', label: 'New York', value: 2100, previousValue: 1700 }, + { id: 'Florida', label: 'Florida', value: 1300, previousValue: 1250 }, +]; + +const READY = { isLoading: false, isError: false }; + +const withCard: Decorator = Story => ( + + + +); + +const meta: Meta< typeof Locations > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/Locations', + component: Locations, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'The locations widget body: hand it the ranking, as countries or as the regions of one country, and the request status, and it renders the list with a flag, share and delta per location beside the map that places them, and the loading, error and empty states. Renders inside `WidgetRoot`.', + }, + }, + }, + decorators: [ withCard, withWidgetRoot() ], + args: { + rows: COUNTRIES, + status: READY, + valueLabel: 'Views', + }, +}; + +export default meta; + +type Story = StoryObj< typeof meta >; + +export const Default: Story = {}; + +export const WithComparison: Story = { + args: { status: { ...READY, hasComparison: true } }, +}; + +export const UnitedStates: Story = { + args: { + rows: STATES, + status: { ...READY, hasComparison: true }, + country: { code: 'US', name: 'United States' }, + }, +}; + +export const Loading: Story = { + args: { status: { isLoading: true } }, +}; + +export const Failed: Story = { + args: { status: { isLoading: false, isError: true, refetch: () => undefined } }, +}; + +export const Empty: Story = { + args: { + rows: [], + empty: { icon: location, description: 'No location data in this period.' }, + }, +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-visitors-by-location-data.test.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-visitors-by-location-data.test.ts deleted file mode 100644 index dac7598db7c6..000000000000 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-visitors-by-location-data.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Internal dependencies - */ -import { - buildVisitorsByLocationData, - type LocationDataEntry, -} from '../build-visitors-by-location-data'; - -const primaryData: LocationDataEntry[] = [ - { id: 'US', label: 'United States', value: 4 }, - { id: 'TW', label: 'Taiwan', value: 1 }, - { id: 'JP', label: 'Japan', value: 1 }, -]; - -// United States and Taiwan overlap the current period; Singapore replaces Japan, -// so Japan has no matching comparison-period value. -const comparisonData: LocationDataEntry[] = [ - { id: 'US', label: 'United States', value: 4 }, - { id: 'TW', label: 'Taiwan', value: 1 }, - { id: 'SG', label: 'Singapore', value: 1 }, -]; - -describe( 'buildVisitorsByLocationData', () => { - it( 'keeps a real delta for locations present in both periods', () => { - const { leaderboardData } = buildVisitorsByLocationData( { - primaryData, - comparisonData, - region: 'world', - } ); - - const [ us, tw ] = leaderboardData; - - // Equal values in both periods → genuine 0% change, not an em dash. - expect( us.previousValue ).toBe( 4 ); - expect( us.delta ).toBe( 0 ); - expect( tw.previousValue ).toBe( 1 ); - expect( tw.delta ).toBe( 0 ); - } ); - - it( 'scales both periods against their combined maximum', () => { - const { leaderboardData } = buildVisitorsByLocationData( { - primaryData, - comparisonData: [ - { id: 'US', label: 'United States', value: 8 }, - { id: 'TW', label: 'Taiwan', value: 2 }, - ], - region: 'world', - } ); - - const [ us ] = leaderboardData; - - expect( us.currentShare ).toBe( 50 ); - expect( us.previousShare ).toBe( 100 ); - } ); - - it( 'leaves comparison fields undefined for a location absent from the comparison period', () => { - const { leaderboardData } = buildVisitorsByLocationData( { - primaryData, - comparisonData, - region: 'world', - } ); - - const japan = leaderboardData.find( row => row.id === 'JP' ); - - // No matching comparison row → comparison fields stay undefined, so the - // chart shows the missing-data placeholder rather than implying a real 0. - expect( japan?.previousValue ).toBeUndefined(); - expect( japan?.previousShare ).toBeUndefined(); - expect( japan?.delta ).toBeUndefined(); - } ); - - it( 'keeps comparison fields for a location with a real zero value', () => { - const { leaderboardData } = buildVisitorsByLocationData( { - primaryData, - comparisonData: [ ...comparisonData, { id: 'JP', label: 'Japan', value: 0 } ], - region: 'world', - } ); - - const japan = leaderboardData.find( row => row.id === 'JP' ); - - // A real 0 is a known previous value, but its percentage change is undefined. - expect( japan?.previousValue ).toBe( 0 ); - expect( japan?.previousShare ).toBe( 0 ); - expect( japan?.delta ).toBeUndefined(); - } ); - - it( 'leaves comparison fields undefined for every row when no comparison data is provided', () => { - const { leaderboardData } = buildVisitorsByLocationData( { - primaryData, - region: 'world', - } ); - - for ( const row of leaderboardData ) { - expect( row.previousValue ).toBeUndefined(); - expect( row.previousShare ).toBeUndefined(); - expect( row.delta ).toBeUndefined(); - } - } ); - - it( 'reports a row comparison when at least one location overlaps', () => { - const { hasRowComparison } = buildVisitorsByLocationData( { - primaryData, - comparisonData, - region: 'world', - } ); - - // Partial overlap still counts: matching rows keep their deltas, and the - // unmatched ones fall back to the placeholder. - expect( hasRowComparison ).toBe( true ); - } ); - - it( 'reports no row comparison when no location overlaps', () => { - const { hasRowComparison } = buildVisitorsByLocationData( { - primaryData, - comparisonData: [ - { id: 'SG', label: 'Singapore', value: 3 }, - { id: 'KR', label: 'South Korea', value: 2 }, - ], - region: 'world', - } ); - - // Every row would render a placeholder, so callers suppress comparison mode. - expect( hasRowComparison ).toBe( false ); - } ); - - it( 'reports no row comparison when no comparison data is provided', () => { - const { hasRowComparison } = buildVisitorsByLocationData( { - primaryData, - region: 'world', - } ); - - expect( hasRowComparison ).toBe( false ); - } ); - - it( 'ignores overlap that falls outside the visible rows', () => { - const { hasRowComparison } = buildVisitorsByLocationData( { - primaryData, - comparisonData: [ { id: 'JP', label: 'Japan', value: 2 } ], - region: 'world', - limit: 1, - } ); - - // Japan overlaps but is cut off by the limit, so no visible row has a - // comparison to show. - expect( hasRowComparison ).toBe( false ); - } ); -} ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-visitors-by-location-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-visitors-by-location-data.ts deleted file mode 100644 index f3ca978556ad..000000000000 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-visitors-by-location-data.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -/** - * Internal dependencies - */ -import { calculateDelta } from './calculate-delta'; -import { getCombinedPeriodMax } from './get-combined-period-max'; -import { sharePercentage } from './share-percentage'; -import type { LeaderboardChartData } from '../components/chart-leaderboard/leaderboard-chart'; -import type { GeoData } from '@jetpack-premium-analytics/externals'; - -export type Region = 'US' | 'world'; - -export type VisitorsByLocationData = { - geoData: GeoData; - leaderboardData: LeaderboardChartData; - - /** - * Whether any visible leaderboard row has a matching comparison-period row. - * - * Callers combine this with the date-range comparison state so a period with - * no overlapping rows hides comparison mode instead of rendering a column of - * placeholders. - */ - hasRowComparison: boolean; -}; - -export type LocationDataEntry = { - id: string; - label: string; - value: number; -}; - -type BuildVisitorsByLocationDataParams = { - primaryData: LocationDataEntry[]; - comparisonData?: LocationDataEntry[]; - region: Region; - limit?: number; -}; - -/** - * Build geo chart and leaderboard data from raw location data. - * - * @param params - Build parameters - * @param params.primaryData - Primary period data - * @param params.comparisonData - Comparison period data (optional) - * @param params.region - The region ('US' or 'world') - * @param params.limit - Maximum number of items for leaderboard (default: 5) - * @return Geo chart data, leaderboard data, and whether any row has a comparison - */ -export function buildVisitorsByLocationData( { - primaryData, - comparisonData, - region, - limit = 5, -}: BuildVisitorsByLocationDataParams ): VisitorsByLocationData { - const headerLabel = - region === 'US' - ? __( 'State', 'jetpack-premium-analytics-pkg' ) - : __( 'Country', 'jetpack-premium-analytics-pkg' ); - - const geoData: GeoData = [ - [ headerLabel, __( 'Store visitors', 'jetpack-premium-analytics-pkg' ) ], - ...primaryData.map( item => [ item.label, item.value ] as [ string, number ] ), - ]; - - let hasRowComparison = false; - const comparisonValues = new Map( comparisonData?.map( item => [ item.id, item.value ] ) ); - const visiblePrimaryData = primaryData.slice( 0, limit ); - const maxValue = getCombinedPeriodMax( - visiblePrimaryData.map( item => item.value ), - visiblePrimaryData.map( item => comparisonValues.get( item.id ) ) - ); - - const leaderboardData: LeaderboardChartData = visiblePrimaryData.map( item => { - // A location absent from the comparison period has an unknown previous - // value, not a real 0, so leave the comparison fields undefined and let - // the chart show a missing-data placeholder instead of a fabricated delta. - // A location present with 0 visitors keeps its known comparison value while - // its unavailable delta renders separately. - const previousValue = comparisonValues.get( item.id ); - const hasComparisonValue = previousValue !== undefined; - - if ( hasComparisonValue ) { - hasRowComparison = true; - } - - return { - id: item.id, - label: item.label, - currentValue: item.value, - previousValue, - currentShare: sharePercentage( item.value, maxValue ), - previousShare: hasComparisonValue ? sharePercentage( previousValue, maxValue ) : undefined, - delta: hasComparisonValue ? calculateDelta( item.value, previousValue ) : undefined, - }; - } ); - - return { geoData, leaderboardData, hasRowComparison }; -} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts index 124316f89c5c..9be30d12eae0 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts @@ -8,12 +8,6 @@ export { export { formatLegendLabels } from './format-legend-labels'; export { getComparisonPeriodLabel } from './get-comparison-period-label'; export { calculateDelta } from './calculate-delta'; -export { - buildVisitorsByLocationData, - type VisitorsByLocationData, - type LocationDataEntry, - type Region, -} from './build-visitors-by-location-data'; export { flagUrl } from './flag-url'; export { isEmptyChartData, isEmptyPieChartData, getEmptyChartDomain } from './chart-empty-state'; export { diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/use-visitors-by-location.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/use-visitors-by-location.ts index 7da84ac12ba1..c8a36699f838 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/use-visitors-by-location.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/use-visitors-by-location.ts @@ -6,25 +6,38 @@ import { useMemo } from 'react'; /** * Internal dependencies */ -import { - buildVisitorsByLocationData, - type Region, - type LocationDataEntry, -} from '../../helpers/build-visitors-by-location-data'; +import type { LocationRowInput } from '../../components/locations'; -export type { Region }; +export type Region = 'US' | 'world'; -type LocationRawData = { - primary: LocationDataEntry[]; - comparison: LocationDataEntry[]; +type LocationItem = { + region?: string | null; + country_code: string; + label: string; + visitors: number; }; +const toEntries = ( items: readonly LocationItem[], region: Region ) => + region === 'US' + ? items + .filter( item => Boolean( item.region ) ) + .map( item => ( { + id: item.region as string, + label: item.region as string, + value: item.visitors, + } ) ) + : items.map( item => ( { + id: item.country_code.toLowerCase(), + label: item.label, + value: item.visitors, + } ) ); + /** - * Hook to fetch and build visitors by location chart data. + * The visitors by location report of the region as location rows: US states, or countries. * - * @param reportParams - Report parameters from widget context - * @param region - The region to get data for ('US' or 'world') - * @return Geo chart data and leaderboard data for the selected region + * @param reportParams - The report params from the widget root. + * @param region - Which ranking to read. + * @return The rows and the request status. */ export function useVisitorsByLocation( reportParams: ReportParams, region: Region ) { const usReport = useReportVisitorsByLocation( reportParams, { @@ -33,73 +46,24 @@ export function useVisitorsByLocation( reportParams: ReportParams, region: Regio countryCode: 'US', limit: 100, } ); - const worldReport = useReportVisitorsByLocation( reportParams, { enabled: region === 'world', groupBy: 'country', limit: 15, } ); + const report = region === 'US' ? usReport : worldReport; + const { primary, comparison, hasComparison, isLoading, isFetching, hasData, isError, refetch } = + report; - const activeReport = region === 'US' ? usReport : worldReport; - const comparisonEnabled = activeReport.hasComparison; - - const rawData: LocationRawData = useMemo( () => { - const primaryItems = activeReport.primary.data?.data ?? []; - const comparisonItems = activeReport.comparison.data?.data ?? []; - - if ( region === 'US' ) { - const mapUsRegions = ( items: typeof primaryItems ) => - items - .filter( item => Boolean( item.region ) ) - .map( item => ( { - id: item.region as string, - label: item.region as string, - value: item.visitors, - } ) ); - - return { - primary: mapUsRegions( primaryItems ), - comparison: mapUsRegions( comparisonItems ), - }; - } - - return { - primary: primaryItems.map( item => ( { - id: item.country_code.toLowerCase(), - label: item.label, - value: item.visitors, - } ) ), - comparison: comparisonItems.map( item => ( { - id: item.country_code.toLowerCase(), - label: item.label, - value: item.visitors, - } ) ), - }; - }, [ region, activeReport.primary.data, activeReport.comparison.data ] ); - - const { geoData, leaderboardData, hasRowComparison } = useMemo( - () => - buildVisitorsByLocationData( { - primaryData: rawData.primary, - comparisonData: comparisonEnabled ? rawData.comparison : undefined, - region, - } ), - [ rawData.primary, rawData.comparison, region, comparisonEnabled ] - ); - - const { isLoading, isFetching, hasData, isError, error, refetch } = activeReport; + const rows = useMemo< LocationRowInput[] >( () => { + const previous = new Map( + toEntries( comparison.data?.data ?? [], region ).map( entry => [ entry.id, entry.value ] ) + ); + return toEntries( primary.data?.data ?? [], region ).map( entry => ( { + ...entry, + previousValue: previous.get( entry.id ), + } ) ); + }, [ primary.data, comparison.data, region ] ); - return { - geoData, - leaderboardData, - // Combine date-range state with row overlap here so consumers get a single - // comparison flag, as the Stats hooks do. - hasComparison: comparisonEnabled && hasRowComparison, - isLoading, - isFetching, - hasData, - isError, - error, - refetch, - }; + return { rows, hasComparison, isLoading, isFetching, hasData, isError, refetch }; } diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.module.scss index 0c71a6473aa0..70f3cb030f36 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.module.scss +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.module.scss @@ -1,82 +1,22 @@ .root { - // Establish an inline-size query container so the layout can respond to the - // rendered tile width (single- vs two-column dashboard tile) with pure CSS, - // instead of measuring the tile from JS. - container-type: inline-size; - height: 100%; - // No `overflow: hidden` here: the toggle sits at the left edge of this box, - // and clipping would shave off its border/focus ring. The map has its own - // `overflow: hidden` to contain the GeoChart SVG. -} - -.container { - display: grid; - // Leaderboard on the left, map on the right — matching the Locations widget. - grid-template-columns: 280fr 400fr; - grid-template-rows: auto 1fr; - column-gap: var(--wpds-dimension-gap-lg); - // Separate the toggle from the leaderboard below it. - row-gap: var(--wpds-dimension-gap-md); - align-items: stretch; - height: 100%; - min-height: 0; + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-md); + block-size: 100%; + min-block-size: 0; } -.toggleControl { - grid-column: 1; - grid-row: 1; - - // The upstream widget content container - // (.next-admin-dashboard-widget__content) sets overflow: auto, which - // clips the ToggleGroupControl's outward focus ring. - // Add padding to create space for the focus indicator. +.toggle { + // The host's content box scrolls, which would clip the control's + // outward focus ring. padding-block-start: var(--wpds-dimension-padding-xs); padding-inline-end: var(--wpds-dimension-padding-xs); - padding-block-end: 0; - padding-inline-start: 0; -} - -.leaderboardChart { - grid-column: 1; - grid-row: 2; align-self: start; - min-width: 0; - min-height: 0; - overflow: hidden; + inline-size: 280px; + max-inline-size: 100%; } -.geoChart { - // Map sits in the content row (beside the leaderboard), so it centers - // against the data rather than the toggle above them — matching Locations. - grid-column: 2; - grid-row: 2; - - // Let the responsive GeoChart fill the cell and scale to fit: it keeps the - // map's aspect ratio and letterboxes inside the box, so a short single-row - // tile shrinks the map instead of clipping it. Avoid fixed max-width/height - // caps, which forced the map larger than a short cell and cut it off. - min-width: 0; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - - // GeoChart renders a fixed pixel-sized SVG that Google redraws on a resize - // debounce. While a tile shrinks the previous (wider) SVG lingers briefly; - // clip it to the column so it can never spill over the leaderboard. - overflow: hidden; -} - -// Narrow tiles (single-column dashboard tile): hide the map and show only the -// leaderboard. The threshold sits between a single-column tile (~381px) and a -// two-column tile (~786px) so the two-column layout keeps both. -@container (max-width: 480px) { - - .container { - grid-template-columns: 1fr; - } - - .geoChart { - display: none; - } +.locations { + flex: 1; + min-block-size: 0; } diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.tsx index ed9a646c0669..62afa644eac9 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.tsx @@ -1,117 +1,76 @@ /** * External dependencies */ -import { GeoChart } from '@jetpack-premium-analytics/externals'; import { location } from '@jetpack-premium-analytics/icons'; import { __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useMemo, useState } from 'react'; -import { WidgetState } from '../../components'; -import { buildLeaderboardRow, LeaderboardChart } from '../../components/chart-leaderboard'; +import { useState } from 'react'; /** * Internal dependencies */ +import { Locations } from '../../components/locations'; import { useWidgetRootContext } from '../../components/widget-root'; -import { RESIZE_DEBOUNCE_MS } from '../../constants'; -import { flagUrl } from '../../helpers'; import { useVisitorsByLocation, type Region } from './use-visitors-by-location'; import styles from './visitors-by-location-widget.module.scss'; +const UNITED_STATES = { code: 'US', name: __( 'United States', 'jetpack-premium-analytics-pkg' ) }; + function isRegion( value: unknown ): value is Region { return value === 'US' || value === 'world'; } +/** + * Store visitors by location: US states or countries, as the toggle says, listed beside the map. + * + * Must render within a WidgetRoot, which provides reportParams via context. + */ export function VisitorsByLocationWidget() { const { reportParams } = useWidgetRootContext(); const [ region, setRegion ] = useState< Region >( 'US' ); - const { - geoData, - leaderboardData, - isLoading, - isFetching, - hasData, - hasComparison, - isError, - refetch, - } = useVisitorsByLocation( reportParams, region ); - - const leaderboardDataWithImages = useMemo( - () => - leaderboardData.map( item => { - const imageUrl = flagUrl( region === 'US' ? 'us' : item.id ); - const labelText = typeof item.label === 'string' ? item.label : ''; - - return { - ...item, - ...buildLeaderboardRow( { - label: labelText, - media: { - kind: 'flag', - url: imageUrl ?? undefined, - country: - region === 'US' - ? __( 'United States', 'jetpack-premium-analytics-pkg' ) - : labelText, - }, - action: { kind: 'static' }, - } ), - }; - } ), - [ leaderboardData, region ] - ); - - const geoChartProps = - region === 'US' - ? ( { - region, - resolution: 'provinces', - } as const ) - : {}; - - const geoChart = ( - - ); + const { rows, hasComparison, isLoading, isFetching, hasData, isError, refetch } = + useVisitorsByLocation( reportParams, region ); return (
-
- { /* The region toggle drives the query, so it stays available in - every state (a sibling of WidgetState, per the widget contract). */ } -
- { - if ( isRegion( value ) ) { - setRegion( value ); - } - } } - value={ region } - > - - - -
- - + { + if ( isRegion( value ) ) { + setRegion( value ); + } + } } + value={ region } + > + + + +
+
+ - - -
{ geoChart }
- + country={ region === 'US' ? UNITED_STATES : undefined } + valueLabel={ __( 'Store visitors', 'jetpack-premium-analytics-pkg' ) } + />
); diff --git a/projects/packages/premium-analytics/tests/groups/widgets-toolkit-no-mocks-part3.test.tsx b/projects/packages/premium-analytics/tests/groups/widgets-toolkit-no-mocks-part3.test.tsx index 5a7df6c8426e..a39e79786a7d 100644 --- a/projects/packages/premium-analytics/tests/groups/widgets-toolkit-no-mocks-part3.test.tsx +++ b/projects/packages/premium-analytics/tests/groups/widgets-toolkit-no-mocks-part3.test.tsx @@ -5,7 +5,6 @@ import '../../packages/widgets-toolkit/src/components/widget-state/__tests__/wid import '../../packages/widgets-toolkit/src/components/wordads-earnings-history/__tests__/fields.test'; import '../../packages/widgets-toolkit/src/helpers/__tests__/build-csv.test'; import '../../packages/widgets-toolkit/src/helpers/__tests__/build-metric-tab.test'; -import '../../packages/widgets-toolkit/src/helpers/__tests__/build-visitors-by-location-data.test'; import '../../packages/widgets-toolkit/src/helpers/__tests__/calculate-delta.test'; import '../../packages/widgets-toolkit/src/helpers/__tests__/calendar-heatmap-layout.test'; import '../../packages/widgets-toolkit/src/hooks/__tests__/use-chart-theme.test'; From 9b2934d004d6ebefa61de5de7a64ea98adc37a5e Mon Sep 17 00:00:00 2001 From: jboland88 <18016357+jboland88@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:56:05 -0400 Subject: [PATCH 18/57] wpcomsh: Keep Activity Log module on for WoA sites (#53546) --- .../fix-wpcomsh-activity-log-default-on | 5 ++ .../plugins/jetpack/tests/php/bootstrap.php | 3 ++ .../fix-wpcomsh-activity-log-default-on | 4 ++ .../wpcomsh/feature-plugins/activity-log.php | 29 +++++++++++ .../plugins/wpcomsh/tests/ActivityLogTest.php | 50 +++++++++++++++++++ projects/plugins/wpcomsh/woa.php | 3 +- projects/plugins/wpcomsh/wpcomsh.php | 1 + 7 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 projects/plugins/jetpack/changelog/fix-wpcomsh-activity-log-default-on create mode 100644 projects/plugins/wpcomsh/changelog/fix-wpcomsh-activity-log-default-on create mode 100644 projects/plugins/wpcomsh/feature-plugins/activity-log.php create mode 100644 projects/plugins/wpcomsh/tests/ActivityLogTest.php diff --git a/projects/plugins/jetpack/changelog/fix-wpcomsh-activity-log-default-on b/projects/plugins/jetpack/changelog/fix-wpcomsh-activity-log-default-on new file mode 100644 index 000000000000..8bb963cb3324 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-wpcomsh-activity-log-default-on @@ -0,0 +1,5 @@ +Significance: patch +Type: other +Comment: Test bootstrap only: ignore a wpcomsh module policy in the wpcomsh test run. + + diff --git a/projects/plugins/jetpack/tests/php/bootstrap.php b/projects/plugins/jetpack/tests/php/bootstrap.php index 0a69b17668a1..d56da3e8a692 100644 --- a/projects/plugins/jetpack/tests/php/bootstrap.php +++ b/projects/plugins/jetpack/tests/php/bootstrap.php @@ -180,6 +180,9 @@ function _manually_load_muplugin() { require_once JETPACK_WPCOMSH_INSTALL_DIR . '/wpcomsh.php'; \Automattic\Jetpack\Jetpack_Mu_Wpcom::init(); + // Module-state tests assume self-hosted defaults, which this policy overrides. + remove_filter( 'jetpack_feature_policy', 'wpcomsh_force_activity_log_module' ); + defined( 'WPCOMSH_PREMIUM_THEMES_PATH' ) || define( 'WPCOMSH_PREMIUM_THEMES_PATH', sys_get_temp_dir() . '/premium' ); if ( ! is_dir( WPCOMSH_PREMIUM_THEMES_PATH ) ) { mkdir( WPCOMSH_PREMIUM_THEMES_PATH, 0777 ); diff --git a/projects/plugins/wpcomsh/changelog/fix-wpcomsh-activity-log-default-on b/projects/plugins/wpcomsh/changelog/fix-wpcomsh-activity-log-default-on new file mode 100644 index 000000000000..aa95eb609517 --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/fix-wpcomsh-activity-log-default-on @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Activity Log: Keep the Activity Log on for all sites, including newly transferred and reset ones. diff --git a/projects/plugins/wpcomsh/feature-plugins/activity-log.php b/projects/plugins/wpcomsh/feature-plugins/activity-log.php new file mode 100644 index 000000000000..1b7f2ec29cf5 --- /dev/null +++ b/projects/plugins/wpcomsh/feature-plugins/activity-log.php @@ -0,0 +1,29 @@ + 'forced-on' ); + + return $policy; +} +add_filter( 'jetpack_feature_policy', 'wpcomsh_force_activity_log_module' ); diff --git a/projects/plugins/wpcomsh/tests/ActivityLogTest.php b/projects/plugins/wpcomsh/tests/ActivityLogTest.php new file mode 100644 index 000000000000..06ccc77984cd --- /dev/null +++ b/projects/plugins/wpcomsh/tests/ActivityLogTest.php @@ -0,0 +1,50 @@ + array( 'activation' => 'forced-on' ) ); + $expected = $forced + ? array_merge( $existing, array( 'activity-log' => array( 'activation' => 'forced-on' ) ) ) + : $existing; + + $this->assertSame( $expected, wpcomsh_force_activity_log_module( $existing, $jetpack_version ) ); + } + + /** + * Data provider for test_forces_module_on_only_when_jetpack_ships_it. + * + * @return array + */ + public static function version_provider() { + return array( + 'before the module' => array( '16.2', false ), + 'prerelease build' => array( '16.3-beta', true ), + 'release' => array( '16.3', true ), + ); + } +} diff --git a/projects/plugins/wpcomsh/woa.php b/projects/plugins/wpcomsh/woa.php index cf38f963ff57..d62396090c82 100644 --- a/projects/plugins/wpcomsh/woa.php +++ b/projects/plugins/wpcomsh/woa.php @@ -442,7 +442,7 @@ function wpcomsh_woa_post_process_store_woocommerce_connection_details( $args, $ /** * Ensures that specific Jetpack modules are activated after a transfer. - * Addresses the issue where certain modules like blocks, account-protection, blaze, and wpcom-reader + * Addresses the issue where certain modules like blocks, account-protection, activity-log, blaze, and wpcom-reader * may be disabled during the transfer process. * * @param array $args Arguments. @@ -460,6 +460,7 @@ function wpcomsh_woa_post_process_activate_jetpack_modules( $args, $assoc_args ) $modules_to_activate = array( 'account-protection', + 'activity-log', 'blaze', 'blocks', 'wpcom-reader', diff --git a/projects/plugins/wpcomsh/wpcomsh.php b/projects/plugins/wpcomsh/wpcomsh.php index 5d73cda1e38d..061dfb194c97 100644 --- a/projects/plugins/wpcomsh/wpcomsh.php +++ b/projects/plugins/wpcomsh/wpcomsh.php @@ -131,6 +131,7 @@ function () { require_once __DIR__ . '/endpoints/rest-api.php'; // Load feature plugins. +require_once __DIR__ . '/feature-plugins/activity-log.php'; require_once __DIR__ . '/feature-plugins/activitypub.php'; require_once __DIR__ . '/feature-plugins/additional-css.php'; require_once __DIR__ . '/feature-plugins/ai-launchpad.php'; From 1045c61870c6bf4a94d6ed1641f4d8b3d4015969 Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Tue, 13 Oct 2026 12:58:17 -0300 Subject: [PATCH 19/57] Backup: make dates, times and sizes follow the locale (#53486) * Backup: make dates, times and sizes follow the locale in the modernized dashboard Date and time formats are translatable strings, so English stays as designed and locales can switch to 24-hour time. Restore and Download point labels use one translatable string. File and storage sizes use locale number formatting with translatable units. The activity list row separator reuses a translatable string. Co-Authored-By: Claude Sonnet 5.5 * Backup: keep en_US file sizes ungrouped, and strip only a trailing AM/PM Co-Authored-By: Claude Opus 5.5 * Backup: Follow the site's date and time formats Read the date, time and date-time formats from the site settings through @wordpress/date instead of new translatable strings. Only the short month and day stays translatable, with its translators comment inside the call so the build keeps it. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- .../fix-jetpack-2979-backup-locale-formats | 4 ++ .../components/activity-detail/index.tsx | 4 +- .../components/activity-list/index.tsx | 29 +++++++-- .../file-info-card/file-info-meta.tsx | 4 +- .../backup/src/dashboard/data/date-format.ts | 61 +++++++++++++++++++ .../src/dashboard/data/normalize/restores.ts | 4 +- .../data/normalize/test/restores.test.ts | 2 +- .../src/dashboard/data/storage-units.ts | 19 +++++- .../dashboard/data/test/date-format.test.ts | 29 +++++++++ .../dashboard/data/test/storage-units.test.ts | 23 +++++++ .../hooks/test/use-backup-schedule.test.ts | 23 +++++++ .../hooks/test/use-path-info.test.ts | 13 ++++ .../dashboard/hooks/use-backup-schedule.ts | 16 ++--- .../src/dashboard/hooks/use-path-info.ts | 5 +- .../backup/src/dashboard/screens/download.tsx | 11 ++-- .../backup/src/dashboard/screens/restore.tsx | 11 ++-- .../tests/next-scheduled-backup.test.tsx | 42 ++++++------- .../restore-rows-in-activity-list.test.tsx | 2 +- .../tests/restore-session-recovery.test.tsx | 6 +- .../backup/tests/tracks-events.test.tsx | 2 +- 20 files changed, 251 insertions(+), 59 deletions(-) create mode 100644 projects/packages/backup/changelog/fix-jetpack-2979-backup-locale-formats create mode 100644 projects/packages/backup/src/dashboard/data/date-format.ts create mode 100644 projects/packages/backup/src/dashboard/data/test/date-format.test.ts create mode 100644 projects/packages/backup/src/dashboard/data/test/storage-units.test.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/test/use-backup-schedule.test.ts diff --git a/projects/packages/backup/changelog/fix-jetpack-2979-backup-locale-formats b/projects/packages/backup/changelog/fix-jetpack-2979-backup-locale-formats new file mode 100644 index 000000000000..0a6a35748757 --- /dev/null +++ b/projects/packages/backup/changelog/fix-jetpack-2979-backup-locale-formats @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed +Comment: Follow the site's date and time settings, and the locale for file sizes, in the modernized dashboard, which is behind a feature flag. + diff --git a/projects/packages/backup/src/dashboard/components/activity-detail/index.tsx b/projects/packages/backup/src/dashboard/components/activity-detail/index.tsx index d3aa116a7527..41588a2a67f4 100644 --- a/projects/packages/backup/src/dashboard/components/activity-detail/index.tsx +++ b/projects/packages/backup/src/dashboard/components/activity-detail/index.tsx @@ -1,7 +1,7 @@ -import { dateI18n } from '@wordpress/date'; import { createInterpolateElement } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { Card, Stack, Text } from '@wordpress/ui'; +import { formatDateTime } from '../../data/date-format'; import './style.scss'; import type { NonBackupActivityItem } from '../../types/activity'; @@ -27,7 +27,7 @@ export default function ActivityDetail( { item }: Props ) { { item.title } - { dateI18n( 'M j, Y, g:i A', item.publishedAt, undefined ) } + { formatDateTime( item.publishedAt ) } { createInterpolateElement( sprintf( diff --git a/projects/packages/backup/src/dashboard/components/activity-list/index.tsx b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx index c7246143b981..8c5092db83cf 100644 --- a/projects/packages/backup/src/dashboard/components/activity-list/index.tsx +++ b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx @@ -146,6 +146,28 @@ function TitleCell( { item, isNew }: { item: ActivityItem; isNew: boolean } ) { ); } +/** + * A backup's size and duration as one line, in whichever of the two is known. + * + * @param run - The backup's run. + * @return The line, or an empty string when neither is known. + */ +function runMeta( run: NonNullable< ReturnType< BackupRunLookup > > ): string { + const parts = [ + run.siteSize !== null && formatStorageSize( run.siteSize ), + run.duration !== null && formatDuration( run.duration, 'short' ), + ].filter( ( part ): part is string => Boolean( part ) ); + + return parts.length === 2 + ? sprintf( + /* translators: %1$s and %2$s: two facts about one backup, such as its size and how long it took. */ + __( '%1$s · %2$s', 'jetpack-backup-pkg' ), + parts[ 0 ], + parts[ 1 ] + ) + : ( parts[ 0 ] ?? '' ); +} + /** * Descriptions cell — single muted line with the timestamp, a backup's * size and duration once known, and the optional summary. @@ -165,12 +187,7 @@ function DescriptionCell( { item }: { item: ActivityItem } ) { { /* Before the summary, which is the part that truncates. */ } { run && ( - { [ - run.siteSize !== null && formatStorageSize( run.siteSize ), - run.duration !== null && formatDuration( run.duration, 'short' ), - ] - .filter( Boolean ) - .join( ' · ' ) } + { runMeta( run ) } ) } { /* diff --git a/projects/packages/backup/src/dashboard/components/file-info-card/file-info-meta.tsx b/projects/packages/backup/src/dashboard/components/file-info-card/file-info-meta.tsx index 7144bc57cb87..1d6e14c5ee8a 100644 --- a/projects/packages/backup/src/dashboard/components/file-info-card/file-info-meta.tsx +++ b/projects/packages/backup/src/dashboard/components/file-info-card/file-info-meta.tsx @@ -1,6 +1,6 @@ -import { dateI18n } from '@wordpress/date'; import { __ } from '@wordpress/i18n'; import { Text } from '@wordpress/ui'; +import { formatDateTime } from '../../data/date-format'; import { formatFileSize } from '../../hooks/use-path-info'; /** @@ -32,7 +32,7 @@ export default function FileInfoMeta( { { modified && (
{ __( 'Modified:', 'jetpack-backup-pkg' ) }
-
{ dateI18n( 'M j, Y, g:i A', modified, undefined ) }
+
{ formatDateTime( modified ) }
) } { size !== null && ( diff --git a/projects/packages/backup/src/dashboard/data/date-format.ts b/projects/packages/backup/src/dashboard/data/date-format.ts new file mode 100644 index 000000000000..24bd99459b2d --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/date-format.ts @@ -0,0 +1,61 @@ +import { dateI18n, getSettings } from '@wordpress/date'; +import { _x } from '@wordpress/i18n'; + +type DateInput = Parameters< typeof dateI18n >[ 1 ]; + +// Core fills `formats` from Settings > General and translates its defaults. + +/** + * A date and time in the site's own formats. + * + * @param value - The instant to format. + * @param timezone - A UTC offset such as `-0300`; omitted, the site's own timezone. + * @return The formatted date and time. + */ +export function formatDateTime( value: DateInput, timezone?: string ): string { + return dateI18n( getSettings().formats.datetime, value, timezone ); +} + +/** + * A date in the site's own format. + * + * @param value - The instant to format. + * @return The formatted date. + */ +export function formatDate( value: DateInput ): string { + return dateI18n( getSettings().formats.date, value ); +} + +/** + * The site's time-of-day format string. + * + * @return The format, in PHP date syntax. + */ +export function getTimeFormat(): string { + return getSettings().formats.time; +} + +/** + * A time of day in the site's own format. + * + * @param value - The instant to format. + * @param timezone - A UTC offset such as `-0300`; omitted, the site's own timezone. + * @return The formatted time. + */ +export function formatTime( value: DateInput, timezone?: string ): string { + return dateI18n( getTimeFormat(), value, timezone ); +} + +/** + * A short month and day. The site settings have no such format, so it is translatable. + * + * @param value - The instant to format. + * @return The formatted month and day. + */ +export function formatShortDate( value: DateInput ): string { + return dateI18n( + // translators: Short month and day format, see https://www.php.net/manual/datetime.format.php + _x( 'M j', 'short month and day date format', 'jetpack-backup-pkg' ), + value + ); +} diff --git a/projects/packages/backup/src/dashboard/data/normalize/restores.ts b/projects/packages/backup/src/dashboard/data/normalize/restores.ts index 1dd409d079ba..72a822c77cc9 100644 --- a/projects/packages/backup/src/dashboard/data/normalize/restores.ts +++ b/projects/packages/backup/src/dashboard/data/normalize/restores.ts @@ -1,6 +1,6 @@ -import { dateI18n } from '@wordpress/date'; import { __, sprintf } from '@wordpress/i18n'; import { parseRestoreWhen } from '../api/restore'; +import { formatDateTime } from '../date-format'; import type { ActivityItem } from '../../types/activity'; import type { ActivitySortOrder } from '../api/activity-log'; import type { RecentRestore } from '../api/restore'; @@ -52,7 +52,7 @@ function restoreSummary( rewindId: string ): string | undefined { return sprintf( /* translators: %s: date and time of the backup that was restored. */ __( 'Restore to %s', 'jetpack-backup-pkg' ), - dateI18n( 'M j, Y, g:i A', new Date( seconds * 1000 ).toISOString(), undefined ) + formatDateTime( new Date( seconds * 1000 ).toISOString() ) ); } diff --git a/projects/packages/backup/src/dashboard/data/normalize/test/restores.test.ts b/projects/packages/backup/src/dashboard/data/normalize/test/restores.test.ts index 2f3da95330bb..d4c6e1a53bc5 100644 --- a/projects/packages/backup/src/dashboard/data/normalize/test/restores.test.ts +++ b/projects/packages/backup/src/dashboard/data/normalize/test/restores.test.ts @@ -48,7 +48,7 @@ describe( 'normalizeRestore', () => { } ); test( 'names the backup it was aiming at', () => { - expect( normalizeRestore( restore() )?.summary ).toBe( 'Restore to Aug 12, 2026, 5:20 AM' ); + expect( normalizeRestore( restore() )?.summary ).toBe( 'Restore to August 12, 2026 5:20 am' ); } ); test( 'leaves the summary off a rewind id that is not a timestamp', () => { diff --git a/projects/packages/backup/src/dashboard/data/storage-units.ts b/projects/packages/backup/src/dashboard/data/storage-units.ts index f1be5d071ad7..0fdbf68e90f2 100644 --- a/projects/packages/backup/src/dashboard/data/storage-units.ts +++ b/projects/packages/backup/src/dashboard/data/storage-units.ts @@ -1,4 +1,5 @@ import { formatNumber } from '@automattic/number-formatters'; +import { _x, sprintf } from '@wordpress/i18n'; // Binary multiples, as legacy spells them. WordPress.com reports these // figures in bytes and sells storage in powers of two, so a 10GB plan is @@ -18,10 +19,22 @@ export function formatStorageSize( bytes: number ): string { const options = { numberFormatOptions: { maximumFractionDigits: 1 } }; if ( bytes < GIGABYTE ) { - return `${ formatNumber( bytes / MEGABYTE ) }MB`; + return sprintf( + /* translators: %s: a storage size number, e.g. "512". */ + _x( '%sMB', 'storage size in megabytes', 'jetpack-backup-pkg' ), + formatNumber( bytes / MEGABYTE ) + ); } return bytes >= TERABYTE - ? `${ formatNumber( bytes / TERABYTE, options ) }TB` - : `${ formatNumber( bytes / GIGABYTE, options ) }GB`; + ? sprintf( + /* translators: %s: a storage size number, e.g. "1.5". */ + _x( '%sTB', 'storage size in terabytes', 'jetpack-backup-pkg' ), + formatNumber( bytes / TERABYTE, options ) + ) + : sprintf( + /* translators: %s: a storage size number, e.g. "12.4". */ + _x( '%sGB', 'storage size in gigabytes', 'jetpack-backup-pkg' ), + formatNumber( bytes / GIGABYTE, options ) + ); } diff --git a/projects/packages/backup/src/dashboard/data/test/date-format.test.ts b/projects/packages/backup/src/dashboard/data/test/date-format.test.ts new file mode 100644 index 000000000000..fa205dd79d9e --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/test/date-format.test.ts @@ -0,0 +1,29 @@ +import { getSettings, setSettings } from '@wordpress/date'; +import { formatDate, formatDateTime, formatTime } from '../date-format'; + +const original = getSettings(); + +afterEach( () => { + setSettings( original ); +} ); + +describe( 'date formats', () => { + it.each( [ + [ + 'core defaults', + { date: 'F j, Y', time: 'g:i a', datetime: 'F j, Y g:i a' }, + [ 'August 12, 2026 5:20 pm', 'August 12, 2026', '5:20 pm' ], + ], + [ + 'a 24-hour site, so it never sees am or pm', + { date: 'j M Y', time: 'H:i', datetime: 'j M Y H:i' }, + [ '12 Aug 2026 17:20', '12 Aug 2026', '17:20' ], + ], + ] )( 'follows the site settings: %s', ( _name, formats, [ dateTime, day, time ] ) => { + setSettings( { ...original, formats: { ...original.formats, ...formats } } ); + + expect( formatDateTime( '2026-08-12T17:20:00Z' ) ).toBe( dateTime ); + expect( formatDate( '2026-08-12T17:20:00Z' ) ).toBe( day ); + expect( formatTime( '2026-08-12T17:20:00Z' ) ).toBe( time ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/data/test/storage-units.test.ts b/projects/packages/backup/src/dashboard/data/test/storage-units.test.ts new file mode 100644 index 000000000000..e91476bddc43 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/test/storage-units.test.ts @@ -0,0 +1,23 @@ +import { formatStorageSize, GIGABYTE, MEGABYTE, TERABYTE } from '../storage-units'; + +describe( 'formatStorageSize', () => { + afterEach( () => { + delete ( window as { wp?: unknown } ).wp; + } ); + + it.each( [ + [ 512 * MEGABYTE, '512MB' ], + [ 12.4 * GIGABYTE, '12.4GB' ], + [ 1.5 * TERABYTE, '1.5TB' ], + ] )( 'formats %d bytes as %s', ( bytes, expected ) => { + expect( formatStorageSize( bytes ) ).toBe( expected ); + } ); + + it( 'uses the locale decimal separator', () => { + ( window as { wp?: unknown } ).wp = { + date: { getSettings: () => ( { l10n: { locale: 'de_DE' } } ) }, + }; + + expect( formatStorageSize( 12.4 * GIGABYTE ) ).toBe( '12,4GB' ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/hooks/test/use-backup-schedule.test.ts b/projects/packages/backup/src/dashboard/hooks/test/use-backup-schedule.test.ts new file mode 100644 index 000000000000..8bda5821b22f --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/test/use-backup-schedule.test.ts @@ -0,0 +1,23 @@ +import { getSettings, setSettings } from '@wordpress/date'; +import { scheduleOptions } from '../use-backup-schedule'; + +const original = getSettings(); + +afterEach( () => { + setSettings( original ); +} ); + +describe( 'scheduleOptions', () => { + it.each( [ + [ 'g:i a', '10:00-10:59 am' ], + [ 'g:i A', '10:00-10:59 AM' ], + [ 'A g:i', 'AM 10:00-AM 10:59' ], + [ 'H:i', '10:00-10:59' ], + ] )( 'labels the windows in the site time format %s', ( time, expected ) => { + setSettings( { ...original, formats: { ...original.formats, time } } ); + + const labels = scheduleOptions( new Date( '2026-10-22T12:00:00Z' ) ).map( o => o.label ); + + expect( labels[ 10 ] ).toBe( expected ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/hooks/test/use-path-info.test.ts b/projects/packages/backup/src/dashboard/hooks/test/use-path-info.test.ts index 812835e7041a..766900af1a9f 100644 --- a/projects/packages/backup/src/dashboard/hooks/test/use-path-info.test.ts +++ b/projects/packages/backup/src/dashboard/hooks/test/use-path-info.test.ts @@ -120,6 +120,7 @@ describe( 'formatFileSize', () => { test.each( [ [ 0, '0 B' ], [ 512, '512 B' ], + [ 1000, '1000 B' ], [ 1024, '1 KB' ], [ 3247, '3.2 KB' ], [ 1048576, '1 MB' ], @@ -127,6 +128,18 @@ describe( 'formatFileSize', () => { ] )( 'formats %i bytes as %s', ( bytes, expected ) => { expect( formatFileSize( bytes ) ).toBe( expected ); } ); + + test( 'uses the locale decimal separator', () => { + ( window as { wp?: unknown } ).wp = { + date: { getSettings: () => ( { l10n: { locale: 'de_DE' } } ) }, + }; + + try { + expect( formatFileSize( 3247 ) ).toBe( '3,2 KB' ); + } finally { + delete ( window as { wp?: unknown } ).wp; + } + } ); } ); describe( 'usePathInfo', () => { diff --git a/projects/packages/backup/src/dashboard/hooks/use-backup-schedule.ts b/projects/packages/backup/src/dashboard/hooks/use-backup-schedule.ts index 4a3cd60e3e40..1826e8189b20 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-backup-schedule.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-backup-schedule.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { dateI18n } from '@wordpress/date'; import { fetchBackupSchedule, type RawBackupSchedule } from '../data/api/backup-schedule'; +import { formatShortDate, formatTime, getTimeFormat } from '../data/date-format'; import { keys } from '../data/query-client'; import { useCanQueryWpcom } from './use-connection'; @@ -99,16 +100,15 @@ function nextWindowStart( scheduledHourUtc: number, now: Date ): Date { */ function windowLabel( start: Date, timezone?: string ): string { const lastMinute = new Date( start.getTime() + WINDOW_LAST_MINUTE_MS ); - // The start drops its AM/PM only when the end's covers both halves. + const timeFormat = getTimeFormat(); + // The start drops a trailing AM/PM only when the end's covers both halves. const startFormat = - dateI18n( 'A', start, timezone ) === dateI18n( 'A', lastMinute, timezone ) ? 'g:i' : 'g:i A'; + dateI18n( 'A', start, timezone ) === dateI18n( 'A', lastMinute, timezone ) + ? timeFormat.replace( /\s*[aA]\s*$/, '' ) + : timeFormat; // Assembled here rather than as a msgid: neither half is translatable prose. - return `${ dateI18n( startFormat, start, timezone ) }-${ dateI18n( - 'g:i A', - lastMinute, - timezone - ) }`; + return `${ dateI18n( startFormat, start, timezone ) }-${ formatTime( lastMinute, timezone ) }`; } /** @@ -174,7 +174,7 @@ export function useNextBackupSchedule(): Result { return { isLoading: query.isLoading, hasSchedule: true, - nextBackupDate: dateI18n( 'M j', start, undefined ), + nextBackupDate: formatShortDate( start ), timeRange: windowLabel( start ), scheduledHour, scheduledBy: query.data?.scheduled_by || null, diff --git a/projects/packages/backup/src/dashboard/hooks/use-path-info.ts b/projects/packages/backup/src/dashboard/hooks/use-path-info.ts index b2a8a8a350ff..ecc79388911a 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-path-info.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-path-info.ts @@ -1,3 +1,4 @@ +import { formatNumber } from '@automattic/number-formatters'; import { useQuery } from '@tanstack/react-query'; import { _x, sprintf } from '@wordpress/i18n'; import { fetchPathInfo } from '../data/api/path-info'; @@ -95,7 +96,9 @@ export function formatFileSize( bytes: number ): string { unit++; } - const rounded = String( Math.round( value * 10 ) / 10 ); + const rounded = formatNumber( value, { + numberFormatOptions: { maximumFractionDigits: 1, useGrouping: false }, + } ); switch ( unit ) { case 1: diff --git a/projects/packages/backup/src/dashboard/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx index 8734dea3de9a..99659210f1be 100644 --- a/projects/packages/backup/src/dashboard/screens/download.tsx +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -1,5 +1,4 @@ import { ProgressBar, VisuallyHidden } from '@wordpress/components'; -import { dateI18n } from '@wordpress/date'; import { useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element'; import { __, isRTL, sprintf } from '@wordpress/i18n'; import { @@ -17,6 +16,7 @@ import ErrorReference from '../components/error-reference'; import InvalidRewindId from '../components/invalid-rewind-id'; import RestoreItemsChecklist from '../components/restore-items-checklist'; import { splitFileSelection } from '../data/api/download'; +import { formatDateTime } from '../data/date-format'; import { useDownload } from '../hooks/use-download'; import { useGateState } from '../hooks/use-gate-state'; import { useStepFocus } from '../hooks/use-step-focus'; @@ -157,8 +157,11 @@ export default function DownloadScreen() { { __( 'Download backup', 'jetpack-backup-pkg' ) }
- { __( 'Download point:', 'jetpack-backup-pkg' ) }{ ' ' } - { dateI18n( 'M j, Y, g:i A', downloadPoint, undefined ) } + { sprintf( + /* translators: %s: date and time of the backup. */ + __( 'Download point: %s', 'jetpack-backup-pkg' ), + formatDateTime( downloadPoint ) + ) } @@ -274,7 +277,7 @@ export default function DownloadScreen() { { sprintf( /* translators: %s: date and time the download link stops working. */ __( 'This expires %s.', 'jetpack-backup-pkg' ), - dateI18n( 'M j, Y, g:i A', state.validUntil, undefined ) + formatDateTime( state.validUntil ) ) } ) } diff --git a/projects/packages/backup/src/dashboard/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx index 5c16eed263ba..8b08aa4f88f8 100644 --- a/projects/packages/backup/src/dashboard/screens/restore.tsx +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -1,5 +1,4 @@ import { ProgressBar, VisuallyHidden } from '@wordpress/components'; -import { dateI18n } from '@wordpress/date'; import { useCallback, useState } from '@wordpress/element'; import { __, isRTL, sprintf } from '@wordpress/i18n'; import { @@ -18,6 +17,7 @@ import DashboardLayout from '../components/dashboard-layout'; import ErrorReference from '../components/error-reference'; import InvalidRewindId from '../components/invalid-rewind-id'; import RestoreItemsChecklist from '../components/restore-items-checklist'; +import { formatDate, formatDateTime } from '../data/date-format'; import { useGateState } from '../hooks/use-gate-state'; import { useRestore } from '../hooks/use-restore'; import { useStepFocus } from '../hooks/use-step-focus'; @@ -121,8 +121,11 @@ export default function RestoreScreen() { { __( 'Restore backup', 'jetpack-backup-pkg' ) } - { __( 'Restore point:', 'jetpack-backup-pkg' ) }{ ' ' } - { dateI18n( 'M j, Y, g:i A', restorePoint, undefined ) } + { sprintf( + /* translators: %s: date and time of the backup. */ + __( 'Restore point: %s', 'jetpack-backup-pkg' ), + formatDateTime( restorePoint ) + ) } @@ -285,7 +288,7 @@ export default function RestoreScreen() { "A restore of your %s backup is already running. You can't start another from here until it finishes.", 'jetpack-backup-pkg' ), - dateI18n( 'M j, Y', restorePoint, undefined ) + formatDate( restorePoint ) ) : __( "A restore is already running for this site. You can't start another from here until it finishes.", diff --git a/projects/packages/backup/tests/next-scheduled-backup.test.tsx b/projects/packages/backup/tests/next-scheduled-backup.test.tsx index 0d842e9d2f1b..13b34c6fa398 100644 --- a/projects/packages/backup/tests/next-scheduled-backup.test.tsx +++ b/projects/packages/backup/tests/next-scheduled-backup.test.tsx @@ -414,7 +414,7 @@ describe( 'the next-backup line', () => { renderWithClient( ); await expect( scheduleLine() ).resolves.toHaveTextContent( - /^Next full backup: Oct 22, 10:00-10:59 AM\.$/ + /^Next full backup: Oct 22, 10:00-10:59 am\.$/ ); } ); @@ -427,7 +427,7 @@ describe( 'the next-backup line', () => { renderWithClient( ); await expect( scheduleLine() ).resolves.toHaveTextContent( - /^Next full backup: Oct 23, 10:00-10:59 AM\.$/ + /^Next full backup: Oct 23, 10:00-10:59 am\.$/ ); } ); @@ -440,7 +440,7 @@ describe( 'the next-backup line', () => { renderWithClient( ); await expect( scheduleLine() ).resolves.toHaveTextContent( - /^Next full backup: Oct 22, 10:00-10:59 AM\.$/ + /^Next full backup: Oct 22, 10:00-10:59 am\.$/ ); } ); @@ -453,7 +453,7 @@ describe( 'the next-backup line', () => { renderWithClient( ); await expect( scheduleLine() ).resolves.toHaveTextContent( - /^Next full backup: Oct 22, 12:00-12:59 AM\.$/ + /^Next full backup: Oct 22, 12:00-12:59 am\.$/ ); } ); } ); @@ -473,14 +473,14 @@ describe( 'the site timezone', () => { timezone: { offset: -5, offsetFormatted: '-5', string: '', abbr: 'EST' }, } ); // The site is told about a backup running on its *previous* calendar day. Read - // as UTC this would say "Oct 22, 2:00-2:59 AM" instead. + // as UTC this would say "Oct 22, 2:00-2:59 am" instead. freezeClock( '2026-10-22T01:00:00Z' ); mockEndpoints( { schedule: { ok: true, scheduled_hour: 2 } } ); renderWithClient( ); await expect( scheduleLine() ).resolves.toHaveTextContent( - /^Next full backup: Oct 21, 9:00-9:59 PM\.$/ + /^Next full backup: Oct 21, 9:00-9:59 pm\.$/ ); } ); } ); @@ -545,7 +545,7 @@ describe( 'when there is nothing to report', () => { it( 'says nothing when the hour is not a whole number', async () => { // `Date.UTC` truncates rather than rejecting, so without `Number.isInteger` - // this renders a confident "10:00-10:59 AM" for an unreadable payload. + // this renders a confident "10:00-10:59 am" for an unreadable payload. freezeClock( '2026-10-22T05:00:00Z' ); mockEndpoints( { schedule: { ok: true, scheduled_hour: 10.5 } } ); @@ -660,7 +660,7 @@ describe( 'while the reads are in flight', () => { releaseSize( { ok: true, backups_stopped: false } ); await expect( scheduleLine() ).resolves.toHaveTextContent( - /^Next full backup: Oct 22, 10:00-10:59 AM\.$/ + /^Next full backup: Oct 22, 10:00-10:59 am\.$/ ); } ); } ); @@ -691,7 +691,7 @@ describe( 'the line, translated', () => { // Sequential placeholders would read this as "At Oct 22 on 10:00-10:59 AM". await expect( screen.findByText( /^At/ ) ).resolves.toHaveTextContent( - /^At 10:00-10:59 AM on Oct 22, the next full backup runs\.$/ + /^At 10:00-10:59 am on Oct 22, the next full backup runs\.$/ ); } ); } ); @@ -719,7 +719,7 @@ describe( 'the Modify dialog', () => { const dialog = await openScheduleDialog(); expect( within( dialog ).getByRole( 'combobox', { name: 'Backup window' } ) ).toHaveTextContent( - '9:00-9:59 PM' + '9:00-9:59 pm' ); } ); @@ -741,12 +741,12 @@ describe( 'the Modify dialog', () => { renderWithClient( ); await openScheduleDialog(); - await pickWindow( '3:00-3:59 AM' ); + await pickWindow( '3:00-3:59 am' ); await userEvent.click( screen.getByRole( 'button', { name: 'Save' } ) ); await waitFor( () => expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument() ); expect( sent ).toEqual( [ 8 ] ); - await expect( scheduleLine() ).resolves.toHaveTextContent( /, 3:00-3:59 AM\.$/ ); + await expect( scheduleLine() ).resolves.toHaveTextContent( /, 3:00-3:59 am\.$/ ); expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Daily backup time changed.' ); } ); @@ -762,7 +762,7 @@ describe( 'the Modify dialog', () => { renderWithClient( ); const dialog = await openScheduleDialog(); - await pickWindow( '3:00-3:59 AM' ); + await pickWindow( '3:00-3:59 am' ); await userEvent.click( screen.getByRole( 'button', { name: 'Save' } ) ); await expect( @@ -791,11 +791,11 @@ describe( 'the Modify dialog', () => { renderWithClient( ); await openScheduleDialog(); - await pickWindow( '3:00-3:59 AM' ); + await pickWindow( '3:00-3:59 am' ); await userEvent.click( screen.getByRole( 'button', { name: 'Save' } ) ); await waitFor( () => - expect( screen.getByText( /^Next full backup/ ) ).toHaveTextContent( /, 3:00-3:59 AM\.$/ ) + expect( screen.getByText( /^Next full backup/ ) ).toHaveTextContent( /, 3:00-3:59 am\.$/ ) ); } ); @@ -811,7 +811,7 @@ describe( 'the Modify dialog', () => { renderWithClient( ); const dialog = await openScheduleDialog(); - await pickWindow( '3:00-3:59 AM' ); + await pickWindow( '3:00-3:59 am' ); await userEvent.click( screen.getByRole( 'button', { name: 'Save' } ) ); await userEvent.keyboard( '{Escape}' ); refuse( { @@ -834,9 +834,9 @@ describe( 'the Modify dialog', () => { const options = scheduleOptions( new Date( '2026-10-22T12:00:00Z' ) ); expect( options ).toHaveLength( 24 ); - expect( options[ 0 ] ).toEqual( { label: '12:30-1:29 AM', value: '19' } ); - expect( options[ 23 ] ).toEqual( { label: '11:30 PM-12:29 AM', value: '18' } ); - expect( options[ 11 ] ).toEqual( { label: '11:30 AM-12:29 PM', value: '6' } ); + expect( options[ 0 ] ).toEqual( { label: '12:30-1:29 am', value: '19' } ); + expect( options[ 23 ] ).toEqual( { label: '11:30 pm-12:29 am', value: '18' } ); + expect( options[ 11 ] ).toEqual( { label: '11:30 am-12:29 pm', value: '6' } ); } ); it( 'goes with the line when there is no schedule to modify', async () => { @@ -864,7 +864,7 @@ describe( 'on the Overview', () => { render( ); const line = await screen.findByText( /^Next full backup/ ); - expect( line ).toHaveTextContent( /^Next full backup: Oct 22, 10:00-10:59 AM\.$/ ); + expect( line ).toHaveTextContent( /^Next full backup: Oct 22, 10:00-10:59 am\.$/ ); // Placement, not just presence: `.jpb-overview` is a two-column grid above // 960px, so a child would be auto-placed into a cell and a following sibling @@ -898,7 +898,7 @@ describe( 'on the Overview', () => { ).resolves.toBeInTheDocument(); // …and so is the next one. await expect( screen.findByText( /^Next full backup/ ) ).resolves.toHaveTextContent( - /^Next full backup: Oct 22, 10:00-10:59 AM\.$/ + /^Next full backup: Oct 22, 10:00-10:59 am\.$/ ); } ); diff --git a/projects/packages/backup/tests/restore-rows-in-activity-list.test.tsx b/projects/packages/backup/tests/restore-rows-in-activity-list.test.tsx index ee26e65507d3..93695690e966 100644 --- a/projects/packages/backup/tests/restore-rows-in-activity-list.test.tsx +++ b/projects/packages/backup/tests/restore-rows-in-activity-list.test.tsx @@ -183,7 +183,7 @@ describe( 'a restore that has already ended', () => { render( ); await expect( screen.findByText( "Restore didn't finish" ) ).resolves.toBeVisible(); - expect( screen.getByText( 'Restore to Aug 13, 2026, 6:08 PM' ) ).toBeVisible(); + expect( screen.getByText( 'Restore to August 13, 2026 6:08 pm' ) ).toBeVisible(); } ); it( 'reads the collection once for the whole screen', async () => { diff --git a/projects/packages/backup/tests/restore-session-recovery.test.tsx b/projects/packages/backup/tests/restore-session-recovery.test.tsx index c23a36e3a153..8ecf89fa92eb 100644 --- a/projects/packages/backup/tests/restore-session-recovery.test.tsx +++ b/projects/packages/backup/tests/restore-session-recovery.test.tsx @@ -159,11 +159,11 @@ describe( 'Restore screen — a restore already running', () => { // heading that silently changes date leaves the reader to work out // why their checklist vanished. const notice = await view.findByText( ALREADY_RUNNING ); - expect( notice ).toHaveTextContent( /Aug 12, 2026/ ); + expect( notice ).toHaveTextContent( /August 12, 2026/ ); const point = await view.findByText( /Restore point:/ ); - expect( point ).toHaveTextContent( /Aug 12, 2026/ ); - expect( point ).not.toHaveTextContent( /Aug 13, 2026/ ); + expect( point ).toHaveTextContent( /August 12, 2026/ ); + expect( point ).not.toHaveTextContent( /August 13, 2026/ ); } ); it( 'never arms the form while it is still finding out', async () => { diff --git a/projects/packages/backup/tests/tracks-events.test.tsx b/projects/packages/backup/tests/tracks-events.test.tsx index aeccdb24a5aa..b5425acd7979 100644 --- a/projects/packages/backup/tests/tracks-events.test.tsx +++ b/projects/packages/backup/tests/tracks-events.test.tsx @@ -379,7 +379,7 @@ describe( 'Modify schedule', () => { await userEvent.click( await modifyButton() ); await userEvent.click( await screen.findByRole( 'combobox', { name: 'Backup window' } ) ); // Jest runs in America/Sao_Paulo and the site settings default to UTC, so 3 AM is hour 3. - await userEvent.click( await screen.findByRole( 'option', { name: '3:00-3:59 AM' } ) ); + await userEvent.click( await screen.findByRole( 'option', { name: '3:00-3:59 am' } ) ); await userEvent.click( screen.getByRole( 'button', { name: 'Save' } ) ); } From a03824a2ce6f5d10c61c09c5b2107ad227345902 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 13 Oct 2026 10:58:31 -0500 Subject: [PATCH 20/57] Post by Email: Clean up the per-user local copy of the address (#53440) Name the option in one place for the module's writer, delete the copy when the user unlinks from WordPress.com or is deleted, and stop storing the boolean a delete request answers with as if it were an address. --- .../damascus-post-by-email-address-cleanup | 4 +++ .../plugins/jetpack/modules/post-by-email.php | 2 ++ .../class-jetpack-post-by-email.php | 32 ++++++++++++++++-- .../post-by-email/Post_By_Email_API_Test.php | 33 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/damascus-post-by-email-address-cleanup diff --git a/projects/plugins/jetpack/changelog/damascus-post-by-email-address-cleanup b/projects/plugins/jetpack/changelog/damascus-post-by-email-address-cleanup new file mode 100644 index 000000000000..c2fb5aac22cf --- /dev/null +++ b/projects/plugins/jetpack/changelog/damascus-post-by-email-address-cleanup @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Post by Email: Forget a user's saved address when they disconnect from WordPress.com, so reconnecting with a different account no longer shows the old one. diff --git a/projects/plugins/jetpack/modules/post-by-email.php b/projects/plugins/jetpack/modules/post-by-email.php index 7c8f9ca7ca22..dcdb236d9768 100644 --- a/projects/plugins/jetpack/modules/post-by-email.php +++ b/projects/plugins/jetpack/modules/post-by-email.php @@ -24,5 +24,7 @@ require_once __DIR__ . '/post-by-email/class-jetpack-post-by-email.php'; add_action( 'jetpack_modules_loaded', array( 'Jetpack_Post_By_Email', 'init' ) ); +add_action( 'jetpack_unlinked_user', array( 'Jetpack_Post_By_Email', 'delete_address_copy' ) ); +add_action( 'deleted_user', array( 'Jetpack_Post_By_Email', 'delete_address_copy' ) ); Jetpack::enable_module_configurable( __FILE__ ); diff --git a/projects/plugins/jetpack/modules/post-by-email/class-jetpack-post-by-email.php b/projects/plugins/jetpack/modules/post-by-email/class-jetpack-post-by-email.php index 8300886655e9..caa0a4aa8785 100644 --- a/projects/plugins/jetpack/modules/post-by-email/class-jetpack-post-by-email.php +++ b/projects/plugins/jetpack/modules/post-by-email/class-jetpack-post-by-email.php @@ -34,6 +34,30 @@ private function __construct() { add_action( 'init', array( $this, 'action_init' ) ); } + /** + * Name of the option holding a user's local copy of their Post by Email address. + * + * @since $$next-version$$ + * + * @param int $user_id Local user ID. + * + * @return string + */ + public static function address_copy_option_name( $user_id ) { + return 'post_by_email_address' . (int) $user_id; + } + + /** + * Delete a user's local copy of their Post by Email address. + * + * @since $$next-version$$ + * + * @param int $user_id Local user ID. + */ + public static function delete_address_copy( $user_id ) { + delete_option( self::address_copy_option_name( $user_id ) ); + } + /** * Adds hooks for PBE. */ @@ -239,8 +263,12 @@ private function process_rest_proxy_request( $endpoint, $error ) { return array( 'message' => $error ); } - // Used only in Jetpack_Core_Json_Api_Endpoints::get_remote_value. - update_option( 'post_by_email_address' . get_current_user_id(), $response ); + // Read by Jetpack_Core_Json_Api_Endpoints::get_remote_value. A delete answers `true`, which is not an address. + if ( is_string( $response ) ) { + update_option( self::address_copy_option_name( get_current_user_id() ), $response ); + } else { + self::delete_address_copy( get_current_user_id() ); + } return $response; } diff --git a/projects/plugins/jetpack/tests/php/modules/post-by-email/Post_By_Email_API_Test.php b/projects/plugins/jetpack/tests/php/modules/post-by-email/Post_By_Email_API_Test.php index a495b03fab7a..3175c11b3f32 100644 --- a/projects/plugins/jetpack/tests/php/modules/post-by-email/Post_By_Email_API_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/post-by-email/Post_By_Email_API_Test.php @@ -6,6 +6,7 @@ */ use Automattic\Jetpack\Connection\Rest_Authentication as Connection_Rest_Authentication; +use PHPUnit\Framework\Attributes\DataProvider; if ( defined( 'JETPACK__PLUGIN_DIR' ) && JETPACK__PLUGIN_DIR ) { require_once JETPACK__PLUGIN_DIR . 'modules/post-by-email.php'; @@ -136,6 +137,7 @@ public function test_create() { $this->assertEquals( self::SAMPLE_EMAIL, $response->data['post_by_email_address'] ); $this->assertEquals( 200, $response->status ); $this->assertTrue( $this->request_validated, "Method 'mock_jetpack_api_response_create' was skipped, failed to validate the request" ); + $this->assertSame( self::SAMPLE_EMAIL, get_option( 'post_by_email_address' . self::$admin_id ) ); remove_filter( 'pre_http_request', array( $this, 'mock_jetpack_api_response_create' ), 10 ); } @@ -161,16 +163,47 @@ public function test_regenerate() { */ public function test_delete() { add_filter( 'pre_http_request', array( $this, 'mock_jetpack_api_response_delete' ), 10, 3 ); + update_option( 'post_by_email_address' . self::$admin_id, self::SAMPLE_EMAIL ); $response = $this->rest_dispatch( 'delete' ); $this->assertEquals( 'success', $response->data['code'] ); $this->assertEquals( 200, $response->status ); $this->assertTrue( $this->request_validated, "Method 'mock_jetpack_api_response_delete' was skipped, failed to validate the request" ); + $this->assertFalse( get_option( 'post_by_email_address' . self::$admin_id ) ); remove_filter( 'pre_http_request', array( $this, 'mock_jetpack_api_response_delete' ), 10 ); } + /** + * The local copy of the address goes when its user unlinks or is deleted. + * + * @dataProvider provide_hooks_deleting_the_address_copy + * + * @param string $hook Action that passes the user ID. + */ + #[DataProvider( 'provide_hooks_deleting_the_address_copy' )] + public function test_address_copy_deleted_with_user( $hook ) { + $user_id = self::factory()->user->create(); + update_option( 'post_by_email_address' . $user_id, self::SAMPLE_EMAIL ); + + do_action( $hook, $user_id ); + + $this->assertFalse( get_option( 'post_by_email_address' . $user_id ) ); + } + + /** + * Data provider for test_address_copy_deleted_with_user. + * + * @return array[] + */ + public static function provide_hooks_deleting_the_address_copy() { + return array( + 'unlinked from WordPress.com' => array( 'jetpack_unlinked_user' ), + 'user deleted' => array( 'deleted_user' ), + ); + } + /** * Most the user token. * From 4db3eafbb2ea9ec4111ae2b4d96a7f5f8e9248df Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 13 Oct 2026 09:08:56 -0700 Subject: [PATCH 21/57] Protect Dashboard: Address Scan feedback (#53439) * Protect Dashboard: Address Scan feedback (in-place delete, arrow keys, history skeleton, threat notices, close inspector on tab switch) * Protect Dashboard: Two-line threat rows, narrower Severity column, sidebar actions pinned to the bottom * Protect dashboard: Keep Scan notices until they are dismissed Action snackbars hid after six seconds, so a fix still running had no visible progress and a result could be missed. * Protect dashboard: Add View to in-progress Scan notices While a threat is being fixed, ignored or unignored, its notice offers View, which opens that threat in the sidebar. * Protect dashboard: Show whether a threat's plugin or theme is active The threat sidebar badges the affected plugin or theme as active, inactive, or the active theme's parent. * Protect dashboard: Keep a Scan notice open after View, and let finished ones hide A snackbar removes itself when its action is clicked, so View shows the progress notice again. Success notices hide on their own; progress and error notices stay until dismissed. * Protect dashboard: Share one threat opener and inspector reset View on a Scan notice now opens the threat on the tab that lists it, closing any other inspector, instead of whatever page started the action. Also reuse get_state() for the delete check and tidy the badge and delete hook. * Protect dashboard: Address review feedback on Scan deletes and arrow keys - Delete waits for a running fix or ignore on any threat in the same plugin or theme, and marks them busy while it runs. - While a threat is open, the arrow keys no longer scroll the page at the first or last row. - Route tests cover deleting an unused theme and keeping the active theme and its parent. - Add a Jetpack changelog entry. * Protect dashboard: Tighten the in-place delete route and its tests - Look a theme up by its key in wp_get_themes(), as plugins already are. - Return the existing error when WordPress needs filesystem credentials. - Clear the History cache after a delete. - Close the inspector after a delete only when its threat was deleted. - Keep the page from scrolling when the arrow keys reach either end. - Use a logical property for the table cell's minimum size. - Test themes, capability, filesystem access, the failed-delete path, and the arrow-key guards. * Protect dashboard: Test a path slug and missing filesystem access on delete * Protect dashboard: Show a checkmark on Scan success notices * Protect dashboard: Guard plugin folders, word plugin deletes honestly, skip fixed threats - A plugin counts as active when any plugin in its folder is, since deleting it removes the whole folder; the badge, Delete and the route all follow. - The plugin delete confirmation says its data goes too, as uninstall routines run. - Fixed threats no longer offer Delete. --------- Co-authored-by: Brandon Kraft --- .../fix-protect-dashboard-scan-feedback | 4 + .../components/use-arrow-key-navigation.ts | 51 ++++ .../components/use-close-on-escape.ts | 3 +- .../sections/history/history-list.tsx | 33 ++- .../sections/history/history-panel.tsx | 10 +- .../sections/history/history-skeleton.tsx | 35 +++ .../dashboard/sections/history/style.scss | 58 ++++ .../dashboard/sections/inspector-params.ts | 5 + .../sections/scan/delete-software.tsx | 131 ++++++--- .../routes/dashboard/sections/scan/store.ts | 7 + .../routes/dashboard/sections/scan/style.scss | 16 ++ .../scan/test/delete-software.test.tsx | 53 ++++ .../sections/scan/test/threat-actions.test.ts | 123 ++++++++- .../dashboard/sections/scan/threat-actions.ts | 250 ++++++++++++++---- .../sections/scan/threat-details.tsx | 36 ++- .../sections/scan/threat-row-actions.tsx | 25 +- .../dashboard/sections/scan/threats-list.tsx | 15 +- .../routes/dashboard/sections/scan/types.ts | 6 +- .../dashboard/sections/use-open-threat.ts | 44 +++ .../protect/routes/dashboard/stage.tsx | 3 + .../routes/dashboard/test/stage.test.tsx | 21 +- .../test/use-arrow-key-navigation.test.tsx | 29 ++ .../dashboard/test/use-open-threat.test.ts | 30 +++ .../protect/src/class-dashboard-threats.php | 97 +++++-- .../protect/src/sections/class-scan.php | 75 ++++++ .../tests/php/Dashboard_Threats_Test.php | 92 +++++++ .../protect/tests/php/Scan_Section_Test.php | 149 +++++++++++ .../fix-protect-dashboard-scan-feedback | 4 + 28 files changed, 1241 insertions(+), 164 deletions(-) create mode 100644 projects/packages/protect/changelog/fix-protect-dashboard-scan-feedback create mode 100644 projects/packages/protect/routes/dashboard/components/use-arrow-key-navigation.ts create mode 100644 projects/packages/protect/routes/dashboard/sections/history/history-skeleton.tsx create mode 100644 projects/packages/protect/routes/dashboard/sections/history/style.scss create mode 100644 projects/packages/protect/routes/dashboard/sections/scan/test/delete-software.test.tsx create mode 100644 projects/packages/protect/routes/dashboard/sections/use-open-threat.ts create mode 100644 projects/packages/protect/routes/dashboard/test/use-arrow-key-navigation.test.tsx create mode 100644 projects/packages/protect/routes/dashboard/test/use-open-threat.test.ts create mode 100644 projects/plugins/jetpack/changelog/fix-protect-dashboard-scan-feedback diff --git a/projects/packages/protect/changelog/fix-protect-dashboard-scan-feedback b/projects/packages/protect/changelog/fix-protect-dashboard-scan-feedback new file mode 100644 index 000000000000..e5a987bed176 --- /dev/null +++ b/projects/packages/protect/changelog/fix-protect-dashboard-scan-feedback @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Protect dashboard: Delete unused plugins and themes without leaving the page, step through threats with the arrow keys, show a placeholder while Scan history loads, close threat details when switching tabs, name the threat in each notice, show threats on two lines with a narrower Severity column, show whether the affected plugin or theme is active, keep the threat actions at the bottom of the sidebar, and keep Scan progress and error notices until they are dismissed, with a View button that opens the threat while an action runs. diff --git a/projects/packages/protect/routes/dashboard/components/use-arrow-key-navigation.ts b/projects/packages/protect/routes/dashboard/components/use-arrow-key-navigation.ts new file mode 100644 index 000000000000..f063105202d5 --- /dev/null +++ b/projects/packages/protect/routes/dashboard/components/use-arrow-key-navigation.ts @@ -0,0 +1,51 @@ +import { useEffect } from '@wordpress/element'; +import { POPUP_SELECTOR } from './use-close-on-escape'; + +const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable="true"]'; + +/** + * While an item is open, the up and down arrow keys open the previous or next one in `items`. + * + * @param items - The rows as shown, sorted and paginated. + * @param selected - The open item's id, if any. + * @param getId - Reads an item's id. + * @param open - Opens an item. + */ +export default function useArrowKeyNavigation< T >( + items: T[], + selected: string | undefined, + getId: ( item: T ) => string, + open: ( item: T ) => void +) { + useEffect( () => { + if ( ! selected ) { + return; + } + const onKeyDown = ( event: KeyboardEvent ) => { + const step = { ArrowDown: 1, ArrowUp: -1 }[ event.key ]; + const target = event.target as Element | null; + if ( + ! step || + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + target?.closest?.( `${ EDITABLE_SELECTOR }, ${ POPUP_SELECTOR }` ) + ) { + return; + } + // While an item is open the keys belong to the list, so the first or last row doesn't scroll the page. + event.preventDefault(); + const index = items.findIndex( item => getId( item ) === selected ); + // An item on another page starts from this page's first or last row. + const start = step > 0 ? 0 : items.length - 1; + const next = items[ index < 0 ? start : index + step ]; + if ( next ) { + open( next ); + } + }; + document.addEventListener( 'keydown', onKeyDown ); + return () => document.removeEventListener( 'keydown', onKeyDown ); + }, [ items, selected, getId, open ] ); +} diff --git a/projects/packages/protect/routes/dashboard/components/use-close-on-escape.ts b/projects/packages/protect/routes/dashboard/components/use-close-on-escape.ts index c63f188d3082..cceae8e403e4 100644 --- a/projects/packages/protect/routes/dashboard/components/use-close-on-escape.ts +++ b/projects/packages/protect/routes/dashboard/components/use-close-on-escape.ts @@ -1,6 +1,7 @@ import { useEffect } from '@wordpress/element'; -const POPUP_SELECTOR = '[role="menu"], [role="listbox"], [role="dialog"], [role="alertdialog"]'; +export const POPUP_SELECTOR = + '[role="menu"], [role="listbox"], [role="dialog"], [role="alertdialog"]'; /** * Call `onClose` when Escape is pressed anywhere on the page. diff --git a/projects/packages/protect/routes/dashboard/sections/history/history-list.tsx b/projects/packages/protect/routes/dashboard/sections/history/history-list.tsx index e1802aab96ae..e64a7d7ead11 100644 --- a/projects/packages/protect/routes/dashboard/sections/history/history-list.tsx +++ b/projects/packages/protect/routes/dashboard/sections/history/history-list.tsx @@ -3,9 +3,12 @@ import { useCallback, useMemo, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { useNavigate } from '@wordpress/route'; import { Tabs } from '@wordpress/ui'; +import useArrowKeyNavigation from '../../components/use-arrow-key-navigation'; +import { CLOSED_INSPECTOR } from '../inspector-params'; import { THREAT_PARAM, useScan, useSearchParam } from '../scan/store'; import { getThreatRowActions } from '../scan/threat-row-actions'; import { createThreatView, getThreatFields } from '../scan/threats-list'; +import useOpenThreat from '../use-open-threat'; import { HISTORY_STATUS_PARAM, HISTORY_THREAT_PARAM } from './store'; import type { ScanThreat } from '../scan/types'; @@ -34,38 +37,31 @@ export default function HistoryList( { threats }: { threats: ScanThreat[] } ) { const fixed = useMemo( () => threats.filter( item => item.status === 'fixed' ), [ threats ] ); // Ignore and unignore update this list in place, so it is current right after either. const ignored = useScan()?.ignored ?? NO_THREATS; - const [ statusParam, setStatusParam ] = useSearchParam( HISTORY_STATUS_PARAM ); + const [ statusParam ] = useSearchParam( HISTORY_STATUS_PARAM ); const status = statusParam === 'ignored' ? 'ignored' : 'fixed'; const [ view, setView ] = useState< View >( () => createThreatView( 'date' ) ); + const navigate = useNavigate(); + // Switching lists closes the inspector, whose threat is in the list being left. const onStatusChange = useCallback( ( value: unknown ) => { - setStatusParam( value === 'ignored' ? 'ignored' : undefined ); - setView( current => ( { ...current, page: 1 } ) ); - }, - [ setStatusParam ] - ); - const [ selectedFixed ] = useSearchParam( HISTORY_THREAT_PARAM ); - const [ selectedIgnored ] = useSearchParam( THREAT_PARAM ); - const selected = status === 'fixed' ? selectedFixed : selectedIgnored; - const selection = useMemo( () => ( selected ? [ selected ] : [] ), [ selected ] ); - const navigate = useNavigate(); - // One navigation sets both params, so only one inspector's param is ever in the URL. - const open = useCallback( - ( item: ScanThreat ) => { - const id = String( item.id ); - const isFixed = item.status === 'fixed'; navigate( { search: ( prev: Record< string, unknown > ) => ( { ...prev, - [ HISTORY_THREAT_PARAM ]: isFixed ? id : undefined, - [ THREAT_PARAM ]: isFixed ? undefined : id, + ...CLOSED_INSPECTOR, + [ HISTORY_STATUS_PARAM ]: value === 'ignored' ? 'ignored' : undefined, } ), } as Parameters< typeof navigate >[ 0 ] ); + setView( current => ( { ...current, page: 1 } ) ); }, [ navigate ] ); + const [ selectedFixed ] = useSearchParam( HISTORY_THREAT_PARAM ); + const [ selectedIgnored ] = useSearchParam( THREAT_PARAM ); + const selected = status === 'fixed' ? selectedFixed : selectedIgnored; + const selection = useMemo( () => ( selected ? [ selected ] : [] ), [ selected ] ); + const open = useOpenThreat(); const getItemId = useCallback( ( item: ScanThreat ) => String( item.id ), [] ); const fields = useMemo( @@ -90,6 +86,7 @@ export default function HistoryList( { threats }: { threats: ScanThreat[] } ) { () => filterSortAndPaginate( status === 'fixed' ? fixed : ignored, view, fields ), [ status, fixed, ignored, view, fields ] ); + useArrowKeyNavigation( data, selected, getItemId, open ); return (
diff --git a/projects/packages/protect/routes/dashboard/sections/history/history-panel.tsx b/projects/packages/protect/routes/dashboard/sections/history/history-panel.tsx index 9fcf98a17670..93944ce0ed31 100644 --- a/projects/packages/protect/routes/dashboard/sections/history/history-panel.tsx +++ b/projects/packages/protect/routes/dashboard/sections/history/history-panel.tsx @@ -1,10 +1,12 @@ import { useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { backup } from '@wordpress/icons'; -import { Notice, Spinner, Stack } from '@wordpress/ui'; +import { Notice } from '@wordpress/ui'; import { CardRow, ProtectCard } from '../../components/card'; import HistoryList from './history-list'; +import HistorySkeleton from './history-skeleton'; import { loadHistory, useHistory } from './store'; +import './style.scss'; /** * The History tab: threats Scan has fixed, and ones that were ignored. @@ -29,10 +31,8 @@ export default function HistoryPanel() { ); } else if ( ! threats ) { body = ( - - - - + + ); } else { diff --git a/projects/packages/protect/routes/dashboard/sections/history/history-skeleton.tsx b/projects/packages/protect/routes/dashboard/sections/history/history-skeleton.tsx new file mode 100644 index 000000000000..d3e4fa76333d --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/history/history-skeleton.tsx @@ -0,0 +1,35 @@ +import { __ } from '@wordpress/i18n'; +import { Skeleton, VisuallyHidden } from '@wordpress/ui'; + +const ROWS = [ '62%', '48%', '70%', '55%', '40%' ]; + +/** + * Placeholder rows shaped like the History list, shown while Scan history loads. + * + * @return The placeholder. + */ +export default function HistorySkeleton() { + return ( +
+ { __( 'Loading scan history…', 'jetpack-protect-pkg' ) } + + { ROWS.map( ( width, index ) => ( + + ) ) } +
+ ); +} diff --git a/projects/packages/protect/routes/dashboard/sections/history/style.scss b/projects/packages/protect/routes/dashboard/sections/history/style.scss new file mode 100644 index 000000000000..f9b611b2704a --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/history/style.scss @@ -0,0 +1,58 @@ +.jp-protect-history-skeleton { + + &__filter { + display: flex; + gap: 16px; + padding: 12px var(--wp-ui-card-padding, 24px); + } + + &__tab { + inline-size: 72px; + block-size: 20px; + } + + &__row { + display: flex; + gap: 12px; + align-items: center; + padding: 12px var(--wp-ui-card-padding, 24px); + border-block-start: + var(--wpds-border-width-xs) solid + var(--wpds-color-stroke-surface-neutral); + } + + &__badge { + flex: none; + inline-size: 56px; + block-size: 20px; + } + + &__icon { + flex: none; + inline-size: 32px; + block-size: 32px; + } + + &__label { + display: flex; + flex: 1; + flex-direction: column; + gap: 6px; + } + + &__text { + block-size: 16px; + } + + &__subtext { + inline-size: 30%; + block-size: 14px; + } + + &__date { + flex: none; + inline-size: 96px; + block-size: 16px; + margin-inline-start: auto; + } +} diff --git a/projects/packages/protect/routes/dashboard/sections/inspector-params.ts b/projects/packages/protect/routes/dashboard/sections/inspector-params.ts index b9ecaf1b766b..f7393141e144 100644 --- a/projects/packages/protect/routes/dashboard/sections/inspector-params.ts +++ b/projects/packages/protect/routes/dashboard/sections/inspector-params.ts @@ -3,3 +3,8 @@ import { THREAT_PARAM } from './scan/store'; /** Each section's inspector param, kept apart from the sections so route.tsx needn't bundle their components. */ export const INSPECTOR_PARAMS = [ THREAT_PARAM, HISTORY_THREAT_PARAM ]; + +/** Search params that close every inspector, to spread into a navigation. */ +export const CLOSED_INSPECTOR: Record< string, undefined > = Object.fromEntries( + INSPECTOR_PARAMS.map( param => [ param, undefined ] ) +); diff --git a/projects/packages/protect/routes/dashboard/sections/scan/delete-software.tsx b/projects/packages/protect/routes/dashboard/sections/scan/delete-software.tsx index 91b1640b5925..dba860e14ad2 100644 --- a/projects/packages/protect/routes/dashboard/sections/scan/delete-software.tsx +++ b/projects/packages/protect/routes/dashboard/sections/scan/delete-software.tsx @@ -1,56 +1,90 @@ -import { useCallback } from '@wordpress/element'; +import { useCallback, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { AlertDialog, Button, LinkButton, Stack, Text } from '@wordpress/ui'; +import { AlertDialog, Button, Notice, Stack, Text } from '@wordpress/ui'; import { getSoftwareActionLabels, getThreatLabel } from './labels'; +import { THREAT_PARAM, useScan, useSearchParam } from './store'; +import { deleteSoftware } from './threat-actions'; import type { ScanThreat } from './types'; import type { RenderModalProps } from '@wordpress/dataviews'; /** - * What deleting a theme does, for the confirmation. + * The confirmation's title: "Delete plugin?" or "Delete theme?". * - * @param threat - A threat in an unused theme. + * @param threat - A threat in an inactive plugin or an unused theme. + * @return The title. + */ +export function getDeleteTitle( threat: ScanThreat ): string { + return threat.extension?.type === 'themes' + ? __( 'Delete theme?', 'jetpack-protect-pkg' ) + : __( 'Delete plugin?', 'jetpack-protect-pkg' ); +} + +/** + * What deleting the software does, for the confirmation. + * + * @param threat - A threat in an inactive plugin or an unused theme. * @return The message. */ -function getDeleteThemeMessage( threat: ScanThreat ): string { - return sprintf( - /* translators: %s is a theme name, such as "Twenty Twenty". */ - __( - '%s isn’t your active theme or its parent, so deleting it doesn’t change your site. Its files are removed and can’t be restored.', - 'jetpack-protect-pkg' - ), - threat.extension?.name || getThreatLabel( threat ).subject - ); +function getDeleteMessage( threat: ScanThreat ): string { + const name = threat.extension?.name || getThreatLabel( threat ).subject; + return threat.extension?.type === 'themes' + ? sprintf( + /* translators: %s is a theme name, such as "Twenty Twenty". */ + __( + '%s isn’t your active theme or its parent, so deleting it doesn’t change your site. Its files are removed and can’t be restored.', + 'jetpack-protect-pkg' + ), + name + ) + : sprintf( + /* translators: %s is a plugin name, such as "Contact Form 7". */ + __( + '%s isn’t active. Deleting it removes its files and any data it saved, such as its settings. This can’t be undone.', + 'jetpack-protect-pkg' + ), + name + ); } /** - * Go to WordPress's delete link, keeping the confirmation busy until the page changes. + * Delete the software, then close the inspector if its threat went with it. * - * @param url - The delete link. - * @return A promise that never settles. + * @param threat - The threat. + * @return Deletes, resolving to `{ error }` when it failed, as AlertDialog's onConfirm takes. */ -const goToDelete = ( url: string ) => new Promise< never >( () => window.location.assign( url ) ); +function useDeleteSoftware( threat: ScanThreat | undefined ) { + const [ selected, setThreat ] = useSearchParam( THREAT_PARAM ); + const scan = useScan(); + return useCallback( async () => { + const error = threat ? await deleteSoftware( threat ) : null; + if ( error ) { + return { error }; + } + const open = [ ...( scan?.threats ?? [] ), ...( scan?.ignored ?? [] ) ].find( + item => String( item.id ) === selected + ); + const isOpenThreatDeleted = + open?.extension?.type === threat?.extension?.type && + open?.extension?.slug === threat?.extension?.slug; + if ( open && isOpenThreatDeleted ) { + setThreat(); + } + }, [ threat, selected, scan, setThreat ] ); +} /** - * Delete an unused plugin or theme. WordPress confirms plugin deletions itself; themes are confirmed here. + * Delete an inactive plugin or an unused theme in place, after a confirmation. * * @param props - Component props. * @param props.threat - The threat. * @return The control, or null when the software can't be deleted. */ export function DeleteSoftwareButton( { threat }: { threat: ScanThreat } ) { - const url = threat.extension?.actions?.delete; - const label = getSoftwareActionLabels( threat ).delete; - const onConfirm = useCallback( () => ( url ? goToDelete( url ) : undefined ), [ url ] ); - if ( ! url ) { + const onConfirm = useDeleteSoftware( threat ); + if ( ! threat.extension?.actions?.delete ) { return null; } - if ( threat.extension?.type !== 'themes' ) { - return ( - - { label } - - ); - } + const label = getSoftwareActionLabels( threat ).delete; return ( }> @@ -58,8 +92,8 @@ export function DeleteSoftwareButton( { threat }: { threat: ScanThreat } ) { @@ -67,28 +101,47 @@ export function DeleteSoftwareButton( { threat }: { threat: ScanThreat } ) { } /** - * The row action's confirmation for deleting an unused theme. + * The row action's confirmation for deleting an inactive plugin or an unused theme. * * @param props - DataViews modal props. * @param props.items - The threat, as a one-item list. * @param props.closeModal - Closes the confirmation. * @return The confirmation. */ -export function DeleteThemeModal( { items, closeModal }: RenderModalProps< ScanThreat > ) { +export function DeleteSoftwareModal( { items, closeModal }: RenderModalProps< ScanThreat > ) { const [ threat ] = items; - const url = threat?.extension?.actions?.delete; - const onDelete = useCallback( () => url && goToDelete( url ), [ url ] ); - if ( ! threat || ! url ) { + const remove = useDeleteSoftware( threat ); + const [ isDeleting, setIsDeleting ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + const onDelete = useCallback( async () => { + setIsDeleting( true ); + setError( null ); + const failure = await remove(); + setIsDeleting( false ); + if ( failure ) { + setError( failure.error ); + } else { + closeModal?.(); + } + }, [ remove, closeModal ] ); + if ( ! threat?.extension?.actions?.delete ) { return null; } return ( - { getDeleteThemeMessage( threat ) } + { getDeleteMessage( threat ) } + { error && ( + + { error } + + ) } - - + ); diff --git a/projects/packages/protect/routes/dashboard/sections/scan/store.ts b/projects/packages/protect/routes/dashboard/sections/scan/store.ts index d3bc64107fb9..631138431b5f 100644 --- a/projects/packages/protect/routes/dashboard/sections/scan/store.ts +++ b/projects/packages/protect/routes/dashboard/sections/scan/store.ts @@ -37,6 +37,13 @@ const scanStore = createStore( () => window.jetpackProtectDashboard?.scan as ScanState | undefined ); +/** + * The latest report, for code outside React. + * + * @return The report, or undefined when PHP registered no Scan section. + */ +export const getScan = () => scanStore.get(); + /** * Update the latest report, which the stage's card and the inspector both read. * diff --git a/projects/packages/protect/routes/dashboard/sections/scan/style.scss b/projects/packages/protect/routes/dashboard/sections/scan/style.scss index 317770b5d218..e1b6f19e6a55 100644 --- a/projects/packages/protect/routes/dashboard/sections/scan/style.scss +++ b/projects/packages/protect/routes/dashboard/sections/scan/style.scss @@ -121,6 +121,10 @@ } .jp-protect-threat-details { + // Fill the sidebar, so the footer sits at its bottom on short details. + display: flex; + flex: 1; + flex-direction: column; &__section { padding: 24px; @@ -171,6 +175,7 @@ &__footer { position: sticky; inset-block-end: 0; + margin-block-start: auto; display: flex; flex-direction: column; gap: 12px; @@ -221,6 +226,17 @@ } } + &__label { + display: flex; + flex-direction: column; + white-space: normal; + } + + // DataViews gives every cell a 15ch minimum; ours only need their content. + .dataviews-view-table .dataviews-view-table__cell-content-wrapper { + min-inline-size: 0; + } + &__nowrap { white-space: nowrap; } diff --git a/projects/packages/protect/routes/dashboard/sections/scan/test/delete-software.test.tsx b/projects/packages/protect/routes/dashboard/sections/scan/test/delete-software.test.tsx new file mode 100644 index 000000000000..bec366a179c8 --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/scan/test/delete-software.test.tsx @@ -0,0 +1,53 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import apiFetch from '@wordpress/api-fetch'; +import { useSearch } from '@wordpress/route'; +import { DeleteSoftwareModal } from '../delete-software'; +import { setScan } from '../store'; + +jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) ); +const mockNavigate = jest.fn(); +jest.mock( '@wordpress/route', () => ( { + useSearch: jest.fn(), + useNavigate: () => mockNavigate, +} ) ); + +const inPlugin = ( id: number, slug: string ) => ( { + id, + title: 'Vulnerable plugin', + extension: { + type: 'plugins' as const, + slug, + name: slug, + version: '1.0', + actions: { delete: true }, + }, +} ); + +window.jetpackProtectDashboard = { + scan: { hasPlan: true, url: '#', error: false, threats: [], ignored: [] }, +}; + +describe( 'DeleteSoftwareModal', () => { + it.each( [ + [ 'closes the inspector on a threat in the deleted plugin', '3', 1 ], + [ 'leaves the inspector on a threat elsewhere', '2', 0 ], + ] )( '%s', async ( _name, open, navigations ) => { + ( apiFetch as unknown as jest.Mock ).mockResolvedValue( { ok: true } ); + ( useSearch as unknown as jest.Mock ).mockReturnValue( { threat: open } ); + mockNavigate.mockClear(); + setScan( current => ( { + ...current, + threats: [ inPlugin( 1, 'akismet' ), inPlugin( 2, 'hello' ), inPlugin( 3, 'akismet' ) ], + } ) ); + const closeModal = jest.fn(); + render( + + ); + + await userEvent.click( screen.getByRole( 'button', { name: 'Delete plugin' } ) ); + + await waitFor( () => expect( closeModal ).toHaveBeenCalled() ); + expect( mockNavigate ).toHaveBeenCalledTimes( navigations ); + } ); +} ); diff --git a/projects/packages/protect/routes/dashboard/sections/scan/test/threat-actions.test.ts b/projects/packages/protect/routes/dashboard/sections/scan/test/threat-actions.test.ts index 5e3398dc918a..e91a529d0acf 100644 --- a/projects/packages/protect/routes/dashboard/sections/scan/test/threat-actions.test.ts +++ b/projects/packages/protect/routes/dashboard/sections/scan/test/threat-actions.test.ts @@ -1,9 +1,9 @@ import { act, renderHook } from '@testing-library/react'; import apiFetch from '@wordpress/api-fetch'; -import { select } from '@wordpress/data'; +import { dispatch, select } from '@wordpress/data'; import { store as noticesStore } from '@wordpress/notices'; -import { useScan } from '../store'; -import { ignoreThreat } from '../threat-actions'; +import { setScan, useScan } from '../store'; +import { deleteSoftware, fixThreat, ignoreThreat } from '../threat-actions'; jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) ); @@ -25,7 +25,12 @@ describe( 'ignoreThreat', () => { expect( result.current?.ignored ).toEqual( [ { ...threat, status: 'ignored' } ] ); const [ notice ] = select( noticesStore ).getNotices(); - expect( notice ).toMatchObject( { type: 'snackbar', content: 'Threat ignored.' } ); + expect( notice ).toMatchObject( { + type: 'snackbar', + explicitDismiss: false, + content: 'Ignored the threat in a.php.', + } ); + expect( notice.icon ).toBeTruthy(); await act( async () => notice.actions[ 0 ].onClick() ); expect( mockApiFetch ).toHaveBeenLastCalledWith( { @@ -35,3 +40,113 @@ describe( 'ignoreThreat', () => { expect( result.current?.ignored ).toEqual( [] ); } ); } ); + +describe( 'threat notices', () => { + it( 'offer View while the action runs, which opens that threat and keeps the notice', () => { + mockApiFetch.mockReturnValue( new Promise( () => {} ) ); + const open = jest.fn(); + const fixable = { ...threat, id: 10 }; + + fixThreat( fixable, open ); + + const notice = select( noticesStore ) + .getNotices() + .find( item => item.id === 'jetpack-protect-threat-action-10' ); + expect( notice ).toMatchObject( { + status: 'info', + explicitDismiss: true, + icon: null, + content: 'Fixing the threat in a.php…', + } ); + + // The snackbar removes the notice before running its action. + dispatch( noticesStore ).removeNotice( notice.id ); + notice.actions[ 0 ].onClick(); + + expect( open ).toHaveBeenCalledWith( fixable ); + expect( select( noticesStore ).getNotices() ).toContainEqual( + expect.objectContaining( { id: notice.id, content: 'Fixing the threat in a.php…' } ) + ); + } ); + + it( 'keep one snackbar per threat, so acting on one doesn’t replace another’s', async () => { + mockApiFetch.mockResolvedValue( {} ); + const other = { id: 8, title: 'Malicious code found in file: b.php' }; + setScan( current => ( { ...current, threats: [ threat, other ] } ) ); + + await act( () => ignoreThreat( { ...threat, id: 9 } ) ); + await act( () => ignoreThreat( other ) ); + + expect( + select( noticesStore ) + .getNotices() + .map( notice => notice.content ) + ).toEqual( + expect.arrayContaining( [ 'Ignored the threat in a.php.', 'Ignored the threat in b.php.' ] ) + ); + } ); +} ); + +describe( 'deleteSoftware', () => { + it( 'drops every threat in the deleted plugin, and only those', async () => { + mockApiFetch.mockResolvedValue( { ok: true } ); + const inPlugin = ( id: number, slug: string ) => ( { + id, + title: 'Vulnerable plugin', + extension: { type: 'plugins' as const, slug, name: slug, version: '1.0' }, + } ); + setScan( current => ( { + ...current, + threats: [ inPlugin( 1, 'akismet' ), inPlugin( 2, 'hello' ), inPlugin( 3, 'akismet' ) ], + ignored: [ inPlugin( 4, 'akismet' ) ], + } ) ); + const { result } = renderHook( () => useScan() ); + + await act( () => deleteSoftware( inPlugin( 1, 'akismet' ) ) ); + + expect( mockApiFetch ).toHaveBeenLastCalledWith( { + path: '/jetpack/v4/protect-dashboard/scan/software/delete', + method: 'POST', + data: { type: 'plugins', slug: 'akismet' }, + } ); + expect( result.current?.threats?.map( item => item.id ) ).toEqual( [ 2 ] ); + expect( result.current?.ignored ).toEqual( [] ); + } ); + + it.each( [ + [ 'the server’s reason', { message: 'Nope' }, 'Nope' ], + [ 'a fallback naming the software', {}, 'akismet couldn’t be deleted.' ], + ] )( + 'resolves to %s when the delete fails, and keeps the threats', + async ( _name, e, reason ) => { + mockApiFetch.mockRejectedValue( e ); + const inPlugin = { + id: 1, + title: 'Vulnerable plugin', + extension: { type: 'plugins' as const, slug: 'akismet', name: 'akismet', version: '1.0' }, + }; + setScan( current => ( { ...current, threats: [ inPlugin ], ignored: [] } ) ); + const { result } = renderHook( () => useScan() ); + + await expect( deleteSoftware( inPlugin ) ).resolves.toBe( reason ); + + expect( result.current?.threats ).toEqual( [ inPlugin ] ); + } + ); + + it( 'waits for a running action on any threat in the same plugin', async () => { + const inPlugin = ( id: number ) => ( { + id, + title: 'Vulnerable plugin', + extension: { type: 'plugins' as const, slug: 'busy', name: 'Busy', version: '1.0' }, + } ); + setScan( current => ( { ...current, threats: [ inPlugin( 20 ), inPlugin( 21 ) ] } ) ); + mockApiFetch.mockReset().mockReturnValue( new Promise( () => {} ) ); + fixThreat( inPlugin( 21 ) ); + + const error = await deleteSoftware( inPlugin( 20 ) ); + + expect( error ).toMatch( /^Busy can’t be deleted while/ ); + expect( mockApiFetch ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/projects/packages/protect/routes/dashboard/sections/scan/threat-actions.ts b/projects/packages/protect/routes/dashboard/sections/scan/threat-actions.ts index 6efca9d45a5a..30cb9591f411 100644 --- a/projects/packages/protect/routes/dashboard/sections/scan/threat-actions.ts +++ b/projects/packages/protect/routes/dashboard/sections/scan/threat-actions.ts @@ -1,15 +1,17 @@ import apiFetch from '@wordpress/api-fetch'; import { dispatch } from '@wordpress/data'; -import { useSyncExternalStore } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { createElement, useSyncExternalStore } from '@wordpress/element'; +import { __, sprintf, type TransformedText } from '@wordpress/i18n'; +import { Icon, check } from '@wordpress/icons'; import { store as noticesStore } from '@wordpress/notices'; -import { SCAN_PATH, createStore, mergeScan, setScan } from './store'; +import { getThreatLabel } from './labels'; +import { SCAN_PATH, createStore, getScan, mergeScan, setScan } from './store'; import type { ScanState, ScanThreat } from './types'; type FixStatus = { status: string; error?: string | null; scan?: ScanState }; /** The action running on a threat, if any. */ -export type ThreatActionState = { busy?: 'fixing' | 'ignoring' | 'unignoring' }; +export type ThreatActionState = { busy?: 'fixing' | 'ignoring' | 'unignoring' | 'deleting' }; const threatPath = ( id: string | number, action: string ) => `${ SCAN_PATH }/threats/${ id }/${ action }`; @@ -18,8 +20,16 @@ const threatPath = ( id: string | number, action: string ) => const FIX_POLL_INTERVAL = 3000; const MAX_FIX_POLLS = 100; -// One id for every threat action, so each snackbar replaces the last ("Ignoring…" with "Threat ignored."). -const NOTICE_ID = 'jetpack-protect-threat-action'; +// One id per threat, so its snackbar replaces its last ("Ignoring…" with "ignored") but not another threat's. +const noticeId = ( threat: ScanThreat ) => `jetpack-protect-threat-action-${ threat.id }`; + +const describe = ( threat: ScanThreat ) => getThreatLabel( threat ).subject || threat.title || ''; + +// The store types `icon` as a string, but the snackbar renders any node; currentColor keeps it white. +const SUCCESS_ICON = createElement( Icon, { + icon: check, + fill: 'currentColor', +} ) as unknown as string; const EMPTY: ThreatActionState = {}; const actionStore = createStore< Record< string, ThreatActionState > >( () => ( {} ) ); @@ -30,35 +40,93 @@ const isBusy = ( id: string | number ) => !! actionStore.get()[ String( id ) ]?. const setBusy = ( id: string | number, busy?: ThreatActionState[ 'busy' ] ) => actionStore.set( states => ( { ...states, [ String( id ) ]: busy ? { busy } : EMPTY } ) ); +type NoticeStatus = 'success' | 'info' | 'error'; + +/** Opens a threat in the inspector. */ +type OpenThreat = ( threat: ScanThreat ) => void; + +type NoticeAction = { label: string; onClick: () => void }; + +/** A translated message with `%s` for the threat's name. */ +type Template = TransformedText< `${ string }%s${ string }` >; + /** - * Show a snackbar in the page's notices area, optionally with an Undo action. + * Show a threat's snackbar in the page's notices area, optionally with one action. * - * @param content - The message. - * @param undo - Reverses what the message reports. + * @param threat - The threat the message is about. + * @param content - The finished message. * @param status - The notice's kind. + * @param action - The snackbar's button, such as Undo. */ -function notify( - content: string, - undo?: () => void, - status: 'success' | 'info' | 'error' = 'success' -) { +function show( threat: ScanThreat, content: string, status: NoticeStatus, action?: NoticeAction ) { dispatch( noticesStore ).createNotice( status, content, { type: 'snackbar', - id: NOTICE_ID, - actions: undo ? [ { label: __( 'Undo', 'jetpack-protect-pkg' ), onClick: undo } ] : [], + id: noticeId( threat ), + // Progress and errors stay until dismissed, so they can't vanish before the action ends. + explicitDismiss: status !== 'success', + icon: status === 'success' ? SUCCESS_ICON : null, + actions: action ? [ action ] : [], } ); } +/** + * Show a threat's snackbar from a message that names it. + * + * @param threat - The threat. + * @param template - The message, with `%s` for the threat's name. + * @param status - The notice's kind. + * @param action - The snackbar's button, such as Undo. + */ +function notify( + threat: ScanThreat, + template: Template, + status: NoticeStatus = 'success', + action?: NoticeAction +) { + show( threat, sprintf( template, describe( threat ) ), status, action ); +} + +/** + * Say an action has started, with a button that opens the threat in the inspector. + * + * @param threat - The threat. + * @param template - The message, with `%s` for the threat's name. + * @param open - Opens the threat in the inspector. + */ +function notifyStarted( threat: ScanThreat, template: Template, open?: OpenThreat ) { + const content = sprintf( template, describe( threat ) ); + const view: NoticeAction | undefined = open && { + label: __( 'View', 'jetpack-protect-pkg' ), + // A snackbar removes itself when its action is clicked, so show it again. + onClick: () => { + open( threat ); + show( threat, content, 'info', view ); + }, + }; + show( threat, content, 'info', view ); +} + /** * End a failed action and say what went wrong. * - * @param id - The threat id. - * @param e - The error, whose message is shown when it has one. - * @param message - What to say otherwise. + * @param threat - The threat. + * @param e - The error, whose message follows the threat's name when it has one. + * @param template - What to say otherwise, with `%s` for the threat's name. */ -function fail( id: string | number, e: unknown, message: string ) { - setBusy( id ); - notify( ( e as { message?: string } )?.message || message, undefined, 'error' ); +function fail( threat: ScanThreat, e: unknown, template: Template ) { + setBusy( threat.id ); + const reason = ( e as { message?: string } )?.message; + if ( reason ) { + const content = sprintf( + /* translators: 1: a threat, such as "Contact Form 7 (5.3.1)" or "index.php". 2: why the action failed. */ + __( 'Threat in %1$s: %2$s', 'jetpack-protect-pkg' ), + describe( threat ), + reason + ); + show( threat, content, 'error' ); + } else { + notify( threat, template, 'error' ); + } } const otherThan = ( threat: ScanThreat ) => ( item: ScanThreat ) => @@ -80,19 +148,22 @@ export function loadIgnored(): Promise< void > { * * @param threat - The threat. * @param ignore - True to ignore, false to unignore. + * @param open - Opens the threat in the inspector, from the notice while it runs. * @return Resolves once done, or once the error is shown. */ -function setIgnored( threat: ScanThreat, ignore: boolean ): Promise< void > { +function setIgnored( threat: ScanThreat, ignore: boolean, open?: OpenThreat ): Promise< void > { if ( isBusy( threat.id ) ) { return Promise.resolve(); } setBusy( threat.id, ignore ? 'ignoring' : 'unignoring' ); - notify( + notifyStarted( + threat, ignore - ? __( 'Ignoring threat…', 'jetpack-protect-pkg' ) - : __( 'Unignoring threat…', 'jetpack-protect-pkg' ), - undefined, - 'info' + ? /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ + __( 'Ignoring the threat in %s…', 'jetpack-protect-pkg' ) + : /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ + __( 'Unignoring the threat in %s…', 'jetpack-protect-pkg' ), + open ); return apiFetch( { @@ -110,49 +181,63 @@ function setIgnored( threat: ScanThreat, ignore: boolean ): Promise< void > { } ); setBusy( threat.id ); notify( + threat, ignore - ? __( 'Threat ignored.', 'jetpack-protect-pkg' ) - : __( 'Threat unignored.', 'jetpack-protect-pkg' ), - () => setIgnored( threat, ! ignore ) + ? /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ + __( 'Ignored the threat in %s.', 'jetpack-protect-pkg' ) + : /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ + __( 'Unignored the threat in %s.', 'jetpack-protect-pkg' ), + 'success', + { + label: __( 'Undo', 'jetpack-protect-pkg' ), + onClick: () => setIgnored( threat, ! ignore, open ), + } ); } ) .catch( e => fail( - threat.id, + threat, e, ignore - ? __( 'The threat couldn’t be ignored.', 'jetpack-protect-pkg' ) - : __( 'The threat couldn’t be unignored.', 'jetpack-protect-pkg' ) + ? /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ + __( 'The threat in %s couldn’t be ignored.', 'jetpack-protect-pkg' ) + : /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ + __( 'The threat in %s couldn’t be unignored.', 'jetpack-protect-pkg' ) ) ); } -export const ignoreThreat = ( threat: ScanThreat ) => setIgnored( threat, true ); -export const unignoreThreat = ( threat: ScanThreat ) => setIgnored( threat, false ); +export const ignoreThreat = ( threat: ScanThreat, open?: OpenThreat ) => + setIgnored( threat, true, open ); +export const unignoreThreat = ( threat: ScanThreat, open?: OpenThreat ) => + setIgnored( threat, false, open ); /** * Ask Scan to fix a threat, then follow the fix until it finishes. * * @param threat - The threat. + * @param open - Opens the threat in the inspector, from the notice while it runs. * @return Resolves once the fix has finished or failed. */ -export async function fixThreat( threat: ScanThreat ): Promise< void > { +export async function fixThreat( threat: ScanThreat, open?: OpenThreat ): Promise< void > { const { id } = threat; if ( isBusy( id ) ) { return; } setBusy( id, 'fixing' ); - notify( __( 'Fixing threat…', 'jetpack-protect-pkg' ), undefined, 'info' ); + /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ + notifyStarted( threat, __( 'Fixing the threat in %s…', 'jetpack-protect-pkg' ), open ); try { let result = await apiFetch< FixStatus >( { path: threatPath( id, 'fix' ), method: 'POST' } ); for ( let polls = 0; result.status !== 'fixed' && result.status !== 'not_fixed'; polls++ ) { if ( polls >= MAX_FIX_POLLS ) { return fail( - id, + threat, null, + /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ __( - 'The fix is taking longer than expected. Check back in a few minutes.', + 'The fix for the threat in %s is taking longer than expected. Check back in a few minutes.', 'jetpack-protect-pkg' ) ); @@ -164,19 +249,25 @@ export async function fixThreat( threat: ScanThreat ): Promise< void > { mergeScan( result.scan ); if ( result.status === 'fixed' ) { setBusy( id ); - return notify( __( 'Threat fixed.', 'jetpack-protect-pkg' ) ); + /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ + return notify( threat, __( 'Fixed the threat in %s.', 'jetpack-protect-pkg' ) ); } fail( - id, - null, - result.error || - __( - 'Jetpack couldn’t fix this threat. Contact Jetpack support for help.', - 'jetpack-protect-pkg' - ) + threat, + { message: result.error }, + /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ + __( + 'Jetpack couldn’t fix the threat in %s. Contact Jetpack support for help.', + 'jetpack-protect-pkg' + ) ); } catch ( e ) { - fail( id, e, __( 'The fix couldn’t be started.', 'jetpack-protect-pkg' ) ); + fail( + threat, + e, + /* translators: %s is a threat, such as "Contact Form 7 (5.3.1)" or "index.php". */ + __( 'The fix for the threat in %s couldn’t be started.', 'jetpack-protect-pkg' ) + ); } } @@ -192,3 +283,64 @@ export function useThreatAction( id: string | number ): ThreatActionState { () => actionStore.get()[ String( id ) ] ?? EMPTY ); } + +/** + * Delete the plugin or theme a threat is in, and drop every threat reported in it. + * + * @param threat - A threat in an inactive plugin or an unused theme. + * @return Resolves to why the delete failed, or null once it's done. + */ +export function deleteSoftware( threat: ScanThreat ): Promise< string | null > { + const { type, slug, name } = threat.extension ?? {}; + const isElsewhere = ( item: ScanThreat ) => + item.extension?.type !== type || item.extension?.slug !== slug; + const scan = getScan(); + const ids = [ threat, ...( scan?.threats ?? [] ), ...( scan?.ignored ?? [] ) ] + .filter( item => ! isElsewhere( item ) ) + .map( item => item.id ); + + // A fix or ignore that ends after the delete would put the deleted threats back. + if ( ids.some( isBusy ) ) { + return Promise.resolve( + sprintf( + /* translators: %s is a plugin or theme, such as "Contact Form 7". */ + __( + '%s can’t be deleted while one of its threats is being fixed or ignored. Try again once that finishes.', + 'jetpack-protect-pkg' + ), + name || slug + ) + ); + } + ids.forEach( id => setBusy( id, 'deleting' ) ); + + return apiFetch( { + path: `${ SCAN_PATH }/software/delete`, + method: 'POST', + data: { type, slug }, + } ) + .then( () => { + setScan( current => ( { + ...current, + threats: ( current.threats ?? [] ).filter( isElsewhere ), + ignored: current.ignored?.filter( isElsewhere ), + } ) ); + const content = sprintf( + /* translators: %s is a plugin or theme, such as "Contact Form 7". */ + __( 'Deleted %s.', 'jetpack-protect-pkg' ), + name || slug + ); + show( threat, content, 'success' ); + return null; + } ) + .catch( + ( e: { message?: string } ) => + e?.message || + sprintf( + /* translators: %s is a plugin or theme, such as "Contact Form 7". */ + __( '%s couldn’t be deleted.', 'jetpack-protect-pkg' ), + name || slug + ) + ) + .finally( () => ids.forEach( id => setBusy( id ) ) ); +} diff --git a/projects/packages/protect/routes/dashboard/sections/scan/threat-details.tsx b/projects/packages/protect/routes/dashboard/sections/scan/threat-details.tsx index 828069c72182..c8528655f08e 100644 --- a/projects/packages/protect/routes/dashboard/sections/scan/threat-details.tsx +++ b/projects/packages/protect/routes/dashboard/sections/scan/threat-details.tsx @@ -3,6 +3,7 @@ import { dateI18n } from '@wordpress/date'; import { useCallback } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { Badge, Button, Link, LinkButton, Stack, Text } from '@wordpress/ui'; +import useOpenThreat from '../use-open-threat'; import { DeleteSoftwareButton } from './delete-software'; import { getSoftwareActionLabels, getThreatLabel } from './labels'; import { fixThreat, ignoreThreat, unignoreThreat, useThreatAction } from './threat-actions'; @@ -29,6 +30,33 @@ function SeverityBadge( { severity = 0 }: { severity?: number } ) { return { label }; } +/** + * Whether the plugin or theme the threat is in is in use, as a badge. + * + * @param props - Component props. + * @param props.threat - The threat. + * @return The badge, or null when the threat isn't in an installed plugin or theme. + */ +function StateBadge( { threat }: { threat: ScanThreat } ) { + const { type, state } = threat.extension ?? {}; + if ( ! state ) { + return null; + } + const isTheme = type === 'themes'; + const labels = { + parent: __( 'Parent of active theme', 'jetpack-protect-pkg' ), + active: isTheme + ? __( 'Active theme', 'jetpack-protect-pkg' ) + : __( 'Active plugin', 'jetpack-protect-pkg' ), + inactive: isTheme + ? __( 'Inactive theme', 'jetpack-protect-pkg' ) + : __( 'Inactive plugin', 'jetpack-protect-pkg' ), + }; + return ( + { labels[ state ] } + ); +} + /** * How to resolve a threat: its fixer's description, the version that fixes it, or manual advice. * @@ -91,9 +119,10 @@ function Section( { title, children }: { title: string; children: ReactNode } ) function ThreatFooter( { threat }: { threat: ScanThreat } ) { const { busy } = useThreatAction( threat.id ); const isIgnored = threat.status === 'ignored'; - const onIgnore = useCallback( () => ignoreThreat( threat ), [ threat ] ); - const onUnignore = useCallback( () => unignoreThreat( threat ), [ threat ] ); - const onFix = useCallback( () => fixThreat( threat ), [ threat ] ); + const open = useOpenThreat(); + const onIgnore = useCallback( () => ignoreThreat( threat, open ), [ threat, open ] ); + const onUnignore = useCallback( () => unignoreThreat( threat, open ), [ threat, open ] ); + const onFix = useCallback( () => fixThreat( threat, open ), [ threat, open ] ); return (
@@ -224,6 +253,7 @@ export default function ThreatDetails( { + { threat.status !== 'fixed' && } { threat.status === 'ignored' && ( { __( 'Ignored', 'jetpack-protect-pkg' ) } ) } diff --git a/projects/packages/protect/routes/dashboard/sections/scan/threat-row-actions.tsx b/projects/packages/protect/routes/dashboard/sections/scan/threat-row-actions.tsx index 4795ac5e4e2a..1b289c69fac8 100644 --- a/projects/packages/protect/routes/dashboard/sections/scan/threat-row-actions.tsx +++ b/projects/packages/protect/routes/dashboard/sections/scan/threat-row-actions.tsx @@ -1,11 +1,11 @@ import { __ } from '@wordpress/i18n'; -import { DeleteThemeModal } from './delete-software'; +import { DeleteSoftwareModal, getDeleteTitle } from './delete-software'; import { getSoftwareActionLabels } from './labels'; import { fixThreat, ignoreThreat, unignoreThreat } from './threat-actions'; import type { ScanThreat } from './types'; import type { Action } from '@wordpress/dataviews'; -type LinkKey = 'update' | 'deactivate' | 'delete' | 'details'; +type LinkKey = 'update' | 'deactivate' | 'details'; /** * A row's DataViews actions: primary ones show on hover, and the ⋯ menu lists them all. @@ -45,14 +45,14 @@ export function getThreatRowActions( isPrimary: true, isEligible: item => canAct && !! item.fixable && item.status !== 'ignored' && item.status !== 'fixed', - callback: ( [ item ] ) => item && fixThreat( item ), + callback: ( [ item ] ) => item && fixThreat( item, open ), }, { id: 'unignore', label: __( 'Unignore', 'jetpack-protect-pkg' ), isPrimary: true, isEligible: item => canAct && item.status === 'ignored', - callback: ( [ item ] ) => item && unignoreThreat( item ), + callback: ( [ item ] ) => item && unignoreThreat( item, open ), }, { id: 'update', @@ -67,18 +67,11 @@ export function getThreatRowActions( callback: link( 'deactivate' ), }, { - id: 'delete-plugin', + id: 'delete', label: softwareLabel( 'delete' ), - isEligible: item => item.extension?.type === 'plugins' && hasLink( 'delete' )( item ), - // WordPress asks to confirm on the page this opens. - callback: link( 'delete' ), - }, - { - id: 'delete-theme', - label: softwareLabel( 'delete' ), - modalHeader: __( 'Delete theme?', 'jetpack-protect-pkg' ), - isEligible: item => item.extension?.type === 'themes' && hasLink( 'delete' )( item ), - RenderModal: DeleteThemeModal, + modalHeader: ( [ item ] ) => ( item ? getDeleteTitle( item ) : '' ), + isEligible: item => !! item.extension?.actions?.delete, + RenderModal: DeleteSoftwareModal, }, { id: 'wordpress-org', @@ -90,7 +83,7 @@ export function getThreatRowActions( id: 'ignore', label: __( 'Ignore', 'jetpack-protect-pkg' ), isEligible: item => canAct && item.status !== 'ignored' && item.status !== 'fixed', - callback: ( [ item ] ) => item && ignoreThreat( item ), + callback: ( [ item ] ) => item && ignoreThreat( item, open ), }, ]; } diff --git a/projects/packages/protect/routes/dashboard/sections/scan/threats-list.tsx b/projects/packages/protect/routes/dashboard/sections/scan/threats-list.tsx index 8cbe52560375..ed8d0eb340a7 100644 --- a/projects/packages/protect/routes/dashboard/sections/scan/threats-list.tsx +++ b/projects/packages/protect/routes/dashboard/sections/scan/threats-list.tsx @@ -3,6 +3,8 @@ import { DataViews, filterSortAndPaginate, type Field, type View } from '@wordpr import { dateI18n } from '@wordpress/date'; import { useCallback, useMemo, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; +import useArrowKeyNavigation from '../../components/use-arrow-key-navigation'; +import useOpenThreat from '../use-open-threat'; import { getThreatLabel } from './labels'; import { THREAT_PARAM, useSearchParam } from './store'; import ThreatMedia from './threat-media'; @@ -33,7 +35,7 @@ function formatDetected( date: string ): string { type RowProps = { item: ScanThreat; onOpen: ( item: ScanThreat ) => void }; /** - * The threat column: the icon and title, as one button that opens the details. + * The threat column: the icon, the kind of threat and what it's in on a second line, as one button that opens the details. * * @param props - Component props. * @param props.item - The threat. @@ -46,9 +48,9 @@ function ThreatCell( { item, onOpen }: RowProps ) { return ( ); @@ -155,9 +157,9 @@ type ThreatsListProps = { export default function ThreatsList( { threats, empty, canAct }: ThreatsListProps ) { const [ view, setView ] = useState< View >( () => createThreatView( 'severity' ) ); - const [ selected, setThreat ] = useSearchParam( THREAT_PARAM ); + const [ selected ] = useSearchParam( THREAT_PARAM ); const selection = useMemo( () => ( selected ? [ selected ] : [] ), [ selected ] ); - const open = useCallback( ( item: ScanThreat ) => setThreat( item.id ), [ setThreat ] ); + const open = useOpenThreat(); const getItemId = useCallback( ( item: ScanThreat ) => String( item.id ), [] ); const fields = useMemo( @@ -175,6 +177,7 @@ export default function ThreatsList( { threats, empty, canAct }: ThreatsListProp () => filterSortAndPaginate( threats, view, fields ), [ threats, view, fields ] ); + useArrowKeyNavigation( data, selected, getItemId, open ); return (
diff --git a/projects/packages/protect/routes/dashboard/sections/scan/types.ts b/projects/packages/protect/routes/dashboard/sections/scan/types.ts index 3e7072e1e321..79a57a1c0c4b 100644 --- a/projects/packages/protect/routes/dashboard/sections/scan/types.ts +++ b/projects/packages/protect/routes/dashboard/sections/scan/types.ts @@ -8,8 +8,10 @@ export type ScanThreat = Omit< Threat, 'extension' > & { vulnerabilities?: { id?: string; title?: string; source?: string }[]; extension?: Threat[ 'extension' ] & { icon?: string | null; - /** Admin links for the current user, each only when it applies. */ - actions?: { update?: string; deactivate?: string; delete?: string; details?: string }; + /** Whether the installed plugin or theme is in use; `parent` is the active theme's parent. */ + state?: 'active' | 'inactive' | 'parent' | null; + /** Admin links for the current user, and whether the dashboard can delete it, each only when it applies. */ + actions?: { update?: string; deactivate?: string; delete?: boolean; details?: string }; }; }; diff --git a/projects/packages/protect/routes/dashboard/sections/use-open-threat.ts b/projects/packages/protect/routes/dashboard/sections/use-open-threat.ts new file mode 100644 index 000000000000..601444d15990 --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/use-open-threat.ts @@ -0,0 +1,44 @@ +import { useCallback } from '@wordpress/element'; +import { useNavigate } from '@wordpress/route'; +import { HISTORY_STATUS_PARAM, HISTORY_THREAT_PARAM } from './history/store'; +import { CLOSED_INSPECTOR } from './inspector-params'; +import { THREAT_PARAM } from './scan/store'; +import type { ScanThreat } from './scan/types'; + +/** + * The tab, list and inspector param that show a threat: Overview for an active one, else History. + * + * @param threat - The threat. + * @return The search params. + */ +function getThreatPlace( threat: ScanThreat ): Record< string, string | undefined > { + const id = String( threat.id ); + if ( threat.status === 'fixed' ) { + return { tab: 'history', [ HISTORY_STATUS_PARAM ]: undefined, [ HISTORY_THREAT_PARAM ]: id }; + } + if ( threat.status === 'ignored' ) { + return { tab: 'history', [ HISTORY_STATUS_PARAM ]: 'ignored', [ THREAT_PARAM ]: id }; + } + return { tab: undefined, [ THREAT_PARAM ]: id }; +} + +/** + * Open a threat in its inspector, on the tab that lists it, closing any other inspector. + * + * @return Opens a threat. + */ +export default function useOpenThreat() { + const navigate = useNavigate(); + return useCallback( + ( threat: ScanThreat ) => + navigate( { + search: ( prev: Record< string, unknown > ) => ( { + ...prev, + ...CLOSED_INSPECTOR, + ...getThreatPlace( threat ), + } ), + // `@wordpress/route` types no route tree, so the navigate argument needs a cast. + } as Parameters< typeof navigate >[ 0 ] ), + [ navigate ] + ); +} diff --git a/projects/packages/protect/routes/dashboard/stage.tsx b/projects/packages/protect/routes/dashboard/stage.tsx index e48dfe1594be..5e0451f395cc 100644 --- a/projects/packages/protect/routes/dashboard/stage.tsx +++ b/projects/packages/protect/routes/dashboard/stage.tsx @@ -7,6 +7,7 @@ import Overview from './components/overview'; import SettingsTab from './components/settings-tab'; import useProtectSettings from './data/use-protect-settings'; import sections from './sections'; +import { CLOSED_INSPECTOR } from './sections/inspector-params'; import './route.scss'; /** @@ -24,6 +25,8 @@ const Stage = () => { navigate( { search: ( prev: Record< string, unknown > ) => ( { ...prev, + // Leaving a tab closes the inspector, whose threat belongs to the list on that tab. + ...CLOSED_INSPECTOR, ...params, tab: next === 'overview' ? undefined : next, } ), diff --git a/projects/packages/protect/routes/dashboard/test/stage.test.tsx b/projects/packages/protect/routes/dashboard/test/stage.test.tsx index 1341472ba706..bae9a46cb5ea 100644 --- a/projects/packages/protect/routes/dashboard/test/stage.test.tsx +++ b/projects/packages/protect/routes/dashboard/test/stage.test.tsx @@ -1,10 +1,15 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import apiFetch from '@wordpress/api-fetch'; import { useSearch } from '@wordpress/route'; import { stage as Stage } from '../stage'; jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) ); -jest.mock( '@wordpress/route', () => ( { useSearch: jest.fn(), useNavigate: () => jest.fn() } ) ); +const mockNavigate = jest.fn(); +jest.mock( '@wordpress/route', () => ( { + useSearch: jest.fn(), + useNavigate: () => mockNavigate, +} ) ); jest.mock( '@automattic/jetpack-components/admin-page', () => ( { __esModule: true, default: ( { children } ) => children, @@ -28,4 +33,18 @@ describe( 'Protect dashboard stage', () => { expect( screen.getByRole( 'tab', { selected: true } ) ).toHaveTextContent( selected ); expect( mockApiFetch ).toHaveBeenCalledTimes( requests ); } ); + + it( 'switching tabs closes the threat inspector', async () => { + mockUseSearch.mockReturnValue( { threat: '7' } ); + render( ); + + await userEvent.click( screen.getByRole( 'tab', { name: 'Settings' } ) ); + + const { search } = mockNavigate.mock.lastCall[ 0 ]; + expect( search( { threat: '7', historyThreat: '3' } ) ).toEqual( { + threat: undefined, + historyThreat: undefined, + tab: 'settings', + } ); + } ); } ); diff --git a/projects/packages/protect/routes/dashboard/test/use-arrow-key-navigation.test.tsx b/projects/packages/protect/routes/dashboard/test/use-arrow-key-navigation.test.tsx new file mode 100644 index 000000000000..6120d4d2d3f0 --- /dev/null +++ b/projects/packages/protect/routes/dashboard/test/use-arrow-key-navigation.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, renderHook } from '@testing-library/react'; +import useArrowKeyNavigation from '../components/use-arrow-key-navigation'; + +const items = [ 'a', 'b', 'c' ]; +const getId = ( item: string ) => item; + +describe( 'useArrowKeyNavigation', () => { + it.each( [ + [ 'ArrowDown opens the next row', 'b', 'ArrowDown', 'button', 'c', false ], + [ 'ArrowUp opens the previous row', 'b', 'ArrowUp', 'button', 'a', false ], + [ 'the last row stays put without scrolling', 'c', 'ArrowDown', 'button', null, false ], + [ 'a row on another page starts from this page', 'z', 'ArrowDown', 'button', 'a', false ], + [ 'nothing open leaves the page to scroll', undefined, 'ArrowDown', 'button', null, true ], + [ 'typing in a field moves its caret', 'b', 'ArrowDown', 'input', null, true ], + ] )( '%s', ( _name, selected, key, tag, expected, scrolls ) => { + const open = jest.fn(); + renderHook( () => useArrowKeyNavigation( items, selected, getId, open ) ); + const target = document.createElement( tag ); + document.body.append( target ); + + // fireEvent returns false once a handler prevents the key's default scroll; userEvent can't tell. + // eslint-disable-next-line testing-library/prefer-user-event + const isDefault = fireEvent.keyDown( target, { key } ); + + expect( open.mock.calls ).toEqual( expected ? [ [ expected ] ] : [] ); + expect( isDefault ).toBe( scrolls ); + target.remove(); + } ); +} ); diff --git a/projects/packages/protect/routes/dashboard/test/use-open-threat.test.ts b/projects/packages/protect/routes/dashboard/test/use-open-threat.test.ts new file mode 100644 index 000000000000..184e4f8eb523 --- /dev/null +++ b/projects/packages/protect/routes/dashboard/test/use-open-threat.test.ts @@ -0,0 +1,30 @@ +import { renderHook } from '@testing-library/react'; +import useOpenThreat from '../sections/use-open-threat'; +import type { ScanThreat } from '../sections/scan/types'; + +const mockNavigate = jest.fn(); +jest.mock( '@wordpress/route', () => ( { useNavigate: () => mockNavigate } ) ); + +describe( 'useOpenThreat', () => { + it.each( [ + [ 'an active threat on Overview', 'current', { tab: undefined, threat: '7' } ], + [ + 'an ignored threat in History', + 'ignored', + { tab: 'history', status: 'ignored', threat: '7' }, + ], + [ 'a fixed threat in History', 'fixed', { tab: 'history', historyThreat: '7' } ], + ] )( 'opens %s, closing the other inspector', ( _name, status, expected ) => { + const { result } = renderHook( () => useOpenThreat() ); + + result.current( { id: 7, status } as ScanThreat ); + + const { search } = mockNavigate.mock.lastCall[ 0 ]; + expect( search( { tab: 'settings', threat: '1', historyThreat: '2' } ) ).toEqual( { + threat: undefined, + historyThreat: undefined, + status: undefined, + ...expected, + } ); + } ); +} ); diff --git a/projects/packages/protect/src/class-dashboard-threats.php b/projects/packages/protect/src/class-dashboard-threats.php index 240676e3eca6..9de3b985605d 100644 --- a/projects/packages/protect/src/class-dashboard-threats.php +++ b/projects/packages/protect/src/class-dashboard-threats.php @@ -57,7 +57,9 @@ public static function format( $threat, $site = null ) { 'version' => $extension->version ?? null, 'type' => $type, 'icon' => 'plugins' === $type ? self::get_plugin_icon( $site, $slug ) : ( $theme && $theme->get_screenshot() ? $theme->get_screenshot() : null ), - 'actions' => self::get_actions( $site, $type, $slug, $file, $theme ), + 'state' => self::get_state( $type, $file, $theme ), + // A fixed threat no longer calls for deleting what it was in. + 'actions' => array_diff_key( self::get_actions( $site, $type, $slug, $file, $theme ), 'fixed' === ( $threat->status ?? null ) ? array( 'delete' => true ) : array() ), ) : null, ); } @@ -73,10 +75,7 @@ private static function get_site_extensions() { } $plugins = get_plugins(); - $files = array(); - foreach ( array_keys( $plugins ) as $file ) { - $files[ '.' === dirname( $file ) ? basename( $file, '.php' ) : dirname( $file ) ] = $file; - } + $files = self::get_plugin_files( $plugins ); $plugin_updates = get_site_transient( 'update_plugins' ); $theme_updates = get_site_transient( 'update_themes' ); @@ -142,6 +141,44 @@ private static function format_vulnerabilities( $vulnerabilities ) { return $formatted; } + /** + * Whether the affected plugin or theme is in use. + * + * @param string|null $type The plural extension type. + * @param string|null $file The installed plugin's file, for a plugin. + * @param \WP_Theme|null $theme The installed theme, for a theme. + * @return string|null `active`, `inactive`, or `parent` for the active theme's parent; null when it isn't installed. + */ + private static function get_state( $type, $file, $theme ) { + if ( 'plugins' === $type && $file ) { + return self::has_active_plugin( $file ) ? 'active' : 'inactive'; + } + if ( 'themes' === $type && $theme ) { + if ( get_stylesheet() === $theme->get_stylesheet() ) { + return 'active'; + } + return get_template() === $theme->get_stylesheet() ? 'parent' : 'inactive'; + } + return null; + } + + /** + * Whether the plugin, or another plugin in its folder, is active, since deleting it removes the whole folder. + * + * @param string $file The plugin's file. + * @return bool + */ + private static function has_active_plugin( $file ) { + $folder = dirname( $file ); + $files = '.' === $folder ? array( $file ) : array_keys( get_plugins( '/' . $folder ) ); + foreach ( $files as $name ) { + if ( is_plugin_active( '.' === $folder ? $name : $folder . '/' . $name ) ) { + return true; + } + } + return false; + } + /** * Admin links that act on the affected plugin, theme or core, for the current user. * @@ -150,7 +187,7 @@ private static function format_vulnerabilities( $vulnerabilities ) { * @param string|null $slug The extension slug. * @param string|null $file The installed plugin's file, for a plugin. * @param \WP_Theme|null $theme The installed theme, for a theme. - * @return array Links keyed `update`, `deactivate`, `delete` and `details`, each only when it applies. + * @return array Links keyed `update`, `deactivate` and `details`, plus `delete` => true, each only when it applies. */ private static function get_actions( $site, $type, $slug, $file, $theme = null ) { $actions = array(); @@ -173,13 +210,8 @@ private static function get_actions( $site, $type, $slug, $file, $theme = null ) if ( current_user_can( 'switch_themes' ) && get_stylesheet() === $slug ) { $actions['deactivate'] = self_admin_url( 'themes.php' ); } - // Core deletes on this link without asking, so the dashboard confirms first. Multisite deletes from Network Admin. - if ( ! is_multisite() && current_user_can( 'delete_themes' ) && $theme && ! in_array( $slug, array( get_stylesheet(), get_template() ), true ) ) { - $actions['delete'] = add_query_arg( - '_wpnonce', - wp_create_nonce( 'delete-theme_' . $slug ), - admin_url( 'themes.php?action=delete&stylesheet=' . rawurlencode( $slug ) ) - ); + if ( self::can_delete( $type, null, $theme ) ) { + $actions['delete'] = true; } return $actions; } @@ -202,13 +234,8 @@ private static function get_actions( $site, $type, $slug, $file, $theme = null ) self_admin_url( 'plugins.php?action=deactivate&plugin=' . rawurlencode( $file ) ) ); } - // Core asks "Are you sure?" on this link. On multisite, another site may still use the plugin. - if ( ! is_multisite() && current_user_can( 'delete_plugins' ) && ! is_plugin_active( $file ) ) { - $actions['delete'] = add_query_arg( - '_wpnonce', - wp_create_nonce( 'bulk-plugins' ), - self_admin_url( 'plugins.php?action=delete-selected&checked[]=' . rawurlencode( $file ) ) - ); + if ( self::can_delete( $type, $file ) ) { + $actions['delete'] = true; } if ( isset( $site['directory'][ $slug ] ) ) { $actions['details'] = 'https://wordpress.org/plugins/' . rawurlencode( $slug ) . '/'; @@ -216,6 +243,36 @@ private static function get_actions( $site, $type, $slug, $file, $theme = null ) return $actions; } + /** + * Installed plugin files by slug: the plugin's directory, or a single-file plugin's name. + * + * @param array $plugins Plugins keyed by file, from get_plugins(). + * @return array + */ + public static function get_plugin_files( $plugins ) { + $files = array(); + foreach ( array_keys( $plugins ) as $file ) { + $files[ '.' === dirname( $file ) ? basename( $file, '.php' ) : dirname( $file ) ] = $file; + } + return $files; + } + + /** + * Whether the current user can delete the affected software from the dashboard: an inactive plugin, or a theme the site doesn't use. + * + * Multisite is left to Network Admin, where another site may still use it. + * + * @param string|null $type The plural extension type. + * @param string|null $file The installed plugin's file, for a plugin. + * @param \WP_Theme|null $theme The installed theme, for a theme. + * @return bool + */ + public static function can_delete( $type, $file, $theme = null ) { + return ! is_multisite() + && 'inactive' === self::get_state( $type, $file, $theme ) + && current_user_can( 'themes' === $type ? 'delete_themes' : 'delete_plugins' ); + } + /** * The plugin's WordPress.org directory icon, from the update check. * diff --git a/projects/packages/protect/src/sections/class-scan.php b/projects/packages/protect/src/sections/class-scan.php index 1571c237f45d..887f65a76f81 100644 --- a/projects/packages/protect/src/sections/class-scan.php +++ b/projects/packages/protect/src/sections/class-scan.php @@ -147,6 +147,27 @@ public function register_routes() { ) ); + register_rest_route( + 'jetpack/v4', + '/protect-dashboard/scan/software/delete', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'delete_software' ), + 'permission_callback' => array( Dashboard::class, 'can_manage' ), + 'args' => array( + 'type' => array( + 'type' => 'string', + 'enum' => array( 'plugins', 'themes' ), + 'required' => true, + ), + 'slug' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ) + ); + register_rest_route( 'jetpack/v4', '/protect-dashboard/scan/threats/(?P\d+)/fix', @@ -272,6 +293,60 @@ public static function get_history_threats() { return $threats; } + /** + * Delete an inactive plugin or a theme the site doesn't use, so its threats go with it. + * + * @param WP_REST_Request $request The request, with the extension `type` and `slug`. + * @return array|WP_Error + */ + public static function delete_software( WP_REST_Request $request ) { + $type = $request['type']; + $slug = $request['slug']; + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + require_once ABSPATH . 'wp-admin/includes/theme.php'; + + // Looked up by key, so only the slug of installed software matches and anything else is refused. + $file = 'plugins' === $type ? ( Dashboard_Threats::get_plugin_files( get_plugins() )[ $slug ] ?? null ) : null; + $theme = 'themes' === $type ? ( wp_get_themes( array( 'errors' => null ) )[ $slug ] ?? null ) : null; + if ( ! Dashboard_Threats::can_delete( $type, $file, $theme ) ) { + return new WP_Error( 'software_not_deletable', __( 'This can’t be deleted here. It may be in use, or already gone.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) ); + } + + $deleted = null; + if ( self::can_write_files() ) { + $deleted = 'plugins' === $type ? delete_plugins( array( $file ) ) : delete_theme( $theme->get_stylesheet() ); + } + if ( true !== $deleted ) { + return new WP_Error( 'software_not_deleted', is_wp_error( $deleted ) ? $deleted->get_error_message() : __( 'WordPress couldn’t delete the files. Try deleting it from the Plugins or Themes screen.', 'jetpack-protect-pkg' ), array( 'status' => 500 ) ); + } + + delete_transient( self::HISTORY_CACHE ); + if ( class_exists( Status::class ) ) { + Scan_Status::delete_option(); + Protect_Status::delete_option(); + } + return array( 'ok' => true ); + } + + /** + * Whether WordPress can delete files without asking for filesystem credentials. + * + * Core's delete functions print the credentials form and exit when it can't, which a REST request can't show. + * + * @return bool + */ + private static function can_write_files() { + if ( 'direct' === get_filesystem_method() ) { + return true; + } + ob_start(); + $has_credentials = request_filesystem_credentials( self_admin_url() ); + ob_end_clean(); + return (bool) $has_credentials; + } + /** * Ask Scan to fix a threat. * diff --git a/projects/packages/protect/tests/php/Dashboard_Threats_Test.php b/projects/packages/protect/tests/php/Dashboard_Threats_Test.php index d5507160ed6b..89ef6887ec63 100644 --- a/projects/packages/protect/tests/php/Dashboard_Threats_Test.php +++ b/projects/packages/protect/tests/php/Dashboard_Threats_Test.php @@ -154,6 +154,98 @@ public function test_plugin_icon( $icons, $expected ) { $this->assertSame( $expected, $threat['extension']['icon'] ); } + /** + * Whether the plugin is active, and the state a threat in it reports. + * + * @return array[] + */ + public static function provider_plugin_state() { + return array( + 'active plugin' => array( true, 'active' ), + 'inactive plugin' => array( false, 'inactive' ), + ); + } + + /** + * Test that a plugin threat says whether the plugin is active. + * + * @dataProvider provider_plugin_state + * @param bool $is_active Whether the plugin is active. + * @param string $expected The expected state. + */ + #[DataProvider( 'provider_plugin_state' )] + public function test_plugin_state( $is_active, $expected ) { + $file = WP_PLUGIN_DIR . '/protect-state-test.php'; + wp_mkdir_p( WP_PLUGIN_DIR ); + file_put_contents( $file, " (object) array( + 'slug' => 'protect-state-test', + 'type' => 'plugin', + ), + ) + ); + wp_delete_file( $file ); + + $this->assertSame( $expected, $threat['extension']['state'] ); + } + + /** + * A threat's status, and whether it offers to delete the inactive plugin it's in. + * + * @return array[] + */ + public static function provider_delete_action() { + return array( + 'current threat offers delete' => array( 'current', true ), + 'ignored threat offers delete' => array( 'ignored', true ), + 'fixed threat does not' => array( 'fixed', false ), + ); + } + + /** + * Test that only a threat that still applies offers to delete its plugin. + * + * @dataProvider provider_delete_action + * @param string $status The threat's status. + * @param bool $expected Whether Delete is offered. + */ + #[DataProvider( 'provider_delete_action' )] + public function test_delete_action( $status, $expected ) { + $file = WP_PLUGIN_DIR . '/protect-state-test.php'; + wp_mkdir_p( WP_PLUGIN_DIR ); + file_put_contents( $file, " 'admin', + 'user_pass' => 'pass', + 'role' => 'administrator', + ) + ) + ); + + $threat = Dashboard_Threats::format( + (object) array( + 'status' => $status, + 'extension' => (object) array( + 'slug' => 'protect-state-test', + 'type' => 'plugin', + ), + ) + ); + wp_delete_file( $file ); + wp_set_current_user( 0 ); + + $this->assertSame( $expected, ! empty( $threat['extension']['actions']['delete'] ) ); + } + /** * Test format(). * diff --git a/projects/packages/protect/tests/php/Scan_Section_Test.php b/projects/packages/protect/tests/php/Scan_Section_Test.php index da4b55495c8b..952c5717427c 100644 --- a/projects/packages/protect/tests/php/Scan_Section_Test.php +++ b/projects/packages/protect/tests/php/Scan_Section_Test.php @@ -135,4 +135,153 @@ public static function provider_fix_status() { public function test_fix_status_is_read_from_the_response( $response, $expected ) { $this->assertSame( $expected, self::call( 'get_threat_fix_status', json_decode( $response ), 7 ) ); } + + /** + * Plugin files, the active ones, the slug to delete, and whether the route deletes it. + * + * @return array[] + */ + public static function provider_delete_plugin() { + $folder = array( 'protect-folder/a.php', 'protect-folder/b.php' ); + return array( + 'inactive plugin is deleted' => array( array( 'protect-delete-test.php' ), array(), 'protect-delete-test', true ), + 'active plugin is kept' => array( array( 'protect-delete-test.php' ), array( 'protect-delete-test.php' ), 'protect-delete-test', false ), + 'folder of inactive plugins is deleted' => array( $folder, array(), 'protect-folder', true ), + 'folder with an active plugin is kept' => array( $folder, array( 'protect-folder/a.php' ), 'protect-folder', false ), + ); + } + + /** + * Test that the route deletes an inactive plugin's files, but never a folder holding an active plugin. + * + * @dataProvider provider_delete_plugin + * @param string[] $files Plugin files to install. + * @param string[] $active The active ones. + * @param string $slug The plugin to delete. + * @param bool $is_deleted Whether its files should be gone. + */ + #[DataProvider( 'provider_delete_plugin' )] + public function test_delete_software_only_deletes_inactive_plugins( $files, $active, $slug, $is_deleted ) { + foreach ( $files as $file ) { + // wp_mkdir_p() refuses the test environment's plugin root, whose path has `..` in it. + if ( ! is_dir( dirname( WP_PLUGIN_DIR . "/$file" ) ) ) { + mkdir( dirname( WP_PLUGIN_DIR . "/$file" ), 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + } + file_put_contents( WP_PLUGIN_DIR . "/$file", "assertSame( $is_deleted, ! is_wp_error( $result ), 'Result' ); + $this->assertSame( $is_deleted, ! $exists, 'File' ); + } + + /** + * Test that the route answers with an error, and keeps the files, when WordPress would ask for filesystem credentials. + */ + public function test_delete_software_needs_direct_filesystem_access() { + $file = WP_PLUGIN_DIR . '/protect-delete-test.php'; + wp_mkdir_p( WP_PLUGIN_DIR ); + file_put_contents( $file, "assertSame( 'software_not_deleted', is_wp_error( $result ) ? $result->get_error_code() : null ); + $this->assertTrue( $exists ); + } + + /** + * Themes, and whether the route should delete each while a child theme is active. + * + * @return array[] + */ + public static function provider_delete_theme() { + return array( + 'unused theme is deleted' => array( 'protect-unused', true ), + 'active theme is kept' => array( 'protect-child', false ), + 'parent of active theme is kept' => array( 'protect-parent', false ), + 'path to an unused theme is kept' => array( './protect-unused', false ), + ); + } + + /** + * Test that the route deletes an unused theme, but never the active theme or its parent. + * + * @dataProvider provider_delete_theme + * @param string $slug The theme to delete. + * @param bool $is_deleted Whether its directory should be gone. + */ + #[DataProvider( 'provider_delete_theme' )] + public function test_delete_software_only_deletes_unused_themes( $slug, $is_deleted ) { + $headers = array( + 'protect-parent' => '', + 'protect-child' => "Template: protect-parent\n", + 'protect-unused' => '', + ); + foreach ( $headers as $theme => $header ) { + // wp_mkdir_p() refuses the test environment's theme root, whose path has `..` in it. + if ( ! is_dir( get_theme_root() . "/$theme" ) ) { + mkdir( get_theme_root() . "/$theme", 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + } + file_put_contents( get_theme_root() . "/$theme/style.css", "/*\nTheme Name: $theme\n{$header}*/\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + } + wp_clean_themes_cache( false ); + update_option( 'template', 'protect-parent' ); + update_option( 'stylesheet', 'protect-child' ); + + $result = self::delete_as_admin( 'themes', $slug ); + $exists = is_dir( get_theme_root() . "/$slug" ); + foreach ( array_keys( $headers ) as $theme ) { + if ( is_dir( get_theme_root() . "/$theme" ) ) { + wp_delete_file( get_theme_root() . "/$theme/style.css" ); + rmdir( get_theme_root() . "/$theme" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + } + } + + $this->assertSame( $is_deleted, ! is_wp_error( $result ), 'Result' ); + $this->assertSame( $is_deleted, ! $exists, 'Directory' ); + } + + /** + * Ask the delete route, as an administrator, to delete a plugin or theme. + * + * @param string $type `plugins` or `themes`. + * @param string $slug The plugin or theme. + * @return array|\WP_Error + */ + private static function delete_as_admin( $type, $slug ) { + $admin_id = wp_insert_user( + array( + 'user_login' => 'admin', + 'user_pass' => 'pass', + 'role' => 'administrator', + ) + ); + wp_set_current_user( $admin_id ); + + $request = new \WP_REST_Request( 'POST', '/jetpack/v4/protect-dashboard/scan/software/delete' ); + $request->set_param( 'type', $type ); + $request->set_param( 'slug', $slug ); + return Scan::delete_software( $request ); + } } diff --git a/projects/plugins/jetpack/changelog/fix-protect-dashboard-scan-feedback b/projects/plugins/jetpack/changelog/fix-protect-dashboard-scan-feedback new file mode 100644 index 000000000000..b818704edb04 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-protect-dashboard-scan-feedback @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Protect: Improve the Scan section of the Protect dashboard: delete unused vulnerable plugins and themes in place, step through threats with the arrow keys, see whether the affected plugin or theme is active, and get clearer notices for each threat. From 4ee971f8a4db495ec2c051d0b84f32a44c8ea488 Mon Sep 17 00:00:00 2001 From: Tony Arcangelini <33258733+arcangelini@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:21:58 +0200 Subject: [PATCH 22/57] Comments: Keep the form nonce out of the page HTML (#53547) * Comments: Keep the form nonce out of the page HTML * Comments: Add the nonce from inline script * Comments: Show the plain form when the bundle never runs * Comments: Add the nonce again on DOMContentLoaded * Comments: Show guests a note instead of the form without JavaScript --- .../changelog/fix-comments-spam-nonce | 4 +++ .../comments/src/form/class-comment-form.php | 35 +++++++++++++++---- projects/packages/comments/src/modal/host.tsx | 6 ++-- 3 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 projects/packages/comments/changelog/fix-comments-spam-nonce diff --git a/projects/packages/comments/changelog/fix-comments-spam-nonce b/projects/packages/comments/changelog/fix-comments-spam-nonce new file mode 100644 index 000000000000..391454c9634a --- /dev/null +++ b/projects/packages/comments/changelog/fix-comments-spam-nonce @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Turn away comments from logged-out readers that arrive without the form's nonce, which only the form's script adds, to stop spam bots that post the form straight from the page. diff --git a/projects/packages/comments/src/form/class-comment-form.php b/projects/packages/comments/src/form/class-comment-form.php index 6cc573a94591..3eda9a2981b8 100644 --- a/projects/packages/comments/src/form/class-comment-form.php +++ b/projects/packages/comments/src/form/class-comment-form.php @@ -297,6 +297,8 @@ private function markup( $args = array() ) { 'subscriptions' => $args['subscriptions'] ?? array(), ); + $noscript = ''; + // Core's own fields and submit, for a page whose settings another release rendered or whose script never ran. if ( get_option( 'comment_registration' ) && ! is_user_logged_in() ) { $plain = ''; if ( ! is_user_logged_in() ) { + // Without script a guest has no nonce to post, so they get a note in place of fields that cannot send. + $noscript = ''; + $commenter = wp_get_current_commenter(); $fields = array( 'author' => array( __( 'Name', 'jetpack-comments' ), 'text', $commenter['comment_author'], $required ), @@ -337,8 +344,8 @@ private function markup( $args = array() ) { . ' data-jetpack-comments="' . esc_attr( (string) wp_json_encode( $settings, JSON_UNESCAPED_SLASHES | JSON_HEX_AMP ) ) . '">' . $plain . '
' - . get_comment_id_fields( $post_id ) - . wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME, false, false ); + . $noscript + . get_comment_id_fields( $post_id ); } /** @@ -485,9 +492,19 @@ public function enqueue_assets( $args = array() ) { Identity::settings() ); + // The nonce is added by script, so a bot posting the form's fields has none. Inline and ES5, so + // a reader whose bundle never runs gets it too, and the plain form once the page has loaded. + // Added again on DOMContentLoaded, for an optimizer that moves this script above the form. + $fallback = sprintf( + '(function(){function n(){Array.prototype.forEach.call(document.forms,function(f){var i;if(f.querySelector(".jetpack-comments")&&!f.querySelector(\'[name="%1$s"]\')){i=document.createElement("input");i.type="hidden";i.name="%1$s";i.value=%2$s;f.appendChild(i);}});}n();document.addEventListener("DOMContentLoaded",n);})();' + . 'window.addEventListener("load",function(){Array.prototype.forEach.call(document.querySelectorAll(".jetpack-comments"),function(e){if(!/\\bis-(mounted|plain)\\b/.test(e.className)){e.className+=" is-plain";}});});', + self::NONCE_NAME, + wp_json_encode( wp_create_nonce( self::NONCE_ACTION ), JSON_HEX_TAG | JSON_HEX_AMP ) + ); + wp_add_inline_script( self::HANDLE, - 'window.JetpackComments = ' . wp_json_encode( $settings, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';', + 'window.JetpackComments = ' . wp_json_encode( $settings, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';' . $fallback, 'before' ); $this->settings_printed = true; @@ -498,22 +515,26 @@ public function enqueue_assets( $args = array() ) { } /** - * Require a logged-in reader's comment to arrive with a nonce made for them, or from this site's own origin. + * Require a comment to arrive with the nonce the script adds to the form, or a logged-in reader's from this site's own origin. * - * A logged-out reader's would be the same string for everybody, which a page cache - * serves past its expiry, so their comments go through core's checks alone, as core's form does. + * A logged-out reader's is the same string for everybody, which a page cache serves + * past its expiry, so for them it only has to be there: a bot posting the form's fields has none. * * @param int $comment_post_id The post being commented on. * @return void */ public function verify_nonce( $comment_post_id = 0 ) { - if ( ! self::enabled_for_post_type( $comment_post_id ) || ! is_user_logged_in() ) { + if ( ! self::enabled_for_post_type( $comment_post_id ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- this is the nonce check. $nonce = isset( $_POST[ self::NONCE_NAME ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_NAME ] ) ) : ''; + if ( ! is_user_logged_in() && '' !== $nonce ) { + return; + } + if ( wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { return; } diff --git a/projects/packages/comments/src/modal/host.tsx b/projects/packages/comments/src/modal/host.tsx index 7a573659ad40..418a1c4b5a7c 100644 --- a/projects/packages/comments/src/modal/host.tsx +++ b/projects/packages/comments/src/modal/host.tsx @@ -42,7 +42,7 @@ export const guestValue = ( { */ export const mountDialog = ( form: HTMLFormElement, signals: CommentSignalsValue ) => { const { isBoxOpen, isDialogOpen } = signals; - const { mustLogIn, identity } = JetpackComments; + const { mustLogIn, identity, user } = JetpackComments; // Inside the form, so what it hands over posts with it. A page can hold two // comment forms, both with core's id, so nothing here may find the form by id. const host = form.appendChild( @@ -51,8 +51,8 @@ export const mountDialog = ( form: HTMLFormElement, signals: CommentSignalsValue const root = host.attachShadow( { mode: 'open' } ); // A returning guest can post without the dialog; once it renders, its own value takes over. const stopGuestValue = effect( () => host.internals.setFormValue( guestValue( signals ) ) ); - // Nothing opens the dialog where the only way in is the site's own login. - let loading = mustLogIn && ! identity.canSignIn; + // Nothing opens the dialog for a site user, or where the only way in is the site's own login. + let loading = Boolean( user ) || ( mustLogIn && ! identity.canSignIn ); const load = () => { if ( loading ) { From 94a7527a53be2ba07874969d870d0776fb44c45c Mon Sep 17 00:00:00 2001 From: Alin Clamba Date: Tue, 13 Oct 2026 19:22:11 +0300 Subject: [PATCH 23/57] Boost: Fix oversized "Learn what to do" link in the Critical CSS failure notice (#53438) On the modernized dashboard, the "Learn what to do" toggle in the Critical CSS failure notice used the shared Button's default 16px size, while the notice text is 13px. Make the toggle inherit the notice's font size and line height on the modern surface only, without touching the shared FoldingElement or Button that other features also use. Fixes BOOST-818 --- .../show-stopper-error/show-stopper-error.module.scss | 9 +++++++++ .../show-stopper-error/show-stopper-error.tsx | 4 +++- .../fix-boost-818-critical-css-learn-what-to-do-size | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 projects/plugins/boost/changelog/fix-boost-818-critical-css-learn-what-to-do-size diff --git a/projects/plugins/boost/app/assets/src/js/features/critical-css/show-stopper-error/show-stopper-error.module.scss b/projects/plugins/boost/app/assets/src/js/features/critical-css/show-stopper-error/show-stopper-error.module.scss index ff07747ba911..06270e525124 100644 --- a/projects/plugins/boost/app/assets/src/js/features/critical-css/show-stopper-error/show-stopper-error.module.scss +++ b/projects/plugins/boost/app/assets/src/js/features/critical-css/show-stopper-error/show-stopper-error.module.scss @@ -1,3 +1,12 @@ .errorLinks a { display: block; } + +.is-modern { + + // Element selector so this wins over Button's size rule. + button:global(.components-button.is-link) { + font-size: inherit; + line-height: inherit; + } +} diff --git a/projects/plugins/boost/app/assets/src/js/features/critical-css/show-stopper-error/show-stopper-error.tsx b/projects/plugins/boost/app/assets/src/js/features/critical-css/show-stopper-error/show-stopper-error.tsx index ddc1c47ba055..ba6db32b4989 100644 --- a/projects/plugins/boost/app/assets/src/js/features/critical-css/show-stopper-error/show-stopper-error.tsx +++ b/projects/plugins/boost/app/assets/src/js/features/critical-css/show-stopper-error/show-stopper-error.tsx @@ -11,6 +11,7 @@ import getCriticalCssErrorSetInterpolateVars from '$lib/utils/get-critical-css-e import formatErrorSetUrls from '$lib/utils/format-error-set-urls'; import actionLinkInterpolateVar from '$lib/utils/action-link-interpolate-var'; import { recordBoostEvent } from '$lib/utils/analytics'; +import { useModuleSurface } from '$features/module/surface'; import { useRetryRegenerate } from '../lib/use-retry-regenerate'; import RawError from '../raw-error/raw-error'; import type { FC } from 'react'; @@ -25,11 +26,12 @@ const ShowStopperError: FC< ShowStopperErrorTypes > = ( { supportLink = 'https://wordpress.org/support/plugin/jetpack-boost/', cssState, } ) => { + const isModern = useModuleSurface() === 'row'; const primaryErrorSet = getPrimaryErrorSet( cssState ); const showLearnSection = primaryErrorSet && cssState.status === 'generated'; return ( - + { __( 'Failed to generate Critical CSS', 'jetpack-boost' ) } { showLearnSection ? ( diff --git a/projects/plugins/boost/changelog/fix-boost-818-critical-css-learn-what-to-do-size b/projects/plugins/boost/changelog/fix-boost-818-critical-css-learn-what-to-do-size new file mode 100644 index 000000000000..69999fa39fc7 --- /dev/null +++ b/projects/plugins/boost/changelog/fix-boost-818-critical-css-learn-what-to-do-size @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Critical CSS: Fix the "Learn what to do" link in the failure notice looking oversized next to the surrounding text on the modernized dashboard. From 7c49b478a51e9a6045819cdb6f30a5b91c546ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Tue, 13 Oct 2026 17:43:14 +0100 Subject: [PATCH 24/57] Premium Analytics: expose the Locations widget kind through the SDK (#53534) * expose the Locations widget kind through the SDK contract 1.11.0 * export Locations from the components barrel the toolkit root imports it there since the kind joined the SDK * declare valueLabel on the Locations SDK type --- .../changelog/update-pa-sdk-locations-kind | 4 +++ .../premium-analytics-sdk/src/index.d.ts | 31 +++++++++++++++++++ .../changelog/update-pa-sdk-locations-kind | 4 +++ .../docs/dashboard-widgets.md | 2 +- .../packages/sdk/src/conformance.ts | 3 ++ .../packages/sdk/src/index.ts | 1 + .../widgets-toolkit/src/components/index.ts | 1 + .../packages/widgets-toolkit/src/index.ts | 1 + .../premium-analytics/src/widget-types.php | 2 +- 9 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 projects/js-packages/premium-analytics-sdk/changelog/update-pa-sdk-locations-kind create mode 100644 projects/packages/premium-analytics/changelog/update-pa-sdk-locations-kind diff --git a/projects/js-packages/premium-analytics-sdk/changelog/update-pa-sdk-locations-kind b/projects/js-packages/premium-analytics-sdk/changelog/update-pa-sdk-locations-kind new file mode 100644 index 000000000000..beac4760567e --- /dev/null +++ b/projects/js-packages/premium-analytics-sdk/changelog/update-pa-sdk-locations-kind @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add `Locations`, the kind that lists a ranking of places beside the map that draws them. diff --git a/projects/js-packages/premium-analytics-sdk/src/index.d.ts b/projects/js-packages/premium-analytics-sdk/src/index.d.ts index 49162518416a..68073b2055d4 100644 --- a/projects/js-packages/premium-analytics-sdk/src/index.d.ts +++ b/projects/js-packages/premium-analytics-sdk/src/index.d.ts @@ -143,6 +143,37 @@ export type ColumnsProps = { format?: DataFormat; }; +// Locations: a ranking of places in, the list with flags beside the map that draws them, the +// shares, deltas and states handled. +export declare const Locations: ComponentType< LocationsProps >; +export type LocationRowInput = { + // ISO country code on the world map; the region's name inside a country. + id: string; + label: string; + // Figure for the selected period. + value: number; + // Figure for the comparison period; undefined when the location has no match there. + previousValue?: number; +}; +export type LocationsProps = { + // The ranking in order: countries, or the regions of `country`. The map draws them all, the + // list the first `maxRows`. + rows: readonly LocationRowInput[]; + status: WidgetStatus; + // Omit for the generic message with a Retry bound to `status.refetch`. + error?: WidgetStateError; + // Omit for the generic "no results for this time period" state. + empty?: WidgetStateEmpty; + // Defaults to compact integers. + format?: DataFormat; + // What the figure is, as the map tooltip names it. + valueLabel: string; + // The country the rows are regions of; omit for countries on the world map. + country?: { code: string; name: string }; + // Rows the list shows. Defaults to the widget row limit. + maxRows?: number; +}; + // Widget attributes. The chart type is not one: a widget declares it as a `jpa/toggle-group` field. export declare function reportParamsAttributeField< Attributes = any >( options?: any ): any; export declare function defaultReportParamsForGrain( ...args: any[] ): any; diff --git a/projects/packages/premium-analytics/changelog/update-pa-sdk-locations-kind b/projects/packages/premium-analytics/changelog/update-pa-sdk-locations-kind new file mode 100644 index 000000000000..e224a4ce78b5 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/update-pa-sdk-locations-kind @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Dashboard: expose the Locations widget kind through the SDK; the widget contract is 1.11.0. diff --git a/projects/packages/premium-analytics/docs/dashboard-widgets.md b/projects/packages/premium-analytics/docs/dashboard-widgets.md index a0c7ff458601..e7b2e6e4f3c3 100644 --- a/projects/packages/premium-analytics/docs/dashboard-widgets.md +++ b/projects/packages/premium-analytics/docs/dashboard-widgets.md @@ -229,7 +229,7 @@ It reads the package's fixed list and, once the registry can answer, the registr `WIDGET_API_VERSION` names the contract a widget is built against: the `@automattic/jetpack-premium-analytics-sdk` module and the exports it declares, the dashboard modules the facade re-exports from (`@jetpack-premium-analytics/widgets-toolkit`, `data`, `fields`, `datetime`, `externals`), and the `Widget_Type` fields the client reads. -The major changes when a widget built against the previous contract stops working; the minor when a consumer can rely on something new. So far, 1.1.0 added `former_names` and 1.2.0 `Leaderboard`, `describeError()` and `useStatsVideoPlays`, and 1.3.0 `ExporterCsvDownloadButton`, which takes the linked report by id. 1.4.0 added `useReport`, which runs a report query and its comparison in the dashboard's query client, and `toBucketStamp`, which writes the bounds of a report row the way the dashboard's time series read them. 1.5.0 added `Donut`, the breakdown kind, and `WidgetStatus`, the request status every kind takes. 1.6.0 resolves the `jpa/` icon references the records carry, through the resolver `packages/init` registers. 1.7.0 added the `bars` variant of `Leaderboard`, which lays the label above the bar and draws the comparison period as a second bar under a period legend. 1.8.0 added the `half-ring` variant of `Donut`, which draws the breakdown as a semi-circle with the total under the arc, and `withTotal`, off for shares that add up to one. 1.9.0 added `Funnel`, the funnel kind: steps as counts in, the rate of the whole, the shares and the states out. 1.10.0 added `Columns`, the kind for a few categories compared across the periods as vertical bars. The field types the dashboard registers are part of the contract too: `jpa/select`, `jpa/toggle-group` and `jpa/array-checkbox` have been there since 1.0.0, so a new name is a minor. `chartTypeAttributeField` left the SDK while it is private and every consumer lives in this repository; a widget declares the attribute itself. +The major changes when a widget built against the previous contract stops working; the minor when a consumer can rely on something new. So far, 1.1.0 added `former_names` and 1.2.0 `Leaderboard`, `describeError()` and `useStatsVideoPlays`, and 1.3.0 `ExporterCsvDownloadButton`, which takes the linked report by id. 1.4.0 added `useReport`, which runs a report query and its comparison in the dashboard's query client, and `toBucketStamp`, which writes the bounds of a report row the way the dashboard's time series read them. 1.5.0 added `Donut`, the breakdown kind, and `WidgetStatus`, the request status every kind takes. 1.6.0 resolves the `jpa/` icon references the records carry, through the resolver `packages/init` registers. 1.7.0 added the `bars` variant of `Leaderboard`, which lays the label above the bar and draws the comparison period as a second bar under a period legend. 1.8.0 added the `half-ring` variant of `Donut`, which draws the breakdown as a semi-circle with the total under the arc, and `withTotal`, off for shares that add up to one. 1.9.0 added `Funnel`, the funnel kind: steps as counts in, the rate of the whole, the shares and the states out. 1.10.0 added `Columns`, the kind for a few categories compared across the periods as vertical bars. 1.11.0 added `Locations`, the kind that lists a ranking of places beside the map that draws them, countries or the regions of one country. The field types the dashboard registers are part of the contract too: `jpa/select`, `jpa/toggle-group` and `jpa/array-checkbox` have been there since 1.0.0, so a new name is a minor. `chartTypeAttributeField` left the SDK while it is private and every consumer lives in this repository; a widget declares the attribute itself. Inside `plugins/jetpack` the package and a consumer module ship together, so the check is a formality. With the standalone `plugins/premium-analytics` next to another plugin, each brings its own copy, and the check is what keeps a widget built against 1.x from registering on a 2.x package. diff --git a/projects/packages/premium-analytics/packages/sdk/src/conformance.ts b/projects/packages/premium-analytics/packages/sdk/src/conformance.ts index 9313ce10abd0..7b1c8f80548f 100644 --- a/projects/packages/premium-analytics/packages/sdk/src/conformance.ts +++ b/projects/packages/premium-analytics/packages/sdk/src/conformance.ts @@ -26,3 +26,6 @@ export const funnelConforms: typeof Api.Funnel = null as unknown as typeof Facad // And when the facade's `Columns` stops accepting the props the contract declares. export const columnsConforms: typeof Api.Columns = null as unknown as typeof Facade.Columns; + +// And when the facade's `Locations` stops accepting the props the contract declares. +export const locationsConforms: typeof Api.Locations = null as unknown as typeof Facade.Locations; diff --git a/projects/packages/premium-analytics/packages/sdk/src/index.ts b/projects/packages/premium-analytics/packages/sdk/src/index.ts index e9bdcabeffbc..36ee4a9b317e 100644 --- a/projects/packages/premium-analytics/packages/sdk/src/index.ts +++ b/projects/packages/premium-analytics/packages/sdk/src/index.ts @@ -11,6 +11,7 @@ export { ExporterCsvDownloadButton, Funnel, Leaderboard, + Locations, MetricTabsChart, MetricTabsChartSkeleton, MetricTileGrid, diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts index 003889c7cc1b..923a5a28b851 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts @@ -67,6 +67,7 @@ export { export { Donut, type DonutProps, type DonutSegmentInput } from './donut'; export { Funnel, type FunnelProps, type FunnelStepInput } from './funnel'; export { Columns, type ColumnsProps, type ColumnInput } from './columns'; +export { Locations } from './locations'; export { BarChart, BarChartSkeleton, 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 5b0851caff3a..6ce0dc347442 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -52,6 +52,7 @@ export { type DonutProps, type DonutSegmentInput, Funnel, + Locations, Columns, type LegendLabels, LeaderboardLabel, diff --git a/projects/packages/premium-analytics/src/widget-types.php b/projects/packages/premium-analytics/src/widget-types.php index 54f42c5800bc..866035001501 100644 --- a/projects/packages/premium-analytics/src/widget-types.php +++ b/projects/packages/premium-analytics/src/widget-types.php @@ -45,7 +45,7 @@ * * @since 0.9.0 */ -const WIDGET_API_VERSION = '1.10.0'; +const WIDGET_API_VERSION = '1.11.0'; /** * Returns the i18n schema describing which widget metadata fields are From 5f27bbfaf72eafd7cfb5b5a4ca5b94ad703ed648 Mon Sep 17 00:00:00 2001 From: Allison Levine <1689238+allilevine@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:15:00 -0400 Subject: [PATCH 25/57] Newsletter: link to the email design screen from the email previewer (#53444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Newsletter: link to the email design screen from the email previewer A creator looking at the preview is the one most likely to want the email to look different, so the previewer's header carries a link to the design screen next to the device and audience controls. A new tab, because that one holds an unsaved post. The script-data URL is bare now that two surfaces link to the screen: each adds its own `return`, because each is somewhere different to go back to — the settings card to the settings page, the previewer to the post being previewed. Placement is a first pass; NL-1023 wants a design call on it. Co-Authored-By: Claude Opus 5 (1M context) * Newsletter: send no return URL from the previewer's design link The link opens a new tab because this one holds an unsaved post — but it also sent `return=`, so the design editor's Back button reopened that same post in the new tab. Two editors, one post, and no warning between them: `wp_check_post_lock()` reports nothing when both are the same user, so whichever tab saved last won silently. The link now carries the bare URL, and Back goes to the dashboard, which is what `exit_url()` already does for Appearance, a bookmark or a reload. Also from review: the click event records `post_id` like every sibling event in this modal; the local script-data augmentation no longer claims to be the only reader of a key the newsletter package also declares; and `exit_url()`'s docblock no longer describes the settings link as the only one sending a return. Co-Authored-By: Claude Opus 5 (1M context) * Newsletter: shrink the previewer's design link to an icon below large On a site with paid tiers the header carries two control groups, and a third text label pushed the modal title out of it — "Preview em…" clipped, "Paid subscribers" wrapped, the link's arrow dropped to its own line. Confirmed against the same window with the feature flag off, where the header fits. Below `large` the link is now its icon, which is the band where the device selector already drops its desktop option. Full label above that. Co-Authored-By: Claude Opus 5 (1M context) * Newsletter: design-review pass on the previewer's design link From Ollie's review of the live header: * The Styles icon now carries at every size, with the label beside it where there is room, so the icon becomes familiar rather than appearing only when the label is dropped. * 8px between the audience control and the icon at the smallest size, where the two were touching. * The link gets its own inline-flex box, so the icon, the label and the new-tab indicator sit on one centre line. The indicator was landing low and right of the icon, on a text baseline the icon is too tall for. Co-Authored-By: Claude Opus 5 (1M context) * Newsletter: keep the underline off the design link's new-tab arrow `inline-flex` on the link blockified `@wordpress/ui`'s indicator span, and a descendant cannot cancel a text-decoration propagated from an ancestor — so the underline drew straight across the arrow. The component keeps that span an `inline-block` for exactly this reason, as its stylesheet says. Aligned inline instead: `vertical-align: middle` on the icon and the label, so the indicator stays the inline-block it was built to be. Dropping the flex `gap` also brings the arrow in, which the design review asked for. Co-Authored-By: Claude Opus 5 (1M context) * Newsletter: keep the new-tab notice in the design link's accessible name `@wordpress/ui`'s `Link` announces `openInNewTab` only through a child span's `aria-label`, and an `aria-label` on the anchor overrides its contents when the accessible name is computed. So below `large` — the icon-only case, where a screen-reader user has no other cue — the name collapsed to "Edit email design" and the notice was dropped, while sighted users still saw the arrow. Above `large`, with no `aria-label`, the same link announced it. The label is a `VisuallyHidden` child now, so the name reads the same at every size. `title` stays for the pointer tooltip. Also from review: the reason this link sends no `return` was written out in three places; it keeps the one on the component that owns the decision. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../changelog/add-email-design-link-previewer | 5 + .../sections/email-design-section.tsx | 6 +- .../tests/email-design-section.test.tsx | 12 ++- .../changelog/add-email-design-link-previewer | 5 + .../blocks/subscriptions/email-preview.scss | 15 +++ .../blocks/subscriptions/email-preview.tsx | 75 ++++++++++++++- .../subscriptions/test/email-preview.test.tsx | 91 ++++++++++++++++++- .../class-jetpack-email-design-editor.php | 22 ++--- .../Jetpack_Email_Design_Editor_Test.php | 11 +-- 9 files changed, 210 insertions(+), 32 deletions(-) create mode 100644 projects/packages/newsletter/changelog/add-email-design-link-previewer create mode 100644 projects/plugins/jetpack/changelog/add-email-design-link-previewer diff --git a/projects/packages/newsletter/changelog/add-email-design-link-previewer b/projects/packages/newsletter/changelog/add-email-design-link-previewer new file mode 100644 index 000000000000..2bdf5eb0daaa --- /dev/null +++ b/projects/packages/newsletter/changelog/add-email-design-link-previewer @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Internal: the email design link builds its own return URL now that a second surface links to the screen. + + diff --git a/projects/packages/newsletter/src/settings/sections/email-design-section.tsx b/projects/packages/newsletter/src/settings/sections/email-design-section.tsx index aa3164d9ad6f..66eae3231690 100644 --- a/projects/packages/newsletter/src/settings/sections/email-design-section.tsx +++ b/projects/packages/newsletter/src/settings/sections/email-design-section.tsx @@ -6,6 +6,7 @@ import { getSiteType } from '@automattic/jetpack-script-data'; import { useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Button, Card, LinkButton, Stack, Text } from '@wordpress/ui'; +import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ @@ -44,6 +45,9 @@ export function EmailDesignSection( { const buttonText = __( 'Edit email design', 'jetpack-newsletter' ); + // So the editor's back button returns here rather than to the dashboard. + const href = addQueryArgs( emailDesignUrl, { return: window.location.href } ); + return ( @@ -58,7 +62,7 @@ export function EmailDesignSection( { ) } { isNewsletterEnabled ? ( - + { buttonText } ) : ( diff --git a/projects/packages/newsletter/tests/email-design-section.test.tsx b/projects/packages/newsletter/tests/email-design-section.test.tsx index ea35e6a6a656..c98f2336a140 100644 --- a/projects/packages/newsletter/tests/email-design-section.test.tsx +++ b/projects/packages/newsletter/tests/email-design-section.test.tsx @@ -72,15 +72,19 @@ describe( 'EmailDesignSection', () => { expect( container ).toBeEmptyDOMElement(); } ); - it( 'links to the email design screen the site reported', () => { + it( 'links to the email design screen the site reported, returning here afterwards', () => { mockEmailDesignUrl( DESIGN_URL ); render( ); - expect( screen.getByRole( 'link', { name: 'Edit email design' } ) ).toHaveAttribute( - 'href', - DESIGN_URL + const href = new URL( + screen.getByRole( 'link', { name: 'Edit email design' } ).getAttribute( 'href' ) ); + + expect( href.href.startsWith( DESIGN_URL ) ).toBe( true ); + expect( href.searchParams.get( 'return' ) ).toBe( window.location.href ); + // Nothing else may ride along: the design screen reads `page`, and `return` is ours. + expect( [ ...href.searchParams.keys() ] ).toEqual( [ 'page', 'return' ] ); } ); it( 'offers a disabled button rather than a live link while the newsletter is off', () => { diff --git a/projects/plugins/jetpack/changelog/add-email-design-link-previewer b/projects/plugins/jetpack/changelog/add-email-design-link-previewer new file mode 100644 index 000000000000..a99d003fba32 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-email-design-link-previewer @@ -0,0 +1,5 @@ +Significance: patch +Type: other +Comment: Behind the jetpack-email-design feature flag, off by default. The user-facing entry belongs in the release that enables it. + + diff --git a/projects/plugins/jetpack/extensions/blocks/subscriptions/email-preview.scss b/projects/plugins/jetpack/extensions/blocks/subscriptions/email-preview.scss index 9f77f39aa414..e7230688bf87 100644 --- a/projects/plugins/jetpack/extensions/blocks/subscriptions/email-preview.scss +++ b/projects/plugins/jetpack/extensions/blocks/subscriptions/email-preview.scss @@ -14,3 +14,18 @@ flex-shrink: 0; } } + +// Inline, not flex: `@wordpress/ui` keeps its new-tab indicator an +// inline-block so the link underline stays off it, and a flex item +// blockifies it back under the underline. +.jetpack-newsletter-preview-modal__design-link { + + svg { + vertical-align: middle; + } + + .jetpack-newsletter-preview-modal__design-link-label { + margin-inline-start: 4px; + vertical-align: middle; + } +} diff --git a/projects/plugins/jetpack/extensions/blocks/subscriptions/email-preview.tsx b/projects/plugins/jetpack/extensions/blocks/subscriptions/email-preview.tsx index 37189a0c6bc0..699b726b22f0 100644 --- a/projects/plugins/jetpack/extensions/blocks/subscriptions/email-preview.tsx +++ b/projects/plugins/jetpack/extensions/blocks/subscriptions/email-preview.tsx @@ -1,6 +1,6 @@ import { getRedirectUrl } from '@automattic/jetpack-components'; import { getUserConnectionUrl, useConnection } from '@automattic/jetpack-connection'; -import { isSimpleSite } from '@automattic/jetpack-script-data'; +import { getScriptData, isSimpleSite } from '@automattic/jetpack-script-data'; import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; import apiFetch from '@wordpress/api-fetch'; import { @@ -36,14 +36,25 @@ import { people, currencyDollar, cautionFilled as warning, + styles, } from '@wordpress/icons'; -import { Link } from '@wordpress/ui'; +import { Link, VisuallyHidden } from '@wordpress/ui'; import './email-preview.scss'; import { accessOptions } from '../../shared/memberships/constants'; import { useAccessLevel } from '../../shared/memberships/edit'; import { SendIcon } from './icons'; import type { JSX } from 'react'; +/* + * The newsletter package declares the same key for its own bundle (`settings/declarations.d.ts`); + * this is the plugin-side copy, because that type is internal to the package. Keep them in step. + */ +declare module '@automattic/jetpack-script-data' { + interface JetpackScriptData { + newsletter?: { emailDesignUrl?: string | null }; + } +} + interface PreviewErrorInfo { code?: string; message: string; @@ -407,18 +418,72 @@ const PreviewAccessSelector = ( { ); }; -interface PreviewControlsProps extends PreviewAccessSelectorProps, PreviewDeviceSelectorProps {} +/** + * Link out to the email design screen, for the creator who looks at the preview and wants the + * email to look different. A new tab, because this one holds an unsaved post. + * + * No `return` arg, unlike the settings card: it would send the design editor's Back button to + * `post.php` in the new tab, leaving the same post open in two editors with no lock warning + * between them -- `wp_check_post_lock()` reports nothing when both are the same user. + * + * @param props - Component props. + * @param props.postId - The post being previewed, for the Tracks event. + * @return The link, or null on a site with no design screen. + */ +const EmailDesignLink = ( { postId }: { postId: number } ) => { + const { tracks } = useAnalytics(); + const isBelowLarge = useViewportMatch( 'large', '<' ); + const emailDesignUrl = getScriptData()?.newsletter?.emailDesignUrl; + + const handleClick = useCallback( () => { + tracks.recordEvent( 'jetpack_newsletter_preview_modal_email_design_click', { + post_id: postId, + } ); + }, [ tracks, postId ] ); + + if ( ! emailDesignUrl ) { + return null; + } + + const label = __( 'Edit email design', 'jetpack' ); + + // The icon carries at every size so it becomes familiar; below `large` it carries alone, + // because a label there clips the modal title on a site that also shows the audience toggle. + return ( + + + { /* Hidden rather than an `aria-label`, which would override the contents and take + `Link`'s own "(opens in a new tab)" notice out of the accessible name with it. */ } + { isBelowLarge ? ( + { label } + ) : ( + { label } + ) } + + ); +}; + +interface PreviewControlsProps extends PreviewAccessSelectorProps, PreviewDeviceSelectorProps { + postId: number; +} const PreviewControls = ( { selectedAccess, setSelectedAccess, selectedDevice, setSelectedDevice, + postId, }: PreviewControlsProps ) => { const isSmall = useViewportMatch( 'small', '<' ); return ( - + + ); }; @@ -555,6 +621,7 @@ export function NewsletterPreviewModal( { isOpen, onClose, postId }: NewsletterP setSelectedAccess={ setSelectedAccess } selectedDevice={ selectedDevice } setSelectedDevice={ setSelectedDevice } + postId={ postId } /> } className="jetpack-newsletter-preview-modal" diff --git a/projects/plugins/jetpack/extensions/blocks/subscriptions/test/email-preview.test.tsx b/projects/plugins/jetpack/extensions/blocks/subscriptions/test/email-preview.test.tsx index 0fefae99a68a..27dfc41f97ce 100644 --- a/projects/plugins/jetpack/extensions/blocks/subscriptions/test/email-preview.test.tsx +++ b/projects/plugins/jetpack/extensions/blocks/subscriptions/test/email-preview.test.tsx @@ -14,9 +14,22 @@ jest.mock( '@automattic/jetpack-connection', () => ( { useConnection: () => ( { isUserConnected: mockIsUserConnected } ), } ) ); -// Treat the site as self-hosted so the connection gate applies. +// Treat the site as self-hosted so the connection gate applies. `getScriptData` answers the +// email design screen's URL; `mockEmailDesignUrl` is what each test varies. +let mockEmailDesignUrl: string | null = null; + +// `useViewportMatch( 'large', '<' )` — true in the band where the header sheds labels. +let mockIsBelowLarge = false; + +jest.mock( '@wordpress/compose', () => ( { + ...jest.requireActual( '@wordpress/compose' ), + useViewportMatch: ( breakpoint: string, operator?: string ) => + breakpoint === 'large' && operator === '<' ? mockIsBelowLarge : false, +} ) ); + jest.mock( '@automattic/jetpack-script-data', () => ( { isSimpleSite: () => false, + getScriptData: () => ( { newsletter: { emailDesignUrl: mockEmailDesignUrl } } ), } ) ); jest.mock( '@automattic/jetpack-shared-extension-utils', () => { @@ -31,7 +44,20 @@ jest.mock( '@automattic/jetpack-shared-extension-utils', () => { // `@wordpress/ui` isn't resolvable in the jest environment; render a plain anchor. jest.mock( '@wordpress/ui', () => ( { - Link: ( { children, href } ) => { children }, + // Renders its children, so the accessible name still includes the hidden label. + VisuallyHidden: ( { children } ) => { children }, + Link: ( { children, href, openInNewTab, onClick, 'aria-label': ariaLabel, className } ) => ( + + { children } + + ), } ) ); // `@wordpress/editor` registers rich-text formats at import time against the real data registry. @@ -437,3 +463,64 @@ describe( 'Test email recipient', () => { expect( screen.getByRole( 'textbox' ) ).toBeDisabled(); } ); } ); + +describe( 'Newsletter preview: email design link', () => { + const DESIGN_URL = 'https://example.com/wp-admin/themes.php?page=jetpack-email-design'; + + beforeEach( () => { + jest.clearAllMocks(); + mockIsUserConnected = true; + mockEmailDesignUrl = null; + mockIsBelowLarge = false; + jest.mocked( useSelect ).mockImplementation( () => 123 ); + jest.mocked( useDispatch ).mockReturnValue( { + __unstableSaveForPreview: jest.fn().mockResolvedValue( undefined ), + } ); + ( select as jest.Mock ).mockReturnValue( { isEditedPostDirty: () => false } ); + jest.mocked( apiFetch ).mockResolvedValue( { html: '

Preview

' } ); + } ); + + it( 'offers no link on a site with no email design screen', async () => { + render( ); + await waitFor( () => expect( apiFetch ).toHaveBeenCalled() ); + + expect( screen.queryByRole( 'link', { name: 'Edit email design' } ) ).not.toBeInTheDocument(); + } ); + + it( 'links to the design screen in a new tab, sending no return URL', async () => { + mockEmailDesignUrl = DESIGN_URL; + + render( ); + const link = await screen.findByRole( 'link', { name: 'Edit email design' } ); + await waitFor( () => expect( apiFetch ).toHaveBeenCalled() ); + + expect( link ).toHaveAttribute( 'href', DESIGN_URL ); + expect( link ).toHaveAttribute( 'target', '_blank' ); + } ); + + it( 'drops to an icon where the header runs out of room', async () => { + mockEmailDesignUrl = DESIGN_URL; + mockIsBelowLarge = true; + + render( ); + const link = await screen.findByRole( 'link', { name: 'Edit email design' } ); + await waitFor( () => expect( apiFetch ).toHaveBeenCalled() ); + + expect( link ).toHaveAttribute( 'href', DESIGN_URL ); + // The label is still the link's name, it is just not on screen beside the icon. + expect( screen.getByTestId( 'visually-hidden' ) ).toHaveTextContent( 'Edit email design' ); + // An `aria-label` would override the contents and discard `Link`'s own new-tab notice. + expect( link ).not.toHaveAttribute( 'aria-label' ); + } ); + + it( 'keeps the label beside the icon where there is room', async () => { + mockEmailDesignUrl = DESIGN_URL; + + render( ); + const link = await screen.findByRole( 'link', { name: 'Edit email design' } ); + await waitFor( () => expect( apiFetch ).toHaveBeenCalled() ); + + expect( link ).toHaveTextContent( 'Edit email design' ); + expect( screen.queryByTestId( 'visually-hidden' ) ).not.toBeInTheDocument(); + } ); +} ); diff --git a/projects/plugins/jetpack/modules/subscriptions/email-design-editor/class-jetpack-email-design-editor.php b/projects/plugins/jetpack/modules/subscriptions/email-design-editor/class-jetpack-email-design-editor.php index ff02f08a88f9..a93c2c819538 100644 --- a/projects/plugins/jetpack/modules/subscriptions/email-design-editor/class-jetpack-email-design-editor.php +++ b/projects/plugins/jetpack/modules/subscriptions/email-design-editor/class-jetpack-email-design-editor.php @@ -6,7 +6,6 @@ */ use Automattic\Jetpack\Feature_Flags\Feature_Flags; -use Automattic\Jetpack\Newsletter\Urls as Newsletter_Urls; if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); @@ -66,7 +65,10 @@ public static function init() { } /** - * Tell the Newsletter settings page where this screen is, or that there is none. + * Tell the admin where this screen is, or that there is none. + * + * The bare URL: each surface that links here adds its own `return` arg, because each one is + * somewhere different to go back to. {@see self::exit_url()} reads and validates it. * * @param array $data The script data so far. * @return array The script data, with `newsletter.emailDesignUrl` null when there is no screen. @@ -74,12 +76,7 @@ public static function init() { public static function add_script_data( $data ) { $url = self::get_url(); - // The `return` arg is what sends the editor's back button here rather than to the - // dashboard; {@see self::exit_url()} is where it is read and validated. Encoded first - // because `add_query_arg()` does not: the settings URL's own `&` would end the value. - $data['newsletter']['emailDesignUrl'] = '' === $url - ? null - : add_query_arg( 'return', rawurlencode( Newsletter_Urls::get_newsletter_settings_url() ), $url ); + $data['newsletter']['emailDesignUrl'] = '' === $url ? null : $url; return $data; } @@ -320,12 +317,13 @@ private static function get_screen_data() { /** * Where the editor's header buttons leave to. * - * The `return` arg the Newsletter settings link carries, so the editor goes back to whichever - * of its two entry points was used. Validated like core's Customizer validates its own + * The `return` arg, where the surface that linked here sent one, so the editor goes back the + * way the reader came. Validated like core's Customizer validates its own * (`wp-admin/customize.php`), and narrowed to wp-admin so the button cannot be aimed off it. * - * Without a usable one -- entered from Appearance, a bookmark, a reload -- the dashboard, as - * the site editor does from the same menu (`__experimentalDashboardLink`). + * Without a usable one -- Appearance, a bookmark, a reload, or the previewer's new tab, which + * sends none on purpose -- the dashboard, as the site editor does from the same menu + * (`__experimentalDashboardLink`). * * @return string */ diff --git a/projects/plugins/jetpack/tests/php/modules/subscriptions/Jetpack_Email_Design_Editor_Test.php b/projects/plugins/jetpack/tests/php/modules/subscriptions/Jetpack_Email_Design_Editor_Test.php index 43474e6acdeb..2f36b0014517 100644 --- a/projects/plugins/jetpack/tests/php/modules/subscriptions/Jetpack_Email_Design_Editor_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/subscriptions/Jetpack_Email_Design_Editor_Test.php @@ -340,17 +340,10 @@ public function test_the_script_data_names_the_screen_once_it_is_registered() { $data = Jetpack_Email_Design_Editor::add_script_data( array() ); - // Parsed rather than rebuilt from the same calls the implementation makes: an expectation - // that composes the URL the same way cannot fail on how the URL is composed. + // Bare: each surface that links here adds its own `return`, so this one carries none. parse_str( (string) wp_parse_url( $data['newsletter']['emailDesignUrl'], PHP_URL_QUERY ), $args ); - $this->assertSame( Jetpack_Email_Design_Editor::PAGE_SLUG, $args['page'] ); - $this->assertSame( - \Automattic\Jetpack\Newsletter\Urls::get_newsletter_settings_url(), - $args['return'], - 'The whole settings URL has to survive as one query arg.' - ); - $this->assertSame( array( 'page', 'return' ), array_keys( $args ), 'No part of it may leak out as its own arg.' ); + $this->assertSame( array( 'page' => Jetpack_Email_Design_Editor::PAGE_SLUG ), $args ); } /** From c3ecc74a9d0817f7cd7eb9e76317e56903e14647 Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Tue, 13 Oct 2026 19:21:53 +0200 Subject: [PATCH 26/57] Newsletter: render the logged-out version of a post for ActivityPub (#53490) * Newsletter: Treat ActivityPub renders as a logged-out visit The ActivityPub plugin renders a post once and shares that copy with everyone, so it has to be the version a logged-out visitor gets. The access checks answered for the request doing the render instead: its user and its subscriber token. Answer as a visitor while ActivityPub is rendering, for both the newsletter access level and the Premium Content block. The check runs before the per-user cache, since the same request keeps rendering for its own user afterwards. * Phan: Drop baseline entries the ActivityPub fixture resolves Likes, Related Posts and Sharing each call \Activitypub\is_activitypub_request() behind a function_exists() check. Phan could not see that function, so the three files each carried a PhanUndeclaredFunction entry in the baseline. The test fixture added for the paywall checks declares the function. Phan now resolves those calls, and the entries no longer match anything. --- projects/plugins/jetpack/.phan/baseline.php | 5 ++- .../changelog/fix-paywall-activitypub-render | 4 +++ .../premium-content/_inc/access-check.php | 9 ++++++ .../_inc/subscription-service/include.php | 13 ++++++++ .../memberships/class-jetpack-memberships.php | 8 ++++- .../fixtures/Activitypub/functions.php | 21 +++++++++++++ .../Jetpack_Premium_Content_Test.php | 18 +++++++++++ ...ck_Memberships_User_Can_View_Post_Test.php | 31 +++++++++++++++++++ 8 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/fix-paywall-activitypub-render create mode 100644 projects/plugins/jetpack/tests/php/3rd-party/fixtures/Activitypub/functions.php diff --git a/projects/plugins/jetpack/.phan/baseline.php b/projects/plugins/jetpack/.phan/baseline.php index f45e4e9d69c5..6bc4a86ac2db 100644 --- a/projects/plugins/jetpack/.phan/baseline.php +++ b/projects/plugins/jetpack/.phan/baseline.php @@ -255,17 +255,16 @@ 'modules/gravatar-hovercards.php' => ['PhanTypeMismatchArgumentProbablyReal'], 'modules/infinite-scroll.php' => ['PhanUndeclaredClassMethod'], 'modules/infinite-scroll/infinity.php' => ['PhanTypeComparisonToArray', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchReturn', 'PhanTypeMismatchReturnProbablyReal', 'PhanTypeMissingReturn'], - 'modules/likes.php' => ['PhanUndeclaredFunction'], 'modules/markdown/easy-markdown.php' => ['PhanTypeArraySuspiciousNullable', 'PhanTypeMismatchArgument'], 'modules/memberships/class-jetpack-memberships.php' => ['PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentProbablyReal', 'PhanUndeclaredMethod'], 'modules/monitor.php' => ['PhanTypeMismatchReturnProbablyReal'], 'modules/plugin-search.php' => ['PhanTypePossiblyInvalidDimOffset'], 'modules/post-by-email/class-jetpack-post-by-email.php' => ['PhanUndeclaredMethodInCallable'], 'modules/publicize.php' => ['PhanRedefineFunction'], - 'modules/related-posts/jetpack-related-posts.php' => ['PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentNullable', 'PhanTypeMismatchReturnProbablyReal', 'PhanUndeclaredFunction'], + 'modules/related-posts/jetpack-related-posts.php' => ['PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentNullable', 'PhanTypeMismatchReturnProbablyReal'], 'modules/scan/class-admin-bar-notice.php' => ['PhanTypeMismatchReturnProbablyReal'], 'modules/search.php' => ['PhanTypeMismatchArgument'], - 'modules/sharedaddy/sharing-service.php' => ['PhanTypeArraySuspicious', 'PhanTypeMismatchArgument', 'PhanTypeMismatchProperty', 'PhanTypeMismatchPropertyDefault', 'PhanTypeMismatchPropertyProbablyReal', 'PhanTypePossiblyInvalidDimOffset', 'PhanUndeclaredFunction'], + 'modules/sharedaddy/sharing-service.php' => ['PhanTypeArraySuspicious', 'PhanTypeMismatchArgument', 'PhanTypeMismatchProperty', 'PhanTypeMismatchPropertyDefault', 'PhanTypeMismatchPropertyProbablyReal', 'PhanTypePossiblyInvalidDimOffset'], 'modules/sharedaddy/sharing-sources.php' => ['PhanTypeMismatchProperty', 'PhanTypeSuspiciousNonTraversableForeach'], 'modules/sharedaddy/sharing.php' => ['PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentProbablyReal'], 'modules/shortcodes/archiveorg-book.php' => ['PhanTypeMismatchArgument'], diff --git a/projects/plugins/jetpack/changelog/fix-paywall-activitypub-render b/projects/plugins/jetpack/changelog/fix-paywall-activitypub-render new file mode 100644 index 000000000000..3e8e4b3eead0 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-paywall-activitypub-render @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Newsletter: Always use the logged-out version of a post when the ActivityPub plugin shares it to the Fediverse. diff --git a/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/access-check.php b/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/access-check.php index b253d6b733c7..f737b9c831b3 100644 --- a/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/access-check.php +++ b/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/access-check.php @@ -58,6 +58,10 @@ function pre_render_checks() { * @return bool Whether the user can edit. */ function current_user_can_edit() { + if ( is_rendering_for_activitypub() ) { + return false; + } + $user = wp_get_current_user(); return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() ); @@ -225,6 +229,11 @@ function prewarm_premium_content_session_cookie() { * @return bool Whether the use can view the content. */ function current_visitor_can_access( $attributes, $block ) { + // The ActivityPub copy is public, so a subscriber token on the request must not unlock it. + if ( is_rendering_for_activitypub() ) { + return false; + } + /** * If the current WordPress install has as signed in user * they can see the content. diff --git a/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php b/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php index 0d63dab2a50a..446d83fddc2a 100644 --- a/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php +++ b/projects/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php @@ -58,6 +58,19 @@ function subscription_service( $user_id = null ) { return $interface; } +/** + * Whether content is being rendered for ActivityPub, where it is published to everyone. + * + * Access checks must then ignore the request's own user and subscriber token. + * + * @since $$next-version$$ + * + * @return bool + */ +function is_rendering_for_activitypub() { + return function_exists( '\Activitypub\is_activitypub_request' ) && \Activitypub\is_activitypub_request(); +} + /** * Gets the default service handling the premium content. * diff --git a/projects/plugins/jetpack/modules/memberships/class-jetpack-memberships.php b/projects/plugins/jetpack/modules/memberships/class-jetpack-memberships.php index e4c8c80e1d66..092c5419e2a0 100644 --- a/projects/plugins/jetpack/modules/memberships/class-jetpack-memberships.php +++ b/projects/plugins/jetpack/modules/memberships/class-jetpack-memberships.php @@ -858,6 +858,13 @@ private static function check_post_access( $post_id, $allow_editor_access ) { $post_id = 0; } + require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php'; + + // Answered before the cache: it is keyed by user, and the same request still renders for that user afterwards. + if ( \Automattic\Jetpack\Extensions\Premium_Content\is_rendering_for_activitypub() ) { + return Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY === self::get_post_access_level( $post_id ); + } + $cache_key = sprintf( '%d_%d_%d', $user_id, $post_id, (int) $allow_editor_access ); if ( isset( self::$user_can_view_post_cache[ $cache_key ] ) ) { return self::$user_can_view_post_cache[ $cache_key ]; @@ -875,7 +882,6 @@ private static function check_post_access( $post_id, $allow_editor_access ) { return true; } - require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php'; $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service(); $all_newsletters_plan_ids = self::get_all_newsletter_plan_ids(); diff --git a/projects/plugins/jetpack/tests/php/3rd-party/fixtures/Activitypub/functions.php b/projects/plugins/jetpack/tests/php/3rd-party/fixtures/Activitypub/functions.php new file mode 100644 index 000000000000..6da56a915eab --- /dev/null +++ b/projects/plugins/jetpack/tests/php/3rd-party/fixtures/Activitypub/functions.php @@ -0,0 +1,21 @@ +assertTrue( current_visitor_can_access( array(), array() ) ); } + public function test_access_check_activitypub_render_ignores_editor_and_subscriber_token() { + list( , , $paid_subscriber_id, $plan_id ) = $this->set_up_users_and_plans(); + $attributes = array( 'selectedPlanIds' => array( $plan_id ) ); + $admin_user_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $GLOBALS['post'] = get_post( $this->factory->post->create() ); + + wp_set_current_user( $paid_subscriber_id ); + $this->set_returned_token( $this->get_payload( true, true ) ); + $this->assertTrue( current_visitor_can_access( $attributes, array() ) ); + + add_filter( 'activitypub_is_activitypub_request', '__return_true' ); + $this->assertFalse( current_visitor_can_access( $attributes, array() ) ); + + wp_set_current_user( $admin_user_id ); + $this->assertFalse( current_visitor_can_access( $attributes, array() ) ); + } + /** * Test current_visitor_can_access works for different types of users * diff --git a/projects/plugins/jetpack/tests/php/modules/memberships/Jetpack_Memberships_User_Can_View_Post_Test.php b/projects/plugins/jetpack/tests/php/modules/memberships/Jetpack_Memberships_User_Can_View_Post_Test.php index eabeb6359167..0275a7dfea82 100644 --- a/projects/plugins/jetpack/tests/php/modules/memberships/Jetpack_Memberships_User_Can_View_Post_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/memberships/Jetpack_Memberships_User_Can_View_Post_Test.php @@ -9,6 +9,7 @@ require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php'; require_once JETPACK__PLUGIN_DIR . 'modules/memberships/class-jetpack-memberships.php'; require_once JETPACK__PLUGIN_DIR . 'tests/php/extensions/blocks/premium-content/class-test-jetpack-token-subscription-service.php'; +require_once JETPACK__PLUGIN_DIR . 'tests/php/3rd-party/fixtures/Activitypub/functions.php'; use Automattic\Jetpack\Extensions\Premium_Content\JWT; use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service; @@ -124,4 +125,34 @@ public function test_paid_all_tiers_downgraded_to_subscribers_when_no_plans() { $this->assertTrue( Jetpack_Memberships::user_can_view_post( $post_id ) ); } + + public function test_activitypub_render_ignores_editor_access_and_leaves_the_cache_alone() { + $post_id = $this->create_post_with_access_level( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $this->assertTrue( Jetpack_Memberships::user_can_view_post( $post_id ) ); + + add_filter( 'activitypub_is_activitypub_request', '__return_true' ); + $this->assertFalse( Jetpack_Memberships::user_can_view_post( $post_id ) ); + remove_filter( 'activitypub_is_activitypub_request', '__return_true' ); + + $this->assertTrue( Jetpack_Memberships::user_can_view_post( $post_id ) ); + } + + public function test_activitypub_render_ignores_subscriber_token() { + $post_id = $this->create_post_with_access_level( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_SUBSCRIBERS ); + $this->set_free_subscriber_token(); + + $this->assertTrue( Jetpack_Memberships::user_can_view_post( $post_id ) ); + + add_filter( 'activitypub_is_activitypub_request', '__return_true' ); + $this->assertFalse( Jetpack_Memberships::user_can_view_post( $post_id ) ); + } + + public function test_activitypub_render_keeps_ungated_post_viewable() { + $post_id = $this->create_post_with_access_level( Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_EVERYBODY ); + + add_filter( 'activitypub_is_activitypub_request', '__return_true' ); + $this->assertTrue( Jetpack_Memberships::user_can_view_post( $post_id ) ); + } } From 057e60e46b8fa9bde383a98462171e01c48af639 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 13 Oct 2026 12:58:42 -0500 Subject: [PATCH 27/57] wpcomsh: Stop forcing the Activity Log module on (#53559) * wpcomsh: Stop forcing the Activity Log module on Removes the jetpack_feature_policy callback that pinned activity-log on for every Atomic site, along with its test and the matching remove_filter in the Jetpack test bootstrap. The module stays in the post-transfer activation list, so it is still on by default. * Changelog: collapse the Activity Log entries into one The forced-on policy was added and removed between releases, so the net change is that Activity Log is turned on after a transfer or reset. Reword the earlier wpcomsh entry to say that, and drop the entries describing the policy and its test workaround. --- .../fix-wpcomsh-activity-log-default-on | 5 -- .../plugins/jetpack/tests/php/bootstrap.php | 3 -- .../fix-wpcomsh-activity-log-default-on | 2 +- .../wpcomsh/feature-plugins/activity-log.php | 29 ----------- .../plugins/wpcomsh/tests/ActivityLogTest.php | 50 ------------------- projects/plugins/wpcomsh/wpcomsh.php | 1 - 6 files changed, 1 insertion(+), 89 deletions(-) delete mode 100644 projects/plugins/jetpack/changelog/fix-wpcomsh-activity-log-default-on delete mode 100644 projects/plugins/wpcomsh/feature-plugins/activity-log.php delete mode 100644 projects/plugins/wpcomsh/tests/ActivityLogTest.php diff --git a/projects/plugins/jetpack/changelog/fix-wpcomsh-activity-log-default-on b/projects/plugins/jetpack/changelog/fix-wpcomsh-activity-log-default-on deleted file mode 100644 index 8bb963cb3324..000000000000 --- a/projects/plugins/jetpack/changelog/fix-wpcomsh-activity-log-default-on +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: other -Comment: Test bootstrap only: ignore a wpcomsh module policy in the wpcomsh test run. - - diff --git a/projects/plugins/jetpack/tests/php/bootstrap.php b/projects/plugins/jetpack/tests/php/bootstrap.php index d56da3e8a692..0a69b17668a1 100644 --- a/projects/plugins/jetpack/tests/php/bootstrap.php +++ b/projects/plugins/jetpack/tests/php/bootstrap.php @@ -180,9 +180,6 @@ function _manually_load_muplugin() { require_once JETPACK_WPCOMSH_INSTALL_DIR . '/wpcomsh.php'; \Automattic\Jetpack\Jetpack_Mu_Wpcom::init(); - // Module-state tests assume self-hosted defaults, which this policy overrides. - remove_filter( 'jetpack_feature_policy', 'wpcomsh_force_activity_log_module' ); - defined( 'WPCOMSH_PREMIUM_THEMES_PATH' ) || define( 'WPCOMSH_PREMIUM_THEMES_PATH', sys_get_temp_dir() . '/premium' ); if ( ! is_dir( WPCOMSH_PREMIUM_THEMES_PATH ) ) { mkdir( WPCOMSH_PREMIUM_THEMES_PATH, 0777 ); diff --git a/projects/plugins/wpcomsh/changelog/fix-wpcomsh-activity-log-default-on b/projects/plugins/wpcomsh/changelog/fix-wpcomsh-activity-log-default-on index aa95eb609517..09a8d6399cc3 100644 --- a/projects/plugins/wpcomsh/changelog/fix-wpcomsh-activity-log-default-on +++ b/projects/plugins/wpcomsh/changelog/fix-wpcomsh-activity-log-default-on @@ -1,4 +1,4 @@ Significance: patch Type: fixed -Activity Log: Keep the Activity Log on for all sites, including newly transferred and reset ones. +Activity Log: Turn the Activity Log on for newly transferred and reset sites. diff --git a/projects/plugins/wpcomsh/feature-plugins/activity-log.php b/projects/plugins/wpcomsh/feature-plugins/activity-log.php deleted file mode 100644 index 1b7f2ec29cf5..000000000000 --- a/projects/plugins/wpcomsh/feature-plugins/activity-log.php +++ /dev/null @@ -1,29 +0,0 @@ - 'forced-on' ); - - return $policy; -} -add_filter( 'jetpack_feature_policy', 'wpcomsh_force_activity_log_module' ); diff --git a/projects/plugins/wpcomsh/tests/ActivityLogTest.php b/projects/plugins/wpcomsh/tests/ActivityLogTest.php deleted file mode 100644 index 06ccc77984cd..000000000000 --- a/projects/plugins/wpcomsh/tests/ActivityLogTest.php +++ /dev/null @@ -1,50 +0,0 @@ - array( 'activation' => 'forced-on' ) ); - $expected = $forced - ? array_merge( $existing, array( 'activity-log' => array( 'activation' => 'forced-on' ) ) ) - : $existing; - - $this->assertSame( $expected, wpcomsh_force_activity_log_module( $existing, $jetpack_version ) ); - } - - /** - * Data provider for test_forces_module_on_only_when_jetpack_ships_it. - * - * @return array - */ - public static function version_provider() { - return array( - 'before the module' => array( '16.2', false ), - 'prerelease build' => array( '16.3-beta', true ), - 'release' => array( '16.3', true ), - ); - } -} diff --git a/projects/plugins/wpcomsh/wpcomsh.php b/projects/plugins/wpcomsh/wpcomsh.php index 061dfb194c97..5d73cda1e38d 100644 --- a/projects/plugins/wpcomsh/wpcomsh.php +++ b/projects/plugins/wpcomsh/wpcomsh.php @@ -131,7 +131,6 @@ function () { require_once __DIR__ . '/endpoints/rest-api.php'; // Load feature plugins. -require_once __DIR__ . '/feature-plugins/activity-log.php'; require_once __DIR__ . '/feature-plugins/activitypub.php'; require_once __DIR__ . '/feature-plugins/additional-css.php'; require_once __DIR__ . '/feature-plugins/ai-launchpad.php'; From 3c232b5a649f083b66498d91688381fe3b06fbcd Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 13 Oct 2026 13:13:25 -0500 Subject: [PATCH 28/57] JSON API: Save the allow list posted to the site settings endpoint (#53316) * JSON API: Save the allow list posted to the site settings endpoint The jetpack_protect_whitelist case checked class_exists() against an unqualified string, which never matches the namespaced class, so the save was skipped. Call the class directly, as the read side already does. Also accept the nested shape the GET response returns, and reject non-string entries with an error. * JSON API: Cover the invalid address error on the site settings allow list --- .../changelog/bangalore-jetpack-2947-pr-add | 4 ++ ....wpcom-json-api-site-settings-endpoint.php | 17 +++++--- ...N_API_Site_Settings_V1_4_Endpoint_Test.php | 41 +++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/bangalore-jetpack-2947-pr-add diff --git a/projects/plugins/jetpack/changelog/bangalore-jetpack-2947-pr-add b/projects/plugins/jetpack/changelog/bangalore-jetpack-2947-pr-add new file mode 100644 index 000000000000..11717e6c4de1 --- /dev/null +++ b/projects/plugins/jetpack/changelog/bangalore-jetpack-2947-pr-add @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +JSON API: Save the brute force protection allow list sent to the site settings endpoint. diff --git a/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-site-settings-endpoint.php b/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-site-settings-endpoint.php index e6b652b03970..bb85e9143597 100644 --- a/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-site-settings-endpoint.php +++ b/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-site-settings-endpoint.php @@ -867,13 +867,18 @@ public function update_settings() { } break; case 'jetpack_protect_whitelist': - if ( class_exists( 'Brute_Force_Protection_Shared_Functions' ) ) { - $result = Brute_Force_Protection_Shared_Functions::save_allow_list( $value ); - if ( is_wp_error( $result ) ) { - return $result; - } - $updated[ $key ] = Brute_Force_Protection_Shared_Functions::format_allow_list(); + // The GET response nests the site's list under `local`, so accept that shape back. + if ( isset( $value['local'] ) && is_array( $value['local'] ) ) { + $value = $value['local']; + } + if ( is_array( $value ) && array_filter( $value, 'is_array' ) ) { + return new WP_Error( 'invalid_input', __( 'Each allowed IP address must be a string.', 'jetpack' ), 400 ); + } + $result = Brute_Force_Protection_Shared_Functions::save_allow_list( $value ); + if ( is_wp_error( $result ) ) { + return $result; } + $updated[ $key ] = Brute_Force_Protection_Shared_Functions::format_allow_list(); break; case 'jetpack_sync_non_public_post_stati': Jetpack_Options::update_option( 'sync_non_public_post_stati', $value ); diff --git a/projects/plugins/jetpack/tests/php/json-api/WPCOM_JSON_API_Site_Settings_V1_4_Endpoint_Test.php b/projects/plugins/jetpack/tests/php/json-api/WPCOM_JSON_API_Site_Settings_V1_4_Endpoint_Test.php index 22a1ead0cdde..bd4605e6f6eb 100644 --- a/projects/plugins/jetpack/tests/php/json-api/WPCOM_JSON_API_Site_Settings_V1_4_Endpoint_Test.php +++ b/projects/plugins/jetpack/tests/php/json-api/WPCOM_JSON_API_Site_Settings_V1_4_Endpoint_Test.php @@ -283,6 +283,37 @@ public function test_post_rejects_non_scalar_site_verification_code() { $this->assertSame( $existing_codes, get_option( 'verification_services_codes' ) ); } + /** + * Data provider for allow lists the endpoint must reject. + * + * @return array [ $allow_list, $expected_error_code ] + */ + public static function invalid_protect_allow_lists() { + return array( + 'nested entry' => array( array( array( '198.51.100.1' ) ), 'invalid_input' ), + 'invalid address' => array( array( '198.51.100.1', 'not-an-ip' ), 'invalid_ip' ), + ); + } + + /** + * An invalid allow list is rejected without changing the stored list. + * + * @dataProvider invalid_protect_allow_lists + * + * @param array $allow_list The posted allow list. + * @param string $expected_error_code The expected error code. + */ + #[DataProvider( 'invalid_protect_allow_lists' )] + public function test_post_rejects_invalid_protect_allow_list( $allow_list, $expected_error_code ) { + update_option( 'jetpack_waf_ip_allow_list', '203.0.113.5' ); + + $response = $this->make_post_request( wp_json_encode( array( 'jetpack_protect_whitelist' => $allow_list ), JSON_UNESCAPED_SLASHES ) ); + + $this->assertWPError( $response ); + $this->assertSame( $expected_error_code, $response->get_error_code() ); + $this->assertSame( '203.0.113.5', get_option( 'jetpack_waf_ip_allow_list' ) ); + } + /** * The free tier description is capped to 500 characters to match the * paid-tier description field. @@ -813,6 +844,16 @@ public static function setting_value_pairs_post_request() { 'woocommerce_onboarding_profile bool' => array( 'woocommerce_onboarding_profile', true, array( true ) ), 'woocommerce_onboarding_profile example' => array( 'woocommerce_onboarding_profile', static::$onboarding_profile_example, static::$onboarding_profile_example ), 'show_on_front' => array( 'show_on_front', 'page', 'page' ), + 'jetpack_protect_whitelist' => array( + 'jetpack_protect_whitelist', + array( '203.0.113.5', '198.51.100.1-198.51.100.20' ), + array( 'local' => array( '203.0.113.5', '198.51.100.1 - 198.51.100.20' ) ), + ), + 'jetpack_protect_whitelist GET shape' => array( + 'jetpack_protect_whitelist', + array( 'local' => array( '203.0.113.5' ) ), + array( 'local' => array( '203.0.113.5' ) ), + ), 'subscription_options html' => array( 'subscription_options', array( From 956e58c0844c315d210a90f7146b24d2afa37fe8 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 13 Oct 2026 13:13:30 -0500 Subject: [PATCH 29/57] Protect: Let the firewall and Account protection endpoints accept the wanted state (#53317) * Protect: Let the on/off endpoints accept the wanted state toggle-waf and toggle-account-protection inverted whatever the current state was, so a stale page or a double submit did the opposite of what was asked. Both now take an optional `enabled` boolean and the Protect UI sends it. A request without it still flips, so a cached copy of the old script keeps working. The firewall notice now reports the state that was requested. * Protect: Make the firewall mutation enable-only The Protect UI can only turn the firewall on, so the mutation now always sends enabled: true and keeps its single pair of notices. The two-way notices were being merged by the production minifier, which failed the i18n check. --- .../fix-protect-toggle-endpoints-set-state | 4 ++ .../protect/src/class-rest-controller.php | 37 ++++++++++++++++--- projects/plugins/protect/src/js/api.ts | 6 ++- ...ggle-account-protection-module-mutation.ts | 10 +++-- .../waf/use-toggle-waf-module-mutation.ts | 6 +-- .../src/js/hooks/use-waf-data/index.ts | 14 +++---- .../protect/src/js/routes/firewall/index.jsx | 4 +- .../protect/src/js/routes/settings/index.jsx | 11 ++++-- 8 files changed, 65 insertions(+), 27 deletions(-) create mode 100644 projects/plugins/protect/changelog/fix-protect-toggle-endpoints-set-state diff --git a/projects/plugins/protect/changelog/fix-protect-toggle-endpoints-set-state b/projects/plugins/protect/changelog/fix-protect-toggle-endpoints-set-state new file mode 100644 index 000000000000..e6e4efdbb2ef --- /dev/null +++ b/projects/plugins/protect/changelog/fix-protect-toggle-endpoints-set-state @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Firewall and Account protection: Fix turning a feature on or off doing the opposite when the page was out of date. diff --git a/projects/plugins/protect/src/class-rest-controller.php b/projects/plugins/protect/src/class-rest-controller.php index 23a54c768041..41eaa8a123c9 100644 --- a/projects/plugins/protect/src/class-rest-controller.php +++ b/projects/plugins/protect/src/class-rest-controller.php @@ -128,6 +128,12 @@ public static function register_rest_endpoints() { 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, + 'args' => array( + 'enabled' => array( + 'description' => __( 'Whether account protection should be on. Omit to flip the current state.', 'jetpack-protect' ), + 'type' => 'boolean', + ), + ), ) ); @@ -152,6 +158,12 @@ public static function register_rest_endpoints() { 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, + 'args' => array( + 'enabled' => array( + 'description' => __( 'Whether the firewall should be on. Omit to flip the current state.', 'jetpack-protect' ), + 'type' => 'boolean', + ), + ), ) ); @@ -343,13 +355,17 @@ public static function api_scan() { } /** - * Toggles the Account Protection module on or off for the API endpoint + * Turns the Account Protection module on or off for the API endpoint + * + * @param WP_REST_Request $request The request object. * * @return WP_REST_Response|WP_Error */ - public static function api_toggle_account_protection() { + public static function api_toggle_account_protection( $request ) { $account_protection = Account_Protection::instance(); - if ( $account_protection->is_enabled() ) { + $enable = $request['enabled'] ?? ! $account_protection->is_enabled(); + + if ( ! $enable ) { $disabled = $account_protection->disable(); if ( ! $disabled ) { return new WP_Error( @@ -384,12 +400,21 @@ public static function api_get_account_protection() { } /** - * Toggles the WAF module on or off for the API endpoint + * Turns the WAF module on or off for the API endpoint + * + * @param WP_REST_Request $request The request object. * * @return WP_REST_Response|WP_Error */ - public static function api_toggle_waf() { - if ( Waf_Runner::is_enabled() ) { + public static function api_toggle_waf( $request ) { + $is_enabled = Waf_Runner::is_enabled(); + $enable = $request['enabled'] ?? ! $is_enabled; + + if ( $enable === $is_enabled ) { + return rest_ensure_response( true ); + } + + if ( ! $enable ) { $disabled = Waf_Runner::disable(); if ( ! $disabled ) { return new WP_Error( diff --git a/projects/plugins/protect/src/js/api.ts b/projects/plugins/protect/src/js/api.ts index cb3ce8ae61fb..eaf4628b3cdf 100644 --- a/projects/plugins/protect/src/js/api.ts +++ b/projects/plugins/protect/src/js/api.ts @@ -11,10 +11,11 @@ const API = { method: 'GET', } ), - toggleAccountProtection: () => + toggleAccountProtection: ( enabled: boolean ) => apiFetch( { method: 'POST', path: 'jetpack-protect/v1/toggle-account-protection', + data: { enabled }, } ), getWaf: () => @@ -23,10 +24,11 @@ const API = { method: 'GET', } ).then( camelize ), - toggleWaf: () => + toggleWaf: ( enabled: boolean ) => apiFetch( { method: 'POST', path: 'jetpack-protect/v1/toggle-waf', + data: { enabled }, } ), updateWaf: data => diff --git a/projects/plugins/protect/src/js/data/account-protection/use-toggle-account-protection-module-mutation.ts b/projects/plugins/protect/src/js/data/account-protection/use-toggle-account-protection-module-mutation.ts index f495a3507105..c661a3f5c15d 100644 --- a/projects/plugins/protect/src/js/data/account-protection/use-toggle-account-protection-module-mutation.ts +++ b/projects/plugins/protect/src/js/data/account-protection/use-toggle-account-protection-module-mutation.ts @@ -10,13 +10,17 @@ import { AccountProtectionStatus } from '../../types/account-protection'; * * @return {UseMutationResult} useMutation result. */ -export default function useToggleAccountProtectionMutation(): UseMutationResult { +export default function useToggleAccountProtectionMutation(): UseMutationResult< + unknown, + Error, + boolean +> { const queryClient = useQueryClient(); const { showSavingNotice, showSuccessNotice, showErrorNotice } = useNotices(); return useMutation( { mutationFn: API.toggleAccountProtection, - onMutate: () => { + onMutate: enabled => { showSavingNotice(); // Get the current cached data @@ -28,7 +32,7 @@ export default function useToggleAccountProtectionMutation(): UseMutationResult if ( previousData ) { queryClient.setQueryData< AccountProtectionStatus >( [ QUERY_ACCOUNT_PROTECTION_KEY ], { ...previousData, - isEnabled: ! previousData.isEnabled, + isEnabled: enabled, } ); } diff --git a/projects/plugins/protect/src/js/data/waf/use-toggle-waf-module-mutation.ts b/projects/plugins/protect/src/js/data/waf/use-toggle-waf-module-mutation.ts index 2313f67b5cc6..e10c42f7bbf6 100644 --- a/projects/plugins/protect/src/js/data/waf/use-toggle-waf-module-mutation.ts +++ b/projects/plugins/protect/src/js/data/waf/use-toggle-waf-module-mutation.ts @@ -5,16 +5,16 @@ import { QUERY_WAF_KEY } from '../../constants'; import useNotices from '../../hooks/use-notices'; /** - * Toggle WAF Mutatation + * Enable WAF Mutation * * @return {UseMutationResult} useMutation result. */ -export default function useToggleWafMutation(): UseMutationResult { +export default function useToggleWafMutation(): UseMutationResult< unknown, Error, void > { const queryClient = useQueryClient(); const { showSuccessNotice, showErrorNotice } = useNotices(); return useMutation( { - mutationFn: API.toggleWaf, + mutationFn: () => API.toggleWaf( true ), onSuccess: () => { showSuccessNotice( __( 'WAF module enabled.', 'jetpack-protect' ) ); }, diff --git a/projects/plugins/protect/src/js/hooks/use-waf-data/index.ts b/projects/plugins/protect/src/js/hooks/use-waf-data/index.ts index ba05c16d7210..0f677e6e8012 100644 --- a/projects/plugins/protect/src/js/hooks/use-waf-data/index.ts +++ b/projects/plugins/protect/src/js/hooks/use-waf-data/index.ts @@ -16,12 +16,12 @@ const useWafData = () => { const toggleWafMutation = useToggleWafMutation(); /** - * Toggle WAF Module + * Enable WAF Module * - * Flips the switch on the WAF module, and then refreshes the data. + * Turns the WAF module on, and then refreshes the data. */ - const toggleWaf = useCallback( async () => { - toggleWafMutation.mutate( undefined ); + const enableWaf = useCallback( async () => { + toggleWafMutation.mutate(); }, [ toggleWafMutation ] ); /** @@ -29,11 +29,11 @@ const useWafData = () => { */ const ensureModuleIsEnabled = useCallback( async () => { if ( ! waf.isEnabled ) { - return await toggleWaf(); + return await enableWaf(); } return true; - }, [ toggleWaf, waf.isEnabled ] ); + }, [ enableWaf, waf.isEnabled ] ); /** * Toggle Automatic Rules @@ -170,7 +170,7 @@ const useWafData = () => { ...waf, isUpdating: wafMutation.isPending, isToggling: toggleWafMutation.isPending, - toggleWaf, + enableWaf, toggleAutomaticRules, toggleIpAllowList, saveIpAllowList, diff --git a/projects/plugins/protect/src/js/routes/firewall/index.jsx b/projects/plugins/protect/src/js/routes/firewall/index.jsx index 0aeabc57555e..682367f0eaa4 100644 --- a/projects/plugins/protect/src/js/routes/firewall/index.jsx +++ b/projects/plugins/protect/src/js/routes/firewall/index.jsx @@ -47,7 +47,7 @@ const FirewallPage = () => { toggleIpBlockList, saveIpBlockList, toggleBruteForceProtection, - toggleWaf, + enableWaf, } = useWafData(); const { hasPlan } = usePlan(); const { upgradePlan } = usePlan( { redirectUrl: `${ ADMIN_URL }#/firewall` } ); @@ -223,7 +223,7 @@ const FirewallPage = () => { { __( 'Re-enable the Firewall to continue.', 'jetpack-protect' ) }
- + { __( 'Enable Firewall', 'jetpack-protect' ) } diff --git a/projects/plugins/protect/src/js/routes/settings/index.jsx b/projects/plugins/protect/src/js/routes/settings/index.jsx index 8d49e9c44f7b..60468fa3df64 100644 --- a/projects/plugins/protect/src/js/routes/settings/index.jsx +++ b/projects/plugins/protect/src/js/routes/settings/index.jsx @@ -19,11 +19,14 @@ const SettingsPage = () => { /** * Toggle Account Protect Module * - * Flips the switch on the Account Protection module, and then refreshes the data. + * Sets the Account Protection module to the wanted state, and then refreshes the data. */ - const toggleAccountProtection = useCallback( async () => { - toggleAccountProtectionMutation.mutate(); - }, [ toggleAccountProtectionMutation ] ); + const toggleAccountProtection = useCallback( + async enabled => { + toggleAccountProtectionMutation.mutate( enabled ); + }, + [ toggleAccountProtectionMutation ] + ); // Track view for Protect Account Protection page. useAnalyticsTracks( { From b105552ad9cee39e52c151b605e2cef98487580a Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 13 Oct 2026 13:13:36 -0500 Subject: [PATCH 30/57] CLI: Read the current allow list in wp jetpack protect allow (#53315) * CLI: Read the current allow list in wp jetpack protect allow The command read the legacy jetpack_protect_whitelist site option but saved to jetpack_waf_ip_allow_list, so list and clear always saw an empty list and adding an IP replaced every existing entry. Read through Brute_Force_Protection_Shared_Functions::get_local_allow_list() instead, the same option save_allow_list() writes. * CLI: Drop duplicate entries before listing or saving the allow list A site that still has the legacy jetpack_protect_whitelist option gets it merged into every read of jetpack_waf_ip_allow_list, so each add saved one more copy of the legacy entries. --- .../changelog/libreville-jetpack-2946-pr | 4 ++++ .../plugins/jetpack/class.jetpack-cli.php | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/libreville-jetpack-2946-pr diff --git a/projects/plugins/jetpack/changelog/libreville-jetpack-2946-pr b/projects/plugins/jetpack/changelog/libreville-jetpack-2946-pr new file mode 100644 index 000000000000..55b33517e4c9 --- /dev/null +++ b/projects/plugins/jetpack/changelog/libreville-jetpack-2946-pr @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +CLI: Make `wp jetpack protect allow` read the current IP allow list, so adding an IP no longer replaces the existing entries. diff --git a/projects/plugins/jetpack/class.jetpack-cli.php b/projects/plugins/jetpack/class.jetpack-cli.php index 7b6e7ad165ed..b5c89aa4e8c2 100644 --- a/projects/plugins/jetpack/class.jetpack-cli.php +++ b/projects/plugins/jetpack/class.jetpack-cli.php @@ -667,14 +667,15 @@ public function protect( $args ) { } switch ( $action ) { case 'allow': - $allow = array(); - $new_ip = $args[1]; - $current_allow = get_site_option( 'jetpack_protect_whitelist', array() ); // @todo Update the option name. + $allow = array(); + $new_ip = $args[1]; - // Build array of IPs that are already on the allowed list. - // Re-build manually instead of using jetpack_protect_format_allow_list() so we can easily get - // low & high range params for IP_Utils::ip_address_is_in_range(). - foreach ( $current_allow as $allowed ) { + /* + * Build array of IPs that are already on the allowed list. + * Re-build manually instead of using format_allow_list() so we can easily get + * low & high range params for IP_Utils::ip_address_is_in_range(). + */ + foreach ( Brute_Force_Protection_Shared_Functions::get_local_allow_list() as $allowed ) { // IP ranges. if ( $allowed->range ) { @@ -685,7 +686,8 @@ public function protect( $args ) { WP_CLI::error( sprintf( __( '%s is already on the always allow list.', 'jetpack' ), $new_ip ) ); break; } - $allow[] = $allowed->range_low . ' - ' . $allowed->range_high; + // A CIDR range has no upper bound of its own. + $allow[] = null === $allowed->range_high ? $allowed->range_low : $allowed->range_low . ' - ' . $allowed->range_high; } else { // Individual IPs. @@ -700,6 +702,9 @@ public function protect( $args ) { } } + // A leftover legacy allow list is merged into every read, so entries already saved come back twice. + $allow = array_unique( $allow ); + /* * List the allowed IPs. * Done here because it's easier to read the $allow array after it's been rebuilt. From 2e9fbfc4423c54fa8ff86c37eddff5c598cdb22f Mon Sep 17 00:00:00 2001 From: Douglas Henri Date: Tue, 13 Oct 2026 15:14:14 -0300 Subject: [PATCH 31/57] Backup: make the Restore to this point focus ring visible (#53416) * Backup: make the Restore to this point focus ring visible The 2px theme-colour box-shadow sat flush against the same theme-colour fill, so the focused link looked like the unfocused one (WCAG 2.4.7). Use the @wordpress/ui ring instead: an outline with an equal offset, on :focus-visible, from WPDS tokens. Clear wp-admin's a:focus box-shadow so it cannot fill the gap. Co-Authored-By: Claude Opus 5.5 (1M context) * Backup: build the detail header actions from @wordpress/ui Render Download and Restore as LinkButton composed with the router Link through render, which yields one with the button styles and the ui focus ring. That removes the hand-styled __restore and __download blocks, their colour, fill and focus overrides, and the comment claiming the two could not be combined. Lay the body out with Stack (gap md, 12px, for the old 10px), and take the header separator's width and colour from WPDS tokens. Co-Authored-By: Claude Opus 5.5 (1M context) * Backup: render Download backup as a @wordpress/ui Link As a minimal LinkButton it took the button's 600 weight and width, which read as a second button and pushed both actions below the title. A ui Link matches the dashboard's other text links. Stack, composed through render, lays out the icon and label on the same single with no new CSS. Restore stays a solid LinkButton. Co-Authored-By: Claude Opus 5.5 (1M context) * Backup: keep Restore to this point at the weight of the link beside it The solid LinkButton set its label in the button's 600, where the old hand-styled link and Download beside it are 400. A body-md Text around the label has the button's size and line height and the default weight, so the label drops to 400 with no CSS on the ui component. Co-Authored-By: Claude Opus 5.5 (1M context) * Add changelog entry Co-Authored-By: Claude Opus 5.5 (1M context) * Backup: take the detail body's top padding from a WPDS token The rule is still needed: Card zeroes a content's top padding when it follows a header, and the separator under the header would otherwise sit flush against the first line. Use --wpds-dimension-padding-2xl, the token Card's padding resolves to, instead of the card's own variable. The build adds the 24px fallback itself. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../backup-focus-ring-and-support-link | 4 ++ .../components/backup-detail/index.tsx | 49 ++++++++++----- .../components/backup-detail/style.scss | 62 +------------------ 3 files changed, 40 insertions(+), 75 deletions(-) create mode 100644 projects/packages/backup/changelog/backup-focus-ring-and-support-link diff --git a/projects/packages/backup/changelog/backup-focus-ring-and-support-link b/projects/packages/backup/changelog/backup-focus-ring-and-support-link new file mode 100644 index 000000000000..16b190875b50 --- /dev/null +++ b/projects/packages/backup/changelog/backup-focus-ring-and-support-link @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed +Comment: The modernized dashboard, behind a feature flag, now shows a visible focus ring on "Restore to this point". + diff --git a/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx b/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx index 0c2503e70e9c..c2e17dd1e344 100644 --- a/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx +++ b/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx @@ -2,8 +2,8 @@ import { dateI18n } from '@wordpress/date'; import { createInterpolateElement, useCallback, useMemo, useState } from '@wordpress/element'; import { __, _n, sprintf } from '@wordpress/i18n'; import { Icon, cloud, download as downloadIcon, rotateLeft } from '@wordpress/icons'; -import { Link } from '@wordpress/route'; -import { Card, Stack, Text } from '@wordpress/ui'; +import { Link as RouterLink } from '@wordpress/route'; +import { Card, Link, LinkButton, Stack, Text } from '@wordpress/ui'; import { formatDuration } from '../../data/durations'; import { formatStorageSize } from '../../data/storage-units'; import { useBackupRuns } from '../../hooks/use-backup-runs'; @@ -140,18 +140,28 @@ export default function BackupDetail( { item }: Props ) { gap="sm" align="center" > - + } + /> + } > { downloadLabel( selectedIds.length ) } - +
{ /* * Deliberately not labelled from the file selection, and its * link carries none. @@ -165,14 +175,21 @@ export default function BackupDetail( { item }: Props ) { * items" confirms a full-site restore believing it is * scoped. */ } - - - { __( 'Restore to this point', 'jetpack-backup-pkg' ) } - + } + > + + { /* Same size and line height as the button; only the weight drops, to Download's. */ } + { __( 'Restore to this point', 'jetpack-backup-pkg' ) } + - + } + > { item.stats } diff --git a/projects/packages/backup/src/dashboard/components/backup-detail/style.scss b/projects/packages/backup/src/dashboard/components/backup-detail/style.scss index 73a4c81311de..79947339bd19 100644 --- a/projects/packages/backup/src/dashboard/components/backup-detail/style.scss +++ b/projects/packages/backup/src/dashboard/components/backup-detail/style.scss @@ -5,7 +5,7 @@ padding-block: var(--wpds-dimension-padding-2xl); // Card.Header doesn't ship its own separator; add one so the // header row and the body content read as distinct sections. - border-bottom: 1px solid var(--wp-components-color-gray-200, #dcdcde); + border-block-end: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); @media (min-width: 600px) { padding-inline: var(--wpds-dimension-padding-3xl) var(--wpds-dimension-padding-2xl); @@ -39,65 +39,9 @@ max-inline-size: 100%; } + // Card drops the top padding after a header; the separator needs it back. &__body { - display: flex; - flex-direction: column; - gap: 10px; - padding-top: var(--wp-ui-card-padding, 24px); - } - - &__download { - display: inline-flex; - gap: 6px; - align-items: center; - text-decoration: none; - - // wp-admin's global `svg { fill: … }` is unlayered and overrides - // `@wordpress/icons`'s default `fill: currentColor` for any SVG - // inside an anchor. Force the icon back to inherit the link's - // blue so it doesn't render black next to the "Download backup" - // text. - svg { - fill: currentColor; - } - } - - // Styled-link "primary button" for the Restore action. The router's - // `` renders an ``, so we avoid nesting it inside a `` HTML). - &__restore { - display: inline-flex; - gap: 6px; - align-items: center; - padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md); - border-radius: 2px; - background-color: var(--wp-admin-theme-color, #007cba); - text-decoration: none; - - // wp-admin's global `a { color: #2271b1 }` is unlayered and wins - // over `@layer wp-ui-components`; force white explicitly. - &, - &:hover, - &:focus, - &:visited { - color: #fff; - } - - // The `` SVG defaults to `fill: currentColor` but wp-admin's - // global `svg { fill: ... }` rules can override it for links. - // Force the icon to inherit the white link color. - svg { - fill: currentColor; - } - - &:hover { - background-color: var(--wp-admin-theme-color-darker-10, #005a87); - } - - &:focus { - outline: 2px solid transparent; - box-shadow: 0 0 0 2px var(--wp-admin-theme-color, #007cba); - } + padding-block-start: var(--wpds-dimension-padding-2xl); } &__by { From c506a5a7b9637940593d020e7b07fc37cbf2d8eb Mon Sep 17 00:00:00 2001 From: Douglas Henri Date: Tue, 13 Oct 2026 15:14:26 -0300 Subject: [PATCH 32/57] Jetpack Settings: retire the jetpack-settings-wp-build flag and the webpack page (#52667) * Jetpack: build the dops button, card and banner styles into style.min.css The modules list, the masthead and the stats nudges print these classes from PHP, and got their rules from the Settings bundle's admin.css. Build them into style.min.css, which every one of those pages loads, so admin.css can go with the bundle. Co-Authored-By: Claude Opus 5.5 (1M context) * Jetpack Settings: retire the jetpack-settings-wp-build flag and the webpack page Settings now always renders through wp-build. Delete the flag, the webpack `admin` entry and its enqueue, and the static.html pre-render. The `static` entry stays for the modules list's notices. IDC-blocked requests show only the IDC banner, so they now load no Settings bundle at all. A missing build shows how to build it instead of falling back. Co-Authored-By: Claude Opus 5.5 (1M context) * Jetpack Settings: always move PHP notices into the app With one page left, the `data-wp-build` marker is always set, so drop it. Co-Authored-By: Claude Opus 5.5 (1M context) * Add changelog entry Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../plugins/jetpack/_inc/client/admin.jsx | 49 -------------- .../client/components/admin-notices/index.jsx | 8 +-- .../admin-notices/test/component.jsx | 16 +---- .../plugins/jetpack/_inc/client/generator.jsx | 10 --- .../jetpack/_inc/client/scss/style.scss | 4 ++ .../jetpack/_inc/client/settings-root.jsx | 2 +- .../jetpack/_inc/client/static-main.jsx | 36 ---------- .../class-jetpack-settings-react-page.php | 65 ++++--------------- .../admin-pages/class.jetpack-admin-page.php | 1 - .../class.jetpack-settings-page.php | 1 - .../class-jetpack-settings-feature-flags.php | 40 ------------ ...pdate-jetpack-2709-retire-settings-webpack | 4 ++ projects/plugins/jetpack/load-jetpack.php | 1 - .../jetpack/routes/settings/route.scss | 2 +- .../plugins/jetpack/routes/settings/stage.tsx | 4 +- .../Jetpack_Settings_Feature_Flags_Test.php | 39 ----------- .../lib/Jetpack_Settings_React_Page_Test.php | 39 ++++++----- .../plugins/jetpack/tools/webpack.config.js | 11 +--- 18 files changed, 51 insertions(+), 281 deletions(-) delete mode 100644 projects/plugins/jetpack/_inc/client/admin.jsx delete mode 100644 projects/plugins/jetpack/_inc/client/static-main.jsx delete mode 100644 projects/plugins/jetpack/_inc/lib/class-jetpack-settings-feature-flags.php create mode 100644 projects/plugins/jetpack/changelog/update-jetpack-2709-retire-settings-webpack delete mode 100644 projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Settings_Feature_Flags_Test.php diff --git a/projects/plugins/jetpack/_inc/client/admin.jsx b/projects/plugins/jetpack/_inc/client/admin.jsx deleted file mode 100644 index 9805675cf1bb..000000000000 --- a/projects/plugins/jetpack/_inc/client/admin.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as WPElement from '@wordpress/element'; -import { _x } from '@wordpress/i18n'; -import SettingsRoot from 'settings-root'; - -render(); - -/** - * Initial render function. - */ -function render() { - const container = document.getElementById( 'jp-plugin-container' ); - - if ( container === null ) { - return; - } - - WPElement.createRoot( container ).render( ); -} - -/** - * Get translated route name according to route path - * - * @param {string} path - route path - * @return {string} translated route name - */ -export function getRouteName( path ) { - switch ( path ) { - case '/discussion': - return _x( 'Discussion', 'Navigation item.', 'jetpack' ); - case '/earn': - return _x( 'Monetize', 'Navigation item.', 'jetpack' ); - case '/newsletter': - return _x( 'Newsletter', 'Navigation item.', 'jetpack' ); - case '/reader': - return _x( 'Reader', 'Navigation item.', 'jetpack' ); - case '/security': - return _x( 'Security', 'Navigation item.', 'jetpack' ); - case '/performance': - return _x( 'Performance', 'Navigation item.', 'jetpack' ); - case '/traffic': - return _x( 'Traffic', 'Navigation item.', 'jetpack' ); - case '/writing': - return _x( 'Writing', 'Navigation item.', 'jetpack' ); - case '/sharing': - return _x( 'Sharing', 'Navigation item.', 'jetpack' ); - default: - return _x( 'Settings', 'Navigation item.', 'jetpack' ); - } -} diff --git a/projects/plugins/jetpack/_inc/client/components/admin-notices/index.jsx b/projects/plugins/jetpack/_inc/client/components/admin-notices/index.jsx index b64055edeca8..8215b6daf73a 100644 --- a/projects/plugins/jetpack/_inc/client/components/admin-notices/index.jsx +++ b/projects/plugins/jetpack/_inc/client/components/admin-notices/index.jsx @@ -101,11 +101,9 @@ class AdminNotices extends Component { } // wp-build's page template hides what PHP prints beside the app, so show Jetpack's notices in it. - if ( this.adminNoticesRef.current.closest( '#jp-plugin-container[data-wp-build]' ) ) { - jQuery( '#wpbody-content' ) - .find( '.jetpack-message, #jetpack-ssl-warning' ) - .prependTo( $adminNotices ); - } + jQuery( '#wpbody-content' ) + .find( '.jetpack-message, #jetpack-ssl-warning' ) + .prependTo( $adminNotices ); // Hide the rest of the core notices, they don't look very good above the react app. const $allNotices = jQuery( '.notice' ); diff --git a/projects/plugins/jetpack/_inc/client/components/admin-notices/test/component.jsx b/projects/plugins/jetpack/_inc/client/components/admin-notices/test/component.jsx index deb8a2f1536f..e80738fcf32e 100644 --- a/projects/plugins/jetpack/_inc/client/components/admin-notices/test/component.jsx +++ b/projects/plugins/jetpack/_inc/client/components/admin-notices/test/component.jsx @@ -11,14 +11,13 @@ const page = `${ privacyNotice }

Settings

${ /** * Print `#wpbody-content`, then mount the notices in the app container. * - * @param {object} containerProps - Extra props for `#jp-plugin-container`. * @return {HTMLElement} The app container. */ -function mountInPage( containerProps = {} ) { +function mountInPage() { document.head.innerHTML = ''; document.body.innerHTML = `
${ page }
`; const { container } = render( -
+
); @@ -30,18 +29,9 @@ describe( 'AdminNotices', () => { document.head.innerHTML = ''; } ); - it( 'leaves Jetpack notices in place on the webpack page, even hidden ones', () => { + it( 'moves Jetpack notices into the app, even hidden ones', () => { const app = mountInPage(); - expect( app ).not.toContainElement( screen.getByText( 'Is this site private?' ) ); - expect( screen.getByText( 'Is this site private?' ) ).toBeVisible(); - expect( app ).not.toContainElement( screen.getByText( 'Outbound HTTPS not working' ) ); - expect( screen.getByText( 'Outbound HTTPS not working' ) ).not.toBeVisible(); - } ); - - it( 'moves Jetpack notices into the app on the wp-build page', () => { - const app = mountInPage( { 'data-wp-build': true } ); - for ( const text of [ 'Is this site private?', 'Outbound HTTPS not working' ] ) { expect( app ).toContainElement( screen.getByText( text ) ); expect( screen.getByText( text ) ).toBeVisible(); diff --git a/projects/plugins/jetpack/_inc/client/generator.jsx b/projects/plugins/jetpack/_inc/client/generator.jsx index 3aae92fbdcf7..eb875f11f0ab 100644 --- a/projects/plugins/jetpack/_inc/client/generator.jsx +++ b/projects/plugins/jetpack/_inc/client/generator.jsx @@ -2,15 +2,6 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { Provider } from 'react-redux'; import StaticWarning from 'components/jetpack-notices/static-warning'; import store from 'state/redux-store'; -import StaticMain from 'static-main'; - -const staticHtml = renderToStaticMarkup( -
- - - -
-); const noscriptNotice = renderToStaticMarkup( @@ -27,7 +18,6 @@ const versionNotice = renderToStaticMarkup( ); export default () => ( { - 'static.html': staticHtml, 'static-noscript-notice.html': noscriptNotice, 'static-version-notice.html': versionNotice, } ); diff --git a/projects/plugins/jetpack/_inc/client/scss/style.scss b/projects/plugins/jetpack/_inc/client/scss/style.scss index 79b6a2e50c64..959e06329d7d 100644 --- a/projects/plugins/jetpack/_inc/client/scss/style.scss +++ b/projects/plugins/jetpack/_inc/client/scss/style.scss @@ -27,6 +27,10 @@ @use "shared/admin-static"; // Components +// PHP-rendered pages print these dops classes too. +@include meta.load-css( "../components/banner/style" ); +@include meta.load-css( "../components/button/style" ); +@include meta.load-css( "../components/card/style" ); @include meta.load-css( "../components/foldable-card/style2" ); @include meta.load-css( "../components/footer/style" ); @include meta.load-css( "../components/button-group/style" ); diff --git a/projects/plugins/jetpack/_inc/client/settings-root.jsx b/projects/plugins/jetpack/_inc/client/settings-root.jsx index d7fa574731d0..fb369f79a47c 100644 --- a/projects/plugins/jetpack/_inc/client/settings-root.jsx +++ b/projects/plugins/jetpack/_inc/client/settings-root.jsx @@ -18,7 +18,7 @@ if ( 'undefined' !== typeof window && process.env.NODE_ENV === 'development' ) { } /** - * The Settings app, rendered by both the webpack entry and the wp-build stage. + * The Settings app, rendered by the wp-build stage. * * @return {import('react').ReactElement} The app. */ diff --git a/projects/plugins/jetpack/_inc/client/static-main.jsx b/projects/plugins/jetpack/_inc/client/static-main.jsx deleted file mode 100644 index ef5cf116dcbd..000000000000 --- a/projects/plugins/jetpack/_inc/client/static-main.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Component } from 'react'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import Footer from 'components/footer'; -import Masthead from 'components/masthead'; -import { setInitialState } from 'state/initial-state'; - -class StaticMain extends Component { - UNSAFE_componentWillMount() { - this.props.setInitialState(); - } - - render() { - return ( -
-
-
- -
-
-
- -
-
- -
- ); - } -} - -export default connect( - state => { - return state; - }, - dispatch => bindActionCreators( { setInitialState }, dispatch ) -)( StaticMain ); diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-settings-react-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-settings-react-page.php index 5dc417e7d455..5d68ca072f6d 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-settings-react-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-settings-react-page.php @@ -8,13 +8,11 @@ use Automattic\Jetpack\Admin_UI\Admin_Menu; use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State; use Automattic\Jetpack\Connection\Manager as Connection_Manager; -use Automattic\Jetpack\Feature_Flags\Feature_Flags; use Automattic\Jetpack\Status; require_once __DIR__ . '/class.jetpack-admin-page.php'; require_once __DIR__ . '/class-jetpack-redux-state-helper.php'; require_once __DIR__ . '/class-jetpack-wp-build-page.php'; -require_once dirname( __DIR__ ) . '/class-jetpack-settings-feature-flags.php'; /** * Renders the Settings app, whose connection screens also serve unconnected sites. @@ -50,17 +48,6 @@ class Jetpack_Settings_React_Page extends Jetpack_Admin_Page { */ private $is_wp_build_loaded = false; - /** - * Whether Settings renders through wp-build; off serves the webpack page at the same address. - * - * @since 16.3 - * - * @return bool - */ - public static function is_wp_build_enabled() { - return Feature_Flags::is_enabled( Jetpack_Settings_Feature_Flags::WP_BUILD ); - } - /** * Whether this request should load wp-build. * @@ -71,7 +58,7 @@ public static function is_wp_build_enabled() { * @return bool */ public function should_load_wp_build() { - if ( ! is_admin() || ! self::is_wp_build_enabled() ) { + if ( ! is_admin() ) { return false; } @@ -274,20 +261,10 @@ public function page_render() { return; } - // Fetch static.html. - $static_html = @file_get_contents( JETPACK__PLUGIN_DIR . '_inc/build/static.html' ); //phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, Not fetching a remote file. - - if ( false === $static_html ) { - - // If we still have nothing, display an error. - echo '

'; - esc_html_e( 'Error fetching static.html. Try running: ', 'jetpack' ); - echo 'pnpm run distclean && pnpm jetpack build plugins/jetpack'; - echo '

'; - } else { - // We got the static.html so let's display it. - echo $static_html; //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - } + echo ''; } /** @@ -306,38 +283,18 @@ public function additional_styles() { * Load admin page scripts. */ public function page_admin_scripts() { - if ( $this->is_redirecting ) { - return; // No need for scripts on a fallback page. + // A fallback page, or one without the route (IDC-blocked or unbuilt), renders no app to feed. + if ( $this->is_redirecting || ! $this->should_render_wp_build() ) { + return; } $status = new Status(); $is_offline_mode = $status->is_offline_mode(); $site_suffix = $status->get_site_suffix(); - if ( $this->should_render_wp_build() ) { - // wp-build enqueues the route bundle; this handle carries the inline state and classic dependencies. - wp_register_script( 'react-plugin', false, $this->get_wp_build_script_dependencies(), JETPACK__VERSION, true ); - wp_enqueue_script( 'react-plugin' ); - } else { - $script_deps_path = JETPACK__PLUGIN_DIR . '_inc/build/admin.asset.php'; - $script_dependencies = array( 'jquery', 'wp-polyfill' ); - $version = JETPACK__VERSION; - if ( file_exists( $script_deps_path ) ) { - $asset_manifest = include $script_deps_path; - $script_dependencies = $asset_manifest['dependencies']; - $version = $asset_manifest['version']; - } - - wp_enqueue_script( - 'react-plugin', - plugins_url( '_inc/build/admin.js', JETPACK__PLUGIN_FILE ), - $script_dependencies, - $version, - true - ); - - wp_set_script_translations( 'react-plugin', 'jetpack' ); - } + // wp-build enqueues the route bundle; this handle carries the inline state and classic dependencies. + wp_register_script( 'react-plugin', false, $this->get_wp_build_script_dependencies(), JETPACK__VERSION, true ); + wp_enqueue_script( 'react-plugin' ); $blog_id_prop = ''; if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) { diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php index 648403a4264e..56d5b3b071ac 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-admin-page.php @@ -254,7 +254,6 @@ public function check_plan_deactivate_modules( $page ) { */ public static function load_wrapper_styles() { $rtl = is_rtl() ? '.rtl' : ''; - wp_enqueue_style( 'dops-css', plugins_url( "_inc/build/admin{$rtl}.css", JETPACK__PLUGIN_FILE ), array(), JETPACK__VERSION ); wp_enqueue_style( 'components-css', plugins_url( "_inc/build/style.min{$rtl}.css", JETPACK__PLUGIN_FILE ), array( 'wp-components' ), JETPACK__VERSION ); } diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php index b3edc8b73aea..4df3f33f5e32 100644 --- a/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class.jetpack-settings-page.php @@ -83,7 +83,6 @@ public function page_render() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is view logic. $current_availability = isset( $_GET['offline_available'] ) ? sanitize_text_field( wp_unslash( $_GET['offline_available'] ) ) : ''; - // We have static.html so let's continue trying to fetch the others. $noscript_notice = @file_get_contents( JETPACK__PLUGIN_DIR . '_inc/build/static-noscript-notice.html' ); //phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, Not fetching a remote file. $rest_api_notice = @file_get_contents( JETPACK__PLUGIN_DIR . '_inc/build/static-version-notice.html' ); //phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, Not fetching a remote file. diff --git a/projects/plugins/jetpack/_inc/lib/class-jetpack-settings-feature-flags.php b/projects/plugins/jetpack/_inc/lib/class-jetpack-settings-feature-flags.php deleted file mode 100644 index 06e226d73f17..000000000000 --- a/projects/plugins/jetpack/_inc/lib/class-jetpack-settings-feature-flags.php +++ /dev/null @@ -1,40 +0,0 @@ - true, - 'description' => 'Render Jetpack Settings through wp-build. Turn it off to serve the webpack page at the same address.', - 'owner' => 'jetpack', - ) - ); - } -} - -Jetpack_Settings_Feature_Flags::register(); diff --git a/projects/plugins/jetpack/changelog/update-jetpack-2709-retire-settings-webpack b/projects/plugins/jetpack/changelog/update-jetpack-2709-retire-settings-webpack new file mode 100644 index 000000000000..855dbc5b64fb --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-jetpack-2709-retire-settings-webpack @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Settings: Remove the legacy Settings page and its jetpack-settings-wp-build feature flag. diff --git a/projects/plugins/jetpack/load-jetpack.php b/projects/plugins/jetpack/load-jetpack.php index 202ca82efb2c..ea89200f1842 100644 --- a/projects/plugins/jetpack/load-jetpack.php +++ b/projects/plugins/jetpack/load-jetpack.php @@ -63,7 +63,6 @@ function jetpack_should_use_minified_assets() { // Simple this bootstrap never runs. The class self-initializes when loaded. require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-settings.php'; require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-feature-flags.php'; -require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-settings-feature-flags.php'; require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-protect-dashboard-feature-flags.php'; // Another plugin may have loaded an older version of this class, so only call the method if it exists. diff --git a/projects/plugins/jetpack/routes/settings/route.scss b/projects/plugins/jetpack/routes/settings/route.scss index 10a92520e4af..e4736320987f 100644 --- a/projects/plugins/jetpack/routes/settings/route.scss +++ b/projects/plugins/jetpack/routes/settings/route.scss @@ -1,4 +1,4 @@ -// The stylesheet the webpack page loads as style.min.css. +// Also built as style.min.css for the PHP-rendered pages. @use "../../_inc/client/scss/style"; body.jetpack_page_jetpack-settings:not(.network-admin) { diff --git a/projects/plugins/jetpack/routes/settings/stage.tsx b/projects/plugins/jetpack/routes/settings/stage.tsx index dba3f7d58612..b09100998bec 100644 --- a/projects/plugins/jetpack/routes/settings/stage.tsx +++ b/projects/plugins/jetpack/routes/settings/stage.tsx @@ -4,12 +4,12 @@ import '@automattic/social-previews/style.css'; import './route.scss'; /** - * Boot stage for Jetpack Settings. The container id matches the webpack page's. + * Boot stage for Jetpack Settings. PHP-rendered pages style the same container id. * * @return The Settings app. */ const Stage = () => ( -
+
); diff --git a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Settings_Feature_Flags_Test.php b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Settings_Feature_Flags_Test.php deleted file mode 100644 index 8f223597c495..000000000000 --- a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Settings_Feature_Flags_Test.php +++ /dev/null @@ -1,39 +0,0 @@ -assertNotNull( Feature_Flags::get( Jetpack_Settings_Feature_Flags::WP_BUILD ) ); - } - - public function test_is_on_by_default() { - $this->assertTrue( Feature_Flags::is_enabled( Jetpack_Settings_Feature_Flags::WP_BUILD ) ); - } - - public function test_is_a_kill_switch() { - add_filter( 'jetpack_feature_flag_enabled_' . Jetpack_Settings_Feature_Flags::WP_BUILD, '__return_false' ); - - $this->assertFalse( Feature_Flags::is_enabled( Jetpack_Settings_Feature_Flags::WP_BUILD ) ); - } -} diff --git a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Settings_React_Page_Test.php b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Settings_React_Page_Test.php index b61be3fd1bf6..22c34c3c0450 100644 --- a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Settings_React_Page_Test.php +++ b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Settings_React_Page_Test.php @@ -51,9 +51,7 @@ public function tear_down() { Status_Cache::clear(); $this->reset_menus(); - remove_all_filters( 'jetpack_feature_flag_enabled_' . Jetpack_Settings_Feature_Flags::WP_BUILD ); wp_deregister_script( 'react-plugin' ); - wp_dequeue_style( 'dops-css' ); wp_dequeue_style( 'components-css' ); set_current_screen( 'front' ); @@ -248,15 +246,7 @@ public function test_does_not_load_wp_build_elsewhere() { $this->assertFalse( $page->should_load_wp_build(), 'Not outside wp-admin.' ); } - public function test_kill_switch_keeps_the_webpack_page() { - set_current_screen( 'dashboard' ); - $_GET['page'] = 'jetpack-settings'; - add_filter( 'jetpack_feature_flag_enabled_' . Jetpack_Settings_Feature_Flags::WP_BUILD, '__return_false' ); - - $this->assertFalse( ( new Jetpack_Settings_React_Page() )->should_load_wp_build() ); - } - - public function test_idc_blocked_page_keeps_the_webpack_page() { + public function test_idc_blocked_page_does_not_load_wp_build() { set_current_screen( 'dashboard' ); $_GET['page'] = 'jetpack-settings'; @@ -281,15 +271,29 @@ public function test_does_not_render_wp_build_before_loading_it() { $this->assertFalse( ( new Jetpack_Settings_React_Page() )->should_render_wp_build() ); } - public function test_webpack_page_enqueues_the_bundle_and_its_styles() { + public function test_page_without_wp_build_enqueues_no_bundle() { $page = new Jetpack_Settings_React_Page(); $page->page_admin_scripts(); - $page->additional_styles(); - $this->assertStringContainsString( '_inc/build/admin.js', wp_scripts()->registered['react-plugin']->src ); - $this->assertStringContainsString( 'var Initial_State=', implode( '', (array) wp_scripts()->get_data( 'react-plugin', 'before' ) ) ); - $this->assertTrue( wp_style_is( 'dops-css' ) ); + $this->assertFalse( wp_script_is( 'react-plugin', 'registered' ) ); + } + + public function test_page_without_wp_build_explains_the_missing_build() { + ob_start(); + ( new Jetpack_Settings_React_Page() )->page_render(); + $html = ob_get_clean(); + + $this->assertStringContainsString( 'jp-settings-build-error', $html ); + $this->assertStringNotContainsString( 'jp-loading-placeholder', $html, 'The static.html pre-render is gone.' ); + } + + public function test_wrapper_styles_no_longer_load_the_settings_bundle_css() { + ( new Jetpack_Settings_React_Page() )->additional_styles(); + $this->assertTrue( wp_style_is( 'components-css' ) ); + foreach ( wp_styles()->queue as $handle ) { + $this->assertStringNotContainsString( '_inc/build/admin', (string) wp_styles()->registered[ $handle ]->src ); + } } public function test_wp_build_page_carries_the_state_without_the_bundle() { @@ -301,7 +305,6 @@ public function test_wp_build_page_carries_the_state_without_the_bundle() { $this->assertTrue( wp_script_is( 'react-plugin' ) ); $this->assertContains( 'lodash', wp_scripts()->registered['react-plugin']->deps ); $this->assertStringContainsString( 'var Initial_State=', implode( '', (array) wp_scripts()->get_data( 'react-plugin', 'before' ) ) ); - $this->assertFalse( wp_style_is( 'dops-css' ), 'The route bundle carries its own styles.' ); - $this->assertFalse( wp_style_is( 'components-css' ) ); + $this->assertFalse( wp_style_is( 'components-css' ), 'The route bundle carries its own styles.' ); } } diff --git a/projects/plugins/jetpack/tools/webpack.config.js b/projects/plugins/jetpack/tools/webpack.config.js index d92e9fd5642a..de98869159bd 100644 --- a/projects/plugins/jetpack/tools/webpack.config.js +++ b/projects/plugins/jetpack/tools/webpack.config.js @@ -211,19 +211,10 @@ module.exports = [ ], }, }, - // Build admin page JS. + // Build the plugins page and Network Admin JS. { ...sharedWebpackConfig, entry: { - admin: { - import: path.join( __dirname, '../_inc/client', 'admin.jsx' ), - // I don't know if we really need to export this. We were in the past, maybe some third party uses it. - library: { - name: 'getRouteName', - type: 'window', - export: 'getRouteName', - }, - }, 'plugins-page': path.join( __dirname, '../_inc/client', 'plugins-entry.jsx' ), 'network-admin': path.join( __dirname, '../_inc/client', 'network-admin.tsx' ), }, From 9c5ba7c84460d354ac163da040f44cd0d0fd7aef Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 13 Oct 2026 13:38:04 -0500 Subject: [PATCH 33/57] Monitor: Store the downtime email setting per user (#53384) * Monitor: Store the downtime email setting per user The settings endpoint cached the current user's WordPress.com email subscription in a single site option, so every admin read whichever value was saved last. Key the local copy by user ID, as Post by Email already does, and stop caching a failed lookup. * Monitor: Drop a value check that can no longer match Neither lookup leaves an error or null in the value now, so Phan flagged the comparison as impossible. * Monitor: Save the per-user email setting through one helper Both writers and the new cleanup now build the option name and cast the value in Jetpack_Monitor, so they cannot drift from what get_remote_value() reads. The saved copy is deleted when the user unlinks from WordPress.com or is deleted, since it belongs to the account they had linked. Tests cover the first remote lookup (on, off, and a failed request that saves nothing), turning the setting off through the ability, and the cleanup. * Monitor: Fire deleted_user with core's arguments in the cleanup test Multisite hooks wp_delete_signup_on_user_delete() to deleted_user, and it requires all three arguments, so firing the action with only the user ID errored under the multisite test run. * Monitor: Forget saved notification choices when the site disconnects A site disconnect deletes every user token without firing jetpack_unlinked_user, so the per-user copies outlived the connection. A user who then linked a different WordPress.com account was shown the old account's answer. Clear the copies on jetpack_site_before_disconnected, while user_tokens still names who was linked. --- .../lib/class.core-rest-api-endpoints.php | 14 ++- .../fix-monitor-notifications-per-user | 4 + projects/plugins/jetpack/modules/monitor.php | 60 +++++++++- .../src/abilities/class-monitor-abilities.php | 25 ++-- .../lib/Jetpack_REST_API_endpoints_Test.php | 109 ++++++++++++++++++ .../tests/php/src/Monitor_Abilities_Test.php | 36 ++++-- 6 files changed, 213 insertions(+), 35 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/fix-monitor-notifications-per-user diff --git a/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php b/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php index 66e72127cc9c..f520fa399068 100644 --- a/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php +++ b/projects/plugins/jetpack/_inc/lib/class.core-rest-api-endpoints.php @@ -3572,7 +3572,8 @@ public static function cast_value( $value, $definition ) { */ public static function get_remote_value( $module, $option ) { - if ( in_array( $module, array( 'post-by-email' ), true ) ) { + // These are per-user values on WordPress.com, so each user gets their own local copy. + if ( in_array( $module, array( 'monitor', 'post-by-email' ), true ) ) { $option .= get_current_user_id(); } @@ -3597,6 +3598,12 @@ public static function get_remote_value( $module, $option ) { return false; } $value = Jetpack_Monitor::user_receives_notifications( false ); + // Not saved: the user may not be connected yet, and would be stuck with this answer. + if ( is_wp_error( $value ) ) { + return false; + } + // update_option() won't create an option whose value is `false`. + $value = (int) (bool) $value; break; case 'post-by-email': @@ -3611,11 +3618,6 @@ public static function get_remote_value( $module, $option ) { break; } - // Normalize value to boolean. - if ( is_wp_error( $value ) || $value === null ) { - $value = false; - } - // Save option to use it next time. update_option( $option, $value ); diff --git a/projects/plugins/jetpack/changelog/fix-monitor-notifications-per-user b/projects/plugins/jetpack/changelog/fix-monitor-notifications-per-user new file mode 100644 index 000000000000..492e4ac81df6 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-monitor-notifications-per-user @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Monitor: Show each user their own downtime email setting instead of the one another admin saved last. diff --git a/projects/plugins/jetpack/modules/monitor.php b/projects/plugins/jetpack/modules/monitor.php index 5d2486ed95d7..943e0dbe66c0 100644 --- a/projects/plugins/jetpack/modules/monitor.php +++ b/projects/plugins/jetpack/modules/monitor.php @@ -41,6 +41,63 @@ class Jetpack_Monitor { public function __construct() { add_action( 'jetpack_modules_loaded', array( $this, 'jetpack_modules_loaded' ) ); add_action( 'jetpack_activate_module_monitor', array( $this, 'activate_module' ) ); + add_action( 'jetpack_unlinked_user', array( $this, 'delete_notifications_copy' ) ); + add_action( 'deleted_user', array( $this, 'delete_notifications_copy' ) ); + add_action( 'jetpack_site_before_disconnected', array( $this, 'delete_all_notifications_copies' ) ); + } + + /** + * Forgets every linked user's saved notification choice, while a site disconnect can still say who was linked. + * + * @since $$next-version$$ + * + * @return void + */ + public function delete_all_notifications_copies() { + $user_tokens = Jetpack_Options::get_option( 'user_tokens' ); + foreach ( array_keys( is_array( $user_tokens ) ? $user_tokens : array() ) as $user_id ) { + $this->delete_notifications_copy( $user_id ); + } + } + + /** + * Forgets a user's saved notification choice, which belongs to the WordPress.com account they had linked. + * + * @since $$next-version$$ + * + * @param int $user_id User ID. + * + * @return void + */ + public function delete_notifications_copy( $user_id ) { + delete_option( self::notifications_copy_option_name( $user_id ) ); + } + + /** + * Name of the option holding a user's local copy of their notification choice. + * + * @since $$next-version$$ + * + * @param int $user_id User ID. + * + * @return string + */ + public static function notifications_copy_option_name( $user_id ) { + return 'monitor_receive_notifications' . (int) $user_id; + } + + /** + * Saves the current user's notification choice where Jetpack_Core_Json_Api_Endpoints::get_remote_value reads it. + * + * @since $$next-version$$ + * + * @param bool $enabled Whether the current user receives notifications. + * + * @return void + */ + public static function save_notifications_copy( $enabled ) { + // An integer: see Jetpack_Core_Json_Api_Endpoints::get_remote_value. + update_option( self::notifications_copy_option_name( get_current_user_id() ), (int) (bool) $enabled ); } /** @@ -82,8 +139,7 @@ public function update_option_receive_jetpack_monitor_notification( $value ) { wp_die( sprintf( '%s: %s', esc_html( $xml->getErrorCode() ), esc_html( $xml->getErrorMessage() ) ) ); } - // To be used only in Jetpack_Core_Json_Api_Endpoints::get_remote_value. - update_option( 'monitor_receive_notifications', (bool) $value ); + self::save_notifications_copy( $value ); return true; } diff --git a/projects/plugins/jetpack/src/abilities/class-monitor-abilities.php b/projects/plugins/jetpack/src/abilities/class-monitor-abilities.php index c9f94dfc3b0f..d223eb211e17 100644 --- a/projects/plugins/jetpack/src/abilities/class-monitor-abilities.php +++ b/projects/plugins/jetpack/src/abilities/class-monitor-abilities.php @@ -13,6 +13,7 @@ use Automattic\Jetpack\WP_Abilities\Registrar; use Jetpack; use Jetpack_IXR_Client; +use Jetpack_Monitor; if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); @@ -225,10 +226,10 @@ public static function get_monitor_status( $input = null ) { // phpcs:ignore Var /** * Execute: declarative state-setter. Idempotent — compares desired vs current - * and returns changed=false when they match. Either way the local - * `monitor_receive_notifications` option is synced to the remote value (after - * the write on a change, and on the no-op path) so the legacy REST reader, - * which trusts that option first, never reports a stale state. + * and returns changed=false when they match. Either way the current user's + * local copy of the value is synced to the remote one (after the write on a + * change, and on the no-op path) so the legacy REST reader, which trusts that + * copy first, never reports a stale state. * * @param array|null $input Input matching the ability's input_schema. * @return array|\WP_Error @@ -270,14 +271,8 @@ public static function set_notifications( $input = null ) { } if ( $desired === $current ) { - // Sync the local `monitor_receive_notifications` option to the - // known-good remote value even on a no-op. The legacy - // `Jetpack_Core_Json_Api_Endpoints::get_remote_value` reader trusts - // this option before falling back to a remote read, so a stale local - // value would let it report the wrong state. The changed=true path - // below mirrors the option after a write; mirroring here keeps the - // unchanged path self-healing too. - update_option( 'monitor_receive_notifications', $current ); + // Sync even on a no-op, so a stale local copy heals itself. + Jetpack_Monitor::save_notifications_copy( $current ); return array( 'enabled' => $current, @@ -290,10 +285,8 @@ public static function set_notifications( $input = null ) { return $applied; } - // Mirror the write to the `monitor_receive_notifications` option so the - // legacy `Jetpack_Core_Json_Api_Endpoints::get_remote_value` reader — the - // only other reader of this option — stays in sync with the remote state. - update_option( 'monitor_receive_notifications', $desired ); + // Keep the legacy REST reader's local copy in sync with the write. + Jetpack_Monitor::save_notifications_copy( $desired ); return array( 'enabled' => $desired, diff --git a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_REST_API_endpoints_Test.php b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_REST_API_endpoints_Test.php index 3adc897feee3..d7059bd2a033 100644 --- a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_REST_API_endpoints_Test.php +++ b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_REST_API_endpoints_Test.php @@ -6,6 +6,7 @@ * @package automattic/jetpack */ +use Automattic\Jetpack\Connection\Manager as Connection_Manager; use Automattic\Jetpack\Connection\REST_Connector; use Automattic\Jetpack\Status\Cache as StatusCache; use PHPUnit\Framework\Attributes\DataProvider; @@ -1052,6 +1053,114 @@ public function test_settings_retrieve() { $this->assertTrue( $response_data['carousel'] ); } + /** + * Each admin reads their own Monitor email choice, not the one another admin saved. + */ + public function test_settings_retrieve_monitor_notifications_per_user() { + $subscribed = $this->create_and_get_user( 'administrator' ); + $unsubscribed = $this->create_and_get_user( 'administrator' ); + update_option( 'monitor_receive_notifications' . $subscribed->ID, 1 ); + update_option( 'monitor_receive_notifications' . $unsubscribed->ID, 0 ); + + wp_set_current_user( $subscribed->ID ); + $this->assertTrue( $this->create_and_get_request( 'settings' )->get_data()['monitor_receive_notifications'] ); + + wp_set_current_user( $unsubscribed->ID ); + $this->assertFalse( $this->create_and_get_request( 'settings' )->get_data()['monitor_receive_notifications'] ); + } + + /** + * A first read saves what WordPress.com answered, and saves nothing when the lookup fails. + * + * @dataProvider provider_monitor_notification_lookups + * + * @param string|null $answer XML-RPC boolean WordPress.com answers with, or null for a failed request. + * @param string $expected The saved copy afterwards. + */ + #[DataProvider( 'provider_monitor_notification_lookups' )] + public function test_settings_retrieve_monitor_notifications_remote_lookup( $answer, $expected ) { + $user = $this->create_and_get_user( 'administrator' ); + wp_set_current_user( $user->ID ); + Jetpack_Options::update_option( 'id', 1234 ); + Jetpack_Options::update_option( 'blog_token', 'asd.qwe.1' ); + Jetpack_Options::update_option( 'user_tokens', array( $user->ID => "honey.badger.$user->ID" ) ); + + add_filter( + 'pre_http_request', + static function () use ( $answer ) { + if ( null === $answer ) { + return new WP_Error( 'http_request_failed', 'Could not connect.' ); + } + return array( + 'body' => "$answer", + 'response' => array( 'code' => 200 ), + ); + } + ); + + $this->assertSame( '1' === $answer, $this->create_and_get_request( 'settings' )->get_data()['monitor_receive_notifications'] ); + $this->assertSame( $expected, (string) get_option( 'monitor_receive_notifications' . $user->ID, 'not saved' ) ); + } + + public static function provider_monitor_notification_lookups() { + return array( + 'subscribed' => array( '1', '1' ), + 'unsubscribed' => array( '0', '0' ), + 'failed lookup' => array( null, 'not saved' ), + ); + } + + /** + * Unlinking or deleting a user forgets their saved Monitor email choice. + * + * @dataProvider provider_monitor_forgetting_hooks + * + * @param string $hook Action that fires with the user ID. + */ + #[DataProvider( 'provider_monitor_forgetting_hooks' )] + public function test_monitor_notifications_copy_is_forgotten( $hook ) { + require_once JETPACK__PLUGIN_DIR . 'modules/monitor.php'; + new Jetpack_Monitor(); + $user = $this->create_and_get_user( 'administrator' ); + update_option( 'monitor_receive_notifications' . $user->ID, 1 ); + + // Core's `deleted_user` passes three arguments, and its own multisite callback requires them. + do_action( $hook, $user->ID, null, $user ); + + $this->assertFalse( get_option( 'monitor_receive_notifications' . $user->ID ) ); + } + + public static function provider_monitor_forgetting_hooks() { + return array( + 'unlinked' => array( 'jetpack_unlinked_user' ), + 'deleted' => array( 'deleted_user' ), + ); + } + + /** + * Disconnecting the site forgets the saved Monitor email choice of every linked user. + */ + public function test_monitor_notifications_copies_are_forgotten_on_site_disconnect() { + require_once JETPACK__PLUGIN_DIR . 'modules/monitor.php'; + new Jetpack_Monitor(); + $first = $this->create_and_get_user( 'administrator' ); + $second = $this->create_and_get_user( 'administrator' ); + Jetpack_Options::update_option( + 'user_tokens', + array( + $first->ID => "honey.badger.$first->ID", + $second->ID => "honey.badger.$second->ID", + ) + ); + update_option( 'monitor_receive_notifications' . $first->ID, 1 ); + update_option( 'monitor_receive_notifications' . $second->ID, 0 ); + + ( new Connection_Manager( 'jetpack' ) )->disconnect_site( false ); + + $this->assertFalse( get_option( 'monitor_receive_notifications' . $first->ID ) ); + $this->assertFalse( get_option( 'monitor_receive_notifications' . $second->ID ) ); + } + /** * Test fetching milestone widget data. * diff --git a/projects/plugins/jetpack/tests/php/src/Monitor_Abilities_Test.php b/projects/plugins/jetpack/tests/php/src/Monitor_Abilities_Test.php index 0275bd40e5a5..676426ba2bc2 100644 --- a/projects/plugins/jetpack/tests/php/src/Monitor_Abilities_Test.php +++ b/projects/plugins/jetpack/tests/php/src/Monitor_Abilities_Test.php @@ -36,6 +36,9 @@ class Monitor_Abilities_Test extends WP_UnitTestCase { public function set_up() { parent::set_up(); + // The abilities save through Jetpack_Monitor, which only loads with the module. + require_once JETPACK__PLUGIN_DIR . 'modules/monitor.php'; + $this->admin_id = wp_insert_user( array( 'user_login' => 'monitor_abilities_admin_' . wp_generate_password( 8, false, false ), @@ -79,7 +82,7 @@ public function tear_down() { // registrar never registers and must never tear down. delete_transient( 'monitor_last_downtime' ); - delete_option( 'monitor_receive_notifications' ); + delete_option( 'monitor_receive_notifications' . $this->admin_id ); parent::tear_down(); } @@ -460,8 +463,13 @@ static function ( $mods ) { * `Monitor_Abilities_Test_Stub` test double (defined below this class) overrides * the protected seams so we can drive `set_notifications()` through its * end-to-end logic without standing up a Jetpack token fixture. + * + * @dataProvider provider_flipped_states + * + * @param bool $desired The state to flip to. */ - public function test_set_notifications_returns_changed_true_when_state_flips() { + #[DataProvider( 'provider_flipped_states' )] + public function test_set_notifications_returns_changed_true_when_state_flips( bool $desired ) { wp_set_current_user( $this->admin_id ); add_filter( @@ -473,18 +481,24 @@ static function ( $mods ) { } ); - Monitor_Abilities_Test_Stub::reset( false ); + Monitor_Abilities_Test_Stub::reset( ! $desired ); - $result = Monitor_Abilities_Test_Stub::set_notifications( array( 'enabled' => true ) ); + $result = Monitor_Abilities_Test_Stub::set_notifications( array( 'enabled' => $desired ) ); $this->assertIsArray( $result ); - $this->assertTrue( $result['enabled'] ); + $this->assertSame( $desired, $result['enabled'] ); $this->assertTrue( $result['changed'] ); $this->assertSame( 1, Monitor_Abilities_Test_Stub::$apply_calls, 'IXR setNotifications should have been called exactly once.' ); - $this->assertTrue( Monitor_Abilities_Test_Stub::$last_applied, 'apply_notifications_update should have been called with the desired value.' ); - // `update_option` stores booleans as '1'/'' through the DB roundtrip; assert truthiness - // rather than the exact string so this test is robust to env differences. - $this->assertTrue( (bool) get_option( 'monitor_receive_notifications' ), 'monitor_receive_notifications option should mirror the new state.' ); + $this->assertSame( $desired, Monitor_Abilities_Test_Stub::$last_applied, 'apply_notifications_update should have been called with the desired value.' ); + $this->assertSame( $desired ? '1' : '0', (string) get_option( 'monitor_receive_notifications' . $this->admin_id, 'not stored' ), 'The user\'s monitor_receive_notifications option should mirror the new state.' ); + $this->assertFalse( get_option( 'monitor_receive_notifications' ), 'The value must not be stored where other users would read it.' ); + } + + public static function provider_flipped_states(): array { + return array( + 'off to on' => array( true ), + 'on to off' => array( false ), + ); } public function test_set_notifications_returns_changed_false_when_state_matches() { @@ -503,7 +517,7 @@ static function ( $mods ) { // Seed a stale local option (false) that disagrees with the remote state // (true) to exercise the self-healing sync on the no-op path. - update_option( 'monitor_receive_notifications', false ); + update_option( 'monitor_receive_notifications' . $this->admin_id, 0 ); $result = Monitor_Abilities_Test_Stub::set_notifications( array( 'enabled' => true ) ); @@ -514,7 +528,7 @@ static function ( $mods ) { // Even on a no-op the local option is synced to the known-good remote // value (true here) so the legacy REST reader, which trusts this option // before going remote, can't surface a stale state. - $this->assertTrue( (bool) get_option( 'monitor_receive_notifications' ), 'No-op should still sync the mirrored option to the remote value.' ); + $this->assertSame( '1', (string) get_option( 'monitor_receive_notifications' . $this->admin_id ), 'No-op should still sync the mirrored option to the remote value.' ); } /** From fb8a2a665269a86c390b030d113e88fcfa76b276 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 13 Oct 2026 11:39:59 -0700 Subject: [PATCH 34/57] Protect Dashboard: Add the Monitor section (#53194) * Protect Dashboard: Add a feature-flagged module with a blank wp-build Protect page * Protect Dashboard: Take over the Protect page and switch from the Jetpack Protect plugin * Protect Dashboard: Name the module Protect and reuse the Protect plugin's description * Protect Dashboard: Pass Page its required children * Protect Dashboard: Add tabbed layout and section framework * Protect Dashboard: Update lockfile for @wordpress/build peer resolution * Protect Dashboard: Address review feedback for the foundation * Protect Dashboard: Render card titles as headings * Protect Dashboard: Keep the _doing_it_wrong version tag replaceable * Protect Dashboard: Add the Monitor section * Protect Dashboard: Address review feedback for the Monitor section * Protect Dashboard: Fix Monitor stylelint error * My Jetpack: Point Protect's manage link at the dashboard module With the protect-dashboard module active and no standalone plugin, the link went to protect-details or Jetpack Cloud instead of the Protect page. * Protect Dashboard: Test the flag gate and the menu takeover * Protect Dashboard: Capture render output with output buffering * Protect Dashboard: Keep IP list drafts and let a failed settings load retry A failed save no longer resets the IP list field to the saved value, and a failed settings load now clears its error on retry and offers a Try again button. Section types take an optional state type, the tab search param is narrowed at runtime, and the settings hook returns a stable object. * Protect Dashboard: Build ProtectCard on the shared Card component ProtectCard and CardRow keep their props and now render through Card from the WordPress UI package, so border, radius, background and header spacing come from the design system. * Protect Dashboard: Add tests for the settings hook, IP list field and section registry Adds routes/ to the client Jest roots so route tests run. * Protect Dashboard: Move section and threat tests into the module suite The base branch added a Jetpack_Protect_Dashboard_Test class of its own, so the section registry tests now live in that class and the threats test moves into the same suite directory. * My Jetpack: Read Protect's Jetpack delivery from a filter My Jetpack no longer depends on the feature flags package, so the Jetpack plugin now reports the flag through jetpack_my_jetpack_protect_in_jetpack. * Protect Dashboard: Type the Monitor section's state Pass MonitorState to ProtectSection and DashboardContext now that they take a state type, and drop the casts. * Protect Dashboard: Move the Monitor section into the protect package * Protect Dashboard: Keep dashboard tests independent of real sections * Protect Dashboard: Harden the Monitor section and add tests Refuse the uptime route while Monitor is off, drop the cached history when Monitor is toggled, wait for a Monitor save before fetching, count the days actually returned in the heading, keep the bars visible in forced-colors mode, and declare the connection package dependency. * Protect Dashboard: Test when the Monitor settings toggles are enabled * Protect Dashboard: Address review findings for the Monitor section Re-read the email setting after Monitor is turned on, lock the email toggle for users without a WordPress.com connection, clear the cached history from any Monitor toggle, stop one user's refused request from being cached for every admin, make the day summary translatable as a whole, and cover the route's permission check and the card's remaining states in tests. * Protect Dashboard: Link the Monitor card to Settings with TabLink Trunk replaced SettingsLink with TabLink and the openSettings context callback with openTab, so the Monitor card no longer built. * Protect Dashboard: Keep the email toggle locked while Monitor turns on Turning Monitor on re-reads the email setting once the save settles. The email toggle unlocked as soon as the save finished, so a change made before the re-read landed was overwritten on screen by the older value while the server kept the new one. Hold the toggle until the re-read is done. --------- Co-authored-by: Brandon Kraft --- .../changelog/add-protect-dashboard-monitor | 4 + projects/packages/protect/composer.json | 1 + .../data/test/use-protect-settings.test.ts | 13 + .../dashboard/data/use-protect-settings.ts | 19 +- .../dashboard/sections/monitor/index.tsx | 9 +- .../sections/monitor/monitor-card.tsx | 203 +++++++++++ .../sections/monitor/settings-card.tsx | 84 +++++ .../dashboard/sections/monitor/style.scss | 42 +++ .../monitor/test/monitor-card.test.tsx | 80 ++++ .../monitor/test/settings-card.test.tsx | 91 +++++ .../dashboard/sections/monitor/types.ts | 26 ++ .../protect/src/sections/class-monitor.php | 215 +++++++++++ .../protect/tests/php/Monitor_Test.php | 345 ++++++++++++++++++ .../changelog/add-protect-dashboard-monitor | 4 + projects/plugins/jetpack/composer.lock | 3 +- 15 files changed, 1135 insertions(+), 4 deletions(-) create mode 100644 projects/packages/protect/changelog/add-protect-dashboard-monitor create mode 100644 projects/packages/protect/routes/dashboard/sections/monitor/monitor-card.tsx create mode 100644 projects/packages/protect/routes/dashboard/sections/monitor/settings-card.tsx create mode 100644 projects/packages/protect/routes/dashboard/sections/monitor/style.scss create mode 100644 projects/packages/protect/routes/dashboard/sections/monitor/test/monitor-card.test.tsx create mode 100644 projects/packages/protect/routes/dashboard/sections/monitor/test/settings-card.test.tsx create mode 100644 projects/packages/protect/routes/dashboard/sections/monitor/types.ts create mode 100644 projects/packages/protect/src/sections/class-monitor.php create mode 100644 projects/packages/protect/tests/php/Monitor_Test.php create mode 100644 projects/plugins/jetpack/changelog/add-protect-dashboard-monitor diff --git a/projects/packages/protect/changelog/add-protect-dashboard-monitor b/projects/packages/protect/changelog/add-protect-dashboard-monitor new file mode 100644 index 000000000000..b3b6a644bec5 --- /dev/null +++ b/projects/packages/protect/changelog/add-protect-dashboard-monitor @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add the Monitor section to the Protect dashboard, with 40 days of uptime and its settings. diff --git a/projects/packages/protect/composer.json b/projects/packages/protect/composer.json index 602496bc6edd..cfe3c49e8b72 100644 --- a/projects/packages/protect/composer.json +++ b/projects/packages/protect/composer.json @@ -6,6 +6,7 @@ "require": { "php": ">=7.4", "automattic/jetpack-admin-ui": "@dev", + "automattic/jetpack-connection": "@dev", "automattic/jetpack-protect-models": "@dev", "automattic/jetpack-protect-status": "@dev", "automattic/jetpack-status": "@dev", diff --git a/projects/packages/protect/routes/dashboard/data/test/use-protect-settings.test.ts b/projects/packages/protect/routes/dashboard/data/test/use-protect-settings.test.ts index a12de26eb91b..8f9b91d69765 100644 --- a/projects/packages/protect/routes/dashboard/data/test/use-protect-settings.test.ts +++ b/projects/packages/protect/routes/dashboard/data/test/use-protect-settings.test.ts @@ -63,6 +63,19 @@ describe( 'useProtectSettings', () => { expect( result.current.isSaving( 'list' ) ).toBe( false ); } ); + it( 'refresh takes only the requested keys from the server, even ones saved earlier', async () => { + respondWith( { + get: Promise.resolve( { email: true, other: 1 } ), + post: Promise.resolve( {} ), + } ); + const { result } = renderHook( () => useProtectSettings() ); + await act( () => result.current.save( { email: false, other: 2 } ) ); + + await act( () => result.current.refresh( [ 'email' ] ) ); + + expect( result.current.settings ).toEqual( { email: true, other: 2 } ); + } ); + it( 'keeps a value saved while the load was in flight', async () => { const get = defer(); respondWith( { get: get.promise, post: Promise.resolve( {} ) } ); diff --git a/projects/packages/protect/routes/dashboard/data/use-protect-settings.ts b/projects/packages/protect/routes/dashboard/data/use-protect-settings.ts index 7175fb16a67c..5b4a8964e03e 100644 --- a/projects/packages/protect/routes/dashboard/data/use-protect-settings.ts +++ b/projects/packages/protect/routes/dashboard/data/use-protect-settings.ts @@ -23,6 +23,8 @@ export type ProtectSettingsData = { load: () => void; /** Saves to `path` (default `/jetpack/v4/settings`) and merges the patch into `settings`. */ save: ( patch: ProtectSettings, path?: string ) => Promise< void >; + /** Re-reads the given keys, for settings the server changes as a side effect of another save. */ + refresh: ( keys: string[] ) => Promise< void >; }; /** @@ -120,11 +122,24 @@ export default function useProtectSettings(): ProtectSettingsData { [ setSettings ] ); + const refresh = useCallback( + async ( keys: string[] ) => { + try { + const latest = await apiFetch< ProtectSettings >( { path: '/jetpack/v4/settings' } ); + const fresh = keys.filter( key => key in latest ).map( key => [ key, latest[ key ] ] ); + setSettings( { ...settingsRef.current, ...Object.fromEntries( fresh ) } ); + } catch { + // Keep what we have; the next full load corrects it. + } + }, + [ setSettings ] + ); + const isSaving = useCallback( ( key: string ) => saving.includes( key ), [ saving ] ); const dismissError = useCallback( () => setError( null ), [] ); return useMemo( - () => ( { settings, isLoaded, waf, error, dismissError, isSaving, load, save } ), - [ settings, isLoaded, waf, error, dismissError, isSaving, load, save ] + () => ( { settings, isLoaded, waf, error, dismissError, isSaving, load, save, refresh } ), + [ settings, isLoaded, waf, error, dismissError, isSaving, load, save, refresh ] ); } diff --git a/projects/packages/protect/routes/dashboard/sections/monitor/index.tsx b/projects/packages/protect/routes/dashboard/sections/monitor/index.tsx index 96a7c5a4384f..875b9b70e2c1 100644 --- a/projects/packages/protect/routes/dashboard/sections/monitor/index.tsx +++ b/projects/packages/protect/routes/dashboard/sections/monitor/index.tsx @@ -1,5 +1,12 @@ +import MonitorCard from './monitor-card'; +import MonitorSettingsCard from './settings-card'; import type { ProtectSection } from '../types'; +import type { MonitorState } from './types'; -const section: ProtectSection = { key: 'monitor' }; +const section: ProtectSection< MonitorState | undefined > = { + key: 'monitor', + OverviewCard: MonitorCard, + SettingsCard: MonitorSettingsCard, +}; export default section; diff --git a/projects/packages/protect/routes/dashboard/sections/monitor/monitor-card.tsx b/projects/packages/protect/routes/dashboard/sections/monitor/monitor-card.tsx new file mode 100644 index 000000000000..e37a6b8e46e7 --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/monitor/monitor-card.tsx @@ -0,0 +1,203 @@ +import apiFetch from '@wordpress/api-fetch'; +import { getSettings, gmdateI18n } from '@wordpress/date'; +import { useEffect, useState } from '@wordpress/element'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { seen } from '@wordpress/icons'; +import { Skeleton, Stack, Text, VisuallyHidden } from '@wordpress/ui'; +import { CardRow, ProtectCard } from '../../components/card'; +import TabLink from '../../components/tab-link'; +import isModuleActive from '../../data/is-module-active'; +import type { MonitorContext, Uptime, UptimeDay } from './types'; +import type { CardStatus } from '../../components/card'; +import './style.scss'; + +/** + * Text for one day's bar. + * + * @param day - The day. + * @return The text. + */ +function describeDay( day: UptimeDay ): string { + // Days are UTC dates, so format them in UTC to keep the same day. + const date = gmdateI18n( getSettings().formats.date, `${ day.date }T00:00:00Z` ); + if ( day.status === 'up' ) { + /* translators: %s is a date. */ + return sprintf( __( '%s: 100%% uptime', 'jetpack-protect-pkg' ), date ); + } + if ( day.status === 'down' ) { + return sprintf( + /* translators: %1$s is a date. %2$d is a number of minutes. */ + _n( + '%1$s: down for %2$d minute', + '%1$s: down for %2$d minutes', + day.downtimeInMinutes, + 'jetpack-protect-pkg' + ), + date, + day.downtimeInMinutes + ); + } + /* translators: %s is a date. */ + return sprintf( __( '%s: no data', 'jetpack-protect-pkg' ), date ); +} + +/** + * The card's badge. + * + * @param available - Whether Monitor can run on this site. + * @param active - Whether Monitor is on. + * @param uptime - The uptime response, once loaded. + * @param failed - Whether loading it failed. + * @return The badge, if any. + */ +function getStatus( + available: boolean, + active: boolean, + uptime: Uptime | null, + failed: boolean +): CardStatus | undefined { + if ( ! available ) { + return { label: __( 'Unavailable', 'jetpack-protect-pkg' ), intent: 'draft' }; + } + if ( ! active ) { + return { label: __( 'Off', 'jetpack-protect-pkg' ), intent: 'draft' }; + } + if ( uptime?.isUp === true ) { + return { label: __( 'Operational', 'jetpack-protect-pkg' ), intent: 'stable' }; + } + if ( uptime?.isUp === false ) { + return { label: __( 'Down', 'jetpack-protect-pkg' ), intent: 'high' }; + } + if ( uptime || failed ) { + return { label: __( 'Status unknown', 'jetpack-protect-pkg' ), intent: 'none' }; + } + return undefined; +} + +/** + * The Monitor card: current status and daily uptime, one bar per day. + * + * @param props - The dashboard context. + * @param props.state - The Monitor module's state from PHP. + * @param props.settings - Jetpack settings, for the module's live state. + * @param props.openTab - Switches dashboard tabs, for the link to Settings. + * @return The card. + */ +export default function MonitorCard( { state: monitor, settings, openTab }: MonitorContext ) { + const available = Boolean( monitor?.available ); + // Saves apply optimistically, and the uptime route refuses until Monitor is really on. + const active = + available && + ! settings.isSaving( 'monitor' ) && + isModuleActive( settings.settings, 'monitor', Boolean( monitor?.active ) ); + const [ uptime, setUptime ] = useState< Uptime | null >( null ); + const [ failed, setFailed ] = useState( false ); + + useEffect( () => { + if ( ! active ) { + return; + } + let current = true; + setUptime( null ); + setFailed( false ); + apiFetch< Uptime >( { path: '/jetpack/v4/protect-dashboard/uptime' } ) + .then( response => current && setUptime( response ) ) + .catch( () => current && setFailed( true ) ); + return () => { + current = false; + }; + }, [ active ] ); + + const days = uptime?.days; + const count = ( status: UptimeDay[ 'status' ] ) => + days?.filter( day => day.status === status ).length ?? 0; + const summary = sprintf( + /* translators: %1$s, %2$s and %3$s are counts of days: "38 days up", "1 day down", "1 day with no data". */ + __( '%1$s, %2$s, %3$s', 'jetpack-protect-pkg' ), + /* translators: %d is a number of days. */ + sprintf( _n( '%d day up', '%d days up', count( 'up' ), 'jetpack-protect-pkg' ), count( 'up' ) ), + sprintf( + /* translators: %d is a number of days. */ + _n( '%d day down', '%d days down', count( 'down' ), 'jetpack-protect-pkg' ), + count( 'down' ) + ), + sprintf( + /* translators: %d is a number of days. */ + _n( + '%d day with no data', + '%d days with no data', + count( 'monitor_inactive' ), + 'jetpack-protect-pkg' + ), + count( 'monitor_inactive' ) + ) + ); + + const body = available + ? __( + 'Turn on downtime monitoring to get an email the moment your site goes down.', + 'jetpack-protect-pkg' + ) + : __( 'Downtime monitoring isn’t available on this site.', 'jetpack-protect-pkg' ); + + return ( + + + { ! active && { body } } + { active && ( + + + { sprintf( + /* translators: %d is a number of days. */ + __( 'Uptime, last %d days (UTC)', 'jetpack-protect-pkg' ), + days?.length ?? monitor?.uptimeDays ?? 0 + ) } + + { failed && ( + + { __( 'Uptime history is unavailable right now.', 'jetpack-protect-pkg' ) } + + ) } + { ! failed && ! days && } + { ! failed && days && ( + <> + + + { summary } + + +
    + { days.map( day => ( +
  • { describeDay( day ) }
  • + ) ) } +
+
+ + ) } +
+ ) } +
+ { available && ( + + + { active + ? __( 'Configure Downtime Monitoring', 'jetpack-protect-pkg' ) + : __( 'Turn on in Settings', 'jetpack-protect-pkg' ) } + + + ) } +
+ ); +} diff --git a/projects/packages/protect/routes/dashboard/sections/monitor/settings-card.tsx b/projects/packages/protect/routes/dashboard/sections/monitor/settings-card.tsx new file mode 100644 index 000000000000..e9506a301bc2 --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/monitor/settings-card.tsx @@ -0,0 +1,84 @@ +import { useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { seen } from '@wordpress/icons'; +import { Stack } from '@wordpress/ui'; +import { CardRow, ProtectCard } from '../../components/card'; +import SettingToggle from '../../components/settings/setting-toggle'; +import type { MonitorContext } from './types'; + +/** + * The Monitor settings: downtime monitoring and its email alerts. + * + * @param props - The dashboard context. + * @param props.state - The Monitor module's state from PHP. + * @param props.settings - Jetpack settings and their save function. + * @return The card. + */ +export default function MonitorSettingsCard( { state, settings }: MonitorContext ) { + const available = Boolean( state?.available ); + const userConnected = Boolean( state?.userConnected ); + const { save, refresh } = settings; + // Locks the email toggle until the re-read lands, which would otherwise overwrite a change made meanwhile. + const [ isEnabling, setIsEnabling ] = useState( false ); + // Turning Monitor on subscribes the user to its emails on the server. + const monitorData = useMemo( + () => ( { + ...settings, + save: async ( patch, path ) => { + if ( patch.monitor !== true ) { + return save( patch, path ); + } + setIsEnabling( true ); + try { + await save( patch, path ); + await refresh( [ 'monitor_receive_notifications' ] ); + } finally { + setIsEnabling( false ); + } + }, + } ), + [ settings, save, refresh ] + ); + return ( + + + + + + + + + ); +} diff --git a/projects/packages/protect/routes/dashboard/sections/monitor/style.scss b/projects/packages/protect/routes/dashboard/sections/monitor/style.scss new file mode 100644 index 000000000000..d89010ce88a3 --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/monitor/style.scss @@ -0,0 +1,42 @@ +$uptime-down: var(--wpds-color-stroke-surface-error-strong); +$uptime-down-bg: var(--wpds-color-background-surface-error); + +.jp-protect-uptime { + display: flex; + align-items: flex-end; + gap: 4px; + block-size: 40px; + + &__bar { + flex: 1; + min-inline-size: 2px; + block-size: 40%; + border-radius: 2px; + background: var(--wpds-color-background-track-neutral); + + &.is-up { + block-size: 100%; + background: var(--wpds-color-stroke-surface-success-strong); + } + + // Stripes, so down days don't rely on color alone. + &.is-down { + block-size: 100%; + background: repeating-linear-gradient(45deg, $uptime-down 0 3px, $uptime-down-bg 3px 5px); + } + + // Forced colors drop solid backgrounds, leaving only down days visible. + @media (forced-colors: active) { + border: 1px solid CanvasText; + + &.is-up { + background: CanvasText; + forced-color-adjust: none; + } + } + } + + &__skeleton { + block-size: 40px; + } +} diff --git a/projects/packages/protect/routes/dashboard/sections/monitor/test/monitor-card.test.tsx b/projects/packages/protect/routes/dashboard/sections/monitor/test/monitor-card.test.tsx new file mode 100644 index 000000000000..cf3d8c391baf --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/monitor/test/monitor-card.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react'; +import apiFetch from '@wordpress/api-fetch'; +import MonitorCard from '../monitor-card'; +import type { ProtectSettingsData } from '../../../data/use-protect-settings'; +import type { MonitorState, UptimeDay } from '../types'; + +jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) ); + +const mockApiFetch = apiFetch as unknown as jest.Mock; + +const days: UptimeDay[] = [ + { date: '2026-03-12', status: 'monitor_inactive', downtimeInMinutes: 0 }, + { date: '2026-03-13', status: 'down', downtimeInMinutes: 12 }, + { date: '2026-03-14', status: 'up', downtimeInMinutes: 0 }, +]; + +const renderCard = ( + settings: Partial< ProtectSettingsData > = {}, + state: Partial< MonitorState > = {} +) => + render( + false, ...settings } as ProtectSettingsData } + openTab={ jest.fn() } + /> + ); + +describe( 'MonitorCard', () => { + beforeEach( () => { + mockApiFetch.mockReset(); + } ); + + it.each( [ + [ 'up', true, 'Operational' ], + [ 'down', false, 'Down' ], + [ 'unknown', null, 'Status unknown' ], + ] )( 'shows the current status when the site is %s', async ( _name, isUp, badge ) => { + mockApiFetch.mockResolvedValue( { days, isUp } ); + renderCard(); + + await expect( screen.findByText( badge ) ).resolves.toBeInTheDocument(); + expect( screen.getByText( 'Uptime, last 3 days (UTC)' ) ).toBeInTheDocument(); + expect( screen.getByText( '1 day up, 1 day down, 1 day with no data' ) ).toBeInTheDocument(); + expect( screen.getAllByRole( 'listitem' ).map( item => item.textContent ) ).toEqual( [ + 'March 12, 2026: no data', + 'March 13, 2026: down for 12 minutes', + 'March 14, 2026: 100% uptime', + ] ); + expect( mockApiFetch ).toHaveBeenCalledWith( { path: '/jetpack/v4/protect-dashboard/uptime' } ); + } ); + + it.each( [ + [ 'Monitor is unavailable', { available: false }, null, 'Unavailable' ], + [ 'Monitor is off', { active: false }, null, 'Off' ], + [ 'Monitor was turned off since page load', {}, { monitor: false }, 'Off' ], + ] )( 'asks for no uptime when %s', ( _name, state, liveSettings, badge ) => { + renderCard( { settings: liveSettings }, state ); + + expect( screen.getByText( badge ) ).toBeInTheDocument(); + expect( mockApiFetch ).not.toHaveBeenCalled(); + } ); + + it( 'says the history is unavailable when it cannot be loaded', async () => { + mockApiFetch.mockRejectedValue( { code: 'not_connected' } ); + renderCard(); + + await expect( + screen.findByText( 'Uptime history is unavailable right now.' ) + ).resolves.toBeInTheDocument(); + expect( screen.getByText( 'Status unknown' ) ).toBeInTheDocument(); + expect( screen.queryByRole( 'listitem' ) ).not.toBeInTheDocument(); + } ); + + it( 'waits for a Monitor save to finish before asking for uptime', () => { + renderCard( { settings: { monitor: true }, isSaving: key => key === 'monitor' } ); + + expect( mockApiFetch ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/projects/packages/protect/routes/dashboard/sections/monitor/test/settings-card.test.tsx b/projects/packages/protect/routes/dashboard/sections/monitor/test/settings-card.test.tsx new file mode 100644 index 000000000000..bbebf31316c6 --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/monitor/test/settings-card.test.tsx @@ -0,0 +1,91 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MonitorSettingsCard from '../settings-card'; +import type { ProtectSettingsData } from '../../../data/use-protect-settings'; + +const MONITOR = 'Monitor your site for downtime'; +const EMAIL = 'Email me when my site goes down and comes back up'; + +const card = ( { + available = true, + monitor = true, + userConnected = true, + saving = false, + save = jest.fn(), + refresh = jest.fn(), +} = {} ) => ( + saving, + save, + refresh, + } as unknown as ProtectSettingsData + } + openTab={ jest.fn() } + /> +); + +const renderCard = ( options: Parameters< typeof card >[ 0 ] = {} ) => render( card( options ) ); + +describe( 'MonitorSettingsCard', () => { + it.each( [ + [ 'Monitor is on', {}, true, true ], + [ 'Monitor is off', { monitor: false }, true, false ], + [ 'Monitor is unavailable', { available: false, monitor: false }, false, false ], + [ 'Monitor is unavailable but still set', { available: false }, false, false ], + [ 'the user has no WordPress.com connection', { userConnected: false }, true, false ], + ] )( 'enables the right toggles when %s', ( _name, options, canToggle, canEmail ) => { + renderCard( options ); + + const enabled = ( label: string ) => + ! screen.getByLabelText( label ).hasAttribute( 'disabled' ); + expect( enabled( MONITOR ) ).toBe( canToggle ); + expect( enabled( EMAIL ) ).toBe( canEmail ); + } ); + + it( 'keeps the email toggle locked while a Monitor save is in flight', () => { + renderCard( { saving: true } ); + + expect( screen.getByLabelText( EMAIL ) ).toBeDisabled(); + } ); + + it( 'keeps the email toggle locked until the re-read after turning Monitor on settles', async () => { + let finishRefresh: () => void; + const refresh = jest.fn( + () => + new Promise< void >( resolve => { + finishRefresh = resolve; + } ) + ); + const save = jest.fn().mockResolvedValue( undefined ); + const { rerender } = renderCard( { monitor: false, save, refresh } ); + + await userEvent.setup().click( screen.getByLabelText( MONITOR ) ); + // The Monitor save has settled and the setting now reads on; only the re-read is pending. + rerender( card( { save, refresh } ) ); + expect( screen.getByLabelText( EMAIL ) ).toBeDisabled(); + + await act( async () => finishRefresh() ); + expect( screen.getByLabelText( EMAIL ) ).toBeEnabled(); + } ); + + it.each( [ + [ 'on', false, [ [ [ 'monitor_receive_notifications' ] ] ] ], + [ 'off', true, [] ], + ] )( + 're-reads the email setting only when Monitor is turned %s', + async ( _name, monitor, calls ) => { + const save = jest.fn().mockResolvedValue( undefined ); + const refresh = jest.fn().mockResolvedValue( undefined ); + renderCard( { monitor, save, refresh } ); + + await userEvent.setup().click( screen.getByLabelText( MONITOR ) ); + + expect( save ).toHaveBeenCalledWith( { monitor: ! monitor }, undefined ); + expect( refresh.mock.calls ).toEqual( calls ); + } + ); +} ); diff --git a/projects/packages/protect/routes/dashboard/sections/monitor/types.ts b/projects/packages/protect/routes/dashboard/sections/monitor/types.ts new file mode 100644 index 000000000000..78b63b3ab265 --- /dev/null +++ b/projects/packages/protect/routes/dashboard/sections/monitor/types.ts @@ -0,0 +1,26 @@ +import type { DashboardContext } from '../types'; + +/** The Monitor section's state from PHP. */ +export type MonitorState = { + available: boolean; + active: boolean; + uptimeDays: number; + /** Whether the current user has a WordPress.com connection. */ + userConnected: boolean; +}; + +export type UptimeDay = { + /** A UTC date, as `Y-m-d`. */ + date: string; + status: 'up' | 'down' | 'monitor_inactive'; + downtimeInMinutes: number; +}; + +export type Uptime = { + days: UptimeDay[]; + /** Whether the site is up right now; null when unknown. */ + isUp: boolean | null; +}; + +/** Undefined when PHP registered no Monitor section. */ +export type MonitorContext = DashboardContext< MonitorState | undefined >; diff --git a/projects/packages/protect/src/sections/class-monitor.php b/projects/packages/protect/src/sections/class-monitor.php new file mode 100644 index 000000000000..55187d4ed620 --- /dev/null +++ b/projects/packages/protect/src/sections/class-monitor.php @@ -0,0 +1,215 @@ + self::UPTIME_DAYS, + 'userConnected' => ( new Connection_Manager() )->is_user_connected(), + ) + ); + } + + /** + * Register the uptime route. + * + * @return void + */ + public function register_routes() { + register_rest_route( + 'jetpack/v4', + '/protect-dashboard/uptime', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_uptime' ), + 'permission_callback' => array( Dashboard::class, 'can_manage' ), + ) + ); + } + + /** + * Daily uptime for the last UPTIME_DAYS days (oldest first) and the current status, from WordPress.com. + * + * @return array|WP_Error + */ + public function get_uptime() { + if ( ! Dashboard::get_module_state( 'monitor' )['active'] ) { + return new WP_Error( 'monitor_inactive', __( 'Downtime monitoring is off.', 'jetpack-protect-pkg' ), array( 'status' => 409 ) ); + } + + // The endpoints check `manage_options` for the requesting user, so a blog token is refused. + if ( ! ( new Connection_Manager() )->is_user_connected() ) { + return new WP_Error( 'not_connected', __( 'Connect your WordPress.com account to see uptime.', 'jetpack-protect-pkg' ), array( 'status' => 403 ) ); + } + + $cached = get_transient( self::UPTIME_TRANSIENT ); + if ( is_array( $cached ) ) { + return $cached; + } + if ( 'failed' === $cached ) { + return $this->unavailable_error(); + } + + // It has no 40-day period, so ask for 90 and keep the newest days. + $history = $this->request( '/jetpack-monitor-uptime?period=' . rawurlencode( '90 days' ) ); + $days = $this->parse_days( $history['body'] ); + if ( ! $days ) { + // A 401 or 403 is about this user's token, so it must not hide the history from other admins. + if ( ! in_array( $history['code'], array( 401, 403 ), true ) ) { + set_transient( self::UPTIME_TRANSIENT, 'failed', MINUTE_IN_SECONDS ); + } + return $this->unavailable_error(); + } + + $uptime = array( + 'days' => $days, + 'isUp' => $this->fetch_is_up(), + ); + set_transient( self::UPTIME_TRANSIENT, $uptime, null === $uptime['isUp'] ? MINUTE_IN_SECONDS : 10 * MINUTE_IN_SECONDS ); + return $uptime; + } + + /** + * Forget the cached uptime history. + * + * @return void + */ + public function clear_cache() { + delete_transient( self::UPTIME_TRANSIENT ); + } + + /** + * The newest UPTIME_DAYS days of an uptime history, oldest first; empty when it has no valid days. + * + * @param mixed $body The decoded history, keyed by `Y-m-d` date. + * @return array + */ + private function parse_days( $body ) { + if ( ! is_array( $body ) ) { + return array(); + } + + $body = array_filter( + $body, + function ( $day, $date ) { + return is_array( $day ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) $date ); + }, + ARRAY_FILTER_USE_BOTH + ); + ksort( $body ); + + $days = array(); + foreach ( array_slice( $body, -self::UPTIME_DAYS, null, true ) as $date => $day ) { + $status = $day['status'] ?? null; + $days[] = array( + 'date' => (string) $date, + 'status' => in_array( $status, array( 'up', 'down' ), true ) ? $status : 'monitor_inactive', + 'downtimeInMinutes' => (int) ( $day['downtime_in_minutes'] ?? 0 ), + ); + } + return $days; + } + + /** + * Whether the site is up right now; null when unknown. + * + * @return bool|null + */ + private function fetch_is_up() { + $body = $this->request( '/jetpack-monitor-status' )['body']; + $status = is_array( $body ) ? ( $body['status'] ?? null ) : null; + return is_bool( $status ) ? $status : null; + } + + /** + * GET a wpcom/v2 site endpoint as the current user. + * + * @param string $path Path after `/sites/`. + * @return array The HTTP `code` (0 when the request failed) and the decoded `body`, null unless the code is 200. + */ + private function request( $path ) { + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d', Jetpack_Options::get_option( 'id' ) ) . $path, + '2', + array(), + null, + 'wpcom' + ); + $code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); + return array( + 'code' => $code, + 'body' => 200 === $code ? json_decode( wp_remote_retrieve_body( $response ), true ) : null, + ); + } + + /** + * The error returned when WordPress.com can't be reached. + * + * @return WP_Error + */ + private function unavailable_error() { + return new WP_Error( 'uptime_unavailable', __( 'Uptime history is unavailable right now.', 'jetpack-protect-pkg' ), array( 'status' => 502 ) ); + } +} diff --git a/projects/packages/protect/tests/php/Monitor_Test.php b/projects/packages/protect/tests/php/Monitor_Test.php new file mode 100644 index 000000000000..c6a0c2cfe74b --- /dev/null +++ b/projects/packages/protect/tests/php/Monitor_Test.php @@ -0,0 +1,345 @@ + 'monitor_admin', + 'user_pass' => 'password', + 'role' => 'administrator', + ) + ); + wp_set_current_user( $user_id ); + + Constants::set_constant( 'JETPACK__WPCOM_JSON_API_BASE', 'https://public-api.wordpress.com' ); + Jetpack_Options::update_option( 'id', 123 ); + Jetpack_Options::update_option( 'blog_token', 'blog.token' ); + Jetpack_Options::update_option( 'user_tokens', array( $user_id => "token.secret.$user_id" ) ); + Jetpack_Options::update_option( 'active_modules', array( 'monitor' ) ); + add_filter( 'jetpack_get_available_standalone_modules', array( $this, 'make_monitor_available' ) ); + + $this->requests = array(); + $this->uptime_response = self::json_response( array( '2026-10-01' => array( 'status' => 'up' ) ) ); + $this->status_response = self::json_response( array( 'status' => true ) ); + add_filter( 'pre_http_request', array( $this, 'answer_as_wpcom' ), 10, 3 ); + } + + /** + * Leave no cache, filters or routes behind. + */ + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + + delete_transient( Monitor::UPTIME_TRANSIENT ); + Constants::clear_constants(); + remove_filter( 'pre_http_request', array( $this, 'answer_as_wpcom' ) ); + remove_filter( 'jetpack_get_available_standalone_modules', array( $this, 'make_monitor_available' ) ); + remove_all_actions( 'rest_api_init' ); + remove_all_actions( 'jetpack_activate_module_monitor' ); + remove_all_actions( 'jetpack_deactivate_module_monitor' ); + parent::tear_down(); + } + + /** + * Filter callback: Monitor is a module of this site. + * + * @param string[] $modules Module slugs. + * @return string[] + */ + public function make_monitor_available( $modules ) { + $modules[] = 'monitor'; + return $modules; + } + + /** + * Filter callback: answer WordPress.com requests from the test's fixtures. + * + * @param false|array|WP_Error $preempt Unused. + * @param array $args Unused. + * @param string $url The requested URL. + * @return array|WP_Error + */ + public function answer_as_wpcom( $preempt, $args, $url ) { + $this->requests[] = $url; + if ( false !== strpos( $url, '/wpcom/v2/sites/123/jetpack-monitor-status' ) ) { + return $this->status_response; + } + if ( false !== strpos( $url, '/wpcom/v2/sites/123/jetpack-monitor-uptime?period=90+days' ) ) { + return $this->uptime_response; + } + return new WP_Error( 'unexpected_request', $url ); + } + + /** + * Build an HTTP response. + * + * @param mixed $body The decoded body. + * @param int $code The status code. + * @return array + */ + private static function json_response( $body, $code = 200 ) { + return array( + 'response' => array( 'code' => $code ), + 'body' => wp_json_encode( $body, JSON_UNESCAPED_SLASHES ), + ); + } + + public function test_get_uptime_keeps_the_newest_valid_days_oldest_first() { + $body = array( + 'total' => array( 'status' => 'up' ), + '2026-01-01' => 'not a day', + ); + // 42 days, newest first, so both the sort and the 40-day cut have work to do. + for ( $i = 41; $i >= 0; $i-- ) { + $body[ gmdate( 'Y-m-d', strtotime( "2026-02-01 +$i days" ) ) ] = array( 'status' => 'up' ); + } + $body['2026-03-13'] = array( + 'status' => 'down', + 'downtime_in_minutes' => '12', + ); + $body['2026-03-14'] = array( 'status' => 'paused' ); + $this->uptime_response = self::json_response( $body ); + + $uptime = ( new Monitor() )->get_uptime(); + + $this->assertCount( Monitor::UPTIME_DAYS, $uptime['days'] ); + $this->assertSame( + array( + 'date' => '2026-02-03', + 'status' => 'up', + 'downtimeInMinutes' => 0, + ), + $uptime['days'][0] + ); + $this->assertSame( + array( + array( + 'date' => '2026-03-13', + 'status' => 'down', + 'downtimeInMinutes' => 12, + ), + array( + 'date' => '2026-03-14', + 'status' => 'monitor_inactive', + 'downtimeInMinutes' => 0, + ), + ), + array_slice( $uptime['days'], -2 ) + ); + } + + /** + * Current-status answers, the `isUp` each gives, and how long the result may be cached. + * + * @return array[] + */ + public static function provide_statuses() { + return array( + 'up' => array( self::json_response( array( 'status' => true ) ), true, 10 * MINUTE_IN_SECONDS ), + 'down' => array( self::json_response( array( 'status' => false ) ), false, 10 * MINUTE_IN_SECONDS ), + 'not a boolean' => array( self::json_response( array( 'status' => 'up' ) ), null, MINUTE_IN_SECONDS ), + 'WordPress.com 500' => array( self::json_response( array(), 500 ), null, MINUTE_IN_SECONDS ), + ); + } + + /** + * @dataProvider provide_statuses + * + * @param array $status_response WordPress.com's answer for the current status. + * @param bool|null $is_up The expected `isUp`. + * @param int $max_ttl The longest the result may stay cached, in seconds. + */ + #[DataProvider( 'provide_statuses' )] + public function test_get_uptime_reports_the_current_status_and_caches_an_unknown_one_briefly( $status_response, $is_up, $max_ttl ) { + $this->status_response = $status_response; + $monitor = new Monitor(); + + $uptime = $monitor->get_uptime(); + $monitor->get_uptime(); + + $this->assertSame( $is_up, $uptime['isUp'] ); + $this->assertCount( 2, $this->requests, 'The second call should be served from the cache.' ); + $ttl = (int) get_option( '_transient_timeout_' . Monitor::UPTIME_TRANSIENT ) - time(); + $this->assertGreaterThan( $max_ttl - 10, $ttl ); + $this->assertLessThanOrEqual( $max_ttl, $ttl ); + } + + /** + * Uptime-history answers that can't be shown, and how many requests two calls make. + * + * @return array[] + */ + public static function provide_unusable_histories() { + return array( + 'request failed' => array( new WP_Error( 'http_request_failed', 'Timed out' ), 1 ), + 'WordPress.com 500' => array( self::json_response( array(), 500 ), 1 ), + 'no days' => array( self::json_response( array() ), 1 ), + 'no valid days' => array( self::json_response( array( 'total' => array( 'status' => 'up' ) ) ), 1 ), + 'this user is refused' => array( self::json_response( array(), 403 ), 2 ), + 'this user is not known' => array( self::json_response( array(), 401 ), 2 ), + ); + } + + /** + * @dataProvider provide_unusable_histories + * + * @param array|WP_Error $uptime_response WordPress.com's answer for the uptime history. + * @param int $requests Requests made by two calls: 1 when the failure is cached for everyone. + */ + #[DataProvider( 'provide_unusable_histories' )] + public function test_get_uptime_is_a_502_when_the_history_is_unusable_and_caches_only_shared_failures( $uptime_response, $requests ) { + $this->uptime_response = $uptime_response; + $monitor = new Monitor(); + + $first = $monitor->get_uptime(); + $second = $monitor->get_uptime(); + + $this->assertInstanceOf( WP_Error::class, $first ); + $this->assertSame( 'uptime_unavailable', $first->get_error_code() ); + $this->assertSame( array( 'status' => 502 ), $first->get_error_data() ); + $this->assertInstanceOf( WP_Error::class, $second ); + $this->assertCount( $requests, $this->requests ); + } + + public function test_get_uptime_refuses_a_disconnected_user_even_with_a_warm_cache() { + $monitor = new Monitor(); + $monitor->get_uptime(); + Jetpack_Options::delete_option( 'user_tokens' ); + + $uptime = $monitor->get_uptime(); + + $this->assertInstanceOf( WP_Error::class, $uptime ); + $this->assertSame( 'not_connected', $uptime->get_error_code() ); + $this->assertSame( array( 'status' => 403 ), $uptime->get_error_data() ); + } + + public function test_get_uptime_asks_nothing_of_wpcom_while_monitor_is_off() { + Jetpack_Options::update_option( 'active_modules', array() ); + + $uptime = ( new Monitor() )->get_uptime(); + + $this->assertInstanceOf( WP_Error::class, $uptime ); + $this->assertSame( 'monitor_inactive', $uptime->get_error_code() ); + $this->assertSame( array(), $this->requests ); + } + + public function test_get_state_says_whether_the_user_can_reach_wpcom() { + $monitor = new Monitor(); + + $this->assertTrue( $monitor->get_state()['userConnected'] ); + Jetpack_Options::delete_option( 'user_tokens' ); + $this->assertFalse( $monitor->get_state()['userConnected'] ); + } + + /** + * The hooks Jetpack fires when Monitor is turned on or off. + * + * @return array[] + */ + public static function provide_toggle_hooks() { + return array( + array( 'jetpack_activate_module_monitor' ), + array( 'jetpack_deactivate_module_monitor' ), + ); + } + + /** + * @dataProvider provide_toggle_hooks + * + * @param string $hook The module hook. + */ + #[DataProvider( 'provide_toggle_hooks' )] + public function test_toggling_monitor_drops_the_cached_history( $hook ) { + ( new Monitor() )->get_uptime(); + $this->assertIsArray( get_transient( Monitor::UPTIME_TRANSIENT ) ); + + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- The Jetpack module hooks under test. + do_action( $hook, 'monitor' ); + + $this->assertFalse( get_transient( Monitor::UPTIME_TRANSIENT ) ); + } + + /** + * Users who may not use the route, and the status each gets. + * + * @return array[] + */ + public static function provide_refused_users() { + return array( + 'logged out' => array( null, 401 ), + 'subscriber' => array( 'subscriber', 403 ), + ); + } + + /** + * @dataProvider provide_refused_users + * + * @param string|null $role The user's role, or null when logged out. + * @param int $status The expected HTTP status. + */ + #[DataProvider( 'provide_refused_users' )] + public function test_the_route_refuses_users_who_cannot_manage_the_site( $role, $status ) { + global $wp_rest_server; + $wp_rest_server = null; + add_action( 'rest_api_init', array( new Monitor(), 'register_routes' ) ); + $user_id = null === $role ? 0 : wp_insert_user( + array( + 'user_login' => 'monitor_visitor', + 'user_pass' => 'password', + 'role' => $role, + ) + ); + wp_set_current_user( $user_id ); + + $response = rest_do_request( new \WP_REST_Request( 'GET', '/jetpack/v4/protect-dashboard/uptime' ) ); + + $this->assertSame( $status, $response->get_status() ); + $this->assertSame( array(), $this->requests ); + } +} diff --git a/projects/plugins/jetpack/changelog/add-protect-dashboard-monitor b/projects/plugins/jetpack/changelog/add-protect-dashboard-monitor new file mode 100644 index 000000000000..966ec7d19655 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-protect-dashboard-monitor @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Protect: Add the Monitor section to the Protect dashboard, with 40 days of uptime and its settings. diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index b9bf9f5075f8..873c4e8746ab 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -3236,10 +3236,11 @@ "dist": { "type": "path", "url": "../../packages/protect", - "reference": "3a8c2090533df980dc334554ca364bc1ac471ca0" + "reference": "bfe0e82f7a2d9fac6019ec6ea1c07fe797fcf5ec" }, "require": { "automattic/jetpack-admin-ui": "@dev", + "automattic/jetpack-connection": "@dev", "automattic/jetpack-protect-models": "@dev", "automattic/jetpack-protect-status": "@dev", "automattic/jetpack-status": "@dev", From b41f94d06b6b56296d943af72252a81ca3343425 Mon Sep 17 00:00:00 2001 From: Devin Walker Date: Tue, 13 Oct 2026 11:45:23 -0700 Subject: [PATCH 35/57] WAF: Preserve approved login attempts during math fallback (#50681) * WAF: Add single-use login attempt token * WAF: Preserve approved login attempts * WAF: Add login handoff changelog * WAF: Harden approved login attempt tokens * WAF: Persist single-use login claims * WAF: Keep the username and destination through the math page Carry the username (in its own field, so the math POST is not a login attempt), redirect_to, rememberme and interim-login through the standalone math page, then prompt for the password with the username prefilled. Bind login attempt tokens to the trusted client IP, keep up to five per IP with per-token expiry, claim them with INSERT IGNORE, and check them in math_authenticate() so the kill setting still applies. Make instance() a real singleton. * Jetpack: Rebuild the Brute Force Protection singleton in the Protect sync test instance() is now a real singleton, so calling it no longer re-registers the hooks that each test resets; reset it in set_up() so the Sync module sees jetpack_has_login_ability again. * WAF: Only let login attempt tokens skip math during an API outage A soft block from the Protect API now always requires the math check, even for a form loaded before the block. --- .../changelog/fix-protect-math-login-handoff | 4 + .../fix-protect-math-login-handoff-singleton | 4 + .../class-login-attempt-token.php | 147 ++++++ .../class-math-fallback.php | 79 ++- .../waf/src/class-brute-force-protection.php | 137 ++++-- ...teForceProtectionLoginAttemptTokenTest.php | 452 ++++++++++++++++++ .../BruteForceProtectionMathFallbackTest.php | 143 ++++++ .../integration/BruteForceProtectionTest.php | 7 + .../changelog/fix-protect-math-login-handoff | 4 + .../sync/Jetpack_Sync_Module_Protect_Test.php | 7 +- .../changelog/fix-protect-math-login-handoff | 4 + 11 files changed, 941 insertions(+), 47 deletions(-) create mode 100644 projects/packages/waf/changelog/fix-protect-math-login-handoff create mode 100644 projects/packages/waf/changelog/fix-protect-math-login-handoff-singleton create mode 100644 projects/packages/waf/src/brute-force-protection/class-login-attempt-token.php create mode 100644 projects/packages/waf/tests/php/integration/BruteForceProtectionLoginAttemptTokenTest.php create mode 100644 projects/packages/waf/tests/php/integration/BruteForceProtectionMathFallbackTest.php create mode 100644 projects/plugins/jetpack/changelog/fix-protect-math-login-handoff create mode 100644 projects/plugins/protect/changelog/fix-protect-math-login-handoff diff --git a/projects/packages/waf/changelog/fix-protect-math-login-handoff b/projects/packages/waf/changelog/fix-protect-math-login-handoff new file mode 100644 index 000000000000..85f42283da6a --- /dev/null +++ b/projects/packages/waf/changelog/fix-protect-math-login-handoff @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Brute Force Protection: Fix correct logins being rejected after the math challenge, and keep the username and login destination when the password must be entered again. diff --git a/projects/packages/waf/changelog/fix-protect-math-login-handoff-singleton b/projects/packages/waf/changelog/fix-protect-math-login-handoff-singleton new file mode 100644 index 000000000000..c78aa26b0eda --- /dev/null +++ b/projects/packages/waf/changelog/fix-protect-math-login-handoff-singleton @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Brute Force Protection: Run login checks once per request instead of once per internal lookup. diff --git a/projects/packages/waf/src/brute-force-protection/class-login-attempt-token.php b/projects/packages/waf/src/brute-force-protection/class-login-attempt-token.php new file mode 100644 index 000000000000..09d242e9487a --- /dev/null +++ b/projects/packages/waf/src/brute-force-protection/class-login-attempt-token.php @@ -0,0 +1,147 @@ +protection = $protection; + } + + /** + * Render a token for a login attempt that Protect already approved. + */ + public function render_field() { + $transient_name = $this->transient_name(); + if ( ! $transient_name ) { + return; + } + + try { + $token = bin2hex( random_bytes( 16 ) ); + } catch ( \Exception $error ) { + return; // Fail closed by omitting the token. + } + + $tokens = $this->outstanding_tokens( $transient_name ); + $tokens[ hash( 'sha256', $token ) ] = time(); + $tokens = array_slice( $tokens, -self::MAX_TOKENS_PER_IP, null, true ); + + if ( ! $this->protection->set_transient( $transient_name, $tokens, self::EXPIRATION ) ) { + return; + } + + printf( + '', + esc_attr( self::FIELD_NAME ), + esc_attr( $token ) + ); + } + + /** + * Consume a submitted token. + * + * @return bool Whether a valid token was consumed. + */ + public function consume() { + if ( ! isset( $_POST[ self::FIELD_NAME ] ) || ! is_string( $_POST[ self::FIELD_NAME ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The random, single-use token authorizes this request. + return false; + } + + $transient_name = $this->transient_name(); + if ( ! $transient_name ) { + return false; + } + + $token_hash = hash( 'sha256', wp_unslash( $_POST[ self::FIELD_NAME ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only hashed; altered input cannot match a stored hash. + if ( ! isset( $this->outstanding_tokens( $transient_name )[ $token_hash ] ) ) { + return false; + } + + // The claim, not the token list, enforces single use, so concurrent renders never lose tokens. + return $this->protection->add_login_attempt_claim( + self::CLAIM_TRANSIENT_PREFIX . substr( $token_hash, 0, 32 ), + self::EXPIRATION + ); + } + + /** + * Get the slot for the current IP's outstanding tokens. + * + * @return string|false + */ + private function transient_name() { + $ip = IP_Utils::get_ip(); + + return $ip ? self::TRANSIENT_PREFIX . md5( $ip ) : false; + } + + /** + * Get the unexpired token hashes for a slot. + * + * @param string $transient_name Slot name. + * @return int[] Issue times keyed by token hash. + */ + private function outstanding_tokens( $transient_name ) { + $tokens = $this->protection->get_transient( $transient_name ); + if ( ! is_array( $tokens ) ) { + return array(); + } + + // Each render refreshes the slot's expiry, so expire tokens individually. + $oldest = time() - self::EXPIRATION; + + return array_filter( + $tokens, + static function ( $issued_at ) use ( $oldest ) { + return $issued_at > $oldest; + } + ); + } +} diff --git a/projects/packages/waf/src/brute-force-protection/class-math-fallback.php b/projects/packages/waf/src/brute-force-protection/class-math-fallback.php index faeebaa5b908..641333b48f1e 100644 --- a/projects/packages/waf/src/brute-force-protection/class-math-fallback.php +++ b/projects/packages/waf/src/brute-force-protection/class-math-fallback.php @@ -18,6 +18,11 @@ class Brute_Force_Protection_Math_Authenticate { */ public static $loaded; + /** + * Math page field for the username; core's log field would turn the math POST into a login attempt. + */ + const USER_LOGIN_FIELD = 'jetpack_protect_user_login'; + /** * Class constructor. */ @@ -49,9 +54,16 @@ private static function time_window() { * @return bool Returns true if the math is correct. Exits if not. */ public static function math_authenticate() { + $brute_force_protection = Brute_Force_Protection::instance(); + + // A form loaded before the Protect API stopped answering may finish once, but a soft-blocked IP still solves math. + if ( 'blocked' !== $brute_force_protection->get_cached_status() + && ( new Brute_Force_Protection_Login_Attempt_Token( $brute_force_protection ) )->consume() ) { + return true; + } + if ( isset( $_COOKIE['jpp_math_pass'] ) ) { - $brute_force_protection = Brute_Force_Protection::instance(); - $transient = $brute_force_protection->get_transient( 'jpp_math_pass_' . sanitize_key( $_COOKIE['jpp_math_pass'] ) ); + $transient = $brute_force_protection->get_transient( 'jpp_math_pass_' . sanitize_key( $_COOKIE['jpp_math_pass'] ) ); if ( ! $transient || $transient < 1 ) { self::generate_math_page(); @@ -93,9 +105,14 @@ public static function math_authenticate() { * @return never */ public static function generate_math_page( $error = false ) { + $carried_fields = self::carried_login_fields(); ob_start(); ?> -

+ +

+ +

+

@@ -103,6 +120,9 @@ public static function generate_math_page( $error = false ) {
+ $field_value ) : ?> + +

set_transient( 'jpp_math_pass_' . $temp_pass, 3, DAY_IN_SECONDS ); setcookie( 'jpp_math_pass', $temp_pass, time() + DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, false, true ); remove_action( 'login_form', array( $this, 'math_form' ) ); + add_filter( 'wp_login_errors', array( __CLASS__, 'prompt_for_password' ) ); + add_filter( 'shake_error_codes', array( __CLASS__, 'skip_password_prompt_shake' ) ); return true; } } + /** + * Login form fields to send back through the math page, never the password. + * + * @return string[] Values keyed by field name; empty when not submitted. + */ + private static function carried_login_fields() { + // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Echoed back into the login form; sanitized after the type check. + $user_login = $_POST['log'] ?? $_POST[ self::USER_LOGIN_FIELD ] ?? ''; + $redirect_to = $_REQUEST['redirect_to'] ?? ''; + + return array( + self::USER_LOGIN_FIELD => is_string( $user_login ) ? sanitize_user( wp_unslash( $user_login ) ) : '', + 'redirect_to' => is_string( $redirect_to ) ? esc_url_raw( wp_unslash( $redirect_to ) ) : '', + 'rememberme' => empty( $_POST['rememberme'] ) ? '' : 'forever', + 'interim-login' => isset( $_REQUEST['interim-login'] ) ? '1' : '', + ); + // phpcs:enable + } + + /** + * After the standalone math page, ask for the password instead of reporting empty fields. + * + * @param \WP_Error $errors Login page errors. + * @return \WP_Error + */ + public static function prompt_for_password( $errors ) { + if ( ! is_wp_error( $errors ) || array( 'empty_username', 'empty_password' ) !== $errors->get_error_codes() ) { + return $errors; + } + + $user_login = self::carried_login_fields()[ self::USER_LOGIN_FIELD ]; + if ( '' === $user_login ) { + return new \WP_Error( 'empty_password', __( 'Thanks for solving the math problem. Please log in again.', 'jetpack-waf' ), 'message' ); + } + + // Core prefills the username from log, and only for an empty_password error. + $_POST['log'] = wp_slash( $user_login ); + + return new \WP_Error( 'empty_password', __( 'Thanks for solving the math problem. Enter your password to log in.', 'jetpack-waf' ), 'message' ); + } + + /** + * Keep the login form from shaking at the post-math password prompt. + * + * @param string[] $codes Error codes that shake the login form. + * @return string[] + */ + public static function skip_password_prompt_shake( $codes ) { + return array_values( array_diff( (array) $codes, array( 'empty_password' ) ) ); + } + /** * Requires a user to solve a simple equation. Added to any WordPress login form. * diff --git a/projects/packages/waf/src/class-brute-force-protection.php b/projects/packages/waf/src/class-brute-force-protection.php index b2a322e71bd6..d99fe75aeb26 100644 --- a/projects/packages/waf/src/class-brute-force-protection.php +++ b/projects/packages/waf/src/class-brute-force-protection.php @@ -102,11 +102,11 @@ class Brute_Force_Protection { public $last_response; /** - * Block login with math, default is 1. + * Block login with math, default is 0. * * @var int */ - private $block_login_with_math; + private $block_login_with_math = 0; /** * Singleton implementation @@ -114,7 +114,7 @@ class Brute_Force_Protection { * @return object */ public static function instance() { - if ( ! is_a( self::$instance, 'Brute_Force_Protection' ) ) { + if ( ! self::$instance instanceof self ) { self::$instance = new Brute_Force_Protection(); } @@ -142,6 +142,7 @@ private function __construct() { // This is a backup in case $pagenow fails for some reason. add_action( 'login_form', array( $this, 'check_login_ability' ), 1 ); + add_action( 'login_form', array( $this, 'render_login_attempt_token' ), 2 ); // Runs a script every day to clean up expired transients so they don't // clog up our users' databases. @@ -600,21 +601,18 @@ public function log_successful_login( $user_login, $user = null ) { * * If we are using our math fallback, authenticate via math-fallback.php * - * @param string $user - the user. + * @param mixed $user - the current authentication result. * @param string $username - the username. * @param string $password - the password. * - * @return string $user + * @return mixed $user */ public function check_preauth( $user = 'Not Used By Protect', $username = 'Not Used By Protect', $password = 'Not Used By Protect' ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - $allow_login = $this->check_login_ability( true ); - $use_math = $this->get_transient( 'brute_use_math' ); - - if ( ! $allow_login ) { + if ( ! $this->check_login_ability( true ) ) { $this->block_with_math(); } - if ( ( 1 == $use_math || 1 == $this->block_login_with_math ) && isset( $_POST['log'] ) ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual, WordPress.Security.NonceVerification.Missing -- POST request just determines if we use math authentication. + if ( $this->math_required() && isset( $_POST['log'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- POST request just determines if we use math authentication. Brute_Force_Protection_Math_Authenticate::math_authenticate(); } @@ -622,6 +620,26 @@ public function check_preauth( $user = 'Not Used By Protect', $username = 'Not U return $user; } + /** + * Add a token to a login form that Protect is not asking to solve math for. + */ + public function render_login_attempt_token() { + if ( $this->math_required() ) { + return; + } + + ( new Brute_Force_Protection_Login_Attempt_Token( $this ) )->render_field(); + } + + /** + * Whether this request must pass the math check, after a soft block or an unreachable Protect API. + * + * @return bool + */ + private function math_required() { + return 1 === $this->block_login_with_math || 1 === (int) $this->get_transient( 'brute_use_math' ); + } + /** * Get all IP headers so that we can process on our server... * @@ -990,15 +1008,11 @@ public function get_main_blog_id() { * @return int */ public function get_main_blog_jetpack_id() { - if ( ! is_main_site() ) { - switch_to_blog( $this->get_main_blog_id() ); - $id = Jetpack_Options::get_option( 'id', false ); - restore_current_blog(); - } else { - $id = Jetpack_Options::get_option( 'id' ); - } - - return $id; + return $this->on_main_site( + static function () { + return Jetpack_Options::get_option( 'id' ); + } + ); } /** @@ -1145,15 +1159,60 @@ public function get_transient_name() { * @return bool False if value was not set and true if value was set. */ public function set_transient( $transient, $value, $expiration ) { - if ( is_multisite() && ! is_main_site() ) { - switch_to_blog( $this->get_main_blog_id() ); - $return = set_transient( $transient, $value, $expiration ); - restore_current_blog(); + return $this->on_main_site( + static function () use ( $transient, $value, $expiration ) { + return set_transient( $transient, $value, $expiration ); + } + ); + } - return $return; + /** + * Claim a login attempt token, succeeding only for the first request that claims it. + * + * Writes the options table directly: add_option() upserts, so concurrent claims could all succeed. + * + * @param string $claim_name Claim name, stored as a transient the daily purge removes. + * @param int $expiration Time until expiration in seconds. + * + * @return bool Whether this request made the claim. + */ + public function add_login_attempt_claim( $claim_name, $expiration ) { + return $this->on_main_site( + static function () use ( $claim_name, $expiration ) { + global $wpdb; + + $insert = "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')"; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- Atomic insert-if-absent has no core API; $insert is prepared here. + $wpdb->query( $wpdb->prepare( $insert, '_transient_' . $claim_name, '1' ) ); + if ( 1 !== (int) $wpdb->rows_affected ) { + return false; + } + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- See above. + $wpdb->query( $wpdb->prepare( $insert, '_transient_timeout_' . $claim_name, (string) ( time() + $expiration ) ) ); + + return true; + } + ); + } + + /** + * Run a callback on the network's main site, where Protect keeps its transients. + * + * @param callable $callback Callback to run. + * + * @return mixed The callback's return value. + */ + private function on_main_site( $callback ) { + if ( ! is_multisite() || is_main_site() ) { + return $callback(); } - return set_transient( $transient, $value, $expiration ); + switch_to_blog( $this->get_main_blog_id() ); + $return = $callback(); + restore_current_blog(); + + return $return; } /** @@ -1165,15 +1224,11 @@ public function set_transient( $transient, $value, $expiration ) { * @return bool true if successful, false otherwise */ public function delete_transient( $transient ) { - if ( is_multisite() && ! is_main_site() ) { - switch_to_blog( $this->get_main_blog_id() ); - $return = delete_transient( $transient ); - restore_current_blog(); - - return $return; - } - - return delete_transient( $transient ); + return $this->on_main_site( + static function () use ( $transient ) { + return delete_transient( $transient ); + } + ); } /** @@ -1185,15 +1240,11 @@ public function delete_transient( $transient ) { * @return mixed Value of transient. */ public function get_transient( $transient ) { - if ( is_multisite() && ! is_main_site() ) { - switch_to_blog( $this->get_main_blog_id() ); - $return = get_transient( $transient ); - restore_current_blog(); - - return $return; - } - - return get_transient( $transient ); + return $this->on_main_site( + static function () use ( $transient ) { + return get_transient( $transient ); + } + ); } /** diff --git a/projects/packages/waf/tests/php/integration/BruteForceProtectionLoginAttemptTokenTest.php b/projects/packages/waf/tests/php/integration/BruteForceProtectionLoginAttemptTokenTest.php new file mode 100644 index 000000000000..1d0ad99dbeae --- /dev/null +++ b/projects/packages/waf/tests/php/integration/BruteForceProtectionLoginAttemptTokenTest.php @@ -0,0 +1,452 @@ +render_token(); + + $this->assertTrue( $this->token_manager()->consume() ); + $this->assertFalse( $this->token_manager()->consume() ); + } + + /** + * Verify that a claim the database ignores as a duplicate is reported as lost. + */ + public function test_claim_is_an_insert_that_ignores_duplicates() { + $this->emulate_insert_ignore(); + $protection = ( new ReflectionClass( Brute_Force_Protection::class ) )->newInstanceWithoutConstructor(); + + $this->assertTrue( $protection->add_login_attempt_claim( 'jpp_claim_test', 600 ) ); + $this->assertFalse( $protection->add_login_attempt_claim( 'jpp_claim_test', 600 ) ); + $this->assertStringStartsWith( 'INSERT IGNORE', $this->queries[0] ); + } + + /** + * Verify that a token cannot move to another client IP. + */ + public function test_token_is_bound_to_the_client_ip() { + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $this->render_token(); + $_SERVER['REMOTE_ADDR'] = '203.0.113.11'; + + $this->assertFalse( $this->token_manager()->consume() ); + } + + /** + * Verify that malformed input is not normalized into a valid token. + */ + public function test_malformed_token_does_not_consume_the_valid_token() { + $token = $this->render_token(); + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $token . '!!!'; + + $this->assertFalse( $this->token_manager()->consume() ); + + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $token; + $this->assertTrue( $this->token_manager()->consume() ); + } + + /** + * Verify that a token without its transient is rejected. + */ + public function test_expired_token_is_rejected() { + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $this->render_token(); + delete_transient( $this->slot_name() ); + + $this->assertFalse( $this->token_manager()->consume() ); + } + + /** + * Verify that later renders refreshing the slot do not extend an older token's lifetime. + */ + public function test_token_expires_even_when_later_renders_keep_its_slot_alive() { + $old_token = $this->render_token(); + $tokens = get_transient( $this->slot_name() ); + $tokens[ hash( 'sha256', $old_token ) ] = time() - Brute_Force_Protection_Login_Attempt_Token::EXPIRATION - 1; + set_transient( $this->slot_name(), $tokens, Brute_Force_Protection_Login_Attempt_Token::EXPIRATION ); + $this->render_token(); + + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $old_token; + $this->assertFalse( $this->token_manager()->consume() ); + } + + /** + * Verify that an IP keeps a bounded set of tokens that spoofed proxy headers cannot enlarge. + */ + public function test_tokens_beyond_the_per_ip_cap_are_evicted_oldest_first() { + $tokens = array(); + for ( $i = 0; $i <= Brute_Force_Protection_Login_Attempt_Token::MAX_TOKENS_PER_IP; $i++ ) { + $_SERVER['HTTP_X_FORWARDED_FOR'] = '198.51.100.' . $i; + $tokens[] = $this->render_token(); + } + + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $tokens[0]; + $this->assertFalse( $this->token_manager()->consume() ); + + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $tokens[1]; + $this->assertTrue( $this->token_manager()->consume() ); + } + + /** + * Verify that a render interleaved with a consume keeps both tokens usable exactly once. + */ + public function test_render_during_consume_keeps_the_replacement_token() { + $state = array(); + $replacement_token = null; + $protection = $this->protection( array( 'get_transient', 'set_transient' ) ); + $protection->method( 'set_transient' ) + ->willReturnCallback( + static function ( $name, $value ) use ( &$state ) { + $state[ $name ] = $value; + return true; + } + ); + $protection->method( 'get_transient' ) + ->willReturnCallback( + function ( $name ) use ( &$state, &$replacement_token, $protection ) { + $value = $state[ $name ] ?? false; + + if ( 'pending' === $replacement_token ) { + $replacement_token = ''; + $replacement_token = $this->render_token_for_protection( $protection ); + } + + return $value; + } + ); + + $old_token = $this->render_token_for_protection( $protection ); + $replacement_token = 'pending'; + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $old_token; + $this->assertTrue( ( new Brute_Force_Protection_Login_Attempt_Token( $protection ) )->consume() ); + + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $replacement_token; + $this->assertTrue( ( new Brute_Force_Protection_Login_Attempt_Token( $protection ) )->consume() ); + + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $old_token; + $this->assertFalse( ( new Brute_Force_Protection_Login_Attempt_Token( $protection ) )->consume() ); + } + + /** + * Verify that a form Protect is not asking to solve math for receives a token, allow-listed IPs included. + * + * @dataProvider approved_form_data_provider + * + * @param bool $allow_listed Whether the client IP is allow-listed. + */ + #[DataProvider( 'approved_form_data_provider' )] + public function test_protection_renders_token_for_approved_login_form( $allow_listed ) { + if ( $allow_listed ) { + add_filter( 'jpp_allow_login', '__return_true' ); + } + + ob_start(); + $this->protection()->render_login_attempt_token(); + $html = ob_get_clean(); + + $this->assertStringContainsString( 'name="' . Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME . '"', $html ); + } + + /** + * Client IPs whose approved forms need a token. + * + * @return array + */ + public static function approved_form_data_provider(): array { + return array( + 'regular IP' => array( false ), + 'allow-listed IP' => array( true ), + ); + } + + /** + * Verify that the token is rendered after the login form status checks. + */ + public function test_protection_registers_token_after_login_form_status_checks() { + $reflection = new ReflectionClass( Brute_Force_Protection::class ); + $protection = $reflection->newInstanceWithoutConstructor(); + $constructor = $reflection->getConstructor(); + if ( PHP_VERSION_ID < 80100 ) { + $constructor->setAccessible( true ); + } + $constructor->invoke( $protection ); + + $this->assertSame( 2, has_action( 'login_form', array( $protection, 'render_login_attempt_token' ) ) ); + } + + /** + * Verify that no token is issued for a form Protect is asking to solve math for. + * + * @dataProvider unapproved_form_data_provider + * + * @param string $state The login form state. + */ + #[DataProvider( 'unapproved_form_data_provider' )] + public function test_protection_does_not_render_token_for_unapproved_form( $state ) { + $protection = $this->protection(); + if ( 'math fallback' === $state ) { + $protection->set_transient( 'brute_use_math', 1, 600 ); + } else { + $protection->block_with_math(); + } + + ob_start(); + $protection->render_login_attempt_token(); + + $this->assertSame( '', ob_get_clean() ); + } + + /** + * Login form states that must not receive a token. + * + * @return array + */ + public static function unapproved_form_data_provider(): array { + return array( + 'math fallback is active' => array( 'math fallback' ), + 'soft block selected math' => array( 'soft block' ), + ); + } + + /** + * Verify that an approved token passes the math check once, then the math page applies again. + */ + public function test_approved_token_passes_math_once() { + $this->emulate_insert_ignore(); + add_filter( 'wp_die_handler', array( $this, 'throw_on_wp_die' ) ); + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $this->render_token_for_protection( Brute_Force_Protection::instance() ); + + $this->assertTrue( Brute_Force_Protection_Math_Authenticate::math_authenticate() ); + + $this->expectException( Exception::class ); + Brute_Force_Protection_Math_Authenticate::math_authenticate(); + } + + /** + * Verify that a token does not skip math for an IP the Protect API soft-blocked. + */ + public function test_token_does_not_skip_math_for_a_soft_blocked_ip() { + $this->emulate_insert_ignore(); + add_filter( 'wp_die_handler', array( $this, 'throw_on_wp_die' ) ); + $protection = Brute_Force_Protection::instance(); + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $this->render_token_for_protection( $protection ); + $protection->set_transient( $protection->get_transient_name(), array( 'status' => 'blocked' ), 600 ); + + $this->expectException( Exception::class ); + Brute_Force_Protection_Math_Authenticate::math_authenticate(); + } + + /** + * Verify that a database-style string transient still invokes the math fallback. + */ + public function test_string_math_transient_still_requires_math_without_approved_token() { + $protection = $this->protection( array( 'check_login_ability', 'get_transient' ) ); + $protection->method( 'check_login_ability' )->willReturn( true ); + $protection->method( 'get_transient' )->with( 'brute_use_math' )->willReturn( '1' ); + $_POST['log'] = 'example'; + add_filter( 'wp_die_handler', array( $this, 'throw_on_wp_die' ) ); + + $this->expectException( Exception::class ); + + $protection->check_preauth(); + } + + /** + * Verify that a token never skips the authoritative status check. + */ + public function test_approved_attempt_does_not_bypass_hard_block_check() { + $protection = $this->protection( array( 'get_cached_status', 'is_current_ip_allowed', 'kill_login' ) ); + $protection->method( 'is_current_ip_allowed' )->willReturn( false ); + $protection->method( 'get_cached_status' )->willReturn( 'blocked-hard' ); + $protection->expects( $this->once() ) + ->method( 'kill_login' ) + ->willThrowException( new RuntimeException( 'hard block' ) ); + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $this->render_token_for_protection( $protection ); + + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'hard block' ); + + $protection->check_preauth( 'approved-user' ); + } + + /** + * Verify that a token does not override a site that kills logins instead of showing math. + */ + public function test_token_does_not_override_the_kill_setting() { + $protection = $this->protection( array( 'check_login_ability', 'kill_login' ) ); + $protection->method( 'check_login_ability' )->willReturn( false ); + $protection->expects( $this->once() ) + ->method( 'kill_login' ) + ->willThrowException( new RuntimeException( 'killed' ) ); + add_filter( 'jpp_use_captcha_when_blocked', '__return_false' ); + $_POST[ Brute_Force_Protection_Login_Attempt_Token::FIELD_NAME ] = $this->render_token_for_protection( $protection ); + $_POST['log'] = 'example'; + + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'killed' ); + + $protection->check_preauth( 'approved-user' ); + } + + /** + * Record a claim, failing for one already made. + * + * @param string $claim_name Claim name. + * @return bool + */ + public function claim( $claim_name ) { + if ( isset( $this->claims[ $claim_name ] ) ) { + return false; + } + $this->claims[ $claim_name ] = true; + + return true; + } + + /** + * Report WorDBless queries' affected rows as a table with a unique option_name would. + * + * @param array $results Query results. + * @param string $query SQL query. + * @return array + */ + public function insert_ignore( $results, $query ) { + global $wpdb; + $wpdb->rows_affected = in_array( $query, $this->queries, true ) ? 0 : 1; + $this->queries[] = $query; + + return $results; + } + + /** + * Emulate INSERT IGNORE on WorDBless, which has no database. + */ + private function emulate_insert_ignore() { + add_filter( 'wordbless_wpdb_query_results', array( $this, 'insert_ignore' ), 10, 2 ); + } + + /** + * Create a token manager for the current request. + * + * @return Brute_Force_Protection_Login_Attempt_Token + */ + private function token_manager() { + return new Brute_Force_Protection_Login_Attempt_Token( $this->protection() ); + } + + /** + * Create a testable Brute Force Protection instance whose claims use this test's store. + * + * @param string[] $methods Additional methods to mock. + * @return Brute_Force_Protection|\PHPUnit\Framework\MockObject\MockObject + */ + private function protection( $methods = array() ) { + $protection = $this->getMockBuilder( Brute_Force_Protection::class ) + ->disableOriginalConstructor() + ->onlyMethods( array_merge( array( 'add_login_attempt_claim' ), $methods ) ) + ->getMock(); + $protection->method( 'add_login_attempt_claim' )->willReturnCallback( array( $this, 'claim' ) ); + + return $protection; + } + + /** + * Get the token slot for the current client IP. + * + * @return string + */ + private function slot_name() { + return Brute_Force_Protection_Login_Attempt_Token::TRANSIENT_PREFIX . md5( $_SERVER['REMOTE_ADDR'] ); + } + + /** + * Render and extract a login attempt token. + * + * @return string + */ + private function render_token() { + return $this->render_token_for_protection( $this->protection() ); + } + + /** + * Render and extract a login attempt token for a protection instance. + * + * @param Brute_Force_Protection $protection Brute Force Protection instance. + * @return string + */ + private function render_token_for_protection( $protection ) { + ob_start(); + ( new Brute_Force_Protection_Login_Attempt_Token( $protection ) )->render_field(); + $html = ob_get_clean(); + + $this->assertSame( 1, preg_match( '/value="([a-f0-9]{32})"/', $html, $matches ) ); + + return $matches[1]; + } + + /** + * Return a wp_die handler that throws for assertions. + * + * @return callable + */ + public function throw_on_wp_die() { + /** + * Throw instead of terminating the test process. + * + * @return never + */ + return static function () { + throw new Exception( 'wp_die called' ); + }; + } +} diff --git a/projects/packages/waf/tests/php/integration/BruteForceProtectionMathFallbackTest.php b/projects/packages/waf/tests/php/integration/BruteForceProtectionMathFallbackTest.php new file mode 100644 index 000000000000..cf9e6cadbbd6 --- /dev/null +++ b/projects/packages/waf/tests/php/integration/BruteForceProtectionMathFallbackTest.php @@ -0,0 +1,143 @@ +math_page(); + + $this->assertStringContainsString( 'name="' . Brute_Force_Protection_Math_Authenticate::USER_LOGIN_FIELD . '" value="devin"wp"', $page ); + $this->assertStringContainsString( 'name="redirect_to" value="https://example.com/wp-admin/post.php?post=1&action=edit"', $page ); + $this->assertStringContainsString( 'name="rememberme" value="forever"', $page ); + $this->assertStringContainsString( 'name="interim-login" value="1"', $page ); + $this->assertStringNotContainsString( 'name="log"', $page ); + $this->assertStringNotContainsString( 'correct horse battery', $page ); + } + + /** + * Where the username arrives: the original login POST, or a math page answered wrongly. + * + * @return array + */ + public static function username_source_data_provider(): array { + return array( + 'login form' => array( 'log' ), + 'wrong math answer' => array( Brute_Force_Protection_Math_Authenticate::USER_LOGIN_FIELD ), + ); + } + + /** + * Verify only the empty-fields errors after the math page become a prompt, prefilling a carried username. + * + * @dataProvider login_errors_data_provider + * + * @param string[] $codes Error codes on the login page. + * @param string $carried_login Username carried by the math page. + * @param string|null $expected_text Expected prompt text, or null to leave the errors alone. + */ + #[DataProvider( 'login_errors_data_provider' )] + public function test_prompt_for_password_after_math_page( $codes, $carried_login, $expected_text ) { + $_POST[ Brute_Force_Protection_Math_Authenticate::USER_LOGIN_FIELD ] = $carried_login; + $errors = new WP_Error(); + foreach ( $codes as $code ) { + $errors->add( $code, $code ); + } + + $result = Brute_Force_Protection_Math_Authenticate::prompt_for_password( $errors ); + + if ( null === $expected_text ) { + $this->assertSame( $errors, $result ); + $this->assertArrayNotHasKey( 'log', $_POST ); + return; + } + $this->assertSame( array( 'empty_password' ), $result->get_error_codes() ); + $this->assertSame( 'message', $result->get_error_data( 'empty_password' ) ); + $this->assertStringContainsString( $expected_text, $result->get_error_message() ); + $this->assertSame( '' === $carried_login ? null : $carried_login, $_POST['log'] ?? null ); + } + + /** + * Login errors and carried usernames, with the expected prompt. + * + * @return array + */ + public static function login_errors_data_provider(): array { + return array( + 'username carried' => array( array( 'empty_username', 'empty_password' ), 'devin', 'Enter your password' ), + 'no username' => array( array( 'empty_username', 'empty_password' ), '', 'Please log in again' ), + 'wrong password' => array( array( 'incorrect_password' ), 'devin', null ), + ); + } + + /** + * Render the standalone math page. + * + * @return string + */ + private function math_page() { + try { + Brute_Force_Protection_Math_Authenticate::generate_math_page(); + } catch ( RuntimeException $page ) { + return $page->getMessage(); + } + } + + /** + * Return a wp_die handler that throws the page for assertions. + * + * @return callable + */ + public function throw_on_wp_die() { + /** + * Throw the page instead of terminating the test process. + * + * @param string $message The page HTML. + * @return never + */ + return static function ( $message ) { + throw new RuntimeException( $message ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Test-only page capture. + }; + } +} diff --git a/projects/packages/waf/tests/php/integration/BruteForceProtectionTest.php b/projects/packages/waf/tests/php/integration/BruteForceProtectionTest.php index 629e98789974..1eae32021870 100644 --- a/projects/packages/waf/tests/php/integration/BruteForceProtectionTest.php +++ b/projects/packages/waf/tests/php/integration/BruteForceProtectionTest.php @@ -186,6 +186,13 @@ public function test_get_local_host_handles_wp_parse_url_false() { $this->assertNotEmpty( $result ); } + /** + * Verify that every caller shares one instance, so login hooks are registered once. + */ + public function test_instance_is_reused() { + $this->assertSame( Brute_Force_Protection::instance(), Brute_Force_Protection::instance() ); + } + /** * Verifies that the transient value is not acted upon (decremented) if the transient value was indeed not set. * diff --git a/projects/plugins/jetpack/changelog/fix-protect-math-login-handoff b/projects/plugins/jetpack/changelog/fix-protect-math-login-handoff new file mode 100644 index 000000000000..f9166d6fd593 --- /dev/null +++ b/projects/plugins/jetpack/changelog/fix-protect-math-login-handoff @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Brute Force Protection: Fix correct logins being rejected after the math challenge, and keep the username and login destination when the password must be entered again. diff --git a/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_Module_Protect_Test.php b/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_Module_Protect_Test.php index f9a08cfde792..fa0569b4c8fd 100644 --- a/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_Module_Protect_Test.php +++ b/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_Module_Protect_Test.php @@ -13,7 +13,12 @@ class Jetpack_Sync_Module_Protect_Test extends Jetpack_Sync_TestBase { public function set_up() { parent::set_up(); - // Ensure the Brute Force Protection module is loaded + // Rebuild the singleton: its constructor registers the hooks, which each test resets. + $instance = new ReflectionProperty( Brute_Force_Protection::class, 'instance' ); + if ( PHP_VERSION_ID < 80100 ) { + $instance->setAccessible( true ); + } + $instance->setValue( null, null ); Brute_Force_Protection::instance(); } diff --git a/projects/plugins/protect/changelog/fix-protect-math-login-handoff b/projects/plugins/protect/changelog/fix-protect-math-login-handoff new file mode 100644 index 000000000000..85f42283da6a --- /dev/null +++ b/projects/plugins/protect/changelog/fix-protect-math-login-handoff @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Brute Force Protection: Fix correct logins being rejected after the math challenge, and keep the username and login destination when the password must be entered again. From 16f2d996fc4df99189c3c681bf18faf03b92970e Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Tue, 13 Oct 2026 16:05:10 -0300 Subject: [PATCH 36/57] Backup: survive failed status polls, and only say a restore didn't start after checking (#53487) * Backup: tolerate failed status polls on download and restore A failed status poll while a download or restore is in progress no longer switches the screen to an error. Progress stays, with a short Reconnecting note after three failed polls in a row. Co-Authored-By: Claude Sonnet 5.5 * Backup: do not say a restore didn't start without reading the restores list When the restore request gets no usable reply, the Your restore didn't start verdict now needs at least one successful read of the restores list since submitting. Otherwise the screen shows the lost-track state, which offers no retry. Co-Authored-By: Claude Sonnet 5.5 * Backup: keep polling a download whose first status reads fail With no reading yet, a failed status poll stopped polling and showed the error screen, so Try again abandoned an archive that was likely being built. The screen now stays on progress at 0 and shows the Reconnecting note after three failed polls. Tests also cover the production retry and a restore that fails exactly at the silence deadline. Co-Authored-By: Claude Sonnet 5.5 * Backup: test that a reset forgets an earlier good restores read Co-Authored-By: Claude Sonnet 5.5 * Backup: show the error when a download status poll is refused with a 4xx A 4xx from the status poll cannot fix itself, so the screen shows the error and stops polling. Network errors and 5xx stay temporary and show the Reconnecting note. Co-Authored-By: Claude Sonnet 5.5 * Backup: Bound the polling test loops Co-Authored-By: Claude Sonnet 5.5 * Backup: Keep a restore alive when its first status poll fails With no reading yet, one failed poll sent the screen to lost-track and then back on the next good poll. Treat "no reading yet" as temporary, so the 5-minute silence deadline decides, as it does for the download hook. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- .../changelog/backup-poll-failure-tolerance | 4 + .../backup-restore-didnt-start-needs-read | 4 + .../dashboard/hooks/test/use-download.test.ts | 170 ++++++++++++++ .../dashboard/hooks/test/use-restore.test.ts | 220 ++++++++++++++++-- .../src/dashboard/hooks/use-download.ts | 39 +++- .../src/dashboard/hooks/use-poll-failures.ts | 21 ++ .../backup/src/dashboard/hooks/use-restore.ts | 39 +++- .../backup/src/dashboard/screens/download.tsx | 5 + .../backup/src/dashboard/screens/restore.tsx | 5 + .../backup/src/dashboard/types/restore.ts | 2 +- .../backup/tests/restore-lost-track.test.tsx | 33 ++- .../tests/restore-session-recovery.test.tsx | 25 +- 12 files changed, 523 insertions(+), 44 deletions(-) create mode 100644 projects/packages/backup/changelog/backup-poll-failure-tolerance create mode 100644 projects/packages/backup/changelog/backup-restore-didnt-start-needs-read create mode 100644 projects/packages/backup/src/dashboard/hooks/use-poll-failures.ts diff --git a/projects/packages/backup/changelog/backup-poll-failure-tolerance b/projects/packages/backup/changelog/backup-poll-failure-tolerance new file mode 100644 index 000000000000..c4cb3f02d134 --- /dev/null +++ b/projects/packages/backup/changelog/backup-poll-failure-tolerance @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed +Comment: The modernized dashboard, behind a feature flag, keeps showing download and restore progress through a failed status poll. + diff --git a/projects/packages/backup/changelog/backup-restore-didnt-start-needs-read b/projects/packages/backup/changelog/backup-restore-didnt-start-needs-read new file mode 100644 index 000000000000..87214810e7d1 --- /dev/null +++ b/projects/packages/backup/changelog/backup-restore-didnt-start-needs-read @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed +Comment: The modernized dashboard, behind a feature flag, no longer says a restore didn't start unless it has read the restores list at least once. + diff --git a/projects/packages/backup/src/dashboard/hooks/test/use-download.test.ts b/projects/packages/backup/src/dashboard/hooks/test/use-download.test.ts index 0a73e8f5035c..9b19bb928ee1 100644 --- a/projects/packages/backup/src/dashboard/hooks/test/use-download.test.ts +++ b/projects/packages/backup/src/dashboard/hooks/test/use-download.test.ts @@ -164,3 +164,173 @@ describe( 'useDownload', () => { ); } ); } ); + +describe( 'useDownload — a status poll that fails', () => { + let pollFails = false; + let pollStatus: number | null = null; + let failedCalls = 0; + const running = { + id: 4321, + status: 'running', + progress: 40, + url: '', + valid_until: '', + error: '', + }; + + beforeEach( () => { + jest.useFakeTimers(); + pollFails = false; + pollStatus = null; + failedCalls = 0; + mockedApiFetch.mockImplementation( ( options: { method?: string } ) => { + if ( options?.method === 'POST' ) { + return Promise.resolve( { id: 4321 } ); + } + if ( pollStatus !== null ) { + failedCalls++; + return Promise.reject( { + code: 'download_status_fetch_failed', + message: 'Could not fetch download status.', + data: { status: pollStatus }, + } ); + } + if ( pollFails ) { + failedCalls++; + return Promise.reject( new Error( 'Offline.' ) ); + } + return Promise.resolve( running ); + } ); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + + /** + * Advance fake timers inside `act`, letting queued promises settle. + * + * @param ms - How far to advance. + */ + async function advance( ms: number ) { + await act( async () => { + await jest.advanceTimersByTimeAsync( ms ); + } ); + } + + /** + * Submit and wait for the first good reading. + * + * @return The rendered hook result. + */ + async function startAndReadOnce() { + const { wrapper } = makeWrapper(); + const { result } = renderHook( () => useDownload( REWIND_ID ), { wrapper } ); + act( () => result.current.submit( DEFAULT_RESTORE_ITEMS ) ); + await advance( 10 ); + await advance( 10 ); + await advance( 10 ); + expect( result.current.state ).toEqual( { phase: 'progress', percent: 40 } ); + return result; + } + + it( 'keeps showing progress through one failed poll', async () => { + const result = await startAndReadOnce(); + + pollFails = true; + await advance( 1500 ); + + expect( failedCalls ).toBe( 1 ); + expect( result.current.state ).toEqual( { phase: 'progress', percent: 40 } ); + } ); + + it( 'says it is reconnecting after repeated failures, and clears on success', async () => { + const result = await startAndReadOnce(); + + pollFails = true; + for ( let i = 0; i < 10 && ! ( 'reconnecting' in result.current.state ); i++ ) { + await advance( 1500 ); + } + // The note arrives on the third consecutive failure, not before. + expect( failedCalls ).toBe( 3 ); + expect( result.current.state ).toEqual( { + phase: 'progress', + percent: 40, + reconnecting: true, + } ); + + pollFails = false; + await advance( 3000 ); + expect( result.current.state ).toEqual( { phase: 'progress', percent: 40 } ); + } ); + + it( 'keeps polling and never errors when the first reads fail', async () => { + pollFails = true; + const { wrapper } = makeWrapper(); + const { result } = renderHook( () => useDownload( REWIND_ID ), { wrapper } ); + act( () => result.current.submit( DEFAULT_RESTORE_ITEMS ) ); + + for ( let i = 0; i < 10 && ! ( 'reconnecting' in result.current.state ); i++ ) { + await advance( 1500 ); + expect( result.current.state.phase ).toBe( 'progress' ); + } + expect( failedCalls ).toBeGreaterThanOrEqual( 3 ); + expect( result.current.state ).toEqual( { + phase: 'progress', + percent: 0, + reconnecting: true, + } ); + + pollFails = false; + await advance( 3000 ); + expect( result.current.state ).toEqual( { phase: 'progress', percent: 40 } ); + } ); + + // Production retries a failed read once, so one failed poll is two calls. + it( 'counts a poll as one failure after its retry', async () => { + const client = new QueryClient( { + defaultOptions: { queries: { retry: 1 }, mutations: { retry: false } }, + } ); + const wrapper = ( { children }: { children: ReactNode } ) => + createElement( QueryClientProvider, { client }, children ); + const { result } = renderHook( () => useDownload( REWIND_ID ), { wrapper } ); + act( () => result.current.submit( DEFAULT_RESTORE_ITEMS ) ); + await advance( 10 ); + await advance( 10 ); + await advance( 10 ); + expect( result.current.state ).toEqual( { phase: 'progress', percent: 40 } ); + + pollFails = true; + for ( let i = 0; i < 20 && ! ( 'reconnecting' in result.current.state ); i++ ) { + await advance( 1500 ); + } + expect( failedCalls ).toBe( 6 ); + } ); + + it( 'shows the error at once and stops polling on a 4xx', async () => { + const result = await startAndReadOnce(); + + pollStatus = 404; + await advance( 1500 ); + await advance( 1500 ); + expect( result.current.state ).toMatchObject( { + phase: 'error', + message: 'Could not fetch download status.', + } ); + + const calls = mockedApiFetch.mock.calls.length; + await advance( 10_000 ); + expect( mockedApiFetch.mock.calls ).toHaveLength( calls ); + } ); + + it( 'treats a 502 as temporary', async () => { + const result = await startAndReadOnce(); + + pollStatus = 502; + for ( let i = 0; i < 10 && ! ( 'reconnecting' in result.current.state ); i++ ) { + await advance( 1500 ); + expect( result.current.state.phase ).toBe( 'progress' ); + } + expect( result.current.state ).toMatchObject( { phase: 'progress', reconnecting: true } ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/hooks/test/use-restore.test.ts b/projects/packages/backup/src/dashboard/hooks/test/use-restore.test.ts index f0643ca55766..254e5d87350c 100644 --- a/projects/packages/backup/src/dashboard/hooks/test/use-restore.test.ts +++ b/projects/packages/backup/src/dashboard/hooks/test/use-restore.test.ts @@ -318,28 +318,6 @@ describe( 'useRestore — failing safe', () => { expect( result.current.state.phase ).not.toBe( 'success' ); } ); - // Losing the connection is not the same as the restore failing: it - // was accepted and is very likely still running. Reporting it as an - // error would offer a retry, and the retry starts a second concurrent - // restore — so it reports what is true, that we can no longer watch. - it( 'surfaces a mid-poll network failure rather than sitting at the last percent', async () => { - mockedApiFetch.mockImplementation( ( options: { method?: string } ) => { - if ( options?.method === 'POST' ) { - return Promise.resolve( { id: 912682, rewind_id: REWIND_ID } ); - } - return Promise.reject( new Error( 'Could not reach WordPress.com.' ) ); - } ); - const { wrapper } = makeWrapper(); - - const { result } = renderHook( () => useRestore( REWIND_ID ), { wrapper } ); - submitAll( result ); - - await waitFor( () => expect( result.current.state.phase ).toBe( 'lost-track' ) ); - // The transport's own text is kept, beneath the message rather - // than replacing it. - expect( result.current.state ).toMatchObject( { detail: 'Could not reach WordPress.com.' } ); - } ); - // The property the phase exists for, asserted on the state machine as // well as on the screen: `error` is reserved for "nothing is running". it( 'reserves the error phase for restores that definitely are not running', async () => { @@ -1106,6 +1084,60 @@ describe( 'useRestore — a submission we never got an answer to', () => { expect( result.current.state.phase ).toBe( 'error' ); } ); + // A read that fails, or that WordPress.com answers with a bare `null`, + // has not shown that nothing started. Saying so would deny a restore + // that may be running. + it.each( [ + [ 'answers with null', () => Promise.resolve( null ) ], + [ 'rejects', () => Promise.reject( new Error( 'Could not reach WordPress.com.' ) ) ], + ] )( 'does not say nothing started when the restores list %s', async ( _label, readRestores ) => { + mockedApiFetch.mockImplementation( ( options: { path?: string; method?: string } ) => { + if ( options?.method === 'POST' ) { + return Promise.reject( TIMEOUT ); + } + return ( options?.path ?? '' ).includes( '/restores' ) + ? readRestores() + : Promise.resolve( statusPayload() ); + } ); + const { wrapper } = makeWrapper(); + + const { result } = renderHook( () => useRestore( REWIND_ID ), { wrapper } ); + submitAll( result ); + + await settleAt( result, 'unconfirmed' ); + await advance( 5 * 60_000 + 1000 ); + + expect( result.current.state.phase ).toBe( 'lost-track' ); + } ); + + it( 'forgets an earlier good read after a reset', async () => { + let restoresReadable = true; + mockedApiFetch.mockImplementation( ( options: { path?: string; method?: string } ) => { + if ( options?.method === 'POST' ) { + return Promise.reject( TIMEOUT ); + } + if ( ( options?.path ?? '' ).includes( '/restores' ) ) { + return Promise.resolve( restoresReadable ? [] : null ); + } + return Promise.resolve( statusPayload() ); + } ); + const { wrapper } = makeWrapper(); + const { result } = renderHook( () => useRestore( REWIND_ID ), { wrapper } ); + submitAll( result ); + await settleAt( result, 'unconfirmed' ); + await advance( POLL_INTERVAL_MS ); + + act( () => result.current.reset() ); + restoresReadable = false; + // Move the clock on, so the cached read predates the next submission. + await advance( 1000 ); + submitAll( result ); + await settleAt( result, 'unconfirmed' ); + await advance( 5 * 60_000 + 1000 ); + + expect( result.current.state.phase ).toBe( 'lost-track' ); + } ); + /** * Force `navigator.onLine`, restoring it afterwards. * @@ -1291,3 +1323,147 @@ describe( 'useRestore — a restore that starts after the screen loaded', () => expect( initiateCall() ).toBeDefined(); } ); } ); + +describe( 'useRestore — a status poll that fails mid-restore', () => { + let pollFails = false; + let failedCalls = 0; + + beforeEach( () => { + jest.useFakeTimers(); + pollFails = false; + failedCalls = 0; + mockedApiFetch.mockImplementation( ( options: { path?: string; method?: string } ) => { + if ( options?.method === 'POST' ) { + return Promise.resolve( { id: 912682, rewind_id: REWIND_ID } ); + } + if ( ( options?.path ?? '' ).includes( '/restores' ) ) { + return Promise.resolve( [] ); + } + if ( pollFails ) { + failedCalls++; + return Promise.reject( new Error( 'Could not reach WordPress.com.' ) ); + } + return Promise.resolve( statusPayload( { progress: 40 } ) ); + } ); + } ); + + afterEach( () => { + jest.useRealTimers(); + focusManager.setFocused( true ); + } ); + + /** + * Submit and wait for the first good reading. + * + * @return The rendered hook result. + */ + async function startAndReadOnce() { + const { wrapper } = makeWrapper(); + const { result } = renderHook( () => useRestore( REWIND_ID ), { wrapper } ); + submitAll( result ); + await settleAt( result, 'progress' ); + return result; + } + + it( 'keeps showing progress through one failed poll', async () => { + const result = await startAndReadOnce(); + + pollFails = true; + for ( let i = 0; i < 10 && failedCalls < 1; i++ ) { + await advance( POLL_INTERVAL_MS ); + } + + expect( result.current.state ).toMatchObject( { phase: 'progress', percent: 40 } ); + expect( result.current.state ).not.toHaveProperty( 'reconnecting' ); + } ); + + it( 'says it is reconnecting after repeated failures, and clears on success', async () => { + const result = await startAndReadOnce(); + + pollFails = true; + for ( let i = 0; i < 10 && ! ( 'reconnecting' in result.current.state ); i++ ) { + await advance( POLL_INTERVAL_MS ); + } + // The note arrives on the third consecutive failure, not before. + expect( failedCalls ).toBe( 3 ); + expect( result.current.state ).toMatchObject( { phase: 'progress', reconnecting: true } ); + + pollFails = false; + await advance( 2 * POLL_INTERVAL_MS ); + expect( result.current.state ).toMatchObject( { phase: 'progress', percent: 40 } ); + expect( result.current.state ).not.toHaveProperty( 'reconnecting' ); + } ); + + it( 'gives up as lost-track once the silence deadline passes', async () => { + const result = await startAndReadOnce(); + + pollFails = true; + await advance( 5 * 60_000 - 1000 ); + expect( result.current.state.phase ).toBe( 'progress' ); + + await advance( 2000 + POLL_INTERVAL_MS ); + expect( result.current.state.phase ).toBe( 'lost-track' ); + } ); +} ); + +describe( 'useRestore — a restore whose status polls fail from the start', () => { + let failures: number; + let secondPollRuns: boolean; + + beforeEach( () => { + jest.useFakeTimers(); + failures = 0; + mockedApiFetch.mockImplementation( ( options: { path?: string; method?: string } ) => { + if ( options?.method === 'POST' ) { + return Promise.resolve( { id: 912682, rewind_id: REWIND_ID } ); + } + if ( ( options?.path ?? '' ).includes( '/restores' ) ) { + return Promise.resolve( [] ); + } + failures++; + if ( secondPollRuns && failures > 1 ) { + return Promise.resolve( statusPayload( { progress: 40 } ) ); + } + return Promise.reject( new Error( 'Could not reach WordPress.com.' ) ); + } ); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + + it( 'does not give up on one failed first poll', async () => { + secondPollRuns = true; + const { wrapper } = makeWrapper(); + const { result } = renderHook( () => useRestore( REWIND_ID ), { wrapper } ); + submitAll( result ); + + const phases = new Set< string >(); + // Small steps, so the screen is sampled between the failed poll and the next one. + for ( let i = 0; i < 40 && result.current.state.phase !== 'progress'; i++ ) { + await act( async () => { + await jest.advanceTimersByTimeAsync( 250 ); + } ); + phases.add( result.current.state.phase ); + } + + expect( result.current.state.phase ).toBe( 'progress' ); + expect( phases ).not.toContain( 'lost-track' ); + } ); + + it( 'waits for the silence deadline when no poll ever succeeds', async () => { + secondPollRuns = false; + const { wrapper } = makeWrapper(); + const { result } = renderHook( () => useRestore( REWIND_ID ), { wrapper } ); + submitAll( result ); + + await advance( 5 * 60_000 - 2000 ); + expect( result.current.state.phase ).toBe( 'queued' ); + + await advance( 4000 + POLL_INTERVAL_MS ); + expect( result.current.state ).toMatchObject( { + phase: 'lost-track', + detail: 'Could not reach WordPress.com.', + } ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/hooks/use-download.ts b/projects/packages/backup/src/dashboard/hooks/use-download.ts index 41723a55efe6..8a8e89e87e2d 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-download.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-download.ts @@ -1,16 +1,17 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { useCallback, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { errorCode } from '../data/api/_helpers'; +import { ApiError, errorCode } from '../data/api/_helpers'; import { fetchDownloadStatus, initiateDownload, initiateFileDownload } from '../data/api/download'; import { keys } from '../data/query-client'; +import { RECONNECT_AFTER_FAILURES, usePollFailures } from './use-poll-failures'; import type { FailureReference, ReferenceId } from '../types/failure-reference'; import type { RestoreItems } from '../types/restore'; type DownloadState = | { phase: 'idle' } | { phase: 'submitting' } - | { phase: 'progress'; percent: number } + | { phase: 'progress'; percent: number; reconnecting?: boolean } | { phase: 'success'; downloadUrl: string; validUntil: string } | { phase: 'error'; message: string; reference: FailureReference }; @@ -27,6 +28,22 @@ type Result = { const POLL_INTERVAL_MS = 1500; +/** + * Whether a failed poll is a refusal that retrying cannot fix. + * + * The bridge forwards an upstream 4xx as-is in `data.status`. Network + * errors and 5xx stay temporary, as do 408 and 429. + * + * @param error - The rejection from the status poll. + * @return True for a 4xx other than 408 and 429. + */ +function isPermanentPollError( error: unknown ): boolean { + const status = error instanceof ApiError ? ( error.data as { status?: unknown } )?.status : null; + return ( + typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429 + ); +} + /** * Real `useDownload` driving the modernized Download screen via the * `/jetpack/v4/backups/download/$rewindId` bridge. @@ -73,8 +90,16 @@ export function useDownload( rewindId: string, enabled = true ): Result { queryKey: keys.downloadStatus( rewindId, effectiveDownloadId ), queryFn: () => fetchDownloadStatus( rewindId, effectiveDownloadId ), enabled: downloadId !== null && enabled, - refetchInterval: query => ( query.state.data?.status === 'running' ? POLL_INTERVAL_MS : false ), + // No reading yet is not a reason to stop: the first poll may simply have failed. + refetchInterval: query => { + const status = query.state.data?.status; + if ( isPermanentPollError( query.state.error ) ) { + return false; + } + return status === undefined || status === 'running' ? POLL_INTERVAL_MS : false; + }, } ); + const failedPolls = usePollFailures( statusQuery.errorUpdatedAt, statusQuery.dataUpdatedAt ); const submit = useCallback( ( items: RestoreItems ) => mutateInitiate( { items } ), @@ -130,13 +155,11 @@ export function useDownload( rewindId: string, enabled = true ): Result { // Upstream reports this one in prose only, so there is no code to quote. reference: { code: null, id: referenceId }, }; - } else if ( downloadId !== null && statusQuery.error ) { - // Network/HTTP failure mid-poll: surface so the UI doesn't sit - // in `progress` at the last known percent with no way out. + } else if ( downloadId !== null && isPermanentPollError( statusQuery.error ) ) { state = { phase: 'error', message: - statusQuery.error.message || + statusQuery.error?.message || __( 'Lost connection while preparing download.', 'jetpack-backup-pkg' ), reference: { code: errorCode( statusQuery.error ), id: referenceId }, }; @@ -148,6 +171,8 @@ export function useDownload( rewindId: string, enabled = true ): Result { // first poll answers, which is the bar's 0% opening frame. phase: 'progress', percent: statusQuery.data?.progress ?? 0, + // A failed poll mid-download is temporary; say so only once it keeps failing. + ...( failedPolls >= RECONNECT_AFTER_FAILURES && { reconnecting: true } ), }; } else if ( isInitiating ) { state = { phase: 'submitting' }; diff --git a/projects/packages/backup/src/dashboard/hooks/use-poll-failures.ts b/projects/packages/backup/src/dashboard/hooks/use-poll-failures.ts new file mode 100644 index 000000000000..1865c591679a --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-poll-failures.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from '@wordpress/element'; + +/** Consecutive failed polls before the screen says it is reconnecting. */ +export const RECONNECT_AFTER_FAILURES = 3; + +/** + * Count consecutive failed polls of one status query. + * + * @param errorUpdatedAt - The query's `errorUpdatedAt`; 0 when it never failed. + * @param dataUpdatedAt - The query's `dataUpdatedAt`; a change means a poll succeeded. + * @return Failures since the last success. + */ +export function usePollFailures( errorUpdatedAt: number, dataUpdatedAt: number ): number { + const [ failures, setFailures ] = useState( 0 ); + // Do not reorder: the reset must run after the increment on mount. + useEffect( () => { + setFailures( count => ( errorUpdatedAt === 0 ? 0 : count + 1 ) ); + }, [ errorUpdatedAt ] ); + useEffect( () => setFailures( 0 ), [ dataUpdatedAt ] ); + return failures; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-restore.ts index 62e955a2b40b..98753590ae6a 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-restore.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -14,6 +14,7 @@ import { } from '../data/api/restore'; import { keys } from '../data/query-client'; import { useAdoptedRestore } from './use-adopted-restore'; +import { RECONNECT_AFTER_FAILURES, usePollFailures } from './use-poll-failures'; import type { AdoptedRestore } from './use-adopted-restore'; import type { RestoreStatusResponse } from '../data/api/restore'; import type { ReferenceId } from '../types/failure-reference'; @@ -73,8 +74,12 @@ type DeriveInput = { /** Our restore, found already over in the collection; null when no row matches. */ settledOutcome: { succeeded: boolean; restoreId: number } | null; statusError: Error | null; + /** Consecutive failed status polls since the last good one. */ + pollFailures: number; data: RestoreStatusResponse | undefined; lostTrack: boolean; + /** Whether the restores collection has been read successfully since submitting. */ + restoresRead: boolean; }; /** @@ -101,8 +106,10 @@ function deriveState( input: DeriveInput ): RestoreState { restoreId, settledOutcome, statusError, + pollFailures, data, lostTrack, + restoresRead, } = input; if ( errorMessage ) { @@ -143,6 +150,14 @@ function deriveState( input: DeriveInput ): RestoreState { reference: { code: null, id: { kind: 'restore', value: settledOutcome.restoreId } }, }; } + // A collection we could not read has not shown that nothing started. + if ( lostTrack && ! restoresRead ) { + return { + phase: 'lost-track', + detail: unconfirmed, + reference: { code: failureCode, id: referenceId }, + }; + } if ( lostTrack ) { return { phase: 'error', @@ -162,7 +177,14 @@ function deriveState( input: DeriveInput ): RestoreState { // likely still running — we just cannot watch it any more. Reporting // that as an error offers a retry, which upstream refuses as a bare // failure rather than as anything the reader can act on. - if ( restoreId !== null && statusError ) { + // + // Unless the last reading showed the restore alive, or there is no reading + // yet: then a failed poll is temporary, and the silence deadline decides. + if ( + restoreId !== null && + statusError && + ( lostTrack || ( data !== undefined && ! isSignOfLife( data.status ) ) ) + ) { return { phase: 'lost-track', detail: statusError.message || null, @@ -193,6 +215,7 @@ function deriveState( input: DeriveInput ): RestoreState { phase: 'progress', percent: Math.round( data.progress ?? 0 ), message: data.message, + ...( pollFailures >= RECONNECT_AFTER_FAILURES && { reconnecting: true } ), }; default: // `queued`, `not-found`, `unknown`, or nothing yet. All the @@ -392,6 +415,16 @@ export function useRestore( rewindId: string, enabled = true ): Result { refetchInterval: recovering ? POLL_INTERVAL_MS : false, } ); + // Latched: a later unreadable read must not take back an earlier good one. + const [ restoresRead, setRestoresRead ] = useState( false ); + const restoresData = restoresQuery.data; + const restoresUpdatedAt = restoresQuery.dataUpdatedAt; + useEffect( () => { + if ( aliveAt !== null && Array.isArray( restoresData ) && restoresUpdatedAt >= aliveAt ) { + setRestoresRead( true ); + } + }, [ aliveAt, restoresData, restoresUpdatedAt ] ); + const recoveredId = useMemo( () => { if ( ! needsId || startedRewindId === null ) { return null; @@ -461,6 +494,7 @@ export function useRestore( rewindId: string, enabled = true ): Result { // Clearing `lostTrack` is what makes it mean "nothing heard lately" // rather than "there was once a five-minute gap". Without it the flag // latches on the first gap and no later evidence can undo it. + const pollFailures = usePollFailures( statusQuery.errorUpdatedAt, statusQuery.dataUpdatedAt ); const observedStatus = statusQuery.data?.status; const statusUpdatedAt = statusQuery.dataUpdatedAt; useEffect( () => { @@ -496,6 +530,7 @@ export function useRestore( rewindId: string, enabled = true ): Result { setStartedRewindId( null ); setAliveAt( null ); setLostTrack( false ); + setRestoresRead( false ); lastSeenUpdateAt.current = 0; resetMutation(); }, [ resetMutation ] ); @@ -514,8 +549,10 @@ export function useRestore( rewindId: string, enabled = true ): Result { restoreId, settledOutcome, statusError: statusQuery.error, + pollFailures, data: statusQuery.data, lostTrack, + restoresRead, } ); return { diff --git a/projects/packages/backup/src/dashboard/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx index 99659210f1be..01ed522dfbcb 100644 --- a/projects/packages/backup/src/dashboard/screens/download.tsx +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -248,6 +248,11 @@ export default function DownloadScreen() { { titles.preparing } + { state.phase === 'progress' && state.reconnecting && ( + + { __( 'Reconnecting…', 'jetpack-backup-pkg' ) } + + ) } ) } { state.phase === 'success' && ( diff --git a/projects/packages/backup/src/dashboard/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx index 8b08aa4f88f8..ae098e231c2b 100644 --- a/projects/packages/backup/src/dashboard/screens/restore.tsx +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -266,6 +266,11 @@ export default function RestoreScreen() { { state.message && ( { state.message } ) } + { state.reconnecting && ( + + { __( 'Reconnecting…', 'jetpack-backup-pkg' ) } + + ) } ) } { /* diff --git a/projects/packages/backup/src/dashboard/types/restore.ts b/projects/packages/backup/src/dashboard/types/restore.ts index cccd4cdc7422..21ebadc569e3 100644 --- a/projects/packages/backup/src/dashboard/types/restore.ts +++ b/projects/packages/backup/src/dashboard/types/restore.ts @@ -92,7 +92,7 @@ export type RestoreState = | { phase: 'submitting' } | { phase: 'queued' } | { phase: 'unconfirmed'; detail: string | null } - | { phase: 'progress'; percent: number; message: string } + | { phase: 'progress'; percent: number; message: string; reconnecting?: boolean } | { phase: 'success' } | { phase: 'success-with-errors'; message: string; reference: FailureReference } | { phase: 'lost-track'; detail: string | null; reference: FailureReference } diff --git a/projects/packages/backup/tests/restore-lost-track.test.tsx b/projects/packages/backup/tests/restore-lost-track.test.tsx index 742f94f1f49c..8210ead43bca 100644 --- a/projects/packages/backup/tests/restore-lost-track.test.tsx +++ b/projects/packages/backup/tests/restore-lost-track.test.tsx @@ -37,7 +37,7 @@ jest.mock( '@wordpress/route', () => ( { } ) ); // Imports must come after the jest.mock factories above. -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { stage as RestoreStage } from '../routes/restore/stage'; import { queryClient } from '../src/dashboard/data/query-client'; @@ -100,17 +100,40 @@ async function findMessage() { * Start a restore with the default six-of-six checklist. */ async function startRestore() { - await userEvent.click( await screen.findByRole( 'button', { name: /Confirm restore/ } ) ); + const fake = 'clock' in setTimeout; + const user = userEvent.setup( fake ? { advanceTimers: jest.advanceTimersByTime } : {} ); + await user.click( await screen.findByRole( 'button', { name: /Confirm restore/ } ) ); +} + +/** + * Let every poll fail past the silence deadline, then wait for the message. + * A failed first poll is temporary, so only the deadline gives up. + */ +async function findMessageAfterDeadline() { + for ( let elapsed = 0; elapsed <= 6 * 60_000; elapsed += 5000 ) { + await act( async () => { + await jest.advanceTimersByTimeAsync( 5000 ); + } ); + } + await findMessage(); } describe( 'a restore that has gone out of sight', () => { + beforeEach( () => { + jest.useFakeTimers(); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + it( 'offers a way out, not a way to start another one', async () => { arrange( () => Promise.reject( new Error( 'Could not reach WordPress.com.' ) ) ); render( ); await startRestore(); - await findMessage(); + await findMessageAfterDeadline(); // The whole point. Either control would start a second restore: // "Try again" resets to an armed Confirm, and Confirm submits. @@ -134,7 +157,7 @@ describe( 'a restore that has gone out of sight', () => { await startRestore(); - await findMessage(); + await findMessageAfterDeadline(); expect( screen.getByText( 'Could not reach WordPress.com.' ) ).toBeInTheDocument(); } ); @@ -145,7 +168,7 @@ describe( 'a restore that has gone out of sight', () => { render( ); await startRestore(); - await findMessage(); + await findMessageAfterDeadline(); // The notice plus its @wordpress/a11y live-region copy; tell them apart by `aria-live`. const lives = () => diff --git a/projects/packages/backup/tests/restore-session-recovery.test.tsx b/projects/packages/backup/tests/restore-session-recovery.test.tsx index 8ecf89fa92eb..049d7c177ba9 100644 --- a/projects/packages/backup/tests/restore-session-recovery.test.tsx +++ b/projects/packages/backup/tests/restore-session-recovery.test.tsx @@ -24,7 +24,7 @@ jest.mock( '@wordpress/route', () => ( { } ) ); // Imports must come after the jest.mock factories above. -import { render, within } from '@testing-library/react'; +import { act, render, within } from '@testing-library/react'; import { queryClient } from '../src/dashboard/data/query-client'; import QueryClientProvider from '../src/dashboard/providers/query-client-provider'; import RestoreScreen from '../src/dashboard/screens/restore'; @@ -235,13 +235,22 @@ describe( 'Restore screen — a restore already running', () => { return Promise.resolve( CAPABILITIES ); } ); - const view = within( renderScreen() ); + // A failed poll is temporary; only the silence deadline gives up. + jest.useFakeTimers(); + try { + const view = within( renderScreen() ); + for ( let elapsed = 0; elapsed <= 6 * 60_000; elapsed += 5000 ) { + await act( async () => { + await jest.advanceTimersByTimeAsync( 5000 ); + } ); + } - await expect( - view.findByText( /We've lost track of this restore/ ) - ).resolves.toBeInTheDocument(); - expect( view.queryByText( ALREADY_RUNNING ) ).not.toBeInTheDocument(); - // And still no way to start a second one. - expect( view.queryByRole( 'button', { name: CONFIRM } ) ).not.toBeInTheDocument(); + expect( view.getByText( /We've lost track of this restore/ ) ).toBeInTheDocument(); + expect( view.queryByText( ALREADY_RUNNING ) ).not.toBeInTheDocument(); + // And still no way to start a second one. + expect( view.queryByRole( 'button', { name: CONFIRM } ) ).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } } ); } ); From 04209889b1a3a6313c5362beb8988dc2341a8bbc Mon Sep 17 00:00:00 2001 From: tbradsha <32492176+tbradsha@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:44:01 -0600 Subject: [PATCH 37/57] Use `apply_shortcodes()` instead of `do_shortcode()` (#53552) --- ...e-various-do_shortcode_to_apply_shortcodes | 4 ++ .../contact-form/class-contact-form-block.php | 2 +- .../class-contact-form-plugin.php | 18 ++++----- .../class-contact-form-shortcode.php | 2 +- .../src/contact-form/class-contact-form.php | 2 +- .../Conditional_Logic_Required_Field_Test.php | 4 +- .../contact-form/Contact_Form_Block_Test.php | 4 +- .../contact-form/Contact_Form_Field_Test.php | 2 +- .../contact-form/Contact_Form_Synced_Test.php | 8 ++-- .../php/contact-form/Contact_Form_Test.php | 14 +++---- ...e-various-do_shortcode_to_apply_shortcodes | 4 ++ .../templates/class-wp-template.php | 2 +- ...e-various-do_shortcode_to_apply_shortcodes | 4 ++ .../src/class-block-replacement.php | 2 +- ...e-various-do_shortcode_to_apply_shortcodes | 4 ++ ...son-api-render-embed-reversal-endpoint.php | 2 +- .../class.wpcom-json-api-render-endpoint.php | 18 ++++++++- ...com-json-api-render-shortcode-endpoint.php | 2 +- .../jetpack/modules/shortcodes/archiveorg.php | 2 +- .../jetpack/modules/shortcodes/quiz.php | 14 +++---- .../jetpack/modules/widgets/image-widget.php | 2 +- .../jetpack/modules/widgets/mailchimp.php | 2 +- ...s-wordads-ccpa-do-not-sell-link-widget.php | 2 +- .../Jetpack_Shortcodes_ArchiveOrg_Test.php | 30 +++++++------- .../Jetpack_Shortcodes_Archives_Test.php | 2 +- .../Jetpack_Shortcodes_Bandcamp_Test.php | 2 +- .../Jetpack_Shortcodes_Brightcove_Test.php | 6 +-- .../Jetpack_Shortcodes_CrowdSignal_Test.php | 18 ++++----- .../Jetpack_Shortcodes_Dailymotion_Test.php | 20 +++++----- .../Jetpack_Shortcodes_Facebook_Test.php | 2 +- .../Jetpack_Shortcodes_Flickr_Test.php | 10 ++--- .../Jetpack_Shortcodes_Getty_Test.php | 6 +-- .../Jetpack_Shortcodes_Gist_Test.php | 8 ++-- .../Jetpack_Shortcodes_GoogleApps_Test.php | 6 +-- .../Jetpack_Shortcodes_Googlemaps_Test.php | 14 +++---- .../Jetpack_Shortcodes_Gravatar_Test.php | 12 +++--- .../Jetpack_Shortcodes_Instagram_Test.php | 8 ++-- .../Jetpack_Shortcodes_Kickstarter_Test.php | 6 +-- .../Jetpack_Shortcodes_MailChimp_Test.php | 6 +-- .../Jetpack_Shortcodes_Medium_Test.php | 4 +- .../Jetpack_Shortcodes_Mixcloud_Test.php | 12 +++--- .../Jetpack_Shortcodes_Presentations_Test.php | 2 +- .../Jetpack_Shortcodes_Quiz_Test.php | 22 +++++----- .../Jetpack_Shortcodes_Recipe_Test.php | 40 +++++++++---------- .../Jetpack_Shortcodes_Scribd_Test.php | 2 +- .../Jetpack_Shortcodes_Sitemap_Test.php | 6 +-- .../Jetpack_Shortcodes_Slideshare_Test.php | 10 ++--- .../Jetpack_Shortcodes_Slideshow_Test.php | 14 +++---- .../Jetpack_Shortcodes_SmartFrame_Test.php | 4 +- .../Jetpack_Shortcodes_Soundcloud_Test.php | 20 +++++----- .../Jetpack_Shortcodes_Spotify_Test.php | 10 ++--- .../Jetpack_Shortcodes_Ted_Test.php | 10 ++--- .../Jetpack_Shortcodes_Tweet_Test.php | 18 ++++----- ...etpack_Shortcodes_TwitterTimeline_Test.php | 14 +++---- .../Jetpack_Shortcodes_Ustream_Test.php | 8 ++-- .../shortcodes/Jetpack_Shortcodes_VR_Test.php | 6 +-- .../Jetpack_Shortcodes_Vimeo_Test.php | 4 +- .../Jetpack_Shortcodes_Vine_Test.php | 10 ++--- .../Jetpack_Shortcodes_Youtube_Test.php | 4 +- ...e-various-do_shortcode_to_apply_shortcodes | 4 ++ .../widgets/class-music-player-widget.php | 2 +- .../class-wpcom-widget-reservations.php | 2 +- 62 files changed, 268 insertions(+), 236 deletions(-) create mode 100644 projects/packages/forms/changelog/update-various-do_shortcode_to_apply_shortcodes create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/update-various-do_shortcode_to_apply_shortcodes create mode 100644 projects/packages/videopress/changelog/update-various-do_shortcode_to_apply_shortcodes create mode 100644 projects/plugins/jetpack/changelog/update-various-do_shortcode_to_apply_shortcodes create mode 100644 projects/plugins/wpcomsh/changelog/update-various-do_shortcode_to_apply_shortcodes diff --git a/projects/packages/forms/changelog/update-various-do_shortcode_to_apply_shortcodes b/projects/packages/forms/changelog/update-various-do_shortcode_to_apply_shortcodes new file mode 100644 index 000000000000..73d370b0bb92 --- /dev/null +++ b/projects/packages/forms/changelog/update-various-do_shortcode_to_apply_shortcodes @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Use apply_shortcodes() terminology instead of do_shortcode(). diff --git a/projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php b/projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php index b0c05af6ecba..9afd5470c925 100644 --- a/projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php +++ b/projects/packages/forms/src/blocks/contact-form/class-contact-form-block.php @@ -1056,7 +1056,7 @@ public static function apply_background_support( $html, $atts, $target_class ) { $background_styles['backgroundPosition'] = '50% 50%'; } - // A step's styles are still ahead of the do_shortcode() pass in Contact_Form::parse(), + // A step's styles are still ahead of the apply_shortcodes() pass in Contact_Form::parse(), // so percent-encode the brackets that would otherwise make this URL look like a // shortcode: unencoded they are stripped out of the attribute, taking the background // with them, and the shortcode's side effects run. diff --git a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php index 1fe64da4ba5e..42963559b386 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php @@ -204,14 +204,6 @@ protected function __construct() { add_action( 'dynamic_sidebar_before', array( $this, 'track_current_widget_before' ) ); add_action( 'dynamic_sidebar_after', array( $this, 'track_current_widget_after' ) ); - // If Text Widgets don't get shortcode processed, hack ours into place. - if ( - version_compare( get_bloginfo( 'version' ), '4.9-z', '<=' ) - && ! has_filter( 'widget_text', 'do_shortcode' ) - ) { - add_filter( 'widget_text', array( $this, 'widget_shortcode_hack' ), 5 ); - } - add_filter( 'jetpack_contact_form_is_spam', array( $this, 'is_spam_blocklist' ), 10, 2 ); add_filter( 'jetpack_contact_form_in_comment_disallowed_list', array( $this, 'is_in_disallowed_list' ), 10, 2 ); // Akismet to the rescue @@ -1862,7 +1854,7 @@ public function process_form_submission() { // In future version we will be able to skip this step. $form = Contact_Form::$forms[ $hash ] ?? null; - // No form may mean user is using do_shortcode, grab the form using the stored post meta + // No form may mean user is using apply_shortcodes, grab the form using the stored post meta if ( ! $form && is_numeric( $id ) && $hash ) { // Get shortcode from post meta @@ -1883,7 +1875,7 @@ public function process_form_submission() { } $shortcode = '[contact-form' . $parameters . ']' . $shortcode . '[/contact-form]'; - do_shortcode( $shortcode ); + apply_shortcodes( $shortcode ); // Recreate form $form = Contact_Form::$last; @@ -2266,8 +2258,12 @@ public function widget_atts( $text ) { * @param string $text The widget text. * * @return string The contact-form filtered widget text + * + * @deprecated $$next-version$$ Core processes shortcodes in text widgets since WordPress 4.9. */ public function widget_shortcode_hack( $text ) { + _deprecated_function( __METHOD__, 'package-$$next-version$$' ); + if ( ! preg_match( '/\[contact-form([^a-zA-Z_-])/', $text ) ) { return $text; } @@ -2277,7 +2273,7 @@ public function widget_shortcode_hack( $text ) { self::$using_contact_form_field = true; $this->add_shortcode(); - $text = do_shortcode( $text ); + $text = apply_shortcodes( $text ); self::$using_contact_form_field = false; $GLOBALS['shortcode_tags'] = $old; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited diff --git a/projects/packages/forms/src/contact-form/class-contact-form-shortcode.php b/projects/packages/forms/src/contact-form/class-contact-form-shortcode.php index 6878d15518ad..2a0d7c212f1a 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-shortcode.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-shortcode.php @@ -89,7 +89,7 @@ public function parse_content( $content ) { if ( $content === null ) { $this->body = null; } else { - $this->body = do_shortcode( $content ); + $this->body = apply_shortcodes( $content ); } } diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php index 832c44e25695..c173de8bb93d 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -1370,7 +1370,7 @@ public static function get_default_subject( $attributes, $post_data = null ) { /** * Store shortcode content for recall later - * - used to receate shortcode when user uses do_shortcode + * - used to recreate shortcode when user uses apply_shortcodes * * @deprecated 5.0.0 */ diff --git a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Required_Field_Test.php b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Required_Field_Test.php index 8bc500ee1af0..32a0b12fe566 100644 --- a/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Required_Field_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Conditional_Logic_Required_Field_Test.php @@ -47,7 +47,7 @@ protected function tear_down() { /** * Parse a form whose required second field is conditional on the first. * - * Goes through do_shortcode(), so parse_contact_field() runs for real and decides for + * Goes through apply_shortcodes(), so parse_contact_field() runs for real and decides for * itself whether to defer the dependent field's validation. * * @param string $trigger_value Submitted value for the trigger field. @@ -92,7 +92,7 @@ private function parse_form( $trigger_value, $dependent_value ): Contact_Form { . ' conditionallogic="' . $attribute . '"/]' . '[/contact-form]'; - do_shortcode( $shortcode ); + apply_shortcodes( $shortcode ); return Contact_Form::$last; } diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Block_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Block_Test.php index fdf17f7bd9f7..96a6b3bd166a 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Block_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Block_Test.php @@ -655,7 +655,7 @@ public function test_apply_background_support_passes_through_all_properties() { } /** - * A URL containing brackets survives the do_shortcode() pass a step's styles still + * A URL containing brackets survives the apply_shortcodes() pass a step's styles still * sit in front of, instead of being stripped out of the attribute along with the * background — and without running the shortcode it looks like. */ @@ -674,7 +674,7 @@ public function test_apply_background_support_encodes_shortcode_brackets() { $this->assertStringNotContainsString( '[', $result ); $this->assertStringContainsString( '%5Bcontact-field', $result ); - $this->assertStringContainsString( 'background-image', do_shortcode( $result ) ); + $this->assertStringContainsString( 'background-image', apply_shortcodes( $result ) ); } /** diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Field_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Field_Test.php index 41a3f0c956b8..aadfd125f37e 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Field_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Field_Test.php @@ -1485,7 +1485,7 @@ public function test_help_text_punctuation_survives_the_shortcode_round_trip( $h null ); - $html = do_shortcode( '[contact-form]' . $field_shortcode . '[/contact-form]' ); + $html = apply_shortcodes( '[contact-form]' . $field_shortcode . '[/contact-form]' ); $this->assertStringContainsString( 'class="contact-form__field-help">' . esc_html( $help_text ) . '', diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Synced_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Synced_Test.php index 6fc4452355c6..b336a2f8f18b 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Synced_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Synced_Test.php @@ -583,7 +583,7 @@ public function test_shortcode_with_ref_attribute_loads_synced_form() { // Process the shortcode with ref attribute $shortcode = sprintf( '[contact-form ref="%d"]', $form_id ); - $output = do_shortcode( $shortcode ); + $output = apply_shortcodes( $shortcode ); // Verify the output contains the unique label from the synced form // This ensures we're loading the actual referenced form, not a default form @@ -596,7 +596,7 @@ public function test_shortcode_with_ref_attribute_loads_synced_form() { public function test_shortcode_with_invalid_ref_returns_empty() { // Process the shortcode with invalid ref $shortcode = '[contact-form ref="99999"]'; // Non-existent form - $output = do_shortcode( $shortcode ); + $output = apply_shortcodes( $shortcode ); // Should return empty or the original shortcode text (not render as a form) $this->assertEmpty( $output, 'Shortcode with invalid ref should return empty' ); @@ -628,7 +628,7 @@ public function test_shortcode_ref_prevents_circular_reference() { // Process the shortcode $shortcode = sprintf( '[contact-form ref="%d"]', $form_id ); - $output = do_shortcode( $shortcode ); + $output = apply_shortcodes( $shortcode ); // Should return empty to prevent infinite loop $this->assertEmpty( $output, 'Shortcode with circular ref should return empty' ); @@ -650,7 +650,7 @@ public function test_shortcode_ref_only_renders_published_forms() { // Process the shortcode $shortcode = sprintf( '[contact-form ref="%d"]', $trashed_form_id ); - $output = do_shortcode( $shortcode ); + $output = apply_shortcodes( $shortcode ); // Should not render trashed form $this->assertEmpty( $output, 'Shortcode should not render trashed form' ); diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php index 3f9a5d199828..648d5a9917fa 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php @@ -1312,7 +1312,7 @@ public function test_token_with_curly_brackets_can_be_replaced() { * the whole feature could be removed from the markup and they would all still pass. */ public function test_rendered_form_carries_form_fill_duration_markup() { - $html = do_shortcode( "[contact-form][contact-field label='Name' type='name' required='1'/][/contact-form]" ); + $html = apply_shortcodes( "[contact-form][contact-field label='Name' type='name' required='1'/][/contact-form]" ); // Asserted as a pair: an unanchored `value=''` would also match the Name field, which // renders an empty value of its own, so it would pass even if the duration input @@ -1336,7 +1336,7 @@ public function test_rendered_form_carries_form_fill_duration_markup() { */ public function test_parse_contact_field_keeps_string_unchanged_when_no_escaping_necesssary() { $shortcode = '[contact-field label="Name" type="name" required="1"/][contact-field label="Email" type="email" required="1"/][contact-field label="asdasd" type="text"/][contact-field id="1" required derp herp asd lkj]adsasd[/contact-field]'; - $html = do_shortcode( $shortcode ); + $html = apply_shortcodes( $shortcode ); $this->assertEquals( $shortcode, $html ); } @@ -1346,7 +1346,7 @@ public function test_parse_contact_field_keeps_string_unchanged_when_no_escaping */ public function test_make_sure_that_we_add_default_label_when_non_is_present() { $shortcode = "[contact-field type='name' required='1' /]"; - $html = do_shortcode( $shortcode ); + $html = apply_shortcodes( $shortcode ); $this->assertEquals( '[contact-field type="name" required="1" label="Name"/]', $html ); } @@ -1355,7 +1355,7 @@ public function test_make_sure_that_we_add_default_label_when_non_is_present() { */ public function test_make_sure_that_we_remove_empty_options_from_form_field() { $shortcode = "[contact-field type='select' required='1' options='fun,,run' label='fun times' values='go,,have some fun'/]"; - $html = do_shortcode( $shortcode ); + $html = apply_shortcodes( $shortcode ); $this->assertEquals( '[contact-field type="select" required="1" options="fun,run" label="fun times" values="go,have some fun"/]', $html ); } @@ -1364,7 +1364,7 @@ public function test_make_sure_that_we_remove_empty_options_from_form_field() { */ public function test_array_values_with_commas_and_brackets() { $shortcode = "[contact-field type='radio' options='\"foo\",bar, baz,[b\rackets]' label='fun ][ times'/]"; - $html = do_shortcode( $shortcode ); + $html = apply_shortcodes( $shortcode ); $this->assertEquals( '[contact-field type="radio" options=""foo",bar, baz,[b\rackets]" label="fun ][ times"/]', $html ); } @@ -2319,7 +2319,7 @@ function ( $option ) use ( $input ) { public function test_parse_contact_field_escapes_things_inside_a_value_and_attribute_and_the_content() { $shortcode = "[contact-field label='Name' type='name' required='1'/][contact-field label='Email' type=''email'' req'uired='1'/][contact-field label='asdasd' type='text'/][contact-field id='1' required 'derp' herp asd lkj]adsasd[/contact-field]"; - $html = do_shortcode( $shortcode ); + $html = apply_shortcodes( $shortcode ); /* * The expected string has some quotes escaped, since we want to make @@ -2334,7 +2334,7 @@ public function test_parse_contact_field_escapes_things_inside_a_value_and_attri public function test_parse_contact_field_trims_content() { $shortcode = '[contact-field id="1" required] adsasd [/contact-field]'; - $html = do_shortcode( $shortcode ); + $html = apply_shortcodes( $shortcode ); /* * The expected string has some quotes escaped, since we want to make diff --git a/projects/packages/jetpack-mu-wpcom/changelog/update-various-do_shortcode_to_apply_shortcodes b/projects/packages/jetpack-mu-wpcom/changelog/update-various-do_shortcode_to_apply_shortcodes new file mode 100644 index 000000000000..73d370b0bb92 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/update-various-do_shortcode_to_apply_shortcodes @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Use apply_shortcodes() terminology instead of do_shortcode(). diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/templates/class-wp-template.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/templates/class-wp-template.php index 3daa5751b922..29ccedd86fda 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/templates/class-wp-template.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-legacy-fse/templates/class-wp-template.php @@ -188,7 +188,7 @@ public function output_template_content( $template_type ) { $content = wptexturize( $content ); // 11 priority - $content = do_shortcode( $content ); + $content = apply_shortcodes( $content ); $content = prepend_attachment( $content ); diff --git a/projects/packages/videopress/changelog/update-various-do_shortcode_to_apply_shortcodes b/projects/packages/videopress/changelog/update-various-do_shortcode_to_apply_shortcodes new file mode 100644 index 000000000000..73d370b0bb92 --- /dev/null +++ b/projects/packages/videopress/changelog/update-various-do_shortcode_to_apply_shortcodes @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Use apply_shortcodes() terminology instead of do_shortcode(). diff --git a/projects/packages/videopress/src/class-block-replacement.php b/projects/packages/videopress/src/class-block-replacement.php index a59903b17e34..2b796ee97d5b 100644 --- a/projects/packages/videopress/src/class-block-replacement.php +++ b/projects/packages/videopress/src/class-block-replacement.php @@ -48,7 +48,7 @@ public static function replace_media_text_with_videopress( $block_content, $bloc $video_info = video_get_info_by_blogpostid( get_current_blog_id(), $post_id ); if ( $video_info && $video_info->guid ) { $videopress_shortcode = sprintf( '[videopress %s]', esc_attr( $video_info->guid ) ); - $block_content = preg_replace( '//is', do_shortcode( $videopress_shortcode ), $block_content ); + $block_content = preg_replace( '//is', apply_shortcodes( $videopress_shortcode ), $block_content ); } } return $block_content; diff --git a/projects/plugins/jetpack/changelog/update-various-do_shortcode_to_apply_shortcodes b/projects/plugins/jetpack/changelog/update-various-do_shortcode_to_apply_shortcodes new file mode 100644 index 000000000000..61f703999589 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-various-do_shortcode_to_apply_shortcodes @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Use apply_shortcodes() terminology instead of do_shortcode(). diff --git a/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-embed-reversal-endpoint.php b/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-embed-reversal-endpoint.php index e53199f1f21c..358da41d2a05 100644 --- a/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-embed-reversal-endpoint.php +++ b/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-embed-reversal-endpoint.php @@ -135,7 +135,7 @@ public function callback( $path = '', $blog_id = 0 ) { */ public function render_shortcode_reversal( $args ) { if ( $args['is_shortcode'] ) { - return $this->do_shortcode( $args['shortcode_reversal'] ); + return $this->apply_shortcodes( $args['shortcode_reversal'] ); } elseif ( $args['is_embed'] ) { return $this->do_embed( $args['shortcode_reversal'] ); } 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 bd5ae017f35e..a3f4578e231b 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 @@ -152,11 +152,27 @@ public function get_version( $this_scripts_version, $default_version ) { * Given a shortcode, process and return the result. * * @param string $shortcode - the shortcode. + * + * @return string */ - public function do_shortcode( $shortcode ) { + public function apply_shortcodes( $shortcode ) { return apply_shortcodes( $shortcode ); } + /** + * Given a shortcode, process and return the result. + * + * @deprecated $$next-version$$ Use apply_shortcodes(). + * + * @param string $shortcode - the shortcode. + * + * @return string + */ + public function do_shortcode( $shortcode ) { + _deprecated_function( __METHOD__, 'jetpack-$$next-version$$', 'WPCOM_JSON_API_Render_Endpoint::apply_shortcodes' ); + return $this->apply_shortcodes( $shortcode ); + } + /** * Given a one-line embed URL, process and return the result. * diff --git a/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-shortcode-endpoint.php b/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-shortcode-endpoint.php index 83401a204d46..378284b9df5b 100644 --- a/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-shortcode-endpoint.php +++ b/projects/plugins/jetpack/json-endpoints/class.wpcom-json-api-render-shortcode-endpoint.php @@ -73,7 +73,7 @@ public function callback( $path = '', $blog_id = 0 ) { return new WP_Error( 'invalid_shortcode', 'Only one shortcode can be rendered at a time.', 400 ); } - $render = $this->process_render( array( $this, 'do_shortcode' ), $shortcode ); + $render = $this->process_render( array( $this, 'apply_shortcodes' ), $shortcode ); // if nothing happened, then the shortcode does not exist. if ( $shortcode === $render['result'] ) { diff --git a/projects/plugins/jetpack/modules/shortcodes/archiveorg.php b/projects/plugins/jetpack/modules/shortcodes/archiveorg.php index ee5b6c8bb0c6..115f83f0d39b 100644 --- a/projects/plugins/jetpack/modules/shortcodes/archiveorg.php +++ b/projects/plugins/jetpack/modules/shortcodes/archiveorg.php @@ -72,7 +72,7 @@ function jetpack_archiveorg_shortcode( $atts ) { $id = $atts['id']; // Allow extra query parameters to be baked into the identifier, e.g. "myitem&playlist=1" or "myitem?playlist=1". - // In some environments a the_content filter encodes "&" to "&" before do_shortcode runs, so the + // In some environments a the_content filter encodes "&" to "&" before apply_shortcodes runs, so the // parser receives "myitem&playlist=1" — normalize that back before splitting. The sibling function // jetpack_archiveorg_embed_to_shortcode() splits on "&" for the same reason. $id = str_replace( '&', '&', $id ); diff --git a/projects/plugins/jetpack/modules/shortcodes/quiz.php b/projects/plugins/jetpack/modules/shortcodes/quiz.php index 4392156fc552..21ca6202a17f 100644 --- a/projects/plugins/jetpack/modules/shortcodes/quiz.php +++ b/projects/plugins/jetpack/modules/shortcodes/quiz.php @@ -206,7 +206,7 @@ public static function shortcode( $atts, $content = null ) { } } - $quiz = self::do_shortcode( $content ); + $quiz = self::apply_shortcodes( $content ); $quiz_options = ''; if ( self::$quiz_wrapper ) { @@ -236,7 +236,7 @@ public static function shortcode( $atts, $content = null ) { */ public static function shortcode_wrapper( $atts, $content = null ) { self::$quiz_wrapper = true; - return '
' . self::do_shortcode( $content ) . '
'; + return '
' . self::apply_shortcodes( $content ) . '
'; } /** @@ -248,7 +248,7 @@ public static function shortcode_wrapper( $atts, $content = null ) { * * @return mixed|string */ - private static function do_shortcode( $content ) { + private static function apply_shortcodes( $content ) { // strip autoinserted line breaks. $content = preg_replace( '#(<(?:br /|/?p)>\n?)*(\[/?[a-z]+\])(<(?:br /|/?p)>\n?)*#', '$2', $content ); @@ -300,7 +300,7 @@ private static function do_shortcode( $content ) { */ public static function question_shortcode( $atts, $content = null ) { return isset( $atts['quiz_item'] ) - ? '
' . self::do_shortcode( $content ) . '
' + ? '
' . self::apply_shortcodes( $content ) . '
' : ''; } @@ -320,7 +320,7 @@ public static function answer_shortcode( $atts, $content = null ) { } return isset( $atts['quiz_item'] ) - ? '
' . self::do_shortcode( $content ) . '
' + ? '
' . self::apply_shortcodes( $content ) . '
' : ''; } @@ -340,7 +340,7 @@ public static function wrong_shortcode( $atts, $content = null ) { } return isset( $atts['quiz_item'] ) - ? '
' . self::do_shortcode( $content ) . '
' + ? '
' . self::apply_shortcodes( $content ) . '
' : ''; } @@ -360,7 +360,7 @@ public static function explanation_shortcode( $atts, $content = null ) { } return isset( $atts['quiz_item'] ) - ? '
' . self::do_shortcode( $content ) . '
' + ? '
' . self::apply_shortcodes( $content ) . '
' : ''; } } diff --git a/projects/plugins/jetpack/modules/widgets/image-widget.php b/projects/plugins/jetpack/modules/widgets/image-widget.php index b27b0f21c3fe..bd409a28d420 100644 --- a/projects/plugins/jetpack/modules/widgets/image-widget.php +++ b/projects/plugins/jetpack/modules/widgets/image-widget.php @@ -118,7 +118,7 @@ public function widget( $args, $instance ) {
' . $caption . '
'; // wp_kses_post caption on update. } - echo '
' . do_shortcode( $output ) . '
'; + echo '
' . apply_shortcodes( $output ) . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- https://github.com/WordPress/WordPress-Coding-Standards/pull/2782 } elseif ( current_user_can( 'edit_theme_options' ) ) { echo '

' . wp_kses( sprintf( diff --git a/projects/plugins/jetpack/modules/widgets/mailchimp.php b/projects/plugins/jetpack/modules/widgets/mailchimp.php index da2e1e24e85b..a5d0203cd30f 100644 --- a/projects/plugins/jetpack/modules/widgets/mailchimp.php +++ b/projects/plugins/jetpack/modules/widgets/mailchimp.php @@ -80,7 +80,7 @@ public function widget( $args, $instance ) { // Process the shortcode only, if exists. if ( ! empty( $matches[0] ) ) { - echo do_shortcode( $matches[0] ); + echo apply_shortcodes( $matches[0] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- https://github.com/WordPress/WordPress-Coding-Standards/pull/2782 } /** This action is documented in modules/widgets/gravatar-profile.php */ diff --git a/projects/plugins/jetpack/modules/wordads/php/class-wordads-ccpa-do-not-sell-link-widget.php b/projects/plugins/jetpack/modules/wordads/php/class-wordads-ccpa-do-not-sell-link-widget.php index 9dc34a552480..6cd730233cdc 100644 --- a/projects/plugins/jetpack/modules/wordads/php/class-wordads-ccpa-do-not-sell-link-widget.php +++ b/projects/plugins/jetpack/modules/wordads/php/class-wordads-ccpa-do-not-sell-link-widget.php @@ -37,7 +37,7 @@ public function __construct() { */ public function widget( $args, $instance ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo do_shortcode( '[privacy-do-not-sell-link]' ); + echo apply_shortcodes( '[privacy-do-not-sell-link]' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- https://github.com/WordPress/WordPress-Coding-Standards/pull/2782. echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } diff --git a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_ArchiveOrg_Test.php b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_ArchiveOrg_Test.php index 494abab048ef..bb7d82c0423e 100644 --- a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_ArchiveOrg_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_ArchiveOrg_Test.php @@ -17,18 +17,18 @@ public function test_shortcodes_exists() { } /** - * Verify that calling do_shortcode with the shortcode doesn't return the same content. + * Verify that calling apply_shortcodes with the shortcode doesn't return the same content. * * @since 4.5.0 */ public function test_shortcodes() { $content = '[archiveorg]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertNotEquals( $content, $shortcode_content ); $this->assertEquals( '', $shortcode_content ); $content = '[archiveorg-book]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertNotEquals( $content, $shortcode_content ); $this->assertEquals( '', $shortcode_content ); } @@ -42,7 +42,7 @@ public function test_shortcode_single_element() { $id = 'Wonderfu1958'; $title = 'Archive.org'; $content = "[archiveorg id='$id' width='600' height='300']"; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringContainsString( "iframe title=\"$title\" src=\"https://archive.org/embed/$id\" width=\"600\" height=\"300\"", $shortcode_content ); } @@ -51,7 +51,7 @@ public function test_shortcode_single_element() { */ public function test_shortcode_autoplay_and_poster() { $content = "[archiveorg id='Wonderfu1958' autoplay=1 poster='http://archive.org/img.png']"; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringContainsString( 'src="https://archive.org/embed/Wonderfu1958?autoplay=1&poster=http://archive.org/img.png"', $shortcode_content ); } @@ -60,7 +60,7 @@ public function test_shortcode_autoplay_and_poster() { */ public function test_shortcode_playlist_attribute() { $content = "[archiveorg id='sentidodelobjeto' playlist=1]"; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringContainsString( 'src="https://archive.org/embed/sentidodelobjeto?playlist=1"', $shortcode_content ); } @@ -68,7 +68,7 @@ public function test_shortcode_playlist_attribute() { * Verify that query parameters baked into the identifier with "&" are converted to a proper query string. */ public function test_shortcode_query_string_in_id_with_ampersand() { - $shortcode_content = do_shortcode( '[archiveorg sentidodelobjeto&playlist=1 width=640 height=300]' ); + $shortcode_content = apply_shortcodes( '[archiveorg sentidodelobjeto&playlist=1 width=640 height=300]' ); $this->assertStringContainsString( 'src="https://archive.org/embed/sentidodelobjeto?playlist=1"', $shortcode_content ); } @@ -76,16 +76,16 @@ public function test_shortcode_query_string_in_id_with_ampersand() { * Verify that query parameters baked into the identifier with "?" pass through correctly. */ public function test_shortcode_query_string_in_id_with_question_mark() { - $shortcode_content = do_shortcode( '[archiveorg sentidodelobjeto?playlist=1 width=640 height=300]' ); + $shortcode_content = apply_shortcodes( '[archiveorg sentidodelobjeto?playlist=1 width=640 height=300]' ); $this->assertStringContainsString( 'src="https://archive.org/embed/sentidodelobjeto?playlist=1"', $shortcode_content ); } /** * Verify that an HTML-encoded "&" baked into the identifier is normalized before splitting, - * matching what reaches do_shortcode in environments where the_content encodes ampersands first. + * matching what reaches apply_shortcodes in environments where the_content encodes ampersands first. */ public function test_shortcode_query_string_in_id_with_html_encoded_ampersand() { - $shortcode_content = do_shortcode( '[archiveorg sentidodelobjeto&playlist=1 width=640 height=300]' ); + $shortcode_content = apply_shortcodes( '[archiveorg sentidodelobjeto&playlist=1 width=640 height=300]' ); $this->assertStringContainsString( 'src="https://archive.org/embed/sentidodelobjeto?playlist=1"', $shortcode_content ); } @@ -94,8 +94,8 @@ public function test_shortcode_query_string_in_id_with_html_encoded_ampersand() * rather than producing an item-less embed URL. */ public function test_shortcode_returns_error_when_id_is_only_a_query_string() { - $this->assertEquals( '', do_shortcode( '[archiveorg ?playlist=1]' ) ); - $this->assertEquals( '', do_shortcode( '[archiveorg &playlist=1]' ) ); + $this->assertEquals( '', apply_shortcodes( '[archiveorg ?playlist=1]' ) ); + $this->assertEquals( '', apply_shortcodes( '[archiveorg &playlist=1]' ) ); } /** @@ -103,7 +103,7 @@ public function test_shortcode_returns_error_when_id_is_only_a_query_string() { * appears only once in the rendered URL. */ public function test_shortcode_dedupes_duplicate_query_params_from_id_and_atts() { - $shortcode_content = do_shortcode( '[archiveorg myitem&playlist=1 playlist=1]' ); + $shortcode_content = apply_shortcodes( '[archiveorg myitem&playlist=1 playlist=1]' ); $this->assertStringContainsString( 'src="https://archive.org/embed/myitem?playlist=1"', $shortcode_content ); $this->assertSame( 1, substr_count( $shortcode_content, 'playlist=1' ) ); } @@ -112,7 +112,7 @@ public function test_shortcode_dedupes_duplicate_query_params_from_id_and_atts() * Verify that a "#" fragment in the identifier stays after the query string rather than swallowing it. */ public function test_shortcode_places_query_before_fragment_in_id() { - $shortcode_content = do_shortcode( '[archiveorg myitem#frag&playlist=1]' ); + $shortcode_content = apply_shortcodes( '[archiveorg myitem#frag&playlist=1]' ); $this->assertStringContainsString( 'src="https://archive.org/embed/myitem?playlist=1#frag"', $shortcode_content ); } @@ -125,7 +125,7 @@ public function test_shortcode_book() { $id = 'goodytwoshoes00newyiala'; $title = 'Archive.org Book'; $content = "[archiveorg-book id='$id' width='600' height='300']"; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringContainsString( "iframe title=\"$title\" src=\"https://archive.org/stream/$id?ui=embed#mode/1up\" width=\"600\" height=\"300\"", $shortcode_content ); } } diff --git a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Archives_Test.php b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Archives_Test.php index d1fa15959505..095f524719d6 100644 --- a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Archives_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Archives_Test.php @@ -29,7 +29,7 @@ public function test_shortcodes_archives_exists() { public function test_shortcodes_archives() { $content = '[archives]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertNotEquals( $content, $shortcode_content ); } diff --git a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Bandcamp_Test.php b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Bandcamp_Test.php index 7770001a077a..19c4593f6ab8 100644 --- a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Bandcamp_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Bandcamp_Test.php @@ -27,7 +27,7 @@ public function test_shortcodes_bandcamp_exists() { public function test_shortcodes_bandcamp() { $content = '[bandcamp]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertNotEquals( $content, $shortcode_content ); } diff --git a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Brightcove_Test.php b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Brightcove_Test.php index 30342ef48f65..e5a57b3841b5 100644 --- a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Brightcove_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Brightcove_Test.php @@ -16,14 +16,14 @@ public function test_shortcodes_brightcove_exists() { } /** - * Verify that calling do_shortcode with the shortcode doesn't return the same content. + * Verify that calling apply_shortcodes with the shortcode doesn't return the same content. * * @since 4.5.0 */ public function test_shortcodes_brightcove() { $content = '[brightcove]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertNotEquals( $content, $shortcode_content ); $this->assertEquals( '', $shortcode_content ); @@ -39,7 +39,7 @@ public function test_shortcodes_brightcove_video_id() { $account_id = '57838016001'; $content = "[brightcove video_id='$video_id' account_id='$account_id']"; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringContainsString( '', $shortcode_content ); @@ -148,7 +148,7 @@ public function test_shortcodes_mixcloud_sandbox_have_allow_popups() { $content = '[mixcloud width=500 height 100 mini="true" hide_followers="true" hide_artwork="true" light="true"]https://www.mixcloud.com/DJOneF/djonef-mashup-mix-june-2020/[/mixcloud]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertEquals( '', $shortcode_content ); diff --git a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Presentations_Test.php b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Presentations_Test.php index 0e085fc490f0..f265662d07fd 100644 --- a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Presentations_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Presentations_Test.php @@ -35,7 +35,7 @@ public function test_shortcodes_presentations_slide_exists() { public function test_shortcodes_presentations_presentation() { $content = '[presentation]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertNotEquals( $content, $shortcode_content ); } diff --git a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Quiz_Test.php b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Quiz_Test.php index bd05f85334d2..c2fdb5d0f3eb 100644 --- a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Quiz_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Quiz_Test.php @@ -23,7 +23,7 @@ public function test_shortcodes_quiz_exists() { public function test_shortcodes_quiz() { $content = '[quiz][/quiz]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertNotEquals( $content, $shortcode_content ); $this->assertSame( '', $shortcode_content ); @@ -35,7 +35,7 @@ public function test_shortcodes_quiz() { * @since 4.5.0 */ public function test_shortcodes_quiz_id() { - $shortcode_content = do_shortcode( '[quiz trackid="the-quiz"][question]What is the right answer?[/question][/quiz]' ); + $shortcode_content = apply_shortcodes( '[quiz trackid="the-quiz"][question]What is the right answer?[/question][/quiz]' ); $this->assertEquals( '

What is the right answer?
', $shortcode_content ); } @@ -45,10 +45,10 @@ public function test_shortcodes_quiz_id() { * @since 4.5.0 */ public function test_shortcodes_question() { - $shortcode_content = do_shortcode( '[question]What is the right answer?[/question]' ); + $shortcode_content = apply_shortcodes( '[question]What is the right answer?[/question]' ); $this->assertSame( '', $shortcode_content ); - $shortcode_content = do_shortcode( '[quiz][question]What is the right answer?[/question][/quiz]' ); + $shortcode_content = apply_shortcodes( '[quiz][question]What is the right answer?[/question][/quiz]' ); $this->assertEquals( '
What is the right answer?
', $shortcode_content ); } @@ -58,10 +58,10 @@ public function test_shortcodes_question() { * @since 4.5.0 */ public function test_shortcodes_answer() { - $shortcode_content = do_shortcode( '[answer]This is the right answer![/answer]' ); + $shortcode_content = apply_shortcodes( '[answer]This is the right answer![/answer]' ); $this->assertSame( '', $shortcode_content ); - $shortcode_content = do_shortcode( '[quiz][answer]This is the right answer![/answer][/quiz]' ); + $shortcode_content = apply_shortcodes( '[quiz][answer]This is the right answer![/answer][/quiz]' ); $this->assertEquals( '
This is the right answer!
', $shortcode_content ); } @@ -71,10 +71,10 @@ public function test_shortcodes_answer() { * @since 4.5.0 */ public function test_shortcodes_wrong() { - $shortcode_content = do_shortcode( '[wrong]This is so wrong...[/wrong]' ); + $shortcode_content = apply_shortcodes( '[wrong]This is so wrong...[/wrong]' ); $this->assertSame( '', $shortcode_content ); - $shortcode_content = do_shortcode( '[quiz][wrong]This is so wrong...[/wrong][/quiz]' ); + $shortcode_content = apply_shortcodes( '[quiz][wrong]This is so wrong...[/wrong][/quiz]' ); $this->assertEquals( '
This is so wrong...
', $shortcode_content ); } @@ -84,10 +84,10 @@ public function test_shortcodes_wrong() { * @since 4.5.0 */ public function test_shortcodes_explanation() { - $shortcode_content = do_shortcode( '[explanation]This is why this is right or wrong.[/explanation]' ); + $shortcode_content = apply_shortcodes( '[explanation]This is why this is right or wrong.[/explanation]' ); $this->assertSame( '', $shortcode_content ); - $shortcode_content = do_shortcode( '[quiz][explanation]This is why this is right or wrong.[/explanation][/quiz]' ); + $shortcode_content = apply_shortcodes( '[quiz][explanation]This is why this is right or wrong.[/explanation][/quiz]' ); $this->assertEquals( '
This is why this is right or wrong.
', $shortcode_content ); } @@ -97,7 +97,7 @@ public function test_shortcodes_explanation() { * @since 4.5.0 */ public function test_shortcodes_complete() { - $shortcode_content = do_shortcode( '[quiz][question]What is the right answer?[/question][wrong]This is so wrong...[explanation]This is why this is wrong.[/explanation][/wrong][answer]Yes, this is right![explanation]Yay![/explanation][/answer][/quiz]' ); + $shortcode_content = apply_shortcodes( '[quiz][question]What is the right answer?[/question][wrong]This is so wrong...[explanation]This is why this is wrong.[/explanation][/wrong][answer]Yes, this is right![explanation]Yay![/explanation][/answer][/quiz]' ); $this->assertEquals( '
What is the right answer?
This is so wrong...
This is why this is wrong.
Yes, this is right!
Yay!
', $shortcode_content ); } } diff --git a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Recipe_Test.php b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Recipe_Test.php index cdcb384a7cd5..eae3e92c84a2 100644 --- a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Recipe_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Recipe_Test.php @@ -108,7 +108,7 @@ public function test_shortcodes_recipe_exists() { public function test_shortcodes_recipe_preptime() { $content = '[recipe preptime="30 min"]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertEqualHTML( '
  • @@ -125,7 +125,7 @@ public function test_shortcodes_recipe_preptime() { public function test_shortcodes_recipe_cooktime() { $content = '[recipe cooktime="2 hours 30 min"]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertEqualHTML( '
    • @@ -142,7 +142,7 @@ public function test_shortcodes_recipe_cooktime() { public function test_shortcodes_recipe_rating() { $content = '[recipe rating="2 stars"]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringContainsString( '2 stars', $shortcode_content ); } @@ -154,7 +154,7 @@ public function test_shortcodes_recipe_rating() { public function test_shortcodes_recipe_image_empty_src() { $content = '[recipe image=""]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringNotContainsString( 'assertStringNotContainsString( 'assertStringNotContainsString( 'assertEqualHTML( @@ -212,7 +212,7 @@ public function test_shortcodes_recipe_image_valid_attachment() { public function test_shortcodes_recipe_image_src() { $content = '[recipe image="https://example.com"]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $lazy = wp_lazy_loading_enabled( 'img', 'wp_get_attachment_image' ) ? ' loading="lazy"' : ''; $this->assertEqualHTML( @@ -229,7 +229,7 @@ public function test_shortcodes_recipe_image_src() { public function test_shortcodes_recipe_image_location_move() { $content = '[recipe image="https://example.com"][recipe-image][/recipe]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringNotContainsString( 'assertStringNotContainsString( 'assertStringNotContainsString( 'assertStringNotContainsString( 'assertStringNotContainsString( 'assertStringContainsString( 'src="' . $url . '" class="jetpack-recipe-image u-photo photo"', $shortcode_content ); } @@ -349,7 +349,7 @@ public function test_shortcodes_recipe_image_shortcode_attachment_attr() { // Get shortcode with new attachment. $content = '[recipe-image image="' . $attachment_id . '"]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringContainsString( 'src="' . $url . '" class="jetpack-recipe-image u-photo photo"', $shortcode_content ); } @@ -366,7 +366,7 @@ public function test_shortcodes_recipe_nutrition() { [/recipe-nutrition] EOT; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringContainsString( 'itemprop="nutrition"', $shortcode_content ); $this->assertStringContainsString( '
    • food 100%
    • ', $shortcode_content ); $this->assertStringContainsString( '
    • taste 500mg
    • ', $shortcode_content ); @@ -391,7 +391,7 @@ public function test_shortcodes_recipe_kses_content() {
      EOT; - $shortcode_content = do_shortcode( "[recipe]\n$tags\n[/recipe]" ); + $shortcode_content = apply_shortcodes( "[recipe]\n$tags\n[/recipe]" ); $this->assertEqualHTML( '
      ' . "\n$tags\n" . '
      ', $shortcode_content @@ -450,7 +450,7 @@ public function test_shortcodes_recipe_amp( $shortcode, $expected ) { $expected = preg_replace( '/\s+/', ' ', $expected ); $expected = preg_replace( '/(?<=>)\s+(?=<)/', '', trim( $expected ) ); - $actual = do_shortcode( $shortcode ); + $actual = apply_shortcodes( $shortcode ); $actual = preg_replace( '/\s+/', ' ', $actual ); $actual = preg_replace( '/(?<=>)\s+(?=<)/', '', trim( $actual ) ); diff --git a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Scribd_Test.php b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Scribd_Test.php index ac6d5f80d4da..1def6231faa9 100644 --- a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Scribd_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Scribd_Test.php @@ -63,7 +63,7 @@ public function test_shortcodes_scribd( $shortcode, $is_amp, $expected ) { add_filter( 'jetpack_is_amp_request', '__return_true' ); } - $actual = preg_replace( '/\s+/', ' ', do_shortcode( $shortcode ) ); + $actual = preg_replace( '/\s+/', ' ', apply_shortcodes( $shortcode ) ); $actual = preg_replace( '/(?<=>)\s+(?=<)/', '', trim( $actual ) ); $this->assertEquals( $expected, $actual ); diff --git a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Sitemap_Test.php b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Sitemap_Test.php index cbf3a3b32b6a..f9e67b586c95 100644 --- a/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Sitemap_Test.php +++ b/projects/plugins/jetpack/tests/php/modules/shortcodes/Jetpack_Shortcodes_Sitemap_Test.php @@ -16,14 +16,14 @@ public function test_shortcodes_sitemap_exists() { } /** - * Verify that calling do_shortcode with the shortcode doesn't return the same content and that its output is '' because we have no pages. + * Verify that calling apply_shortcodes with the shortcode doesn't return the same content and that its output is '' because we have no pages. * * @since 4.5.0 */ public function test_shortcodes_sitemap() { $content = '[sitemap]'; - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertNotEquals( $content, $shortcode_content ); $this->assertSame( '', $shortcode_content ); @@ -54,7 +54,7 @@ public function test_shortcodes_sitemap_image() { ) ); - $shortcode_content = do_shortcode( $content ); + $shortcode_content = apply_shortcodes( $content ); $this->assertStringContainsString( '
    ) } - { /* Checkout button preview — theme-native unless the Styles tab says - otherwise, labeled with the buttonText attribute. */ } + { /* Checkout button preview - theme-native unless the Styles tab says + otherwise, labeled with the buttonText attribute. Gray until the + block has a link. The gray skips the button's own Styles settings - + they would override it. Width and Powered by PayPal still follow the + Styles tab. */ }
    @@ -7868,6 +7869,10 @@ describe( 'PayPalPaymentButtonsEdit (V2)', () => { await expect( screen.findByLabelText( 'Product Name' ) ).resolves.toBeInTheDocument(); expect( screen.queryByTestId( 'inspector-controls-styles' ) ).not.toBeInTheDocument(); + expect( screen.getByTestId( 'paypal-button-preview' ) ).toHaveAttribute( + 'data-disabled', + 'true' + ); rerender( { const styles = await screen.findByTestId( 'inspector-controls-styles' ); expect( within( styles ).getByLabelText( 'URL' ) ).toHaveValue( link ); + expect( screen.getByTestId( 'paypal-button-preview' ) ).toHaveAttribute( + 'data-disabled', + 'false' + ); } ); // A saved button still works while disconnected, so it stays stylable. diff --git a/projects/plugins/jetpack/changelog/update-wooptp-592-embed-as-after-create b/projects/plugins/jetpack/changelog/update-wooptp-592-embed-as-after-create new file mode 100644 index 000000000000..1d3832b37304 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-wooptp-592-embed-as-after-create @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +PayPal Payment Buttons: Gray the preview button in the Create new form until the link is created. diff --git a/projects/plugins/paypal-payment-buttons/changelog/update-wooptp-592-embed-as-after-create b/projects/plugins/paypal-payment-buttons/changelog/update-wooptp-592-embed-as-after-create new file mode 100644 index 000000000000..dc75838c4ed6 --- /dev/null +++ b/projects/plugins/paypal-payment-buttons/changelog/update-wooptp-592-embed-as-after-create @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Gray the preview button in the Create new form until the link is created. From baff5bfac7f2e38f1b9fc71699cf787a731134ce Mon Sep 17 00:00:00 2001 From: Ariana Kataoka Date: Wed, 14 Oct 2026 13:25:07 +1100 Subject: [PATCH 50/57] Jetpack AI: Send the editor and Settings links to the Jetpack AI Hub (#53519) --- ...update-jetpack-ai-my-jetpack-ai-page-links | 4 ++ projects/js-packages/script-data/src/types.ts | 2 + ...update-jetpack-ai-my-jetpack-ai-page-links | 4 ++ .../shared-extension-utils/index.js | 1 + .../src/get-jetpack-ai-manage-url.js | 12 ++++++ .../test/get-jetpack-ai-manage-url.test.js | 23 +++++++++++ ...update-jetpack-ai-my-jetpack-ai-page-links | 4 ++ .../my-jetpack/src/class-initializer.php | 7 +++- .../src/products/class-jetpack-ai.php | 2 +- .../my-jetpack/tests/php/Script_Data_Test.php | 21 ++++++++++ .../jetpack/_inc/client/writing/composing.jsx | 10 ++++- ...update-jetpack-ai-my-jetpack-ai-page-links | 4 ++ .../hooks/use-ai-product-page/index.ts | 11 +++-- .../use-ai-product-page/test/index.test.ts | 41 +++++++++++++++++++ 14 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 projects/js-packages/script-data/changelog/update-jetpack-ai-my-jetpack-ai-page-links create mode 100644 projects/js-packages/shared-extension-utils/changelog/update-jetpack-ai-my-jetpack-ai-page-links create mode 100644 projects/js-packages/shared-extension-utils/src/get-jetpack-ai-manage-url.js create mode 100644 projects/js-packages/shared-extension-utils/src/test/get-jetpack-ai-manage-url.test.js create mode 100644 projects/packages/my-jetpack/changelog/update-jetpack-ai-my-jetpack-ai-page-links create mode 100644 projects/plugins/jetpack/changelog/update-jetpack-ai-my-jetpack-ai-page-links create mode 100644 projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-product-page/test/index.test.ts diff --git a/projects/js-packages/script-data/changelog/update-jetpack-ai-my-jetpack-ai-page-links b/projects/js-packages/script-data/changelog/update-jetpack-ai-my-jetpack-ai-page-links new file mode 100644 index 000000000000..f306414f96d4 --- /dev/null +++ b/projects/js-packages/script-data/changelog/update-jetpack-ai-my-jetpack-ai-page-links @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add `aiManageUrl` to the My Jetpack script data type. diff --git a/projects/js-packages/script-data/src/types.ts b/projects/js-packages/script-data/src/types.ts index 7871f88f9935..508b532f1f1a 100644 --- a/projects/js-packages/script-data/src/types.ts +++ b/projects/js-packages/script-data/src/types.ts @@ -68,6 +68,8 @@ export interface MyJetpackScriptData { assetsUrl?: string; /** Navigation data for released footers; current My Jetpack always points to Features. */ productsSection?: { slug: 'features'; label: string } | null; + /** Where Jetpack AI is managed: the Jetpack AI Hub, or My Jetpack's AI page while the Hub is not open to the site. */ + aiManageUrl?: string; } export interface JetpackScriptData { diff --git a/projects/js-packages/shared-extension-utils/changelog/update-jetpack-ai-my-jetpack-ai-page-links b/projects/js-packages/shared-extension-utils/changelog/update-jetpack-ai-my-jetpack-ai-page-links new file mode 100644 index 000000000000..40983c54f787 --- /dev/null +++ b/projects/js-packages/shared-extension-utils/changelog/update-jetpack-ai-my-jetpack-ai-page-links @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add getJetpackAiManageUrl(), the URL where Jetpack AI is managed on the site. diff --git a/projects/js-packages/shared-extension-utils/index.js b/projects/js-packages/shared-extension-utils/index.js index f6dcde2aea39..ec814fe13c84 100644 --- a/projects/js-packages/shared-extension-utils/index.js +++ b/projects/js-packages/shared-extension-utils/index.js @@ -25,6 +25,7 @@ export { getBlockIconComponent, getBlockIconProp } from './src/get-block-icon-fr export { default as getJetpackBlocksVariation } from './src/get-jetpack-blocks-variation'; export { store, JETPACK_MODULES_STORE_ID } from '@automattic/jetpack-shared-stores'; export { default as isMyJetpackAvailable } from './src/is-my-jetpack-available'; +export { default as getJetpackAiManageUrl } from './src/get-jetpack-ai-manage-url'; export { default as hasFeatureFlag } from './src/has-feature-flag'; export * from './src/libs'; export { default as useUpgradeFlow } from './src/hooks/use-upgrade-flow'; diff --git a/projects/js-packages/shared-extension-utils/src/get-jetpack-ai-manage-url.js b/projects/js-packages/shared-extension-utils/src/get-jetpack-ai-manage-url.js new file mode 100644 index 000000000000..cb8f4eb39d67 --- /dev/null +++ b/projects/js-packages/shared-extension-utils/src/get-jetpack-ai-manage-url.js @@ -0,0 +1,12 @@ +import { getMyJetpackUrl, getScriptData } from '@automattic/jetpack-script-data'; + +/** + * Return the URL where Jetpack AI is managed on this site, as printed by My Jetpack. + * + * Falls back to My Jetpack's AI page when the site did not print one. + * + * @return {string} The Jetpack AI manage URL. + */ +export default function getJetpackAiManageUrl() { + return getScriptData()?.myJetpack?.aiManageUrl || getMyJetpackUrl( '#/jetpack-ai' ); +} diff --git a/projects/js-packages/shared-extension-utils/src/test/get-jetpack-ai-manage-url.test.js b/projects/js-packages/shared-extension-utils/src/test/get-jetpack-ai-manage-url.test.js new file mode 100644 index 000000000000..e589f299b0b3 --- /dev/null +++ b/projects/js-packages/shared-extension-utils/src/test/get-jetpack-ai-manage-url.test.js @@ -0,0 +1,23 @@ +import getJetpackAiManageUrl from '../get-jetpack-ai-manage-url'; + +const adminUrl = 'https://example.com/wp-admin/'; +const hubUrl = `${ adminUrl }admin.php?page=jetpack-ai`; + +describe( 'getJetpackAiManageUrl', () => { + afterEach( () => { + delete window.JetpackScriptData; + } ); + + it.each( [ + [ 'the printed URL', hubUrl, hubUrl ], + [ + "My Jetpack's AI page when none is printed", + undefined, + `${ adminUrl }admin.php?page=my-jetpack#/jetpack-ai`, + ], + ] )( 'returns %s', ( _, aiManageUrl, expected ) => { + window.JetpackScriptData = { site: { admin_url: adminUrl }, myJetpack: { aiManageUrl } }; + + expect( getJetpackAiManageUrl() ).toBe( expected ); + } ); +} ); diff --git a/projects/packages/my-jetpack/changelog/update-jetpack-ai-my-jetpack-ai-page-links b/projects/packages/my-jetpack/changelog/update-jetpack-ai-my-jetpack-ai-page-links new file mode 100644 index 000000000000..f95c3edf7b66 --- /dev/null +++ b/projects/packages/my-jetpack/changelog/update-jetpack-ai-my-jetpack-ai-page-links @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Print where Jetpack AI is managed in the admin script data, so other screens can link to it. diff --git a/projects/packages/my-jetpack/src/class-initializer.php b/projects/packages/my-jetpack/src/class-initializer.php index cc81091201cb..c1ab9803ed44 100644 --- a/projects/packages/my-jetpack/src/class-initializer.php +++ b/projects/packages/my-jetpack/src/class-initializer.php @@ -695,7 +695,7 @@ public static function get_products_section() { } /** - * Add My Jetpack availability and image base URL to admin script data. + * Add My Jetpack's availability, image base URL and link targets to admin script data. * * Printed on every admin page by Script_Data, so the connection screen can resolve its * illustrations and Jetpack footers know whether to link to My Jetpack. @@ -710,6 +710,11 @@ public static function add_admin_script_data( $data ) { $data['myJetpack']['assetsUrl'] = self::get_assets_url(); $data['myJetpack']['productsSection'] = self::get_products_section(); + // Only administrators can open the pages this points at, so spare everyone else the lookup. + if ( current_user_can( 'manage_options' ) ) { + $data['myJetpack']['aiManageUrl'] = esc_url_raw( Products\Jetpack_Ai::get_manage_url() ); + } + if ( ( new Status() )->is_offline_mode() /** diff --git a/projects/packages/my-jetpack/src/products/class-jetpack-ai.php b/projects/packages/my-jetpack/src/products/class-jetpack-ai.php index 1f8fa680b44f..6a83e8b4273f 100644 --- a/projects/packages/my-jetpack/src/products/class-jetpack-ai.php +++ b/projects/packages/my-jetpack/src/products/class-jetpack-ai.php @@ -557,7 +557,7 @@ public static function is_feature_ui_enabled() { /** * Get the URL where the user manages the product. * - * @return ?string + * @return string */ public static function get_manage_url() { if ( self::is_feature_ui_enabled() ) { diff --git a/projects/packages/my-jetpack/tests/php/Script_Data_Test.php b/projects/packages/my-jetpack/tests/php/Script_Data_Test.php index 18847cfc9a40..84d7198b08ba 100644 --- a/projects/packages/my-jetpack/tests/php/Script_Data_Test.php +++ b/projects/packages/my-jetpack/tests/php/Script_Data_Test.php @@ -226,4 +226,25 @@ public function test_the_image_base_url_is_registered_where_my_jetpack_is_off() remove_filter( 'jetpack_my_jetpack_should_initialize', '__return_false' ); } + + public function test_prints_where_jetpack_ai_is_managed_to_administrators_only() { + wp_set_current_user( + wp_insert_user( + array( + 'user_login' => 'ai-admin', + 'user_pass' => 'password', + 'role' => 'administrator', + ) + ) + ); + + $this->assertSame( + Products\Jetpack_Ai::get_manage_url(), + Initializer::add_admin_script_data( array() )['myJetpack']['aiManageUrl'] + ); + + wp_set_current_user( 0 ); + + $this->assertArrayNotHasKey( 'aiManageUrl', Initializer::add_admin_script_data( array() )['myJetpack'] ); + } } diff --git a/projects/plugins/jetpack/_inc/client/writing/composing.jsx b/projects/plugins/jetpack/_inc/client/writing/composing.jsx index 77f3ce5437ce..726fdbf7cc94 100644 --- a/projects/plugins/jetpack/_inc/client/writing/composing.jsx +++ b/projects/plugins/jetpack/_inc/client/writing/composing.jsx @@ -1,4 +1,6 @@ import { getRedirectUrl } from '@automattic/jetpack-components'; +import { currentUserCan } from '@automattic/jetpack-script-data'; +import { getJetpackAiManageUrl } from '@automattic/jetpack-shared-extension-utils'; import { __, _x } from '@wordpress/i18n'; import { Component } from 'react'; import { connect } from 'react-redux'; @@ -210,7 +212,13 @@ export class Composing extends Component { ), aiAssistantLink = ( - + { __( 'Learn more about all Jetpack AI features', 'jetpack' ) } diff --git a/projects/plugins/jetpack/changelog/update-jetpack-ai-my-jetpack-ai-page-links b/projects/plugins/jetpack/changelog/update-jetpack-ai-my-jetpack-ai-page-links new file mode 100644 index 000000000000..3346a1b21c25 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-jetpack-ai-my-jetpack-ai-page-links @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +AI: Send the editor's and Jetpack Settings' AI links to the Jetpack AI Hub where it is available, and to jetpack.com for users who cannot manage the site. diff --git a/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-product-page/index.ts b/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-product-page/index.ts index 3649c96b26e4..e613b5c64e24 100644 --- a/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-product-page/index.ts +++ b/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-product-page/index.ts @@ -2,8 +2,9 @@ * External dependencies */ import { getRedirectUrl } from '@automattic/jetpack-components'; -import { isSimpleSite, getMyJetpackUrl } from '@automattic/jetpack-script-data'; +import { isSimpleSite, currentUserCan } from '@automattic/jetpack-script-data'; import { + getJetpackAiManageUrl, isMyJetpackAvailable, useAutosaveAndRedirect, } from '@automattic/jetpack-shared-extension-utils'; @@ -19,9 +20,11 @@ export default function useAiProductPage(): { isMyJetpackAvailable: boolean; } { const isMyJetpackReallyAvailable = isMyJetpackAvailable() && ! isSimpleSite(); - const productPageUrl = isMyJetpackReallyAvailable - ? getMyJetpackUrl( '#/jetpack-ai' ) - : getRedirectUrl( 'org-ai' ); + // The Jetpack AI Hub needs manage_options; everyone else gets the public page. + const productPageUrl = + isMyJetpackReallyAvailable && currentUserCan( 'manage_options' ) + ? getJetpackAiManageUrl() + : getRedirectUrl( 'org-ai' ); const { autosaveAndRedirect, isRedirecting } = useAutosaveAndRedirect( productPageUrl ); diff --git a/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-product-page/test/index.test.ts b/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-product-page/test/index.test.ts new file mode 100644 index 000000000000..a62c14c28742 --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-product-page/test/index.test.ts @@ -0,0 +1,41 @@ +import { renderHook } from '@testing-library/react'; +import useAiProductPage from '..'; + +jest.mock( '@automattic/jetpack-components', () => ( { + getRedirectUrl: ( source: string ) => `https://jetpack.com/redirect/?source=${ source }`, +} ) ); + +jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( { + ...jest.requireActual( '@automattic/jetpack-shared-extension-utils' ), + isMyJetpackAvailable: () => true, + useAutosaveAndRedirect: () => ( { autosaveAndRedirect: jest.fn(), isRedirecting: false } ), +} ) ); + +const adminUrl = 'https://example.com/wp-admin/'; +const hubUrl = `${ adminUrl }admin.php?page=jetpack-ai`; + +describe( 'useAiProductPage', () => { + afterEach( () => { + delete window.JetpackScriptData; + } ); + + it.each( [ + [ 'an administrator to the Jetpack AI Hub when the site prints it', true, hubUrl, hubUrl ], + [ + 'everyone else to the public Jetpack AI page', + false, + hubUrl, + 'https://jetpack.com/redirect/?source=org-ai', + ], + ] )( 'sends %s', ( _, manageOptions, aiManageUrl, expected ) => { + window.JetpackScriptData = { + site: { admin_url: adminUrl }, + user: { current_user: { capabilities: { manage_options: manageOptions } } }, + myJetpack: { aiManageUrl }, + } as unknown as Window[ 'JetpackScriptData' ]; + + const { result } = renderHook( () => useAiProductPage() ); + + expect( result.current.productPageUrl ).toBe( expected ); + } ); +} ); From 0cf5fca7e18d3dc02a9175b0eebc63cac00581f0 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 14 Oct 2026 12:12:17 +0800 Subject: [PATCH 51/57] Premium Analytics: Add All time to the report date menu (#53275) * Premium Analytics: Add All time to the report date menu * Premium Analytics: Scope the all-time Top authors cap to the Authors report - Send num=-1 from the Authors report on All time and let WPCOM apply its three-year cap, instead of a client-side copy of it in the shared query. The author page's widgets keep their whole window. - Share one ANCHORED_SURFACE_PRESETS list between detail and report pages. - Simplify the origin window params and correct the PHP fallback comment. - Tighten the route and layout tests, and cover WPCOM's zero registration date. * test: Trim all-time report coverage and verify dashboard-only lookups * Premium Analytics: Give the negative-num Top authors test a full report window * Premium Analytics: Hold off a failed start-date lookup for that site ID only * Premium Analytics: Add the All time changelog entry to wpcomsh * Premium Analytics: Let WPCOM pick where All time starts on reports Reports send num: -1 for All time instead of a start date, as classic Stats does, so merged stats history is included and no registration lookup is needed. * Premium Analytics: Keep the author page's All time on its own dates Only report pages let WPCOM pick where All time starts, so they now say so with allTimeStartsOnServer instead of the dropdown inferring it from a missing allTimeStart. The author page's Top viewed posts CSV shares the widget's query and dated filename, so the file matches the card. * fix: Honor all-time query windows and simplify report helpers * Premium Analytics: Keep All time off the Videos report until its endpoint supports it stats/video-plays clamps num below 1 to 1, so num=-1 without a start date summed a single day (STATS-396). Reports now opt in to All time, and Videos stays out. --- .../change-wooprd-3785-all-time-reports | 4 ++ .../queries/__tests__/stats-queries.test.ts | 27 +++++++++++++ .../packages/data/src/queries/stats-query.ts | 5 ++- .../data/src/queries/stats-utm-query.ts | 9 ++++- .../src/utils/__tests__/stats-params.test.ts | 14 +++++++ .../packages/data/src/utils/stats-params.ts | 18 ++++++++- .../__tests__/menu-surface-presets.test.ts | 4 +- .../packages/datetime/src/index.ts | 2 +- .../packages/datetime/src/presets/index.ts | 2 +- .../packages/datetime/src/presets/primary.ts | 4 +- .../packages/datetime/src/presets/types.ts | 12 +++--- .../report-params/report-params.test.ts | 11 ++---- .../src/search/report-params/report-params.ts | 22 +++++------ .../__tests__/date-filters-panel.test.tsx | 4 +- .../date-filters-panel/date-filters-panel.tsx | 9 ++++- .../stories/date-filters-panel.stories.tsx | 4 +- .../__tests__/date-period-dropdown.test.tsx | 15 +++++-- .../date-period-dropdown.tsx | 18 +++++++-- .../stories/date-period-dropdown.stories.tsx | 7 +++- .../__tests__/report-page-layout.test.tsx | 18 ++++++++- .../report-page/report-page-layout.tsx | 23 ++++++++++- .../packages/widgets-toolkit/src/index.ts | 1 + .../__tests__/author-posts.test.ts | 16 +++++--- .../__tests__/download-report-csv.test.ts | 20 ++++++++-- .../__tests__/query-params.test.ts | 39 +++++++++++++++++++ .../src/report-exports/authors.ts | 14 ++++++- .../src/report-exports/download-report-csv.ts | 15 +++++-- .../src/report-exports/index.ts | 1 + .../src/report-exports/locations.ts | 2 + .../src/report-exports/posts.ts | 6 ++- .../src/report-exports/query-params.ts | 22 ++++++++++- .../src/report-exports/types.ts | 4 +- .../widgets-toolkit/src/report-exports/utm.ts | 2 + .../routes/post-detail/route.test.ts | 17 ++++++++ .../routes/reports/authors/page.tsx | 2 +- .../routes/reports/clicks/page.tsx | 2 +- .../routes/reports/downloads/page.tsx | 2 +- .../routes/reports/locations/page.tsx | 1 + .../routes/reports/posts/page.tsx | 1 + .../routes/reports/referrers/page.tsx | 2 +- .../routes/reports/search-terms/page.tsx | 2 +- .../routes/reports/utm/page.tsx | 1 + .../routes/use-detail-date-controls.test.ts | 4 +- .../routes/use-detail-date-controls.ts | 6 +-- .../author-top-posts/use-author-top-posts.ts | 7 +++- .../change-wooprd-3785-all-time-reports | 4 ++ .../change-wooprd-3785-all-time-reports | 4 ++ .../change-wooprd-3785-all-time-reports | 4 ++ 48 files changed, 350 insertions(+), 83 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/change-wooprd-3785-all-time-reports create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/report-exports/__tests__/query-params.test.ts create mode 100644 projects/plugins/jetpack/changelog/change-wooprd-3785-all-time-reports create mode 100644 projects/plugins/premium-analytics/changelog/change-wooprd-3785-all-time-reports create mode 100644 projects/plugins/wpcomsh/changelog/change-wooprd-3785-all-time-reports diff --git a/projects/packages/premium-analytics/changelog/change-wooprd-3785-all-time-reports b/projects/packages/premium-analytics/changelog/change-wooprd-3785-all-time-reports new file mode 100644 index 000000000000..596ccf019b9c --- /dev/null +++ b/projects/packages/premium-analytics/changelog/change-wooprd-3785-all-time-reports @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add All time to the report date menu, covering everything since the site's stats began. diff --git a/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts b/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts index 11248ee0f531..0411c5bf63eb 100644 --- a/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts +++ b/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts @@ -665,6 +665,33 @@ describe( 'Stats query factories', () => { } ); + it( 'leaves an all-time window to WPCOM as a summarized negative num', () => { + const params = statsReferrersQuery( { + from: '2020-01-01T00:00:00.000+00:00', + to: '2026-10-07T23:59:59.999+00:00', + interval: 'day', + num: -1, + } ).queryKey[ 5 ]; + + expect( params ).toMatchObject( { + num: -1, + summarize: 1, + date: '2026-10-07T23:59:59.999+00:00', + } ); + expect( params ).not.toHaveProperty( 'start_date' ); + } ); + + it( 'asks the UTM endpoint for all time in days', () => { + const query = statsUtmQuery( { + from: '2020-01-01', + to: '2026-10-07', + interval: 'day', + num: -1, + } ); + + expect( query.queryKey[ 5 ] ).toMatchObject( { days: -1, start_date: '' } ); + } ); + // The endpoint rewrites `max < 1` back to its default of 10 rather than reading // it as "all rows", so a `0` must not reach it. it( 'sends the tags window as date and start_date, with max only when positive', () => { diff --git a/projects/packages/premium-analytics/packages/data/src/queries/stats-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/stats-query.ts index 2753fc461c29..519593c3bb9d 100644 --- a/projects/packages/premium-analytics/packages/data/src/queries/stats-query.ts +++ b/projects/packages/premium-analytics/packages/data/src/queries/stats-query.ts @@ -45,6 +45,7 @@ import { import { getStatsRefetchInterval } from '../utils/refetch-interval'; import { resolveReportTimeZone } from '../utils/report-timezone'; import { + isAllTimeNum, reportParamsToStatsQueryParams, statsQueryParamsToApiParams, type StatsQueryParams, @@ -212,8 +213,8 @@ export function statsReportQuery< TSanitizer extends StatsSanitizerKey >( ...( params.period === undefined ? { period: 'day' as const } : {} ), ...extraParams, ...( statsParams.summarize === undefined && - typeof statsParams.days === 'number' && - statsParams.days > 1 + ( ( typeof statsParams.days === 'number' && statsParams.days > 1 ) || + isAllTimeNum( statsParams.num ) ) ? { summarize: 1 } : {} ), }; diff --git a/projects/packages/premium-analytics/packages/data/src/queries/stats-utm-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/stats-utm-query.ts index f509c48bd0dc..4a5605884321 100644 --- a/projects/packages/premium-analytics/packages/data/src/queries/stats-utm-query.ts +++ b/projects/packages/premium-analytics/packages/data/src/queries/stats-utm-query.ts @@ -1,7 +1,11 @@ /** * Internal dependencies */ -import { reportParamsToStatsQueryParams, statsQueryParamsToApiParams } from '../utils/stats-params'; +import { + isAllTimeNum, + reportParamsToStatsQueryParams, + statsQueryParamsToApiParams, +} from '../utils/stats-params'; import { statsProxyQuery, type StatsReportParams, @@ -29,7 +33,8 @@ export const statsUtmQuery = ( params: StatsUtmParams ): StatsReportQueryOptions const utmParams: StatsProxyParams = { max: apiParams.max ?? 10, date: apiParams.date, - days: apiParams.days, + // This endpoint counts its window in `days`, so all time is `days: -1` here. + days: isAllTimeNum( apiParams.num ) ? -1 : apiParams.days, // Match Calypso's UTM request shape; the endpoint accepts empty values for // missing optional filters. start_date: apiParams.start_date ?? '', diff --git a/projects/packages/premium-analytics/packages/data/src/utils/__tests__/stats-params.test.ts b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/stats-params.test.ts index f2a030191bbd..2342ba8a011e 100644 --- a/projects/packages/premium-analytics/packages/data/src/utils/__tests__/stats-params.test.ts +++ b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/stats-params.test.ts @@ -81,6 +81,20 @@ describe( 'getPeriodsBetweenInclusive', () => { } ); describe( 'reportParamsToStatsQueryParams', () => { + it.each( [ + [ -1, {} ], + [ 1, { start_date: '2020-01-01', days: 7 } ], + ] )( 'handles an explicit start date with num=%s', ( num, window ) => { + expect( + reportParamsToStatsQueryParams( { + num, + start_date: '2020-01-01', + from: '2020-01-03', + to: '2020-01-07', + } ) + ).toEqual( { num, period: 'day', end_date: '2020-01-07', ...window } ); + } ); + it( 'passes the offset-bearing date through untrimmed, still counting days correctly', () => { expect( reportParamsToStatsQueryParams( { diff --git a/projects/packages/premium-analytics/packages/data/src/utils/stats-params.ts b/projects/packages/premium-analytics/packages/data/src/utils/stats-params.ts index d14fc66cfc57..b066ec41026e 100644 --- a/projects/packages/premium-analytics/packages/data/src/utils/stats-params.ts +++ b/projects/packages/premium-analytics/packages/data/src/utils/stats-params.ts @@ -152,6 +152,16 @@ export function getPeriodsBetweenInclusive( return BUCKET_COUNTERS[ period ]( from, to ); } +/** + * Whether a `num` asks WPCOM for all time: every period from the start of the site's Stats. + * + * @param num - The `num` query param. + * @return True for a negative `num`. + */ +export function isAllTimeNum( num: unknown ): boolean { + return Number( num ) < 0; +} + export function reportParamsToStatsQueryParams( params: StatsQueryParamInput = {} ): StatsQueryParams { @@ -161,11 +171,17 @@ export function reportParamsToStatsQueryParams( .map( key => [ key, params[ key ] ] ) ) as StatsQueryParams; + const allTime = isAllTimeNum( params.num ); + if ( allTime ) { + delete statsParams.start_date; + } + const period = params.period ?? getStatsPeriodFromInterval( params.interval ); // Stats v1.1 resolves an offset-bearing ISO datetime to the local calendar // day (WOOA7S-1656/1664), so start_date/end_date pass through untrimmed. const endDate = params.end_date ?? params.date ?? params.to; - const startDate = params.start_date ?? params.from; + // A start date would override the all-time window WPCOM derives from `num`. + const startDate = allTime ? undefined : ( params.start_date ?? params.from ); const days = params.days ?? ( startDate && endDate ? getDaysBetweenInclusive( startDate, endDate ) : undefined ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/menu-surface-presets.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/menu-surface-presets.test.ts index c835f4834302..4fb16c6b0c91 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/menu-surface-presets.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/menu-surface-presets.test.ts @@ -2,7 +2,7 @@ * Internal dependencies */ import { getMenuSurfacePresetGroups } from '../presets'; -import { DETAIL_SURFACE_PRESETS } from '../presets/types'; +import { MENU_SURFACE_PRESETS_WITH_ALL_TIME } from '../presets/types'; const TIME_ZONE = 'America/New_York'; @@ -25,7 +25,7 @@ describe( 'getMenuSurfacePresetGroups', () => { it( 'offers a detail surface every period the menu lists, all time last', () => { const groups = getMenuSurfacePresetGroups( TIME_ZONE, { - presetIds: DETAIL_SURFACE_PRESETS, + presetIds: MENU_SURFACE_PRESETS_WITH_ALL_TIME, startDate: new Date( '2024-03-01T00:00:00.000Z' ), } ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/index.ts b/projects/packages/premium-analytics/packages/datetime/src/index.ts index 96891e5a308d..5fe41c4d24e3 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/index.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/index.ts @@ -67,7 +67,7 @@ export { MENU_SURFACE_PRESETS, MENU_SURFACE_PRESET_GROUPS, QUICK_SURFACE_PRESETS, - DETAIL_SURFACE_PRESETS, + MENU_SURFACE_PRESETS_WITH_ALL_TIME, YEAR_PRESET_PREFIX, // Guards diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/index.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/index.ts index e58511c09592..bf4accf7d241 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/presets/index.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/index.ts @@ -17,7 +17,7 @@ export { MENU_SURFACE_PRESETS, MENU_SURFACE_PRESET_GROUPS, QUICK_SURFACE_PRESETS, - DETAIL_SURFACE_PRESETS, + MENU_SURFACE_PRESETS_WITH_ALL_TIME, YEAR_PRESET_PREFIX, isSelectablePreset, isPrimaryPreset, diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts index 7a29b3af59f6..f118d352eb67 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts @@ -348,9 +348,7 @@ export function getDefaultDateRangePresets( timeZone: string ): DateRangePreset[ */ export type QuickSurfaceOptions = AllTimeRangeOptions & { /** - * The presets to render, in display order. Defaults to the rolling windows - * of `QUICK_SURFACE_PRESETS`; a detail page passes `DETAIL_SURFACE_PRESETS` - * to lead with all time. + * The presets to render, in display order. Defaults to `QUICK_SURFACE_PRESETS`. */ presetIds?: readonly QuickSurfacePresetId[]; }; diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/types.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/types.ts index 1fe7985f0668..025af5f4c6e7 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/presets/types.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/types.ts @@ -48,8 +48,8 @@ export const DAY_COUNT_PRESETS: readonly PrimaryPresetId[] = [ /** * The all-time marker. On the year surface it covers every year the surface - * lists; on a detail page's quick surface it runs from the resource's own start - * (its publish date) through today. + * lists; on a detail page it runs from the resource's own start (its publish + * date) through today. A report sends it as `num: -1`, so WPCOM picks the start. */ export const PRESET_ALL_TIME = 'all-time' as const; @@ -90,10 +90,12 @@ export const MENU_SURFACE_PRESET_GROUPS = [ export const MENU_SURFACE_PRESETS = SELECTABLE_PRESETS; /** - * What a resource detail page (post, video) offers: the whole menu, plus all - * time, which such a page anchors on the resource's own publish date. + * The whole menu plus all time, as a detail page or a report offers it. */ -export const DETAIL_SURFACE_PRESETS = [ ...MENU_SURFACE_PRESETS, PRESET_ALL_TIME ] as const; +export const MENU_SURFACE_PRESETS_WITH_ALL_TIME = [ + ...MENU_SURFACE_PRESETS, + PRESET_ALL_TIME, +] as const; /** * Prefix of the per-year preset IDs, e.g. `year-2024`. diff --git a/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.test.ts b/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.test.ts index fccaf0f0377a..5bf4f33e058a 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.test.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.test.ts @@ -171,15 +171,10 @@ describe( 'origin window params', () => { } ); } ); - it( 'stores an all-time window as its preset alone, without its interval', () => { + it( 'stores an all-time window with its own start', () => { expect( - toReportOriginWindowParams( { - from: '2020-03-04', - to: '2026-01-31', - preset: 'all-time', - interval: 'month', - } ) - ).toEqual( { ref_preset: 'all-time' } ); + toReportOriginWindowParams( { from: '2020-03-04', to: '2026-01-31', preset: 'all-time' } ) + ).toEqual( { ref_preset: 'all-time', ref_from: '2020-03-04', ref_to: '2026-01-31' } ); } ); it( 'returns a detail page to its linking window, leaving the dates to the seed', () => { diff --git a/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts b/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts index 59e6f46cbbcb..366d91f88ed7 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts @@ -70,9 +70,7 @@ const ORIGIN_WINDOW_PREFIX = 'ref_'; /** * Store the linking page's window under the origin-window params, keeping only * what `expandOriginWindow()` cannot rebuild: a named range is its preset alone. - * - * An all-time window keeps its preset alone: the destination falls back to its - * default range, but the way back still names a window and keeps its comparison. + * All time keeps its bounds too, since its start is the linking surface's own. * * @param search - The search params the detail page was linked with. * @return The origin-window params. @@ -81,18 +79,18 @@ export function toReportOriginWindowParams( search: Record< string, unknown > | undefined ): Record< string, unknown > { const linked = pickReportDateParams( search ) as NormalizeInput; - const isAllTime = linked.preset === PRESET_ALL_TIME; - const origin: Record< string, unknown > = - isAllTime || isSelectablePreset( linked.preset ) || isYearPresetId( linked.preset ) + isSelectablePreset( linked.preset ) || isYearPresetId( linked.preset ) ? { preset: linked.preset } - : { from: linked.from, to: linked.to }; + : { + from: linked.from, + to: linked.to, + ...( linked.preset === PRESET_ALL_TIME ? { preset: PRESET_ALL_TIME } : {} ), + }; - if ( ! isAllTime ) { - const rebuilt = normalizeReportParams( origin as NormalizeInput ); - if ( linked.interval !== undefined && linked.interval !== rebuilt.interval ) { - origin.interval = linked.interval; - } + const rebuilt = normalizeReportParams( origin as NormalizeInput ); + if ( linked.interval !== undefined && linked.interval !== rebuilt.interval ) { + origin.interval = linked.interval; } if ( hasComparisonEnabled( linked ) ) { diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/__tests__/date-filters-panel.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/__tests__/date-filters-panel.test.tsx index 77389d5fac64..4bcbc113cd9e 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/__tests__/date-filters-panel.test.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/__tests__/date-filters-panel.test.tsx @@ -1,6 +1,6 @@ import { TZDate } from '@date-fns/tz'; import { ReportScopeProvider } from '@jetpack-premium-analytics/data'; -import { DETAIL_SURFACE_PRESETS } from '@jetpack-premium-analytics/datetime'; +import { MENU_SURFACE_PRESETS_WITH_ALL_TIME } from '@jetpack-premium-analytics/datetime'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DateFiltersPanel } from '../date-filters-panel'; @@ -94,7 +94,7 @@ describe( 'DateFiltersPanel', () => { it( 'renders the detail surface: every period, plus all time and a custom range', async () => { const user = userEvent.setup(); renderPanel( { - presetIds: DETAIL_SURFACE_PRESETS, + presetIds: MENU_SURFACE_PRESETS_WITH_ALL_TIME, appliedPresetId: 'all-time', } ); diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx index e0dbe7dea0c8..8c1704755026 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx @@ -51,8 +51,8 @@ export type DateFiltersPanelProps = { /** * The periods the menu offers. Defaults to every selectable preset; a detail - * page adds all time (`DETAIL_SURFACE_PRESETS`). The menu keeps its own - * order whatever order they arrive in, so its grouping by scale holds. + * or report page adds all time (`MENU_SURFACE_PRESETS_WITH_ALL_TIME`). The menu keeps + * its own order whatever order they arrive in, so its grouping by scale holds. */ presetIds?: readonly QuickSurfacePresetId[]; @@ -62,6 +62,9 @@ export type DateFiltersPanelProps = { */ allTimeStart?: Date; + /** Whether WPCOM picks where all time starts; see `DatePeriodDropdown`. */ + allTimeStartsOnServer?: boolean; + /** * Whether to offer Custom range at the end of the menu. On by default; a * surface whose design lists common periods only turns it off. @@ -140,6 +143,7 @@ export function DateFiltersPanel( { comparisonPresetId, presetIds, allTimeStart, + allTimeStartsOnServer, withCustomRange = true, withIntervalControl = false, interval, @@ -285,6 +289,7 @@ export function DateFiltersPanel( { onOpenChange={ clearDraft } presetIds={ presetIds } allTimeStart={ allTimeStart } + allTimeStartsOnServer={ allTimeStartsOnServer } withCustomRange={ withCustomRange } attentionId={ attentionId } /> diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx index 1648c0cabf00..2fe31efb73cb 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/stories/date-filters-panel.stories.tsx @@ -1,6 +1,6 @@ import { computePrimaryRange, - DETAIL_SURFACE_PRESETS, + MENU_SURFACE_PRESETS_WITH_ALL_TIME, type ComparisonPresetId, type IntervalType, type PrimaryPresetId, @@ -184,7 +184,7 @@ function DateFiltersPanelStory( { comparisonPresetId={ comparisonPresetId } { ...( detailSurface ? { - presetIds: DETAIL_SURFACE_PRESETS, + presetIds: MENU_SURFACE_PRESETS_WITH_ALL_TIME, allTimeStart: STORY_PUBLISHED_DATE, } : {} ) } diff --git a/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/__tests__/date-period-dropdown.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/__tests__/date-period-dropdown.test.tsx index 72329aa957c5..b245d16e0484 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/__tests__/date-period-dropdown.test.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/__tests__/date-period-dropdown.test.tsx @@ -189,14 +189,23 @@ describe( 'DatePeriodDropdown', () => { ] ); } ); - it( 'describes the trigger with its exact dates', () => { - renderDropdown(); + it.each( [ + [ 'Last 30 days', {} ], + [ 'All time', { appliedPresetId: 'all-time' } ], + ] as const )( 'describes the %s trigger with its exact dates', ( name, overrides ) => { + renderDropdown( overrides ); - expect( screen.getByRole( 'button', { name: 'Last 30 days' } ) ).toHaveAccessibleDescription( + expect( screen.getByRole( 'button', { name } ) ).toHaveAccessibleDescription( /July 1.+31, 2026/ ); } ); + it( 'leaves an All time that WPCOM starts undated', () => { + renderDropdown( { appliedPresetId: 'all-time', allTimeStartsOnServer: true } ); + + expect( screen.getByRole( 'button', { name: 'All time' } ) ).not.toHaveAccessibleDescription(); + } ); + describe( 'tooltip', () => { beforeEach( () => jest.useFakeTimers() ); afterEach( () => jest.useRealTimers() ); diff --git a/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/date-period-dropdown.tsx b/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/date-period-dropdown.tsx index 070599e945f4..6b872940170c 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/date-period-dropdown.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/date-period-dropdown.tsx @@ -6,6 +6,7 @@ import { computePrimaryRange, getMenuSurfacePresetGroups, getPresetLabel, + PRESET_ALL_TIME, PRESET_CUSTOM, type DateRange, type PrimaryPresetId, @@ -55,8 +56,8 @@ type DatePeriodDropdownProps = { appliedPresetId?: PrimaryPresetId; /** - * The applied range. Names the period on the trigger where no preset does, - * and its exact dates are the trigger's tooltip either way. + * The applied range, used for the trigger label and date tooltip. + * Server-defined All time ranges omit the tooltip. */ appliedRange: DateRange; @@ -72,6 +73,12 @@ type DatePeriodDropdownProps = { */ allTimeStart?: Date; + /** + * Whether WPCOM picks where all time starts, which leaves the applied range's start a + * placeholder that the tooltip must not show. + */ + allTimeStartsOnServer?: boolean; + /** * IANA timezone string (e.g., 'America/New_York'). */ @@ -144,6 +151,7 @@ export function DatePeriodDropdown( { appliedRange, presetIds, allTimeStart, + allTimeStartsOnServer = false, timeZone, onSelect, range, @@ -233,7 +241,11 @@ export function DatePeriodDropdown( { onOpenChange={ handleOpenChange } // The label names the period, so the dates live here: the design // keeps them off the control's face. - tooltip={ formatDateRange( appliedRange ) } + tooltip={ + appliedPresetId === PRESET_ALL_TIME && allTimeStartsOnServer + ? undefined + : formatDateRange( appliedRange ) + } trigger={