From e611f74a85dad3cd6428076953f030db2f9ecd40 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:51:46 -0300 Subject: [PATCH 01/11] Backup: add QueryClient + provider to the modernized dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the @tanstack/react-query infrastructure with no behavior change. DashboardLayout now wraps children in a QueryClientProvider so every screen in every route shares a module-scope singleton client — caches survive navigation between routes. Phase 2 setup commit. Capability gates and real REST hooks follow. Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 3 + .../wire-backup-modernization-data-layer | 3 + projects/packages/backup/package.json | 1 + .../backup/routes/dashboard/package.json | 5 +- .../backup/routes/download/package.json | 5 +- .../backup/routes/restore/package.json | 5 +- .../components/dashboard-layout/index.tsx | 13 +-- .../backup/src/dashboard/data/api/_helpers.ts | 85 +++++++++++++++++++ .../dashboard/data/api/test/_helpers.test.ts | 15 ++++ .../backup/src/dashboard/data/query-client.ts | 43 ++++++++++ .../providers/query-client-provider.tsx | 19 +++++ .../wire-backup-modernization-data-layer | 3 + .../wire-backup-modernization-data-layer | 3 + 13 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 projects/packages/backup/changelog/wire-backup-modernization-data-layer create mode 100644 projects/packages/backup/src/dashboard/data/api/_helpers.ts create mode 100644 projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts create mode 100644 projects/packages/backup/src/dashboard/data/query-client.ts create mode 100644 projects/packages/backup/src/dashboard/providers/query-client-provider.tsx create mode 100644 projects/plugins/backup/changelog/wire-backup-modernization-data-layer create mode 100644 projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11fa4a079704..c57b50d3bebd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2080,6 +2080,9 @@ importers: '@wordpress/ui': specifier: 0.17.0 version: 0.17.0(@types/react@18.3.28)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/url': + specifier: 4.51.0 + version: 4.51.0 moment: specifier: 2.30.1 version: 2.30.1 diff --git a/projects/packages/backup/changelog/wire-backup-modernization-data-layer b/projects/packages/backup/changelog/wire-backup-modernization-data-layer new file mode 100644 index 000000000000..5108082a2f28 --- /dev/null +++ b/projects/packages/backup/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: patch +Type: added +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability/connection checks, drop the mock-mode dev banner. No-op when the modernization filter is off. diff --git a/projects/packages/backup/package.json b/projects/packages/backup/package.json index a7c850a5c4b5..c3f63ea49eaf 100644 --- a/projects/packages/backup/package.json +++ b/projects/packages/backup/package.json @@ -54,6 +54,7 @@ "@wordpress/route": "0.17.0", "@wordpress/theme": "0.17.0", "@wordpress/ui": "0.17.0", + "@wordpress/url": "4.51.0", "moment": "2.30.1", "prop-types": "^15.8.1", "react": "18.3.1", diff --git a/projects/packages/backup/routes/dashboard/package.json b/projects/packages/backup/routes/dashboard/package.json index 76fdade90cc3..f430381c241d 100644 --- a/projects/packages/backup/routes/dashboard/package.json +++ b/projects/packages/backup/routes/dashboard/package.json @@ -3,8 +3,10 @@ "private": true, "dependencies": { "@automattic/jetpack-components": "workspace:*", + "@tanstack/react-query": "5.101.2", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.5.0", + "@wordpress/api-fetch": "7.51.0", "@wordpress/components": "37.0.0", "@wordpress/dataviews": "17.1.0", "@wordpress/date": "5.51.0", @@ -13,7 +15,8 @@ "@wordpress/icons": "15.1.0", "@wordpress/route": "0.17.0", "@wordpress/theme": "0.17.0", - "@wordpress/ui": "0.17.0" + "@wordpress/ui": "0.17.0", + "@wordpress/url": "4.51.0" }, "route": { "path": "/", diff --git a/projects/packages/backup/routes/download/package.json b/projects/packages/backup/routes/download/package.json index 74b643b5053b..e40f27654ee1 100644 --- a/projects/packages/backup/routes/download/package.json +++ b/projects/packages/backup/routes/download/package.json @@ -3,8 +3,10 @@ "private": true, "dependencies": { "@automattic/jetpack-components": "workspace:*", + "@tanstack/react-query": "5.101.2", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.5.0", + "@wordpress/api-fetch": "7.51.0", "@wordpress/components": "37.0.0", "@wordpress/date": "5.51.0", "@wordpress/element": "8.3.0", @@ -12,7 +14,8 @@ "@wordpress/icons": "15.1.0", "@wordpress/route": "0.17.0", "@wordpress/theme": "0.17.0", - "@wordpress/ui": "0.17.0" + "@wordpress/ui": "0.17.0", + "@wordpress/url": "4.51.0" }, "route": { "path": "/download/$rewindId", diff --git a/projects/packages/backup/routes/restore/package.json b/projects/packages/backup/routes/restore/package.json index 65cd7936b9af..81868397ce94 100644 --- a/projects/packages/backup/routes/restore/package.json +++ b/projects/packages/backup/routes/restore/package.json @@ -3,8 +3,10 @@ "private": true, "dependencies": { "@automattic/jetpack-components": "workspace:*", + "@tanstack/react-query": "5.101.2", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.5.0", + "@wordpress/api-fetch": "7.51.0", "@wordpress/components": "37.0.0", "@wordpress/date": "5.51.0", "@wordpress/element": "8.3.0", @@ -12,7 +14,8 @@ "@wordpress/icons": "15.1.0", "@wordpress/route": "0.17.0", "@wordpress/theme": "0.17.0", - "@wordpress/ui": "0.17.0" + "@wordpress/ui": "0.17.0", + "@wordpress/url": "4.51.0" }, "route": { "path": "/restore/$rewindId", diff --git a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx index 3e43ce035dfb..3bc818b87737 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -2,6 +2,7 @@ import JetpackFooter from '@automattic/jetpack-components/jetpack-footer'; import JetpackLogo from '@automattic/jetpack-components/jetpack-logo'; import { Page } from '@wordpress/admin-ui'; import { __ } from '@wordpress/i18n'; +import QueryClientProvider from '../../providers/query-client-provider'; import DevModeBanner from '../dev-mode-banner'; import './style.scss'; import type { ReactNode } from 'react'; @@ -44,11 +45,13 @@ export default function DashboardLayout( { children, actions }: Props ) { hasPadding={ false } actions={ actions } > - -
-
{ children }
-
- + + +
+
{ children }
+
+ +
); } diff --git a/projects/packages/backup/src/dashboard/data/api/_helpers.ts b/projects/packages/backup/src/dashboard/data/api/_helpers.ts new file mode 100644 index 000000000000..32ea521d87e2 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/_helpers.ts @@ -0,0 +1,85 @@ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import type { APIFetchOptions } from '@wordpress/api-fetch'; + +const ROOT = '/jetpack/v4'; + +/** + * Prepend `/jetpack/v4` to a path and optionally append query args. + * + * @param path - Path under `/jetpack/v4`, e.g. `/site/capabilities`. + * @param args - Optional query args record. + * @return The fully-qualified API path. + */ +export function apiPath( + path: string, + args?: Record< string, string | number | boolean | undefined > +): string { + const base = `${ ROOT }${ path }`; + if ( ! args ) { + return base; + } + const filtered: Record< string, string | number | boolean > = {}; + for ( const key of Object.keys( args ) ) { + const value = args[ key ]; + if ( value !== undefined && value !== '' ) { + filtered[ key ] = value; + } + } + return Object.keys( filtered ).length ? addQueryArgs( base, filtered ) : base; +} + +/** + * Strip the decimal suffix WPCOM ships on rewind ids (e.g. + * `'1777035492.615'` → `'1777035492'`). The `/rewind/backup/$rewindId` + * URLs reject the suffixed form with a 404. + * + * @param rewindId - Raw rewind id, possibly with a decimal suffix. + * @return The integer-seconds portion only. + */ +export function toIntRewindId( rewindId: string ): string { + const idx = rewindId.indexOf( '.' ); + return idx === -1 ? rewindId : rewindId.slice( 0, idx ); +} + +/** + * Error thrown by the data-layer fetchers when WPCOM (via the bridge) + * responds with a known error code. Consumers branch on `code` to pick + * the right user-facing message. + */ +export class ApiError extends Error { + public readonly code: string; + public readonly data?: unknown; + + constructor( code: string, message: string, data?: unknown ) { + super( message ); + this.name = 'ApiError'; + this.code = code; + this.data = data; + } +} + +/** + * Thin wrapper around `@wordpress/api-fetch` that re-throws bridge errors + * as `ApiError` so React Query's onError handlers can branch on `code`. + * + * Options are typed `APIFetchOptions< true >` — the parsing variant — + * because every fetcher here wants the decoded JSON body rather than the + * raw `Response`. `Parameters< typeof apiFetch >[ 0 ]` would widen the + * `parse` generic to `boolean`, which apiFetch's overload then refuses. + * + * @param options - apiFetch options. + * @return The decoded JSON response. + */ +export async function apiCall< T >( options: APIFetchOptions< true > ): Promise< T > { + try { + return await apiFetch< T >( options ); + } catch ( raw ) { + const err = raw as { code?: string; message?: string; data?: unknown }; + throw new ApiError( + typeof err.code === 'string' ? err.code : 'unknown', + typeof err.message === 'string' ? err.message : 'Request failed', + err.data + ); + } +} diff --git a/projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts b/projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts new file mode 100644 index 000000000000..bc71c2ef7ecd --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts @@ -0,0 +1,15 @@ +import { toIntRewindId } from '../_helpers'; + +describe( 'toIntRewindId', () => { + test( 'returns the input unchanged when there is no decimal suffix', () => { + expect( toIntRewindId( '1777035492' ) ).toBe( '1777035492' ); + } ); + + test( 'strips a single decimal suffix', () => { + expect( toIntRewindId( '1777035492.615' ) ).toBe( '1777035492' ); + } ); + + test( 'strips everything from the first dot onwards', () => { + expect( toIntRewindId( '1777035492.615.123' ) ).toBe( '1777035492' ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/data/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts new file mode 100644 index 000000000000..43f9e7be6f7e --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -0,0 +1,43 @@ +import { QueryClient } from '@tanstack/react-query'; + +const STALE_TIME_DEFAULT_MS = 30_000; +const GC_TIME_DEFAULT_MS = 5 * 60_000; + +/** + * Module-scope QueryClient shared across the modernized Backup dashboard. + * + * Each wp-build route mounts its own `` (via + * ``), but the client itself is created once so the + * cache survives the per-route bundle re-mount on navigation. + */ +export const queryClient = new QueryClient( { + defaultOptions: { + queries: { + staleTime: STALE_TIME_DEFAULT_MS, + gcTime: GC_TIME_DEFAULT_MS, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +} ); + +/** + * Stable cache keys for each query the dashboard issues. + * + * Centralizing keys here means any consumer can invalidate or read + * cached data without re-deriving the tuple shape ad hoc. + */ +export const keys = { + capabilities: () => [ 'backup', 'capabilities' ] as const, + activityLog: ( args: { page: number; pageSize: number; search: string } ) => + [ 'backup', 'activity-log', args ] as const, + fileTree: ( rewindId: string, folderPath: string | null ) => + [ 'backup', 'file-tree', rewindId, folderPath ] as const, + fileContents: ( rewindId: string, path: string ) => + [ 'backup', 'file-contents', rewindId, path ] as const, + pathInfo: ( rewindId: string, path: string | null ) => + [ 'backup', 'path-info', rewindId, path ] as const, + downloadStatus: ( rewindId: string, downloadId: number ) => + [ 'backup', 'download-status', rewindId, downloadId ] as const, + restoreStatus: ( restoreId: number ) => [ 'backup', 'restore-status', restoreId ] as const, +}; diff --git a/projects/packages/backup/src/dashboard/providers/query-client-provider.tsx b/projects/packages/backup/src/dashboard/providers/query-client-provider.tsx new file mode 100644 index 000000000000..5ba39f6a66d4 --- /dev/null +++ b/projects/packages/backup/src/dashboard/providers/query-client-provider.tsx @@ -0,0 +1,19 @@ +import { QueryClientProvider as TanStackProvider } from '@tanstack/react-query'; +import { queryClient } from '../data/query-client'; +import type { ReactNode } from 'react'; + +type Props = { + children: ReactNode; +}; + +/** + * Wraps children in TanStack's `` with the + * dashboard's shared singleton client. + * + * @param props - Component props. + * @param props.children - Children to wrap. + * @return The provider tree. + */ +export default function QueryClientProvider( { children }: Props ) { + return { children }; +} diff --git a/projects/plugins/backup/changelog/wire-backup-modernization-data-layer b/projects/plugins/backup/changelog/wire-backup-modernization-data-layer new file mode 100644 index 000000000000..5108082a2f28 --- /dev/null +++ b/projects/plugins/backup/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: patch +Type: added +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability/connection checks, drop the mock-mode dev banner. No-op when the modernization filter is off. diff --git a/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer new file mode 100644 index 000000000000..5108082a2f28 --- /dev/null +++ b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: patch +Type: added +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability/connection checks, drop the mock-mode dev banner. No-op when the modernization filter is off. From 8f27912eeceff9f0dd578d21c6720b257156178e Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:52:17 -0300 Subject: [PATCH 02/11] Backup: add Capabilities REST bridge + Gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the per-feature REST bridges pattern with a thin Rest_Controller that registers routes only when the modernization filter is on. Capabilities_Bridge proxies /sites/{id}/rewind to expose hasBackupPlan + planSlug for the React layer. Gates mounts inside DashboardLayout between the QueryClientProvider and the body slot. Top-to-bottom: loading → not-connected → secondary-admin → no-plan → children. All three routes inherit gating for free. Connection state comes from window.JP_CONNECTION_INITIAL_STATE, emitted inline on the modernized Backup admin page from class-jetpack-backup.php (the modernized enqueue path skips the legacy render_script call, so we need to put the global up ourselves). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backup/src/class-jetpack-backup.php | 24 ++++- .../components/dashboard-layout/index.tsx | 5 +- .../src/dashboard/components/gates/index.tsx | 49 +++++++++ .../components/gates/no-backup-plan.tsx | 32 ++++++ .../components/gates/not-connected.tsx | 35 ++++++ .../components/gates/secondary-admin.tsx | 36 +++++++ .../src/dashboard/components/gates/style.scss | 31 ++++++ .../src/dashboard/data/api/capabilities.ts | 19 ++++ .../src/dashboard/hooks/use-capabilities.ts | 29 +++++ .../src/dashboard/hooks/use-connection.ts | 48 +++++++++ .../src/rest/class-capabilities-bridge.php | 102 ++++++++++++++++++ .../backup/src/rest/class-rest-controller.php | 52 +++++++++ 12 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/components/gates/index.tsx create mode 100644 projects/packages/backup/src/dashboard/components/gates/no-backup-plan.tsx create mode 100644 projects/packages/backup/src/dashboard/components/gates/not-connected.tsx create mode 100644 projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx create mode 100644 projects/packages/backup/src/dashboard/components/gates/style.scss create mode 100644 projects/packages/backup/src/dashboard/data/api/capabilities.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-capabilities.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-connection.ts create mode 100644 projects/packages/backup/src/rest/class-capabilities-bridge.php create mode 100644 projects/packages/backup/src/rest/class-rest-controller.php diff --git a/projects/packages/backup/src/class-jetpack-backup.php b/projects/packages/backup/src/class-jetpack-backup.php index cb18ab7a586a..bc63acd4848c 100644 --- a/projects/packages/backup/src/class-jetpack-backup.php +++ b/projects/packages/backup/src/class-jetpack-backup.php @@ -122,6 +122,7 @@ public static function initialize() { Connection_Rest_Authentication::init(); add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) ); + add_action( 'rest_api_init', array( \Automattic\Jetpack\Backup\V0005\REST\Rest_Controller::class, 'register_routes' ) ); add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 1 ); // Akismet uses 4, so we need to use 1 to ensure both menus are added when only they exist. @@ -969,6 +970,27 @@ public static function maybe_load_wp_build() { } add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) ); + add_action( 'admin_print_scripts', array( __CLASS__, 'render_connection_initial_state' ), 1 ); + } + + /** + * Emit `window.JP_CONNECTION_INITIAL_STATE` inline on the modernized + * Backup admin page. + * + * The modernized enqueue path short-circuits before the legacy + * `Connection_Initial_State::render_script()` call, so without this + * the React `` component never sees the connection state and + * sits on its loading skeleton forever. We emit the same JS payload + * the legacy path emits, just outside of a registered script handle + * (wp-build's handles aren't reliable here, and the global is + * page-scoped — any tag setting it works). + * + * @return void + */ + public static function render_connection_initial_state() { + echo ''; } /** @@ -1024,7 +1046,7 @@ public static function alias_screen_id_for_wp_build( $screen ) { * * @return bool */ - private static function is_modernized() { + public static function is_modernized() { return (bool) apply_filters( self::MODERNIZATION_FILTER, false ); } diff --git a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx index 3bc818b87737..5d981f6cb4cb 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -4,6 +4,7 @@ import { Page } from '@wordpress/admin-ui'; import { __ } from '@wordpress/i18n'; import QueryClientProvider from '../../providers/query-client-provider'; import DevModeBanner from '../dev-mode-banner'; +import Gates from '../gates'; import './style.scss'; import type { ReactNode } from 'react'; @@ -48,7 +49,9 @@ export default function DashboardLayout( { children, actions }: Props ) {
-
{ children }
+
+ { children } +
diff --git a/projects/packages/backup/src/dashboard/components/gates/index.tsx b/projects/packages/backup/src/dashboard/components/gates/index.tsx new file mode 100644 index 000000000000..2dcd25032aac --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/index.tsx @@ -0,0 +1,49 @@ +import { Spinner } from '@wordpress/components'; +import { useCapabilities } from '../../hooks/use-capabilities'; +import { useConnection } from '../../hooks/use-connection'; +import NoBackupPlanScreen from './no-backup-plan'; +import NotConnectedScreen from './not-connected'; +import SecondaryAdminScreen from './secondary-admin'; +import './style.scss'; +import type { ReactNode } from 'react'; + +type Props = { + children: ReactNode; +}; + +/** + * Capability + connection gate that wraps the modernized dashboard body. + * + * Top-to-bottom decision tree, first match wins: + * loading → not-connected → secondary-admin → no-plan → children + * + * @param props - Component props. + * @param props.children - The dashboard body to render when all gates pass. + * @return The matching fallback screen, or `children`. + */ +export default function Gates( { children }: Props ) { + const connection = useConnection(); + const capabilities = useCapabilities(); + + if ( ! connection.isLoaded || capabilities.isLoading ) { + return ( +
+ +
+ ); + } + + if ( ! connection.isFullyConnected ) { + return ; + } + + if ( connection.isSecondaryAdminNotConnected ) { + return ; + } + + if ( ! capabilities.data?.hasBackupPlan ) { + return ; + } + + return <>{ children }; +} diff --git a/projects/packages/backup/src/dashboard/components/gates/no-backup-plan.tsx b/projects/packages/backup/src/dashboard/components/gates/no-backup-plan.tsx new file mode 100644 index 000000000000..4bc3d16b2fb8 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/no-backup-plan.tsx @@ -0,0 +1,32 @@ +import { Card, Notice } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; + +const UPGRADE_URL = 'https://jetpack.com/upgrade/backup/'; + +/** + * Fallback shown when the site is fully connected but has no active + * Backup plan. + * + * @return The rendered fallback. + */ +export default function NoBackupPlanScreen() { + return ( + + + }> + { __( "This site doesn't have an active Backup plan", 'jetpack-backup-pkg' ) } + + + { __( + 'Add Jetpack Backup to start protecting your site with automatic backups, granular restores, and offsite storage.', + 'jetpack-backup-pkg' + ) } + + + { __( 'See Backup plans', 'jetpack-backup-pkg' ) } + + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/gates/not-connected.tsx b/projects/packages/backup/src/dashboard/components/gates/not-connected.tsx new file mode 100644 index 000000000000..eb954ffcb9d0 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/not-connected.tsx @@ -0,0 +1,35 @@ +import { Button, Card } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; + +const JETPACK_CONNECT_URL = 'admin.php?page=jetpack#/connection'; + +/** + * Fallback shown when Jetpack isn't fully connected to WPCOM. + * + * Links out to the Jetpack settings connection screen instead of + * embedding `` — the connection package pulls in SCSS + * that wp-build can't resolve cleanly today. + * + * @return The rendered fallback. + */ +export default function NotConnectedScreen() { + return ( + + + }> + { __( 'Connect Jetpack to get started', 'jetpack-backup-pkg' ) } + + + { __( + 'Backup needs an active Jetpack connection to show your backup history.', + 'jetpack-backup-pkg' + ) } + + + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx b/projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx new file mode 100644 index 000000000000..805f65fc5bff --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx @@ -0,0 +1,36 @@ +import { Button, Card } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; + +const JETPACK_CONNECT_USER_URL = 'admin.php?page=jetpack#/connection'; + +/** + * Fallback shown when the current user is an admin but isn't personally + * linked to a WordPress.com account on this site. + * + * Links out to the Jetpack settings connection screen rather than + * embedding `` (see `not-connected.tsx` for the + * wp-build/SCSS rationale). + * + * @return The rendered fallback. + */ +export default function SecondaryAdminScreen() { + return ( + + + }> + { __( 'Link your account to view backups', 'jetpack-backup-pkg' ) } + + + { __( + "This site's Jetpack connection is already set up, but your account isn't linked to a WordPress.com user yet.", + 'jetpack-backup-pkg' + ) } + + + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/gates/style.scss b/projects/packages/backup/src/dashboard/components/gates/style.scss new file mode 100644 index 000000000000..dba6af0927be --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/style.scss @@ -0,0 +1,31 @@ +.jpb-gates { + + &__skeleton { + display: flex; + justify-content: center; + align-items: center; + min-height: 320px; + } + + &__card { + max-width: 560px; + margin-inline: auto; + margin-block: 48px; + padding: 32px; + } + + &__cta { + display: inline-block; + padding: 8px 16px; + background-color: var(--wp-admin-theme-color, #007cba); + border-radius: 2px; + text-decoration: none; + + &, + &:hover, + &:focus, + &:visited { + color: #fff; + } + } +} diff --git a/projects/packages/backup/src/dashboard/data/api/capabilities.ts b/projects/packages/backup/src/dashboard/data/api/capabilities.ts new file mode 100644 index 000000000000..5d8da74aa87e --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/capabilities.ts @@ -0,0 +1,19 @@ +import { apiCall, apiPath } from './_helpers'; + +export type Capabilities = { + hasBackupPlan: boolean; + hasScan: boolean; +}; + +/** + * Fetch the site's backup capabilities. + * + * Wrapped in `apiCall` (not raw `apiFetch`) so bridge errors come back as + * `ApiError`s with a `code` field, matching the rest of the data-layer + * fetchers. + * + * @return The capabilities payload. + */ +export async function fetchCapabilities(): Promise< Capabilities > { + return apiCall< Capabilities >( { path: apiPath( '/site/capabilities' ) } ); +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-capabilities.ts b/projects/packages/backup/src/dashboard/hooks/use-capabilities.ts new file mode 100644 index 000000000000..0bd6766f7767 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-capabilities.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchCapabilities, type Capabilities } from '../data/api/capabilities'; +import { keys } from '../data/query-client'; + +const CAPABILITIES_STALE_MS = 5 * 60_000; + +type Result = { + data: Capabilities | undefined; + isLoading: boolean; + error: Error | null; +}; + +/** + * React Query hook returning the site's backup capabilities. + * + * @return Capabilities query state. + */ +export function useCapabilities(): Result { + const query = useQuery( { + queryKey: keys.capabilities(), + queryFn: fetchCapabilities, + staleTime: CAPABILITIES_STALE_MS, + } ); + return { + data: query.data, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-connection.ts b/projects/packages/backup/src/dashboard/hooks/use-connection.ts new file mode 100644 index 000000000000..b6487f7404a3 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-connection.ts @@ -0,0 +1,48 @@ +import { useMemo } from '@wordpress/element'; + +type Result = { + isLoaded: boolean; + isFullyConnected: boolean; + isSecondaryAdminNotConnected: boolean; + hasConnectionError: boolean; +}; + +type ConnectionStatus = { + isRegistered?: boolean; + isUserConnected?: boolean; + hasConnectedOwner?: boolean; +}; + +/** + * Read Jetpack's connection state from the `JP_CONNECTION_INITIAL_STATE` + * global emitted by `Connection_Initial_State::render_script()` in PHP. + * + * We deliberately avoid `@automattic/jetpack-connection`'s store / hooks + * because the package's barrel pulls in SCSS that wp-build's bundler + * can't resolve, and the connection state is page-load static anyway — + * a single `window` read at hook-mount time covers every consumer. + * + * `@automattic/jetpack-connection`'s `declarations.d.ts` already types + * the global ambiently — and declares it non-optional — so we don't + * redeclare it here, but we do keep the runtime guard because nothing + * guarantees PHP emitted it. + * + * @return Connection state. + */ +export function useConnection(): Result { + return useMemo( () => { + const state = typeof window !== 'undefined' ? window.JP_CONNECTION_INITIAL_STATE : undefined; + const status: ConnectionStatus = state?.connectionStatus ?? {}; + + const isLoaded = Object.keys( status ).length > 0; + const isFullyConnected = Boolean( isLoaded && status.isRegistered && status.hasConnectedOwner ); + const isSecondaryAdminNotConnected = Boolean( isFullyConnected && ! status.isUserConnected ); + + return { + isLoaded, + isFullyConnected, + isSecondaryAdminNotConnected, + hasConnectionError: false, + }; + }, [] ); +} diff --git a/projects/packages/backup/src/rest/class-capabilities-bridge.php b/projects/packages/backup/src/rest/class-capabilities-bridge.php new file mode 100644 index 000000000000..2d73e7ebcb44 --- /dev/null +++ b/projects/packages/backup/src/rest/class-capabilities-bridge.php @@ -0,0 +1,102 @@ +` decision tree. + */ +class Capabilities_Bridge { + + /** + * Register the GET /jetpack/v4/site/capabilities route. + * + * @return void + */ + public static function register_routes() { + register_rest_route( + 'jetpack/v4', + '/site/capabilities', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_capabilities' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + ) + ); + } + + /** + * Proxy `/sites/{id}/rewind/capabilities` (v2, as_user) and project + * the response into the shape the React layer expects. + * + * This is the same endpoint the legacy `/jetpack/v4/backup-capabilities` + * route hits — it returns a flat `{ capabilities: [...] }` envelope. + * The earlier `/rewind?force=wpcom` variant returns site *state*, not + * a capabilities list, and on some plan shapes (e.g. Jetpack Complete) + * the `capabilities` key is missing entirely, which produced a false + * "no plan" gate for plans that do include Backup. + * + * @return \WP_REST_Response|WP_Error The decoded capabilities, or WP_Error on failure. + */ + public static function get_capabilities() { + $blog_id = Jetpack_Options::get_option( 'id' ); + if ( ! $blog_id ) { + return new WP_Error( + 'not_connected', + __( 'This site is not connected to Jetpack.', 'jetpack-backup-pkg' ), + array( 'status' => 412 ) + ); + } + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/rewind/capabilities', $blog_id ), + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'capabilities_fetch_failed', + __( 'Could not fetch site capabilities.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $body ) ) { + $body = array(); + } + + $capabilities = isset( $body['capabilities'] ) && is_array( $body['capabilities'] ) + ? $body['capabilities'] + : array(); + + return rest_ensure_response( + array( + 'hasBackupPlan' => in_array( 'backup', $capabilities, true ), + 'hasScan' => in_array( 'scan', $capabilities, true ), + ) + ); + } +} diff --git a/projects/packages/backup/src/rest/class-rest-controller.php b/projects/packages/backup/src/rest/class-rest-controller.php new file mode 100644 index 000000000000..a117d1cecb3e --- /dev/null +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -0,0 +1,52 @@ + Date: Sat, 16 May 2026 16:52:52 -0300 Subject: [PATCH 03/11] Backup: wire the activity list to /jetpack/v4/activity-log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces useMockActivityLog with useActivityLog backed by an apiFetch -> bridge -> WPCOM /sites/{id}/activity/rewindable chain. A small normalize/activity-log.ts maps WPCOM's rewindable-activity shape (gridicon, content.text, object.backup_stats) onto the ActivityItem discriminated union the UI already consumes. The hook returns `totalItems` in addition to `totalPages` because the trunk ActivityList drives DataViews' `paginationInfo` from both. The WPCOM endpoint doesn't accept `page` or `search`, so the hook fetches a 100-item window and filters/paginates client-side — DataViews remains the single source of truth for the visible slice. Overview's right-pane lookup swaps from the fixture's findActivityById to getCachedActivityById, which reads the same single-window cache the list populates. The default selection (newest backup) is derived from the cache the same way. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/activity-list/index.tsx | 9 +- .../src/dashboard/data/api/activity-log.ts | 47 +++++++ .../dashboard/data/normalize/activity-log.ts | 72 ++++++++++ .../data/normalize/test/activity-log.test.ts | 79 +++++++++++ .../src/dashboard/hooks/use-activity-log.ts | 127 ++++++++++++++++++ .../backup/src/dashboard/screens/overview.tsx | 29 ++-- .../src/rest/class-activity-log-bridge.php | 99 ++++++++++++++ 7 files changed, 435 insertions(+), 27 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/data/api/activity-log.ts create mode 100644 projects/packages/backup/src/dashboard/data/normalize/activity-log.ts create mode 100644 projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-activity-log.ts create mode 100644 projects/packages/backup/src/rest/class-activity-log-bridge.php 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 19ddc45af07b..7efbd6cc5c1d 100644 --- a/projects/packages/backup/src/dashboard/components/activity-list/index.tsx +++ b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx @@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Icon, cloud, image, post, plugins as pluginsIcon, color } from '@wordpress/icons'; import { Card, Stack, Text } from '@wordpress/ui'; -import { useMockActivityLog } from '../../hooks/use-mock-activity-log'; +import { useActivityLog } from '../../hooks/use-activity-log'; import { isBackupItem } from '../../types/activity'; import './style.scss'; import type { ActivityItem, ActivityKind } from '../../types/activity'; @@ -115,14 +115,11 @@ export default function ActivityList( { selectedId, onSelect }: Props ) { } ); // Drive the hook with DataViews' own view state so the hook is the - // single source of truth for the visible slice + counts. The mock - // hook's contract matches the real `useActivityLog` from the - // data-layer follow-up, so swapping the import line later is the - // only change required here. + // single source of truth for the visible slice + counts. const page = view.page ?? 1; const perPage = view.perPage ?? DEFAULT_PER_PAGE; const search = view.search ?? ''; - const { items, totalItems, totalPages, isLoading } = useMockActivityLog( { + const { items, totalItems, totalPages, isLoading } = useActivityLog( { page, pageSize: perPage, search, diff --git a/projects/packages/backup/src/dashboard/data/api/activity-log.ts b/projects/packages/backup/src/dashboard/data/api/activity-log.ts new file mode 100644 index 000000000000..7e04059bb033 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/activity-log.ts @@ -0,0 +1,47 @@ +import { apiCall, apiPath } from './_helpers'; + +export type WpcomActivityEntry = { + activity_id: string; + name: string; + gridicon: string; + rewind_id?: string; + published: string; + summary: string; + actor?: { type: string; name: string }; + content?: { text?: string }; + object?: { backup_stats?: string }; + is_rewindable?: boolean; +}; + +export type WpcomActivityLogResponse = { + current?: { + orderedItems?: WpcomActivityEntry[]; + }; + total_items?: number; +}; + +type FetchArgs = { + number?: number; + aggregate?: boolean; + after?: string; + before?: string; +}; + +/** + * Fetch the rewindable activity log entries from the bridge. + * + * @param args - Optional pagination and filter args. + * @return The raw WPCOM-shaped response. + */ +export async function fetchActivityLog( + args: FetchArgs = {} +): Promise< WpcomActivityLogResponse > { + return apiCall< WpcomActivityLogResponse >( { + path: apiPath( '/activity-log', { + number: args.number, + aggregate: args.aggregate, + after: args.after, + before: args.before, + } ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts b/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts new file mode 100644 index 000000000000..9208a8ea023d --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts @@ -0,0 +1,72 @@ +import type { ActivityItem, ActivityKind } from '../../types/activity'; +import type { WpcomActivityEntry } from '../api/activity-log'; + +const GRIDICON_TO_KIND: Record< string, ActivityKind | null > = { + cloud: 'backup', + image: 'upload', + post: 'post', + plugins: 'plugin-update', + color: 'theme-update', +}; + +/** + * Convert a single WPCOM rewindable-activity entry into the UI's + * `ActivityItem` shape. Returns null when the entry's gridicon doesn't + * map to a kind we render. + * + * @param entry - WPCOM entry. + * @return The mapped item, or null when the entry is filtered out. + */ +export function normalizeEntry( entry: WpcomActivityEntry ): ActivityItem | null { + const kind = GRIDICON_TO_KIND[ entry.gridicon ]; + if ( ! kind ) { + return null; + } + + const actor = entry.actor ?? { type: 'Application', name: 'Jetpack' }; + const base = { + id: entry.activity_id, + title: entry.summary, + publishedAt: entry.published, + actor: { + type: actor.type === 'Person' ? ( 'Person' as const ) : ( 'Application' as const ), + name: actor.name, + }, + summary: entry.content?.text ?? undefined, + }; + + if ( kind === 'backup' ) { + return { + ...base, + kind: 'backup', + rewindId: entry.rewind_id ?? entry.activity_id, + // `content.text` is the human-readable summary + // ("44 plugins, 23 themes, 1562 uploads…"). WPCOM also ships + // `object.backup_stats`, but in production that field is a + // stringified JSON blob, not a friendly string — rendering + // it verbatim dumps raw JSON into the UI. + stats: entry.content?.text ?? '', + isComplete: + entry.name === 'rewind__backup_complete_full' || entry.name === 'rewind__backup_complete', + }; + } + + return { + ...base, + kind, + }; +} + +/** + * Convert an array of WPCOM entries into UI `ActivityItem`s, filtering + * out entries whose `gridicon` doesn't map to a known kind. + * + * @param entries - WPCOM entries (possibly empty). + * @return Normalized items. + */ +export function normalizeActivityLog( entries: WpcomActivityEntry[] | undefined ): ActivityItem[] { + if ( ! entries ) { + return []; + } + return entries.map( normalizeEntry ).filter( ( item ): item is ActivityItem => item !== null ); +} diff --git a/projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts b/projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts new file mode 100644 index 000000000000..090e2ae040e7 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts @@ -0,0 +1,79 @@ +import { normalizeActivityLog, normalizeEntry } from '../activity-log'; +import type { WpcomActivityEntry } from '../../api/activity-log'; + +const backupEntry: WpcomActivityEntry = { + activity_id: 'a-1', + name: 'rewind__backup_complete_full', + gridicon: 'cloud', + rewind_id: '1777035492', + published: '2026-05-15T12:26:00.000Z', + summary: 'Backup and scan complete by Jetpack', + actor: { type: 'Application', name: 'Jetpack' }, + content: { text: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page' }, + // In production, `backup_stats` is a JSON-encoded blob — captured + // here verbatim so the test catches any regression that goes back + // to rendering this field as a string. + object: { backup_stats: '{"themes":{"count":1,"list":["twentytwentyfive"]}}' }, +}; + +const postEntry: WpcomActivityEntry = { + activity_id: 'a-2', + name: 'post__published', + gridicon: 'post', + published: '2026-05-14T01:23:00.000Z', + summary: 'Post published by Totoro', + actor: { type: 'Person', name: 'Totoro' }, + content: { text: 'The Perks of Having a Cat' }, +}; + +const unknownEntry: WpcomActivityEntry = { + activity_id: 'a-3', + name: 'mystery__event', + gridicon: 'something-new', + published: '2026-05-13T01:23:00.000Z', + summary: 'A mystery event', +}; + +describe( 'normalizeEntry', () => { + test( 'maps a backup entry to a backup ActivityItem', () => { + const item = normalizeEntry( backupEntry ); + expect( item ).not.toBeNull(); + expect( item ).toMatchObject( { + id: 'a-1', + kind: 'backup', + rewindId: '1777035492', + // `stats` reads from `content.text`, NOT the JSON-encoded + // `object.backup_stats`. Rendering backup_stats verbatim + // would dump JSON into the UI. + stats: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page', + isComplete: true, + } ); + } ); + + test( 'maps a post entry to a post ActivityItem', () => { + const item = normalizeEntry( postEntry ); + expect( item ).toMatchObject( { id: 'a-2', kind: 'post', actor: { type: 'Person' } } ); + } ); + + test( 'returns null for unknown gridicons', () => { + expect( normalizeEntry( unknownEntry ) ).toBeNull(); + } ); + + test( 'falls back to activity_id when rewind_id is missing', () => { + const fallback = { ...backupEntry, rewind_id: undefined }; + const item = normalizeEntry( fallback ); + expect( item ).toMatchObject( { kind: 'backup', rewindId: 'a-1' } ); + } ); +} ); + +describe( 'normalizeActivityLog', () => { + test( 'returns an empty array for undefined input', () => { + expect( normalizeActivityLog( undefined ) ).toEqual( [] ); + } ); + + test( 'filters out unmapped gridicons', () => { + const items = normalizeActivityLog( [ backupEntry, postEntry, unknownEntry ] ); + expect( items ).toHaveLength( 2 ); + expect( items.map( i => i.kind ) ).toEqual( [ 'backup', 'post' ] ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts new file mode 100644 index 000000000000..413f7d402a08 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts @@ -0,0 +1,127 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from '@wordpress/element'; +import { fetchActivityLog } from '../data/api/activity-log'; +import { normalizeActivityLog } from '../data/normalize/activity-log'; +import { keys, queryClient } from '../data/query-client'; +import type { ActivityItem } from '../types/activity'; + +type Args = { + page: number; + pageSize: number; + search: string; +}; + +type Result = { + items: ActivityItem[]; + totalItems: number; + totalPages: number; + isLoading: boolean; + error: Error | null; +}; + +const MAX_FETCH_ITEMS = 100; + +/** + * Case-insensitive predicate: returns true when the activity item's title + * or summary contains the search query. An empty query matches every item. + * + * WPCOM's `/sites/{id}/activity/rewindable` endpoint doesn't accept a + * search parameter, so the hook fetches a single window of recent + * activity (capped at `MAX_FETCH_ITEMS`) and filters client-side. This + * matches what DataViews expects: server returns a fixed window, the + * hook hands DataViews the filtered + paginated slice plus the totals + * it needs for its pagination footer. + * + * @param item - Activity item to test. + * @param q - Search query (raw, untrimmed). + * @return True when the item matches the query. + */ +function matchesSearch( item: ActivityItem, q: string ): boolean { + if ( ! q ) { + return true; + } + const haystack = `${ item.title } ${ item.summary ?? '' }`.toLowerCase(); + return haystack.includes( q.toLowerCase() ); +} + +/** + * React Query hook returning a paginated, search-filtered slice of the + * site's rewindable activity log. + * + * @param args - Query args. + * @param args.page - 1-indexed page number. + * @param args.pageSize - Items per page. + * @param args.search - Search query (title + summary, case-insensitive). + * @return Items, total items, total pages, loading, error. + */ +export function useActivityLog( { page, pageSize, search }: Args ): Result { + const query = useQuery( { + queryKey: keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ), + queryFn: () => fetchActivityLog( { number: MAX_FETCH_ITEMS } ), + } ); + + const allItems = useMemo( + () => normalizeActivityLog( query.data?.current?.orderedItems ), + [ query.data ] + ); + + const { items, totalItems, totalPages } = useMemo( () => { + const filtered = allItems.filter( item => matchesSearch( item, search ) ); + const total = Math.max( 1, Math.ceil( filtered.length / pageSize ) ); + const start = ( page - 1 ) * pageSize; + return { + items: filtered.slice( start, start + pageSize ), + totalItems: filtered.length, + totalPages: total, + }; + }, [ allItems, page, pageSize, search ] ); + + return { + items, + totalItems, + totalPages, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} + +/** + * Look up a single activity item from the React Query cache. + * + * Reads the same single window the hook fetches, so the right-pane + * detail card on Overview can resolve its selected id without issuing + * an extra request. + * + * @param id - Selection id: `rewindId` for backup items, `activity_id` otherwise. + * @return The matching item, or null when the cache hasn't been populated yet or the id doesn't match anything in the cached page. + */ +export function getCachedActivityById( id: string ): ActivityItem | null { + const data = queryClient.getQueryData< Awaited< ReturnType< typeof fetchActivityLog > > >( + keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ) + ); + const items = normalizeActivityLog( data?.current?.orderedItems ); + return ( + items.find( item => ( item.kind === 'backup' ? item.rewindId === id : item.id === id ) ) ?? null + ); +} + +/** + * Returns the newest backup row in the cached activity log, or null when + * the cache isn't populated or holds no backup rows. + * + * Used by the Overview to preselect a default right-pane on first load. + * + * @return The newest backup item's rewindId, or null. + */ +export function getCachedDefaultBackupRewindId(): string | null { + const data = queryClient.getQueryData< Awaited< ReturnType< typeof fetchActivityLog > > >( + keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ) + ); + const items = normalizeActivityLog( data?.current?.orderedItems ); + for ( const item of items ) { + if ( item.kind === 'backup' ) { + return item.rewindId; + } + } + return null; +} diff --git a/projects/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx index fcf6572c1c80..3a2a32c94317 100644 --- a/projects/packages/backup/src/dashboard/screens/overview.tsx +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from '@wordpress/element'; +import { useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { calendar } from '@wordpress/icons'; import { useNavigate, useSearch } from '@wordpress/route'; @@ -7,28 +7,11 @@ import ActivityDetail from '../components/activity-detail'; import ActivityList from '../components/activity-list'; import BackupDetail from '../components/backup-detail'; import DashboardLayout from '../components/dashboard-layout'; -import { MOCK_ACTIVITY_LOG, findActivityById } from '../fixtures/activity-log'; +import { getCachedActivityById, getCachedDefaultBackupRewindId } from '../hooks/use-activity-log'; import { isBackupItem } from '../types/activity'; -import type { ActivityItem } from '../types/activity'; type OverviewSearch = Record< string, unknown > & { selected?: string }; -/** - * Returns the selection id of the newest backup row in the fixture, so the - * Overview can preselect it on first load and keep the right pane populated. - * - * @param items - Activity items to scan. - * @return The newest backup's rewindId, or null when none exists. - */ -function findDefaultSelection( items: readonly ActivityItem[] ): string | null { - for ( const item of items ) { - if ( isBackupItem( item ) ) { - return item.rewindId; - } - } - return null; -} - /** * Overview screen for the modernized Backup dashboard. * @@ -46,7 +29,11 @@ export default function OverviewScreen() { strict: false, } ) as OverviewSearch; const navigate = useNavigate(); - const defaultSelectedId = useMemo( () => findDefaultSelection( MOCK_ACTIVITY_LOG ), [] ); + // The default selection comes from the activity-log cache; ActivityList + // populates that cache on mount, so the right pane will reconcile to + // the newest backup once the first fetch resolves. Until then, + // `selectedId` is null and the empty-state placeholder renders. + const defaultSelectedId = getCachedDefaultBackupRewindId(); const selectedId = typeof search.selected === 'string' ? search.selected : defaultSelectedId; const setSelected = useCallback( @@ -102,7 +89,7 @@ function RightPane( { selectedId }: { selectedId: string | null } ) { ); } - const item = findActivityById( selectedId ); + const item = getCachedActivityById( selectedId ); if ( ! item ) { return (
diff --git a/projects/packages/backup/src/rest/class-activity-log-bridge.php b/projects/packages/backup/src/rest/class-activity-log-bridge.php new file mode 100644 index 000000000000..d7044ad77ed5 --- /dev/null +++ b/projects/packages/backup/src/rest/class-activity-log-bridge.php @@ -0,0 +1,99 @@ + WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_activity_log' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'number' => array( 'type' => 'integer' ), + 'aggregate' => array( 'type' => 'boolean' ), + 'after' => array( 'type' => 'string' ), + 'before' => array( 'type' => 'string' ), + ), + ) + ); + } + + /** + * Proxy the WPCOM rewindable activity log. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_activity_log( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + + $query = array_filter( + array( + 'number' => $request->get_param( 'number' ), + 'aggregate' => $request->get_param( 'aggregate' ) ? 'true' : null, + 'after' => $request->get_param( 'after' ), + 'before' => $request->get_param( 'before' ), + ), + static function ( $value ) { + return null !== $value && '' !== $value; + } + ); + + $endpoint = sprintf( '/sites/%d/activity/rewindable', $blog_id ); + if ( ! empty( $query ) ) { + $endpoint .= '?' . http_build_query( $query ); + } + + $response = Client::wpcom_json_api_request_as_user( + $endpoint, + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'activity_log_fetch_failed', + __( 'Could not fetch the site activity log.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) ); + } +} From 2d462479b96b1db876e0d142dfe824ef4ff90417 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:53:12 -0300 Subject: [PATCH 04/11] Backup: wire the file browser to /jetpack/v4/rewind/backup/$rewindId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the File_Browser_Bridge (ls + path-info + file-content), each proxying WPCOM /sites/{id}/rewind/backup/* via as_user. The file-content endpoint resolves the signed stream URL server-side and caps the returned body at 64 KB so previews can't balloon the REST response. React side: useFileTree replaces useMockFileTree, useFileContents replaces the static findContents() fixture lookup. The rewindId prop on FileBrowser is now actually consumed and threaded through every NodeRow so each folder fetches its own children. FileInfoCard no longer reads metadata from fixture nodes — usePathInfo fetches size + mime + last-modified for the open file, and useFileContents pulls the preview body only when the mime type is text-shaped. FileNodeFile's metadata fields drop to optional since WPCOM's /ls endpoint only returns name + type. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/file-browser/index.tsx | 102 ++++---- .../components/file-info-card/index.tsx | 166 +++++++++---- .../src/dashboard/data/api/file-contents.ts | 29 +++ .../src/dashboard/data/api/file-tree.ts | 47 ++++ .../backup/src/dashboard/data/query-client.ts | 2 - .../src/dashboard/hooks/use-file-contents.ts | 63 +++++ .../src/dashboard/hooks/use-file-tree.ts | 100 ++++++++ .../backup/src/dashboard/types/file-tree.ts | 17 +- .../src/rest/class-file-browser-bridge.php | 221 ++++++++++++++++++ 9 files changed, 652 insertions(+), 95 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/data/api/file-contents.ts create mode 100644 projects/packages/backup/src/dashboard/data/api/file-tree.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-file-contents.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-file-tree.ts create mode 100644 projects/packages/backup/src/rest/class-file-browser-bridge.php 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 3babaa8d41e3..abb818254542 100644 --- a/projects/packages/backup/src/dashboard/components/file-browser/index.tsx +++ b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx @@ -9,8 +9,7 @@ import { category as folderIcon, } from '@wordpress/icons'; import { Stack } from '@wordpress/ui'; -import { MOCK_FILE_TREE } from '../../fixtures/file-tree'; -import { useMockFileTree } from '../../hooks/use-mock-file-tree'; +import { useFileTree } from '../../hooks/use-file-tree'; import { isFolder } from '../../types/file-tree'; import FileInfoCard from '../file-info-card'; import './style.scss'; @@ -322,8 +321,9 @@ export default function FileBrowser( { onSelectionChange, onSelectionCountChange, }: Props ) { - const [ openFilePath, setOpenFilePath ] = useState< string | null >( null ); - const roots = MOCK_FILE_TREE; + const [ openFile, setOpenFile ] = useState< FileNodeFile | null >( null ); + const { children: rootsData, isLoading: rootsLoading } = useFileTree( rewindId, null ); + const roots = useMemo< FileNode[] >( () => rootsData ?? [], [ rootsData ] ); const { selected, deselected } = selection; // Loaded folder children, hoisted here so toggle propagation and the @@ -331,9 +331,25 @@ export default function FileBrowser( { // already expanded. Top-level roots live under the empty-string key // so `parentOf('/foo') === ''` resolves consistently. const [ loadedChildren, setLoadedChildren ] = useState< Map< string, FileNode[] > >( - () => new Map< string, FileNode[] >( [ [ '', roots ] ] ) + () => new Map< string, FileNode[] >() ); + // Sync the lazily-fetched root list into the loadedChildren map once + // it resolves so toggle propagation can see the top-level siblings. + useEffect( () => { + if ( ! rootsData ) { + return; + } + setLoadedChildren( prev => { + if ( prev.get( '' ) === rootsData ) { + return prev; + } + const next = new Map( prev ); + next.set( '', rootsData ); + return next; + } ); + }, [ rootsData ] ); + const registerChildren = useCallback( ( path: string, children: FileNode[] ) => { setLoadedChildren( prev => { if ( prev.get( path ) === children ) { @@ -400,9 +416,7 @@ export default function FileBrowser( { } ); }, [ selected.size, roots, onSelectionChange ] ); - const closeInfoCard = useCallback( () => setOpenFilePath( null ), [] ); - - const openFile = findFileInTree( roots, openFilePath ); + const closeInfoCard = useCallback( () => setOpenFile( null ), [] ); return (
@@ -420,19 +434,26 @@ export default function FileBrowser( {
- { roots.map( ( node, index ) => ( - - ) ) } + { rootsLoading && ( +
+ +
+ ) } + { ! rootsLoading && + roots.map( ( node, index ) => ( + + ) ) }
{ openFile && }
@@ -445,9 +466,10 @@ type NodeRowProps = { depth: number; isAlternate: boolean; ancestorSelected: boolean; + rewindId: string; selection: FileSelection; onToggle: ( path: string, effectiveBefore: boolean ) => void; - onOpenFile: ( path: string ) => void; + onOpenFile: ( file: FileNodeFile ) => void; onRegisterChildren: ( path: string, children: FileNode[] ) => void; }; @@ -463,6 +485,7 @@ type NodeRowProps = { * @param props.depth - Indent depth (root = 0). * @param props.isAlternate - Whether this row gets the alt (gray) background. * @param props.ancestorSelected - True when this row inherits a checked state from a selected ancestor (modulo its own deselection). + * @param props.rewindId - The selected backup's rewind id, threaded down so each folder row can fetch its own children via `useFileTree`. * @param props.selection - Current selection state (selected + deselected sets). * @param props.onToggle - Called with the row's path and current effective state when the checkbox toggles. * @param props.onOpenFile - Open the info-card for a file path. @@ -474,6 +497,7 @@ function NodeRow( { depth, isAlternate, ancestorSelected, + rewindId, selection, onToggle, onOpenFile, @@ -481,7 +505,7 @@ function NodeRow( { }: NodeRowProps ) { const [ open, setOpen ] = useState( false ); const nodeIsFolder = isFolder( node ); - const { children, isLoading } = useMockFileTree( open && nodeIsFolder ? node.path : null ); + const { children, isLoading } = useFileTree( rewindId, open && nodeIsFolder ? node.path : null ); const { selected, deselected } = selection; // Effective check: own positive > own negative > inherited positive. @@ -508,7 +532,11 @@ function NodeRow( { [ onToggle, node.path, isEffectivelySelected ] ); const handleToggleOpen = useCallback( () => setOpen( v => ! v ), [] ); - const handleOpenFile = useCallback( () => onOpenFile( node.path ), [ onOpenFile, node.path ] ); + const handleOpenFile = useCallback( () => { + if ( ! nodeIsFolder ) { + onOpenFile( node as FileNodeFile ); + } + }, [ onOpenFile, node, nodeIsFolder ] ); // Register the loaded children with the FileBrowser parent once // they've actually resolved for this folder. The gate skips the @@ -578,6 +606,7 @@ function NodeRow( { // parent's parity, then alternates from there. isAlternate={ index % 2 === 0 ? ! isAlternate : isAlternate } ancestorSelected={ isEffectivelySelected } + rewindId={ rewindId } selection={ selection } onToggle={ onToggle } onOpenFile={ onOpenFile } @@ -589,28 +618,3 @@ function NodeRow( {
); } - -/** - * Recursively searches the rendered tree for a file at the given path. - * - * @param nodes - Nodes to search. - * @param path - File path to match, or null to short-circuit. - * @return The matching file node, or null. - */ -function findFileInTree( nodes: FileNode[], path: string | null ): FileNodeFile | null { - if ( ! path ) { - return null; - } - for ( const node of nodes ) { - if ( node.path === path && ! isFolder( node ) ) { - return node; - } - if ( isFolder( node ) && node.children ) { - const found = findFileInTree( node.children, path ); - if ( found ) { - return found; - } - } - } - return null; -} diff --git a/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx index dc5b7300ef13..e47ee16835ae 100644 --- a/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx +++ b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx @@ -1,16 +1,108 @@ +import { Spinner } from '@wordpress/components'; import { dateI18n } from '@wordpress/date'; import { __, sprintf } from '@wordpress/i18n'; import { closeSmall } from '@wordpress/icons'; import { Button, Card, Stack, Text } from '@wordpress/ui'; -import { findContents } from '../../fixtures/file-contents'; +import { useFileContents } from '../../hooks/use-file-contents'; import './style.scss'; import type { FileNodeFile } from '../../types/file-tree'; +/** + * Heuristic mime-type lookup by file extension. + * + * WPCOM's `/path-info` endpoint, which PR 48859 originally relied on, + * returns "No file found" for every variant we tried — it appears to + * query an internal index that isn't populated for every site / file. + * Deriving mime from the extension keeps the FileInfoCard's preview-or- + * not decision working without that fetch, and matches the heuristic + * legacy file managers use. + */ +const EXT_TO_MIME: Record< string, string > = { + css: 'text/css', + csv: 'text/csv', + htm: 'text/html', + html: 'text/html', + js: 'application/javascript', + json: 'application/json', + log: 'text/plain', + md: 'text/markdown', + php: 'application/x-php', + po: 'text/plain', + pot: 'text/plain', + sql: 'application/sql', + svg: 'image/svg+xml', + txt: 'text/plain', + xml: 'application/xml', + yaml: 'text/yaml', + yml: 'text/yaml', +}; + +/** + * Derive a mime type from the given filename's extension. Returns an + * empty string when the extension isn't recognized, in which case the + * card falls back to the "preview unavailable" branch. + * + * @param name - Filename, e.g. `wp-config.php`. + * @return The matched mime type, or `''`. + */ +function mimeFromName( name: string ): string { + const idx = name.lastIndexOf( '.' ); + if ( idx <= 0 || idx === name.length - 1 ) { + return ''; + } + const ext = name.slice( idx + 1 ).toLowerCase(); + return EXT_TO_MIME[ ext ] ?? ''; +} + type Props = { file: FileNodeFile; onClose: () => void; }; +/** + * Renders the preview slot's body: a spinner while loading, the file + * contents in a `
` when available, or a "preview unavailable" muted
+ * line when the mime type isn't text or the fetch returned nothing.
+ *
+ * Pulled out as a standalone component to keep `FileInfoCard`'s JSX flat
+ * (no nested ternaries) and to give the loading / error / empty branches
+ * unique render paths the linter can reason about.
+ *
+ * @param props             - Component props.
+ * @param props.showPreview - Whether the file's mime type is renderable as text.
+ * @param props.isLoading   - Whether the file-contents query is in flight.
+ * @param props.content     - The fetched body, or null when not yet resolved.
+ * @return The preview body.
+ */
+function PreviewBody( {
+	showPreview,
+	isLoading,
+	content,
+}: {
+	showPreview: boolean;
+	isLoading: boolean;
+	content: string | null;
+} ) {
+	if ( ! showPreview ) {
+		return (
+			
+				{ __( 'Preview unavailable for this file.', 'jetpack-backup-pkg' ) }
+			
+		);
+	}
+	if ( isLoading ) {
+		return ;
+	}
+	if ( content === null ) {
+		return (
+			
+				{ __( 'Preview unavailable for this file.', 'jetpack-backup-pkg' ) }
+			
+		);
+	}
+	return 
{ content }
; +} + /** * Returns true when the given mime type is renderable as plain text. * @@ -27,33 +119,29 @@ function isTextual( mime: string ): boolean { } /** - * Formats a byte count as a short human-readable string. - * - * @param bytes - Size in bytes. - * @return Formatted size (e.g. `4.7 KB`). - */ -function humanSize( bytes: number ): string { - if ( bytes < 1024 ) { - return `${ bytes } B`; - } - if ( bytes < 1024 * 1024 ) { - return `${ ( bytes / 1024 ).toFixed( 1 ) } KB`; - } - return `${ ( bytes / 1024 / 1024 ).toFixed( 1 ) } MB`; -} - -/** - * Side panel showing details for the currently-open file: size, modified - * timestamp, hash, monospace text preview for recognized text mime types, + * Side panel showing details for the currently-open file: modified + * timestamp, monospace text preview for recognized text mime types, * plus per-file Download and Restore buttons. * + * `lastModified`, `period`, and `manifestPath` all come from `/ls` and + * are carried on the FileNode itself — no extra fetch needed. The + * preview pulls content via the file's own `period` (not the parent + * backup's rewindId) because VaultPress addresses file blobs by their + * per-entry snapshot timestamp. + * * @param props - Component props. - * @param props.file - The file to render. + * @param props.file - The file node clicked in the tree. * @param props.onClose - Callback to close the card. * @return The rendered info card. */ export default function FileInfoCard( { file, onClose }: Props ) { - const contents = isTextual( file.mimeType ) ? findContents( file.path ) : null; + const mimeType = mimeFromName( file.name ); + const showPreview = mimeType ? isTextual( mimeType ) : false; + const { content, isLoading: contentsLoading } = useFileContents( + file.period, + file.manifestPath, + showPreview + ); return ( @@ -77,27 +165,25 @@ export default function FileInfoCard( { file, onClose }: Props ) {
-
-
{ __( 'Size:', 'jetpack-backup-pkg' ) }
-
{ humanSize( file.sizeBytes ) }
-
-
-
{ __( 'Modified:', 'jetpack-backup-pkg' ) }
-
{ dateI18n( 'M j, Y, g:i A', file.lastModified, undefined ) }
-
-
-
{ __( 'Hash:', 'jetpack-backup-pkg' ) }
-
{ file.hash }
-
+ { file.lastModified && ( +
+
{ __( 'Modified:', 'jetpack-backup-pkg' ) }
+
{ dateI18n( 'M j, Y, g:i A', file.lastModified, undefined ) }
+
+ ) } + { mimeType && ( +
+
{ __( 'Type:', 'jetpack-backup-pkg' ) }
+
{ mimeType }
+
+ ) }
- { contents !== null ? ( -
{ contents }
- ) : ( - - { __( 'Preview unavailable for this file.', 'jetpack-backup-pkg' ) } - - ) } +
{ + return apiCall< FileContentsResponse >( { + path: apiPath( '/rewind/backup/file-content', { + file_period: filePeriod, + encoded_manifest_path: encodedManifestPath, + } ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/file-tree.ts b/projects/packages/backup/src/dashboard/data/api/file-tree.ts new file mode 100644 index 000000000000..eda3f12684b9 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/file-tree.ts @@ -0,0 +1,47 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +/** + * A single entry in WPCOM's `/rewind/backup/ls` `contents` map. + * + * The endpoint returns `contents` as an object keyed by filename, so the + * entry value itself has no `name` field — the parent's key carries it. + * Type discriminators: `'dir'` (folder), `'file'` (regular file), + * `'wordpress'` (virtual core-version marker that has no children). + */ +export type WpcomFileNode = { + type: 'dir' | 'file' | 'wordpress' | string; + manifest_path?: string; + has_children?: boolean; + total_items?: number; + period?: string; + id?: string; + wordpress_version?: string; + sort?: number; +}; + +export type WpcomLsResponse = { + ok?: boolean; + error?: string; + contents?: Record< string, WpcomFileNode >; +}; + +/** + * List the children of a folder inside a backup. + * + * @param rewindId - The backup's rewind id (decimal suffix stripped here). + * @param folderPath - Folder to list, relative to backup root. + * @return The decoded WPCOM ls response. + */ +export async function fetchFileTree( + rewindId: string, + folderPath: string +): Promise< WpcomLsResponse > { + return apiCall< WpcomLsResponse >( { + path: apiPath( '/rewind/backup/ls' ), + method: 'POST', + data: { + rewind_id: toIntRewindId( rewindId ), + path: folderPath, + }, + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts index 43f9e7be6f7e..72aad9119dba 100644 --- a/projects/packages/backup/src/dashboard/data/query-client.ts +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -35,8 +35,6 @@ export const keys = { [ 'backup', 'file-tree', rewindId, folderPath ] as const, fileContents: ( rewindId: string, path: string ) => [ 'backup', 'file-contents', rewindId, path ] as const, - pathInfo: ( rewindId: string, path: string | null ) => - [ 'backup', 'path-info', rewindId, path ] as const, downloadStatus: ( rewindId: string, downloadId: number ) => [ 'backup', 'download-status', rewindId, downloadId ] as const, restoreStatus: ( restoreId: number ) => [ 'backup', 'restore-status', restoreId ] as const, diff --git a/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts new file mode 100644 index 000000000000..39edbb4c9870 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts @@ -0,0 +1,63 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchFileContents } from '../data/api/file-contents'; +import { keys } from '../data/query-client'; + +type Result = { + content: string | null; + isLoading: boolean; + error: Error | null; +}; + +/** + * Base64-encode a manifest path the way WPCOM's file-content endpoint + * expects. UTF-8 safe: passing the string straight to `btoa` throws on + * any code point > 0xFF (uploads with accented or CJK filenames), so we + * encode to UTF-8 bytes with `TextEncoder` first and feed `btoa` the + * resulting binary string. `btoa` and `TextEncoder` are both DOM + * globals, present in every browser this dashboard runs in and in the + * jsdom environment the Jest suite uses. + * + * @param manifestPath - The raw manifest path. + * @return Base64-encoded path. + */ +function encodeManifestPath( manifestPath: string ): string { + const bytes = new TextEncoder().encode( manifestPath ); + let binary = ''; + for ( const byte of bytes ) { + binary += String.fromCharCode( byte ); + } + return btoa( binary ); +} + +/** + * Hook fetching a text file's contents for the preview pane. + * + * `filePeriod` and `manifestPath` both come from the file's `/ls` + * row — NOT from the parent backup. VaultPress addresses file content + * by the per-entry snapshot timestamp and the volume-prefixed manifest + * path, so the parent backup's rewindId is irrelevant here. + * + * @param filePeriod - The file's own snapshot timestamp (from /ls `period`). + * @param manifestPath - The volume-prefixed manifest path (from /ls `manifest_path`, e.g. `f5:/wp-config.php`). Base64-encoded before sending. + * @param enabled - When false, the query is skipped (e.g. binary mime types). + * @return Content + loading state. + */ +export function useFileContents( + filePeriod: string | undefined, + manifestPath: string | undefined, + enabled: boolean +): Result { + const safePeriod = filePeriod ?? ''; + const safePath = manifestPath ?? ''; + const query = useQuery( { + queryKey: keys.fileContents( safePeriod, safePath ), + queryFn: () => fetchFileContents( safePeriod, encodeManifestPath( safePath ) ), + enabled: enabled && Boolean( safePeriod ) && Boolean( safePath ), + } ); + + return { + content: query.data?.content ?? null, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-file-tree.ts b/projects/packages/backup/src/dashboard/hooks/use-file-tree.ts new file mode 100644 index 000000000000..2b67b1bfa5a9 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-file-tree.ts @@ -0,0 +1,100 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from '@wordpress/element'; +import { fetchFileTree, type WpcomFileNode } from '../data/api/file-tree'; +import { keys } from '../data/query-client'; +import type { FileNode } from '../types/file-tree'; + +const BASE_FOLDER_PATH = '/'; + +type Result = { + children: FileNode[] | null; + isLoading: boolean; + error: Error | null; +}; + +/** + * Convert a single WPCOM ls entry into the dashboard's `FileNode` shape. + * + * WPCOM returns `contents` as a map keyed by filename; the entry value + * has no `name` of its own, so the key is passed in here as `name`. + * + * The `type` field on the entry is ambiguous: at the backup root WPCOM + * uses `'dir'` / `'file'` / `'wordpress'`, but inside `/wp-content` + * folders like `"languages"` and `"mu-plugins"` are reported as + * `type: 'file'` with `has_children: true`. So the real folder/leaf + * discriminator is `has_children`, not `type`. + * + * `period` is a Unix-seconds timestamp the entry was last modified — + * we surface it as `lastModified` so the FileInfoCard can render the + * date without a separate path-info call. + * + * @param name - The entry's filename (the key in the parent contents map). + * @param raw - The WPCOM ls entry. + * @param parentPath - Path of the folder the entry lives in. + * @return The mapped `FileNode`. + */ +function toFileNode( name: string, raw: WpcomFileNode, parentPath: string ): FileNode { + const fullPath = parentPath === BASE_FOLDER_PATH ? `/${ name }` : `${ parentPath }/${ name }`; + + const isFolder = raw.type === 'dir' || raw.has_children === true; + if ( isFolder ) { + return { + type: 'folder', + name, + path: fullPath, + }; + } + + const lastModified = raw.period + ? new Date( Number.parseInt( raw.period, 10 ) * 1000 ).toISOString() + : undefined; + return { + type: 'file', + name, + path: fullPath, + lastModified, + period: raw.period, + manifestPath: raw.manifest_path, + }; +} + +/** + * Hook returning the children of a folder inside a backup. + * + * Passing `null` for `folderPath` is the "ask for the root tree" idiom; + * the hook treats it as a query for `/`. + * + * @param rewindId - The backup's rewind id (decimal-suffix-safe). + * @param folderPath - Folder to load, or null for the root. + * @return Children list, loading flag, error. + */ +export function useFileTree( rewindId: string, folderPath: string | null ): Result { + const path = folderPath ?? BASE_FOLDER_PATH; + const query = useQuery( { + queryKey: keys.fileTree( rewindId, path ), + queryFn: () => fetchFileTree( rewindId, path ), + enabled: Boolean( rewindId ), + } ); + + const children = useMemo< FileNode[] | null >( () => { + if ( ! query.data ) { + return null; + } + const contents = query.data.contents; + if ( ! contents || typeof contents !== 'object' ) { + return []; + } + // Skip the virtual `wordpress` core-version markers — they have no + // children and no manifest_path, so neither tree-render nor preview + // works on them. + return Object.entries( contents ) + .filter( ( [ , raw ] ) => raw.type !== 'wordpress' ) + .map( ( [ name, raw ] ) => toFileNode( name, raw, path ) ); + }, [ query.data, path ] ); + + return { + children, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} diff --git a/projects/packages/backup/src/dashboard/types/file-tree.ts b/projects/packages/backup/src/dashboard/types/file-tree.ts index 031fecd4e9a7..e55aa2a9f2e3 100644 --- a/projects/packages/backup/src/dashboard/types/file-tree.ts +++ b/projects/packages/backup/src/dashboard/types/file-tree.ts @@ -10,10 +10,19 @@ export type FolderNode = FileNodeBase & { export type FileNodeFile = FileNodeBase & { type: 'file'; - sizeBytes: number; - mimeType: string; - lastModified: string; - hash: string; + // `/rewind/backup/ls` returns a `period` Unix-seconds timestamp per + // entry — the timestamp when this file itself last changed. The + // hook converts it to an ISO string for display here. + lastModified?: string; + // The same `period` value in its raw Unix-seconds form. Used by the + // file-content preview as the `{period}` URL segment when streaming + // from VaultPress — content is stored against the file's own period, + // not the parent backup's rewindId. + period?: string; + // The volume-prefixed path WPCOM uses to identify this file + // (e.g. "f5:/wp-config.php"). Required for the file-content preview + // call. The display path on `FileNodeBase` strips this prefix. + manifestPath?: string; }; export type FileNode = FolderNode | FileNodeFile; diff --git a/projects/packages/backup/src/rest/class-file-browser-bridge.php b/projects/packages/backup/src/rest/class-file-browser-bridge.php new file mode 100644 index 000000000000..a2c13f534c94 --- /dev/null +++ b/projects/packages/backup/src/rest/class-file-browser-bridge.php @@ -0,0 +1,221 @@ + WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'list_directory' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'path' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/rewind/backup/file-content', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_file_content' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'file_period' => array( + 'type' => 'string', + 'required' => true, + ), + 'encoded_manifest_path' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ) + ); + } + + /** + * List folder children. Proxies POST wpcom/v2 /sites/{id}/rewind/backup/ls. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function list_directory( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/rewind/backup/ls', $blog_id ), + 'v2', + array( 'method' => 'POST' ), + array( + 'backup_id' => $request->get_param( 'rewind_id' ), + 'path' => $request->get_param( 'path' ), + ), + 'wpcom' + ); + + return self::forward_response( $response, 'backup_ls_fetch_failed', __( 'Could not list backup contents.', 'jetpack-backup-pkg' ) ); + } + + /** + * Fetch a text file's content for the preview pane. + * + * Resolves the one-time signed URL from WPCOM, then fetches the + * stream server-side and caps the body at PREVIEW_MAX_BYTES. + * WPCOM's signed-URL stream endpoint doesn't send CORS headers, so + * the browser can't fetch it directly. + * + * VaultPress stores file content per the file's own snapshot + * `period` — the timestamp when the file last changed — not by the + * parent backup's rewindId. Files don't get re-snapshotted every + * backup, so the `{period}` URL segment below is the per-entry + * `period` from `/ls`, never the rewindId of the backup the user + * is browsing. Sending the rewindId instead silently produces a + * signed URL for a non-existent storage location and 400s with + * "File not found" at stream time. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_file_content( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $file_period = (string) $request->get_param( 'file_period' ); + $encoded_manifest_path = (string) $request->get_param( 'encoded_manifest_path' ); + + // Step 1: resolve the signed stream URL. + $url_response = Client::wpcom_json_api_request_as_user( + sprintf( + '/sites/%d/rewind/backup/%s/file/%s/url', + $blog_id, + rawurlencode( $file_period ), + rawurlencode( $encoded_manifest_path ) + ), + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $url_response ) ) { + return $url_response; + } + + $url_status = wp_remote_retrieve_response_code( $url_response ); + if ( 200 !== $url_status ) { + return new WP_Error( + 'backup_file_content_url_failed', + __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $url_status ) && $url_status > 0 ? $url_status : 500 ) + ); + } + + $url_body = json_decode( wp_remote_retrieve_body( $url_response ), true ); + $signed_url = is_array( $url_body ) && isset( $url_body['url'] ) ? $url_body['url'] : null; + if ( ! $signed_url ) { + return new WP_Error( + 'backup_file_content_url_missing', + __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ), + array( 'status' => 502 ) + ); + } + + // Step 2: fetch the stream body server-side. + $stream_response = wp_remote_get( $signed_url, array( 'timeout' => 15 ) ); + + if ( is_wp_error( $stream_response ) ) { + return $stream_response; + } + + $stream_status = wp_remote_retrieve_response_code( $stream_response ); + if ( 200 !== $stream_status ) { + return new WP_Error( + 'backup_file_content_stream_failed', + __( 'Could not fetch file content.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $stream_status ) && $stream_status > 0 ? $stream_status : 500 ) + ); + } + + $content = wp_remote_retrieve_body( $stream_response ); + + if ( strlen( $content ) > self::PREVIEW_MAX_BYTES ) { + $content = substr( $content, 0, self::PREVIEW_MAX_BYTES ); + } + + return rest_ensure_response( array( 'content' => $content ) ); + } + + /** + * Shared response forwarder for the bridges that just pass through + * WPCOM JSON. Wraps WP_Error / non-200 responses with bridge-level + * error codes the front-end branches on. + * + * @param array|\WP_Error $response The wp_remote_* response. + * @param string $code Error code for non-200. + * @param string $message Translated error message. + * @return \WP_REST_Response|WP_Error + */ + private static function forward_response( $response, $code, $message ) { + if ( is_wp_error( $response ) ) { + return $response; + } + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + $code, + $message, + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) ); + } +} From d8b1caa58f025b5645945b3ca943f63a6a5a6abc Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:53:28 -0300 Subject: [PATCH 05/11] Backup: wire Download to /jetpack/v4/backups/download/$rewindId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useDownload replaces useMockDownload, driving the same idle → submitting → progress → success | error state machine off a TanStack useMutation (initiate) + polled useQuery (status). The bridge proxies WPCOM's /rewind/backups/{rewindId}/prepare-download and /rewind/backups/{rewindId}/downloads/{id} endpoints. The screen now derives its "download point" timestamp directly from the rewindId (it's unix-seconds) instead of resolving it through the activity-log fixture — Download / Restore can stand alone without relying on Overview having populated its cache first. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backup/src/dashboard/data/api/download.ts | 43 +++++ .../src/dashboard/hooks/use-download.ts | 87 +++++++++ .../backup/src/dashboard/screens/download.tsx | 28 ++- .../backup/src/rest/class-download-bridge.php | 168 ++++++++++++++++++ 4 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/data/api/download.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-download.ts create mode 100644 projects/packages/backup/src/rest/class-download-bridge.php diff --git a/projects/packages/backup/src/dashboard/data/api/download.ts b/projects/packages/backup/src/dashboard/data/api/download.ts new file mode 100644 index 000000000000..e494b4d30a30 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/download.ts @@ -0,0 +1,43 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +export type InitiateDownloadResponse = { + id: number; +}; + +export type DownloadStatusResponse = { + url?: string; + valid_until?: string; + progress?: number; + status: 'in-progress' | 'completed' | 'failed' | string; +}; + +/** + * Initiate a backup download. + * + * @param rewindId - The backup's rewind id. + * @return The download id. + */ +export async function initiateDownload( rewindId: string ): Promise< InitiateDownloadResponse > { + return apiCall< InitiateDownloadResponse >( { + path: apiPath( `/backups/download/${ toIntRewindId( rewindId ) }` ), + method: 'POST', + } ); +} + +/** + * Poll status for an in-flight download. + * + * @param rewindId - The backup's rewind id. + * @param downloadId - The download id returned by `initiateDownload`. + * @return The current download state. + */ +export async function fetchDownloadStatus( + rewindId: string, + downloadId: number +): Promise< DownloadStatusResponse > { + return apiCall< DownloadStatusResponse >( { + path: apiPath( `/backups/download/${ toIntRewindId( rewindId ) }/status`, { + download_id: downloadId, + } ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-download.ts b/projects/packages/backup/src/dashboard/hooks/use-download.ts new file mode 100644 index 000000000000..a959fe95e57f --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-download.ts @@ -0,0 +1,87 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { fetchDownloadStatus, initiateDownload } from '../data/api/download'; +import { keys } from '../data/query-client'; + +type DownloadState = + | { phase: 'idle' } + | { phase: 'submitting' } + | { phase: 'progress'; percent: number } + | { phase: 'success'; downloadUrl: string } + | { phase: 'error'; message: string }; + +type Result = { + state: DownloadState; + submit: () => void; + reset: () => void; +}; + +const POLL_INTERVAL_MS = 1500; + +/** + * Real `useDownload` driving the modernized Download screen via the + * `/jetpack/v4/backups/download/$rewindId` bridge. + * + * Submit kicks off the WPCOM `prepare-download` and stores the download + * id; a polled status query then advances the state machine + * idle → submitting → progress → success | error. + * + * @param rewindId - The backup's rewind id. + * @return state + submit + reset. + */ +export function useDownload( rewindId: string ): Result { + const [ downloadId, setDownloadId ] = useState< number | null >( null ); + const [ errorMessage, setErrorMessage ] = useState< string | null >( null ); + + const { + mutate: mutateInitiate, + reset: resetMutation, + isPending: isInitiating, + } = useMutation( { + mutationFn: () => initiateDownload( rewindId ), + onSuccess: result => { + setDownloadId( result.id ); + setErrorMessage( null ); + }, + onError: ( err: Error ) => { + setErrorMessage( err.message ); + }, + } ); + + const effectiveDownloadId = downloadId ?? -1; + const statusQuery = useQuery( { + queryKey: keys.downloadStatus( rewindId, effectiveDownloadId ), + queryFn: () => fetchDownloadStatus( rewindId, effectiveDownloadId ), + enabled: downloadId !== null, + refetchInterval: query => + query.state.data?.status === 'in-progress' ? POLL_INTERVAL_MS : false, + } ); + + const submit = useCallback( () => mutateInitiate(), [ mutateInitiate ] ); + const reset = useCallback( () => { + setDownloadId( null ); + setErrorMessage( null ); + resetMutation(); + }, [ resetMutation ] ); + + let state: DownloadState = { phase: 'idle' }; + if ( errorMessage ) { + state = { phase: 'error', message: errorMessage }; + } else if ( isInitiating ) { + state = { phase: 'submitting' }; + } else if ( statusQuery.data?.status === 'completed' && statusQuery.data.url ) { + state = { phase: 'success', downloadUrl: statusQuery.data.url }; + } else if ( statusQuery.data?.status === 'failed' ) { + state = { phase: 'error', message: __( 'Download failed.', 'jetpack-backup-pkg' ) }; + } else if ( downloadId !== null && statusQuery.data ) { + state = { + phase: 'progress', + percent: Math.round( ( statusQuery.data.progress ?? 0 ) * 100 ), + }; + } else if ( downloadId !== null ) { + state = { phase: 'progress', percent: 0 }; + } + + return { state, submit, reset }; +} diff --git a/projects/packages/backup/src/dashboard/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx index bf4a3f2afebd..182051d8bd44 100644 --- a/projects/packages/backup/src/dashboard/screens/download.tsx +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -7,23 +7,37 @@ import { Link, useParams } from '@wordpress/route'; import { Button, Card, Stack, Text } from '@wordpress/ui'; import DashboardLayout from '../components/dashboard-layout'; import RestoreItemsChecklist from '../components/restore-items-checklist'; -import { findActivityById } from '../fixtures/activity-log'; -import { useMockDownload } from '../hooks/use-mock-download'; +import { useDownload } from '../hooks/use-download'; import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; +/** + * Derive an ISO timestamp for the download-point label from the WPCOM + * rewind id (unix seconds, possibly suffixed with a decimal). + * + * @param rewindId - The rewind id from the URL. + * @return ISO timestamp or null when the id isn't numeric. + */ +function rewindIdToIso( rewindId: string ): string | null { + const seconds = Number.parseInt( rewindId, 10 ); + if ( ! Number.isFinite( seconds ) || seconds <= 0 ) { + return null; + } + return new Date( seconds * 1000 ).toISOString(); +} + /** * Download screen — same narrow layout as the Restore screen minus the - * warning notice. Submission runs through a mocked state machine; the - * success branch surfaces a synthetic download URL as a link. + * warning notice. Submission runs through a real state machine via the + * `/jetpack/v4/backups/download/$rewindId` bridge; the success branch + * surfaces the signed download URL as a link. * * @return The rendered Download screen. */ export default function DownloadScreen() { const { rewindId } = useParams( { from: '/download/$rewindId' } ); - const item = findActivityById( rewindId ); - const downloadPoint = item ? item.publishedAt : null; + const downloadPoint = rewindIdToIso( rewindId ); const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); - const { state, submit, reset } = useMockDownload(); + const { state, submit, reset } = useDownload( rewindId ); return ( diff --git a/projects/packages/backup/src/rest/class-download-bridge.php b/projects/packages/backup/src/rest/class-download-bridge.php new file mode 100644 index 000000000000..d2730e87e54e --- /dev/null +++ b/projects/packages/backup/src/rest/class-download-bridge.php @@ -0,0 +1,168 @@ +[A-Za-z0-9.\-]+)', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'initiate_download' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'types' => array( + 'type' => 'object', + ), + ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/backups/download/(?P[A-Za-z0-9.\-]+)/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_download_status' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'download_id' => array( + 'type' => 'integer', + 'required' => true, + ), + ), + ) + ); + } + + /** + * Initiate a backup download. + * + * Proxies POST wpcom/v2 /sites/{id}/rewind/backups/{rewindId}/prepare-download. + * Returns `{ id }` for the React layer (the WPCOM key is `downloadId`). + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function initiate_download( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $rewind_id = (string) $request->get_param( 'rewind_id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( + '/sites/%d/rewind/backups/%s/prepare-download', + $blog_id, + rawurlencode( $rewind_id ) + ), + 'v2', + array( 'method' => 'POST' ), + array( + 'types' => $request->get_param( 'types' ) ? $request->get_param( 'types' ) : (object) array(), + ), + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'download_initiate_failed', + __( 'Could not start the backup download.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $download_id = is_array( $body ) && isset( $body['downloadId'] ) ? (int) $body['downloadId'] : 0; + if ( ! $download_id ) { + return new WP_Error( + 'download_initiate_failed', + __( 'Download response missing download id.', 'jetpack-backup-pkg' ), + array( 'status' => 500 ) + ); + } + + return rest_ensure_response( array( 'id' => $download_id ) ); + } + + /** + * Poll download status. + * + * Proxies GET wpcom/v2 /sites/{id}/rewind/backups/{rewindId}/downloads/{downloadId}. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_download_status( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $rewind_id = (string) $request->get_param( 'rewind_id' ); + $download_id = (int) $request->get_param( 'download_id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( + '/sites/%d/rewind/backups/%s/downloads/%d', + $blog_id, + rawurlencode( $rewind_id ), + $download_id + ), + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'download_status_fetch_failed', + __( 'Could not fetch download status.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) ); + } +} From 65533c7e8cbd5839ebf243ad4d701d3583235f6a Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:53:43 -0300 Subject: [PATCH 06/11] Backup: wire Restore to /jetpack/v4/rewind/to/$rewindId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useRestore replaces useMockRestore, driving the same state machine off a TanStack useMutation (initiate) + polled useQuery (status). The bridge proxies WPCOM's v1 /activity-log/{site}/rewind/to/{rewindId} and /rewind/{restoreId}/restore-status endpoints, with the verbatim comment explaining why the rewind endpoint must sign as_user (blog-token auth is rejected by WPCOM with "That API call is not allowed for this account"). Same rewindId → unix-seconds derivation as the Download screen, so Restore stands alone without depending on Overview's cache. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backup/src/dashboard/data/api/restore.ts | 39 ++++ .../backup/src/dashboard/hooks/use-restore.ts | 94 +++++++++ .../backup/src/dashboard/screens/restore.tsx | 30 ++- .../backup/src/rest/class-restore-bridge.php | 183 ++++++++++++++++++ 4 files changed, 338 insertions(+), 8 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/data/api/restore.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-restore.ts create mode 100644 projects/packages/backup/src/rest/class-restore-bridge.php diff --git a/projects/packages/backup/src/dashboard/data/api/restore.ts b/projects/packages/backup/src/dashboard/data/api/restore.ts new file mode 100644 index 000000000000..1b29f13e01ff --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/restore.ts @@ -0,0 +1,39 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +export type InitiateRestoreResponse = { + id: number; +}; + +export type RestoreStatusResponse = { + id: number; + status: 'queued' | 'in-progress' | 'finished' | 'failed' | string; + progress: number; + rewind_id: string; + error_code: string; + message: string; +}; + +/** + * Start a restore for the given backup point. + * + * @param rewindId - The backup's rewind id. + * @return The restore id. + */ +export async function initiateRestore( rewindId: string ): Promise< InitiateRestoreResponse > { + return apiCall< InitiateRestoreResponse >( { + path: apiPath( `/rewind/to/${ toIntRewindId( rewindId ) }` ), + method: 'POST', + } ); +} + +/** + * Poll restore status. + * + * @param restoreId - The restore id returned by `initiateRestore`. + * @return The current restore state. + */ +export async function fetchRestoreStatus( restoreId: number ): Promise< RestoreStatusResponse > { + return apiCall< RestoreStatusResponse >( { + path: apiPath( `/rewind/restore/${ restoreId }/status` ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-restore.ts new file mode 100644 index 000000000000..526477241e17 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -0,0 +1,94 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { fetchRestoreStatus, initiateRestore } from '../data/api/restore'; +import { keys } from '../data/query-client'; +import type { RestoreState } from '../types/restore'; + +type Result = { + state: RestoreState; + submit: () => void; + reset: () => void; +}; + +const POLL_INTERVAL_MS = 1500; + +/** + * Real `useRestore` driving the modernized Restore screen via the + * `/jetpack/v4/rewind/to/$rewindId` bridge. + * + * Submit kicks off the WPCOM rewind initiate and stores the restore id; + * a polled status query then advances the state machine + * idle → submitting → progress → success | error. + * + * @param rewindId - The backup's rewind id. + * @return state + submit + reset. + */ +export function useRestore( rewindId: string ): Result { + const [ restoreId, setRestoreId ] = useState< number | null >( null ); + const [ errorMessage, setErrorMessage ] = useState< string | null >( null ); + + // Destructure to keep stable references for useCallback deps — + // passing the whole `mutation` object trips + // @tanstack/query/no-unstable-deps. + const { + mutate: kickOff, + reset: resetMutation, + isPending, + } = useMutation( { + mutationFn: () => initiateRestore( rewindId ), + onSuccess: result => { + setRestoreId( result.id ); + setErrorMessage( null ); + }, + onError: ( err: Error ) => { + setErrorMessage( err.message ); + }, + } ); + + // Always-defined query key so @tanstack/query/exhaustive-deps stays + // happy; the `enabled` flag keeps the placeholder query from firing. + const effectiveRestoreId = restoreId ?? -1; + const statusQuery = useQuery( { + queryKey: keys.restoreStatus( effectiveRestoreId ), + queryFn: () => fetchRestoreStatus( effectiveRestoreId ), + enabled: restoreId !== null, + refetchInterval: query => + query.state.data?.status === 'in-progress' || query.state.data?.status === 'queued' + ? POLL_INTERVAL_MS + : false, + } ); + + const submit = useCallback( () => kickOff(), [ kickOff ] ); + const reset = useCallback( () => { + setRestoreId( null ); + setErrorMessage( null ); + resetMutation(); + }, [ resetMutation ] ); + + let state: RestoreState = { phase: 'idle' }; + if ( errorMessage ) { + state = { phase: 'error', message: errorMessage }; + } else if ( isPending ) { + state = { phase: 'submitting' }; + } else if ( statusQuery.data?.status === 'finished' ) { + state = { phase: 'success' }; + } else if ( statusQuery.data?.status === 'failed' ) { + state = { + phase: 'error', + message: + statusQuery.data.message || + statusQuery.data.error_code || + __( 'Restore failed.', 'jetpack-backup-pkg' ), + }; + } else if ( restoreId !== null && statusQuery.data ) { + state = { + phase: 'progress', + percent: Math.round( statusQuery.data.progress ?? 0 ), + }; + } else if ( restoreId !== null ) { + state = { phase: 'progress', percent: 0 }; + } + + return { state, submit, reset }; +} diff --git a/projects/packages/backup/src/dashboard/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx index 8a1e03a30446..0ad62504c473 100644 --- a/projects/packages/backup/src/dashboard/screens/restore.tsx +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -7,24 +7,38 @@ import { Link, useParams } from '@wordpress/route'; import { Button, Card, Stack, Text } from '@wordpress/ui'; import DashboardLayout from '../components/dashboard-layout'; import RestoreItemsChecklist from '../components/restore-items-checklist'; -import { findActivityById } from '../fixtures/activity-log'; -import { useMockRestore } from '../hooks/use-mock-restore'; +import { useRestore } from '../hooks/use-restore'; import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; +/** + * Derive an ISO timestamp for the restore-point label from the WPCOM + * rewind id. WPCOM rewind ids are unix-seconds (with an optional decimal + * suffix), so we don't need to look the activity row up in cache just + * to render "Restore point: …". + * + * @param rewindId - The rewind id from the URL. + * @return ISO timestamp or null when the id isn't numeric. + */ +function rewindIdToIso( rewindId: string ): string | null { + const seconds = Number.parseInt( rewindId, 10 ); + if ( ! Number.isFinite( seconds ) || seconds <= 0 ) { + return null; + } + return new Date( seconds * 1000 ).toISOString(); +} + /** * Restore screen — narrow centered layout with the warning notice, the - * shared item checklist, and a Confirm button. Submit transitions - * through a synthetic state machine; ~10% of submits land in the error - * branch. + * shared item checklist, and a Confirm button. Submit drives a real + * state machine over the `/jetpack/v4/rewind/to/$rewindId` bridge. * * @return The rendered Restore screen. */ export default function RestoreScreen() { const { rewindId } = useParams( { from: '/restore/$rewindId' } ); - const item = findActivityById( rewindId ); - const restorePoint = item ? item.publishedAt : null; + const restorePoint = rewindIdToIso( rewindId ); const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); - const { state, submit, reset } = useMockRestore(); + const { state, submit, reset } = useRestore( rewindId ); return ( diff --git a/projects/packages/backup/src/rest/class-restore-bridge.php b/projects/packages/backup/src/rest/class-restore-bridge.php new file mode 100644 index 000000000000..36d3102194b8 --- /dev/null +++ b/projects/packages/backup/src/rest/class-restore-bridge.php @@ -0,0 +1,183 @@ +[A-Za-z0-9.\-]+)', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'initiate_restore' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'types' => array( + 'type' => 'object', + ), + ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/rewind/restore/(?P\d+)/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_restore_status' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'restore_id' => array( + 'type' => 'integer', + 'required' => true, + ), + ), + ) + ); + } + + /** + * Initiate a restore. + * + * Proxies POST rest/v1 /activity-log/{site}/rewind/to/{rewindId}. + * + * Sign as the user: rewind is a destructive admin action and + * WPCOM's audit log identifies the actor by their wpcom user. + * (We tried as_blog v1.1 to side-step user-permission + * requirements; WPCOM rejects it with `That API call is not + * allowed for this account.` — the rewind endpoint refuses + * blog-token auth entirely.) + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function initiate_restore( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $rewind_id = (string) $request->get_param( 'rewind_id' ); + $types = $request->get_param( 'types' ); + + $body = array( + 'force_rewind' => true, + 'types' => is_array( $types ) || is_object( $types ) ? $types : (object) array(), + ); + + // `base_api_path` defaults to 'wpcom' on `as_user`, but the + // activity-log endpoints live under `rest/v1/...` + // (https://public-api.wordpress.com/rest/v1/activity-log/...). + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/activity-log/%d/rewind/to/%s', $blog_id, rawurlencode( $rewind_id ) ), + '1', + array( 'method' => 'POST' ), + $body, + 'rest' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + $upstream = json_decode( wp_remote_retrieve_body( $response ), true ); + return new WP_Error( + 'restore_initiate_failed', + __( 'Could not start the backup restore.', 'jetpack-backup-pkg' ), + array( + 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500, + 'upstream' => is_array( $upstream ) ? $upstream : null, + ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $restore_id_in = is_array( $body ) && isset( $body['restore_id'] ) ? (int) $body['restore_id'] : 0; + if ( ! $restore_id_in ) { + return new WP_Error( + 'restore_initiate_failed', + __( 'Restore response missing restore id.', 'jetpack-backup-pkg' ), + array( 'status' => 500 ) + ); + } + return rest_ensure_response( array( 'id' => $restore_id_in ) ); + } + + /** + * Poll restore status. + * + * Proxies GET rest/v1 /activity-log/{site}/rewind/{restore_id}/restore-status. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_restore_status( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $restore_id = (int) $request->get_param( 'restore_id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/activity-log/%d/rewind/%d/restore-status', $blog_id, $restore_id ), + '1', + array(), + null, + 'rest' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'restore_status_fetch_failed', + __( 'Could not fetch restore progress.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $status = is_array( $body ) && isset( $body['restore_status'] ) && is_array( $body['restore_status'] ) + ? $body['restore_status'] + : ( is_array( $body ) ? $body : array() ); + + return rest_ensure_response( + array( + 'id' => isset( $status['restore_id'] ) ? (int) $status['restore_id'] : $restore_id, + 'status' => isset( $status['status'] ) ? (string) $status['status'] : 'queued', + 'progress' => isset( $status['percent'] ) ? (float) $status['percent'] : 0, + 'rewind_id' => isset( $status['rewind_id'] ) ? (string) $status['rewind_id'] : '', + 'error_code' => isset( $status['error_code'] ) ? (string) $status['error_code'] : '', + 'message' => isset( $status['message'] ) ? (string) $status['message'] : '', + ) + ); + } +} From b9ae6c6a062e00849dcb09cd780d4b0ead44a0b8 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:54:20 -0300 Subject: [PATCH 07/11] Backup: drop the ?jpb-mock=1 dev-mode banner now that everything is real Removes the last mock-mode artifacts: useIsMockMode, the dev banner component, the four mock hooks, the three fixture files, and the mount inside DashboardLayout. The modernized Backup dashboard now only renders real data via the jetpack/v4/* bridges, with handling the not-connected and no-plan fallbacks. This completes Phase 2 of the Backup modernization. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/dashboard-layout/index.tsx | 2 - .../components/dev-mode-banner/index.tsx | 26 ------ .../components/dev-mode-banner/style.scss | 9 -- .../src/dashboard/fixtures/activity-log.ts | 89 ------------------- .../src/dashboard/fixtures/file-contents.ts | 47 ---------- .../src/dashboard/fixtures/file-tree.ts | 81 ----------------- .../src/dashboard/hooks/use-is-mock-mode.ts | 21 ----- .../dashboard/hooks/use-mock-activity-log.ts | 87 ------------------ .../src/dashboard/hooks/use-mock-download.ts | 82 ----------------- .../src/dashboard/hooks/use-mock-file-tree.ts | 47 ---------- .../src/dashboard/hooks/use-mock-restore.ts | 75 ---------------- 11 files changed, 566 deletions(-) delete mode 100644 projects/packages/backup/src/dashboard/components/dev-mode-banner/index.tsx delete mode 100644 projects/packages/backup/src/dashboard/components/dev-mode-banner/style.scss delete mode 100644 projects/packages/backup/src/dashboard/fixtures/activity-log.ts delete mode 100644 projects/packages/backup/src/dashboard/fixtures/file-contents.ts delete mode 100644 projects/packages/backup/src/dashboard/fixtures/file-tree.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-download.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-restore.ts diff --git a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx index 5d981f6cb4cb..0fb1189953d8 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -3,7 +3,6 @@ import JetpackLogo from '@automattic/jetpack-components/jetpack-logo'; import { Page } from '@wordpress/admin-ui'; import { __ } from '@wordpress/i18n'; import QueryClientProvider from '../../providers/query-client-provider'; -import DevModeBanner from '../dev-mode-banner'; import Gates from '../gates'; import './style.scss'; import type { ReactNode } from 'react'; @@ -47,7 +46,6 @@ export default function DashboardLayout( { children, actions }: Props ) { actions={ actions } > -
{ children } diff --git a/projects/packages/backup/src/dashboard/components/dev-mode-banner/index.tsx b/projects/packages/backup/src/dashboard/components/dev-mode-banner/index.tsx deleted file mode 100644 index 78de887bae39..000000000000 --- a/projects/packages/backup/src/dashboard/components/dev-mode-banner/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { useIsMockMode } from '../../hooks/use-is-mock-mode'; -import './style.scss'; - -/** - * Banner shown above the dashboard body when fixture/mock data is active. - * - * Renders nothing in normal mode; only appears when the URL has `?jpb-mock=1` - * so contributors can tell at a glance that the page is not making real requests. - * - * @return The rendered banner, or `null` when mock mode is off. - */ -export default function DevModeBanner() { - const isMock = useIsMockMode(); - if ( ! isMock ) { - return null; - } - return ( -
- { __( - "Dev mode: the backup list below is fixture data ('?jpb-mock=1'). No real requests are being made.", - 'jetpack-backup-pkg' - ) } -
- ); -} diff --git a/projects/packages/backup/src/dashboard/components/dev-mode-banner/style.scss b/projects/packages/backup/src/dashboard/components/dev-mode-banner/style.scss deleted file mode 100644 index cf346aab01b9..000000000000 --- a/projects/packages/backup/src/dashboard/components/dev-mode-banner/style.scss +++ /dev/null @@ -1,9 +0,0 @@ -.jpb-dev-mode-banner { - background-color: #fff8e1; - color: #4a3a06; - text-align: center; - padding: 12px 16px; - font-size: 13px; - line-height: 1.4; - border-bottom: 1px solid #f0e7c2; -} diff --git a/projects/packages/backup/src/dashboard/fixtures/activity-log.ts b/projects/packages/backup/src/dashboard/fixtures/activity-log.ts deleted file mode 100644 index 613e998c270f..000000000000 --- a/projects/packages/backup/src/dashboard/fixtures/activity-log.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { ActivityItem } from '../types/activity'; - -const BASE_DATE = new Date( '2026-05-15T12:26:00.000Z' ); -const DAY_MS = 24 * 60 * 60 * 1000; -const HOUR_MS = 60 * 60 * 1000; - -const at = ( daysAgo: number, hoursAgo = 0 ): string => - new Date( BASE_DATE.getTime() - daysAgo * DAY_MS - hoursAgo * HOUR_MS ).toISOString(); - -const jetpack = { type: 'Application' as const, name: 'Jetpack' }; -const totoro = { type: 'Person' as const, name: 'Totoro' }; - -const backup = ( offsetDays: number ): ActivityItem => ( { - id: `backup-${ offsetDays }`, - kind: 'backup', - title: 'Backup and scan complete by Jetpack', - publishedAt: at( offsetDays ), - actor: jetpack, - summary: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page', - rewindId: String( Math.floor( ( BASE_DATE.getTime() - offsetDays * DAY_MS ) / 1000 ) ), - stats: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page', - isComplete: true, -} ); - -export const MOCK_ACTIVITY_LOG: ActivityItem[] = [ - backup( 0 ), - { - id: 'upload-1', - kind: 'upload', - title: '1 image uploaded by Totoro', - publishedAt: at( 0.95 ), - actor: totoro, - summary: 'cat.png', - }, - { - id: 'post-1', - kind: 'post', - title: 'Post published by Totoro', - publishedAt: at( 1 ), - actor: totoro, - summary: 'The Perks of Having a Cat', - }, - backup( 1 ), - backup( 2 ), - backup( 3 ), - { - id: 'plugin-1', - kind: 'plugin-update', - title: 'Jetpack 15.2 plugin updated by Jetpack', - publishedAt: at( 4, 10 ), - actor: jetpack, - summary: 'Jetpack 15.2', - }, - backup( 4 ), - backup( 5 ), - backup( 6 ), - backup( 7 ), - backup( 8 ), - backup( 9 ), - { - id: 'theme-1', - kind: 'theme-update', - title: 'Twenty Twenty-Five theme updated by Totoro', - publishedAt: at( 10, 6 ), - actor: totoro, - summary: 'Twenty Twenty-Five 1.2', - }, - backup( 10 ), - backup( 11 ), - backup( 12 ), - backup( 13 ), - backup( 14 ), -]; - -/** - * Look up an activity item by the id used in the URL's `?selected=` param. - * - * Backup items match on `rewindId`; non-backup items match on `id`. - * - * @param id - Selection id from the URL. - * @return The matching activity item, or `null` if no row matches. - */ -export function findActivityById( id: string ): ActivityItem | null { - return ( - MOCK_ACTIVITY_LOG.find( item => - item.kind === 'backup' ? item.rewindId === id : item.id === id - ) ?? null - ); -} diff --git a/projects/packages/backup/src/dashboard/fixtures/file-contents.ts b/projects/packages/backup/src/dashboard/fixtures/file-contents.ts deleted file mode 100644 index 91bbe567a4e4..000000000000 --- a/projects/packages/backup/src/dashboard/fixtures/file-contents.ts +++ /dev/null @@ -1,47 +0,0 @@ -const WP_CONFIG = ` = { - '/wp-config.php': WP_CONFIG, - '/wp-content/themes/twentytwentyfive/style.css': STYLE_CSS, - '/sql/database.sql': DATABASE_SQL, -}; - -/** - * Returns the mocked text contents for a path, or null when no fixture is registered. - * - * @param path - Absolute path under the backup. - * @return The text contents, or null. - */ -export function findContents( path: string ): string | null { - return MOCK_FILE_CONTENTS[ path ] ?? null; -} diff --git a/projects/packages/backup/src/dashboard/fixtures/file-tree.ts b/projects/packages/backup/src/dashboard/fixtures/file-tree.ts deleted file mode 100644 index cfb137093626..000000000000 --- a/projects/packages/backup/src/dashboard/fixtures/file-tree.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { isFolder } from '../types/file-tree'; -import type { FileNode } from '../types/file-tree'; - -// Stable timestamps + hashes so snapshot tests can't flake on -// import-time clock reads. The exact values mirror the kind of -// metadata the future REST path-info call will return. -const FIXTURE_LAST_MODIFIED = '2026-02-16T20:42:00.000Z'; - -const file = ( - name: string, - path: string, - sizeBytes: number, - mimeType: string, - hash = 'ce4fc8526a348484a88bba63b08c0976' -): FileNode => ( { - type: 'file', - name, - path, - sizeBytes, - mimeType, - lastModified: FIXTURE_LAST_MODIFIED, - hash, -} ); - -const folder = ( name: string, path: string, children?: FileNode[] ): FileNode => ( { - type: 'folder', - name, - path, - children, -} ); - -export const MOCK_FILE_TREE: FileNode[] = [ - folder( 'wp-content', '/wp-content', [ - folder( 'themes', '/wp-content/themes', [ - folder( 'twentytwentyfive', '/wp-content/themes/twentytwentyfive', [ - file( 'style.css', '/wp-content/themes/twentytwentyfive/style.css', 4_812, 'text/css' ), - ] ), - ] ), - folder( 'plugins', '/wp-content/plugins', [ - folder( 'jetpack', '/wp-content/plugins/jetpack', [ - file( - 'jetpack.php', - '/wp-content/plugins/jetpack/jetpack.php', - 12_034, - 'application/x-php' - ), - ] ), - ] ), - folder( 'uploads', '/wp-content/uploads', [ - file( 'cat.png', '/wp-content/uploads/cat.png', 188_240, 'image/png' ), - ] ), - ] ), - folder( 'sql', '/sql', [ - file( 'database.sql', '/sql/database.sql', 2_485_120, 'application/sql' ), - ] ), - file( 'wp-config.php', '/wp-config.php', 3_312, 'application/x-php' ), -]; - -/** - * Recursively searches `MOCK_FILE_TREE` for a node at the given path. - * - * @param path - Absolute path under the backup (e.g. `'/wp-content/uploads'`). - * @return The matching node, or null when no node is found. - */ -export function findNodeByPath( path: string ): FileNode | null { - const walk = ( nodes: FileNode[] ): FileNode | null => { - for ( const node of nodes ) { - if ( node.path === path ) { - return node; - } - if ( isFolder( node ) && node.children ) { - const found = walk( node.children ); - if ( found ) { - return found; - } - } - } - return null; - }; - return walk( MOCK_FILE_TREE ); -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts b/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts deleted file mode 100644 index 6bdf4f21a0db..000000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts +++ /dev/null @@ -1,21 +0,0 @@ -const MOCK_URL_PARAM = 'jpb-mock'; - -/** - * Hook returning whether the dashboard should use fixture data. - * - * Reads the `?jpb-mock` query parameter from the current URL on every call, - * so client-side navigation that drops the param (e.g. a `` - * back to the overview) flips the answer back to `false` as expected. - * - * @return True when mock mode is active (`?jpb-mock=1`). - */ -export function useIsMockMode(): boolean { - if ( typeof window === 'undefined' ) { - return false; - } - try { - return new URLSearchParams( window.location.search ).has( MOCK_URL_PARAM ); - } catch { - return false; - } -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts deleted file mode 100644 index a4eb35aa05ae..000000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; -import { MOCK_ACTIVITY_LOG } from '../fixtures/activity-log'; -import type { ActivityItem } from '../types/activity'; - -const INITIAL_LOAD_MS = 600; -const PAGE_CHANGE_MS = 150; - -type Args = { - page: number; - pageSize: number; - search: string; -}; - -type Result = { - items: ActivityItem[]; - totalItems: number; - totalPages: number; - isLoading: boolean; - error: Error | null; -}; - -/** - * Predicate that returns true when the given activity item matches the search query. - * - * Matches case-insensitively against the item's title and optional summary — - * the same surface the real `useActivityLog` hook in the data-layer follow-up - * matches against, so swapping the import line preserves search behaviour. - * - * @param item - Activity item to test. - * @param q - Search query (raw, untrimmed). - * @return True when the item matches the query. - */ -function matchesSearch( item: ActivityItem, q: string ): boolean { - if ( ! q ) { - return true; - } - const haystack = `${ item.title } ${ item.summary ?? '' }`.toLowerCase(); - return haystack.includes( q.toLowerCase() ); -} - -/** - * Hook returning a paginated, search-filtered slice of the mock activity log. - * - * Mirrors the shape of the real `useActivityLog` hook (same `Args`, same - * `Result` keys) so the data-layer follow-up can swap the import line - * without touching the consumer. - * - * Adds synthetic latency so the dashboard exercises its loading states even - * with fixture data: a longer delay on first load, a shorter one on every - * page / pageSize / search change after that. - * - * @param args - Query arguments. - * @param args.page - 1-indexed page number. - * @param args.pageSize - Number of items per page. - * @param args.search - Current search query. - * @return The current page of items, total item / page counts, loading flag, and a (mock-always-null) error. - */ -export function useMockActivityLog( { page, pageSize, search }: Args ): Result { - // Tracked in a ref (not state) so settling the first load doesn't itself - // retrigger the effect — a useState here causes a visible spinner blink - // right after the initial 600ms load, with no user input. - const firstLoadDoneRef = useRef( false ); - const [ isLoading, setIsLoading ] = useState( true ); - - useEffect( () => { - const delay = firstLoadDoneRef.current ? PAGE_CHANGE_MS : INITIAL_LOAD_MS; - setIsLoading( true ); - const handle = window.setTimeout( () => { - setIsLoading( false ); - firstLoadDoneRef.current = true; - }, delay ); - return () => window.clearTimeout( handle ); - }, [ page, pageSize, search ] ); - - const { items, totalItems, totalPages } = useMemo( () => { - const filtered = MOCK_ACTIVITY_LOG.filter( item => matchesSearch( item, search ) ); - const total = Math.max( 1, Math.ceil( filtered.length / pageSize ) ); - const start = ( page - 1 ) * pageSize; - return { - items: filtered.slice( start, start + pageSize ), - totalItems: filtered.length, - totalPages: total, - }; - }, [ page, pageSize, search ] ); - - return { items, totalItems, totalPages, isLoading, error: null }; -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-mock-download.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-download.ts deleted file mode 100644 index 2cab0fe7501b..000000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-mock-download.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; - -type DownloadState = - | { phase: 'idle' } - | { phase: 'submitting' } - | { phase: 'progress'; percent: number } - | { phase: 'success'; downloadUrl: string } - | { phase: 'error'; message: string }; - -const SUBMIT_DELAY_MS = 500; -const PROGRESS_TICK_MS = 250; -const PROGRESS_STEPS = 12; -const ERROR_CHANCE = 0.1; - -type Result = { - state: DownloadState; - submit: () => void; - reset: () => void; -}; - -/** - * Mocked Download state machine. Mirrors `useMockRestore` but the success - * state surfaces a synthetic download URL the UI can render as a link. - * - * Calling `submit()` advances through `submitting → progress → success | error` - * on synthetic timers; ~10% of submits land in the error branch. `reset()` - * returns to `idle` and cancels any in-flight timers. - * - * @return The current state plus `submit` / `reset` callbacks. - */ -export function useMockDownload(): Result { - const [ state, setState ] = useState< DownloadState >( { phase: 'idle' } ); - const timers = useRef< number[] >( [] ); - - const clearTimers = useCallback( () => { - timers.current.forEach( h => window.clearTimeout( h ) ); - timers.current = []; - }, [] ); - - useEffect( () => clearTimers, [ clearTimers ] ); - - const reset = useCallback( () => { - clearTimers(); - setState( { phase: 'idle' } ); - }, [ clearTimers ] ); - - const submit = useCallback( () => { - clearTimers(); - setState( { phase: 'submitting' } ); - const submittingHandle = window.setTimeout( () => { - if ( Math.random() < ERROR_CHANCE ) { - setState( { - phase: 'error', - message: __( - 'The download service returned an error. Try again in a moment.', - 'jetpack-backup-pkg' - ), - } ); - return; - } - let step = 0; - setState( { phase: 'progress', percent: 0 } ); - const tick = () => { - step += 1; - if ( step >= PROGRESS_STEPS ) { - setState( { phase: 'success', downloadUrl: '#mock-download-url' } ); - return; - } - const percent = Math.round( ( step / PROGRESS_STEPS ) * 100 ); - setState( { phase: 'progress', percent } ); - const next = window.setTimeout( tick, PROGRESS_TICK_MS ); - timers.current.push( next ); - }; - const first = window.setTimeout( tick, PROGRESS_TICK_MS ); - timers.current.push( first ); - }, SUBMIT_DELAY_MS ); - timers.current.push( submittingHandle ); - }, [ clearTimers ] ); - - return { state, submit, reset }; -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts deleted file mode 100644 index 132b30405454..000000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect, useState } from '@wordpress/element'; -import { findNodeByPath } from '../fixtures/file-tree'; -import { isFolder } from '../types/file-tree'; -import type { FileNode } from '../types/file-tree'; - -const FOLDER_LOAD_MS = 300; - -type Result = { - children: FileNode[] | null; - isLoading: boolean; -}; - -/** - * Hook returning the lazily-loaded children of a folder in the mock - * file tree. Passing a folder path resolves the children after a 300ms - * synthetic delay so the tree exercises its per-folder loading state; - * passing `null` no-ops (keeps the previously-loaded children cached - * so a caller that just collapsed a folder doesn't lose them). - * - * Callers that need the top-level roots should import `MOCK_FILE_TREE` - * directly — this hook only handles non-root lazy loads so a row that - * passes `null` (e.g. a collapsed folder) doesn't accidentally see the - * root tree as its own children. - * - * @param folderPath - Folder path to load, or null to leave state alone. - * @return Loaded children + loading flag. - */ -export function useMockFileTree( folderPath: string | null ): Result { - const [ isLoading, setIsLoading ] = useState( false ); - const [ children, setChildren ] = useState< FileNode[] | null >( null ); - - useEffect( () => { - if ( folderPath === null ) { - setIsLoading( false ); - return; - } - setIsLoading( true ); - const handle = window.setTimeout( () => { - const node = findNodeByPath( folderPath ); - setChildren( node && isFolder( node ) ? node.children ?? [] : [] ); - setIsLoading( false ); - }, FOLDER_LOAD_MS ); - return () => window.clearTimeout( handle ); - }, [ folderPath ] ); - - return { children, isLoading }; -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-mock-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-restore.ts deleted file mode 100644 index 429d0a6ba930..000000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-mock-restore.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import type { RestoreState } from '../types/restore'; - -const SUBMIT_DELAY_MS = 500; -const PROGRESS_TICK_MS = 250; -const PROGRESS_STEPS = 12; -const ERROR_CHANCE = 0.1; - -type Result = { - state: RestoreState; - submit: () => void; - reset: () => void; -}; - -/** - * Mocked Restore state machine. - * - * Calling `submit()` advances through `submitting → progress → success | error` - * on synthetic timers; ~10% of submits land in the error branch so the - * error UI is reachable without code changes. `reset()` returns to `idle`. - * - * @return The current state plus `submit` / `reset` callbacks. - */ -export function useMockRestore(): Result { - const [ state, setState ] = useState< RestoreState >( { phase: 'idle' } ); - const timers = useRef< number[] >( [] ); - - const clearTimers = useCallback( () => { - timers.current.forEach( h => window.clearTimeout( h ) ); - timers.current = []; - }, [] ); - - useEffect( () => clearTimers, [ clearTimers ] ); - - const reset = useCallback( () => { - clearTimers(); - setState( { phase: 'idle' } ); - }, [ clearTimers ] ); - - const submit = useCallback( () => { - clearTimers(); - setState( { phase: 'submitting' } ); - const submittingHandle = window.setTimeout( () => { - if ( Math.random() < ERROR_CHANCE ) { - setState( { - phase: 'error', - message: __( - 'The backup service returned an error. Wait a moment and try again.', - 'jetpack-backup-pkg' - ), - } ); - return; - } - let step = 0; - setState( { phase: 'progress', percent: 0 } ); - const tick = () => { - step += 1; - if ( step >= PROGRESS_STEPS ) { - setState( { phase: 'success' } ); - return; - } - const percent = Math.round( ( step / PROGRESS_STEPS ) * 100 ); - setState( { phase: 'progress', percent } ); - const next = window.setTimeout( tick, PROGRESS_TICK_MS ); - timers.current.push( next ); - }; - const first = window.setTimeout( tick, PROGRESS_TICK_MS ); - timers.current.push( first ); - }, SUBMIT_DELAY_MS ); - timers.current.push( submittingHandle ); - }, [ clearTimers ] ); - - return { state, submit, reset }; -} From 764f18bb69644d254268614d668ab650612c8805 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:54:33 -0300 Subject: [PATCH 08/11] Backup: phpunit smoke tests for the modernized REST bridges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One test class per bridge: - Rest_Capabilities_Bridge_Test (2 tests, 2 assertions) - Rest_Activity_Log_Bridge_Test (2 tests, 2 assertions) - Rest_File_Browser_Bridge_Test (4 tests, 4 assertions) - Rest_Download_Bridge_Test (3 tests, 4 assertions) - Rest_Restore_Bridge_Test (3 tests, 4 assertions) Each class confirms the routes only register when the modernization filter is on and that the manage_options permission_check rejects subscriber-level access with a 403. Upstream WPCOM calls aren't exercised here — they need network and a real connection. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../php/Rest_Activity_Log_Bridge_Test.php | 93 ++++++++++++++++ .../php/Rest_Capabilities_Bridge_Test.php | 93 ++++++++++++++++ .../tests/php/Rest_Download_Bridge_Test.php | 105 ++++++++++++++++++ .../php/Rest_File_Browser_Bridge_Test.php | 103 +++++++++++++++++ .../tests/php/Rest_Restore_Bridge_Test.php | 104 +++++++++++++++++ 5 files changed, 498 insertions(+) create mode 100644 projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php create mode 100644 projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php create mode 100644 projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php create mode 100644 projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php create mode 100644 projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php diff --git a/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php new file mode 100644 index 000000000000..fcfc6d3a07a1 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php @@ -0,0 +1,93 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * Route registers when the modernization filter is on. + */ + public function test_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/activity-log', $routes ); + } + + /** + * Route is gated on manage_options — a subscriber gets 403. + */ + public function test_route_requires_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/activity-log' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php new file mode 100644 index 000000000000..8d52e5bc4a24 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php @@ -0,0 +1,93 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * Route registers when the modernization filter is on. + */ + public function test_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/site/capabilities', $routes ); + } + + /** + * Route is gated on manage_options — a subscriber gets 403. + */ + public function test_route_requires_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/site/capabilities' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php new file mode 100644 index 000000000000..6a205adc9a48 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php @@ -0,0 +1,105 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * The initiate route registers when the modernization filter is on. + */ + public function test_initiate_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/backups/download/(?P[A-Za-z0-9.\-]+)', $routes ); + } + + /** + * The status route registers when the modernization filter is on. + */ + public function test_status_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/backups/download/(?P[A-Za-z0-9.\-]+)/status', $routes ); + } + + /** + * Routes are gated on manage_options — a subscriber gets 403. + */ + public function test_routes_require_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $initiate_request = new WP_REST_Request( 'POST', '/jetpack/v4/backups/download/123' ); + $initiate_response = $this->server->dispatch( $initiate_request ); + $this->assertSame( 403, $initiate_response->get_status() ); + + $status_request = new WP_REST_Request( 'GET', '/jetpack/v4/backups/download/123/status' ); + $status_request->set_param( 'download_id', 1 ); + $status_response = $this->server->dispatch( $status_request ); + $this->assertSame( 403, $status_response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php new file mode 100644 index 000000000000..4e4d0266e751 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php @@ -0,0 +1,103 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * The ls route registers when the modernization filter is on. + */ + public function test_ls_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/backup/ls', $routes ); + } + + /** + * The file-content route registers when the modernization filter is on. + */ + public function test_file_content_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/backup/file-content', $routes ); + } + + /** + * Routes are gated on manage_options — a subscriber gets 403. + */ + public function test_routes_require_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/rewind/backup/file-content' ); + $request->set_param( 'file_period', '123' ); + $request->set_param( 'encoded_manifest_path', 'abc' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php new file mode 100644 index 000000000000..3ee534be5095 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php @@ -0,0 +1,104 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * The initiate route registers when the modernization filter is on. + */ + public function test_initiate_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/to/(?P[A-Za-z0-9.\-]+)', $routes ); + } + + /** + * The status route registers when the modernization filter is on. + */ + public function test_status_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/restore/(?P\d+)/status', $routes ); + } + + /** + * Routes are gated on manage_options — a subscriber gets 403. + */ + public function test_routes_require_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $initiate_request = new WP_REST_Request( 'POST', '/jetpack/v4/rewind/to/123' ); + $initiate_response = $this->server->dispatch( $initiate_request ); + $this->assertSame( 403, $initiate_response->get_status() ); + + $status_request = new WP_REST_Request( 'GET', '/jetpack/v4/rewind/restore/1/status' ); + $status_response = $this->server->dispatch( $status_request ); + $this->assertSame( 403, $status_response->get_status() ); + } +} From d5a155399b03aa0f89b5942b56fbaa388551f613 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sun, 17 May 2026 01:00:06 -0300 Subject: [PATCH 09/11] Backup: address PR review feedback on the modernized data layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST bridges: - Centralize the not-connected check via Rest_Controller::get_blog_id_or_error so every bridge short-circuits with a 412 instead of building /sites/0/… - Require a user-level WPCOM connection in Rest_Controller::permission_check, matching the jetpack-activity-log package's pattern - Rename Activity_Log_Bridge's route from /jetpack/v4/activity-log → /jetpack/v4/site/rewindable-activity to avoid colliding with the jetpack-activity-log package, which owns that route inside the Jetpack plugin - Cap File_Browser_Bridge's preview proxy at the HTTP transport layer (limit_response_size) so multi-GB blobs can't be buffered into PHP memory before truncation, and validate the signed URL with wp_http_validate_url - Drop the raw upstream WPCOM body from Restore_Bridge's WP_Error envelope — the React layer only reads err.message React layer: - Convert getCachedActivityById / getCachedDefaultBackupRewindId to useQuery-backed hooks so Overview's default selection reconciles to the newest backup the moment the activity-log window resolves - Collapse the activityLog cache key to a single shared activityLogWindow key the hook always uses - Drop the raw error_code fallback in useRestore — never surface machine identifiers like "checksum_mismatch" to users - Refresh stale doc comments (DashboardLayout no longer mentions the dev-mode banner; FileBrowser references useFileTree instead of useMockFileTree) Changelog: - Align the Jetpack plugin entry with Phase 1: Type: other for the flag-gated rollout Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/dashboard-layout/index.tsx | 6 +- .../components/file-browser/index.tsx | 4 +- .../src/dashboard/data/api/activity-log.ts | 2 +- .../backup/src/dashboard/data/query-client.ts | 7 +- .../src/dashboard/hooks/use-activity-log.ts | 81 +++++++++++-------- .../backup/src/dashboard/hooks/use-restore.ts | 8 +- .../backup/src/dashboard/screens/overview.tsx | 15 ++-- .../src/rest/class-activity-log-bridge.php | 14 +++- .../src/rest/class-capabilities-bridge.php | 11 +-- .../backup/src/rest/class-download-bridge.php | 11 ++- .../src/rest/class-file-browser-bridge.php | 39 ++++++--- .../backup/src/rest/class-rest-controller.php | 45 ++++++++++- .../backup/src/rest/class-restore-bridge.php | 17 ++-- .../php/Rest_Activity_Log_Bridge_Test.php | 6 +- .../wire-backup-modernization-data-layer | 2 +- 15 files changed, 177 insertions(+), 91 deletions(-) diff --git a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx index 0fb1189953d8..129c9b8581d3 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -18,9 +18,9 @@ const PRODUCT_NAME = 'VaultPress Backup'; // Product name; do not translate. * Shared shell for every screen of the modernized Backup dashboard. * * Wraps a `` from `@wordpress/admin-ui` (the standard wp-admin - * chrome) with a Jetpack logo in the `visual` slot, the dev-mode banner, - * the page body, and `` at the bottom — matching every - * other modernized Jetpack dashboard (Newsletter, VideoPress, Forms). + * chrome) with a Jetpack logo in the `visual` slot, the page body, and + * `` at the bottom — matching every other modernized + * Jetpack dashboard (Newsletter, VideoPress, Forms). * * The page is laid out so the body grows to fill the viewport and the * footer stays parked at the bottom when content is short, but scrolls 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 abb818254542..afaec619040a 100644 --- a/projects/packages/backup/src/dashboard/components/file-browser/index.tsx +++ b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx @@ -300,7 +300,7 @@ function countSelectedInLoadedTree( /** * Lazy file-tree browser for the selected backup. Folders fetch their - * children on first expand via `useMockFileTree`; selecting a file opens + * children on first expand via `useFileTree`; selecting a file opens * `` to the right of the tree with a text preview when the * mime type is text-shaped. * @@ -474,7 +474,7 @@ type NodeRowProps = { }; /** - * Recursive row inside the file-browser tree. Folders own their own expand state; while a folder is open, `useMockFileTree` keeps its children resolved (re-collapsing and re-opening re-issues the fetch). + * 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). * * Two pieces of state propagate top-down: `ancestorSelected` carries the *effective* checked state of the nearest ancestor (own selected beats own deselected beats ancestor), and zebra parity (`isAlternate`) is toggled before each child so the stripe runs continuously through nested branches. * diff --git a/projects/packages/backup/src/dashboard/data/api/activity-log.ts b/projects/packages/backup/src/dashboard/data/api/activity-log.ts index 7e04059bb033..9ce53e0b91c8 100644 --- a/projects/packages/backup/src/dashboard/data/api/activity-log.ts +++ b/projects/packages/backup/src/dashboard/data/api/activity-log.ts @@ -37,7 +37,7 @@ export async function fetchActivityLog( args: FetchArgs = {} ): Promise< WpcomActivityLogResponse > { return apiCall< WpcomActivityLogResponse >( { - path: apiPath( '/activity-log', { + path: apiPath( '/site/rewindable-activity', { number: args.number, aggregate: args.aggregate, after: args.after, diff --git a/projects/packages/backup/src/dashboard/data/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts index 72aad9119dba..3ccbd328bda8 100644 --- a/projects/packages/backup/src/dashboard/data/query-client.ts +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -29,8 +29,11 @@ export const queryClient = new QueryClient( { */ export const keys = { capabilities: () => [ 'backup', 'capabilities' ] as const, - activityLog: ( args: { page: number; pageSize: number; search: string } ) => - [ 'backup', 'activity-log', args ] as const, + // Single shared key: the hook always fetches one fixed window of + // rewindable activity and filters/paginates client-side, so every + // consumer (list, default-selection, by-id lookup) subscribes to + // the same entry. + activityLogWindow: () => [ 'backup', 'activity-log-window' ] as const, fileTree: ( rewindId: string, folderPath: string | null ) => [ 'backup', 'file-tree', rewindId, folderPath ] as const, fileContents: ( rewindId: string, path: string ) => diff --git a/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts index 413f7d402a08..08a6d554ece8 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo } from '@wordpress/element'; import { fetchActivityLog } from '../data/api/activity-log'; import { normalizeActivityLog } from '../data/normalize/activity-log'; -import { keys, queryClient } from '../data/query-client'; +import { keys } from '../data/query-client'; import type { ActivityItem } from '../types/activity'; type Args = { @@ -21,6 +21,23 @@ type Result = { const MAX_FETCH_ITEMS = 100; +/** + * Shared `useQuery` for the single rewindable-activity window the + * dashboard fetches. Centralizing the query options means every + * consumer (`useActivityLog`, `useActivityById`, + * `useDefaultBackupRewindId`) subscribes to the same cache entry, so + * TanStack dedups the fetch and any consumer mounted after the window + * resolves re-renders without a refetch. + * + * @return The cached window query. + */ +function useActivityLogWindowQuery() { + return useQuery( { + queryKey: keys.activityLogWindow(), + queryFn: () => fetchActivityLog( { number: MAX_FETCH_ITEMS } ), + } ); +} + /** * Case-insensitive predicate: returns true when the activity item's title * or summary contains the search query. An empty query matches every item. @@ -55,10 +72,7 @@ function matchesSearch( item: ActivityItem, q: string ): boolean { * @return Items, total items, total pages, loading, error. */ export function useActivityLog( { page, pageSize, search }: Args ): Result { - const query = useQuery( { - queryKey: keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ), - queryFn: () => fetchActivityLog( { number: MAX_FETCH_ITEMS } ), - } ); + const query = useActivityLogWindowQuery(); const allItems = useMemo( () => normalizeActivityLog( query.data?.current?.orderedItems ), @@ -86,42 +100,45 @@ export function useActivityLog( { page, pageSize, search }: Args ): Result { } /** - * Look up a single activity item from the React Query cache. - * - * Reads the same single window the hook fetches, so the right-pane - * detail card on Overview can resolve its selected id without issuing - * an extra request. + * Look up a single activity item from the cached rewindable-activity + * window. Reactive: subscribes via `useQuery`, so when the window + * fetch resolves the calling component re-renders with the item. * * @param id - Selection id: `rewindId` for backup items, `activity_id` otherwise. * @return The matching item, or null when the cache hasn't been populated yet or the id doesn't match anything in the cached page. */ -export function getCachedActivityById( id: string ): ActivityItem | null { - const data = queryClient.getQueryData< Awaited< ReturnType< typeof fetchActivityLog > > >( - keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ) - ); - const items = normalizeActivityLog( data?.current?.orderedItems ); - return ( - items.find( item => ( item.kind === 'backup' ? item.rewindId === id : item.id === id ) ) ?? null - ); +export function useActivityById( id: string | null ): ActivityItem | null { + const query = useActivityLogWindowQuery(); + return useMemo( () => { + if ( ! id ) { + return null; + } + const items = normalizeActivityLog( query.data?.current?.orderedItems ); + return ( + items.find( item => ( item.kind === 'backup' ? item.rewindId === id : item.id === id ) ) ?? + null + ); + }, [ query.data, id ] ); } /** - * Returns the newest backup row in the cached activity log, or null when - * the cache isn't populated or holds no backup rows. - * - * Used by the Overview to preselect a default right-pane on first load. + * Returns the newest backup row in the cached rewindable-activity + * window, or null when the cache isn't populated or holds no backup + * rows. Reactive: subscribes via `useQuery`, so Overview's first-load + * default selection reconciles to the newest backup the moment the + * window fetch resolves. * * @return The newest backup item's rewindId, or null. */ -export function getCachedDefaultBackupRewindId(): string | null { - const data = queryClient.getQueryData< Awaited< ReturnType< typeof fetchActivityLog > > >( - keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ) - ); - const items = normalizeActivityLog( data?.current?.orderedItems ); - for ( const item of items ) { - if ( item.kind === 'backup' ) { - return item.rewindId; +export function useDefaultBackupRewindId(): string | null { + const query = useActivityLogWindowQuery(); + return useMemo( () => { + const items = normalizeActivityLog( query.data?.current?.orderedItems ); + for ( const item of items ) { + if ( item.kind === 'backup' ) { + return item.rewindId; + } } - } - return null; + return null; + }, [ query.data ] ); } diff --git a/projects/packages/backup/src/dashboard/hooks/use-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-restore.ts index 526477241e17..0cc30fce687e 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-restore.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -74,12 +74,12 @@ export function useRestore( rewindId: string ): Result { } else if ( statusQuery.data?.status === 'finished' ) { state = { phase: 'success' }; } else if ( statusQuery.data?.status === 'failed' ) { + // `error_code` is a machine identifier (e.g. `"checksum_mismatch"`) + // — never surface it to users; fall straight through to the + // translated generic message when `message` is empty. state = { phase: 'error', - message: - statusQuery.data.message || - statusQuery.data.error_code || - __( 'Restore failed.', 'jetpack-backup-pkg' ), + message: statusQuery.data.message || __( 'Restore failed.', 'jetpack-backup-pkg' ), }; } else if ( restoreId !== null && statusQuery.data ) { state = { diff --git a/projects/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx index 3a2a32c94317..9b9811b6e1d3 100644 --- a/projects/packages/backup/src/dashboard/screens/overview.tsx +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -7,7 +7,7 @@ import ActivityDetail from '../components/activity-detail'; import ActivityList from '../components/activity-list'; import BackupDetail from '../components/backup-detail'; import DashboardLayout from '../components/dashboard-layout'; -import { getCachedActivityById, getCachedDefaultBackupRewindId } from '../hooks/use-activity-log'; +import { useActivityById, useDefaultBackupRewindId } from '../hooks/use-activity-log'; import { isBackupItem } from '../types/activity'; type OverviewSearch = Record< string, unknown > & { selected?: string }; @@ -29,11 +29,12 @@ export default function OverviewScreen() { strict: false, } ) as OverviewSearch; const navigate = useNavigate(); - // The default selection comes from the activity-log cache; ActivityList - // populates that cache on mount, so the right pane will reconcile to - // the newest backup once the first fetch resolves. Until then, - // `selectedId` is null and the empty-state placeholder renders. - const defaultSelectedId = getCachedDefaultBackupRewindId(); + // Subscribe to the activity-log window so the right pane reconciles + // to the newest backup the moment the first fetch resolves. Until + // then, `defaultSelectedId` is null and the empty-state placeholder + // renders. The hook shares its query key with ActivityList's + // `useActivityLog`, so this doesn't issue an extra fetch. + const defaultSelectedId = useDefaultBackupRewindId(); const selectedId = typeof search.selected === 'string' ? search.selected : defaultSelectedId; const setSelected = useCallback( @@ -82,6 +83,7 @@ export default function OverviewScreen() { * @return The rendered detail card or an empty-state placeholder. */ function RightPane( { selectedId }: { selectedId: string | null } ) { + const item = useActivityById( selectedId ); if ( ! selectedId ) { return (
@@ -89,7 +91,6 @@ function RightPane( { selectedId }: { selectedId: string | null } ) {
); } - const item = getCachedActivityById( selectedId ); if ( ! item ) { return (
diff --git a/projects/packages/backup/src/rest/class-activity-log-bridge.php b/projects/packages/backup/src/rest/class-activity-log-bridge.php index d7044ad77ed5..2b101e4e202d 100644 --- a/projects/packages/backup/src/rest/class-activity-log-bridge.php +++ b/projects/packages/backup/src/rest/class-activity-log-bridge.php @@ -8,7 +8,6 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Connection\Client; -use Jetpack_Options; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -30,9 +29,15 @@ class Activity_Log_Bridge { * @return void */ public static function register_routes() { + // NOTE: not `/activity-log` — that namespace is owned by the + // `automattic/jetpack-activity-log` package's REST controller, + // which is loaded inside the Jetpack plugin. Sharing the route + // would let `register_rest_route`'s endpoint-merge pick the + // wrong handler at dispatch time (the two bridges proxy + // different WPCOM endpoints with different response shapes). register_rest_route( 'jetpack/v4', - '/activity-log', + '/site/rewindable-activity', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'get_activity_log' ), @@ -54,7 +59,10 @@ public static function register_routes() { * @return \WP_REST_Response|WP_Error */ public static function get_activity_log( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } $query = array_filter( array( diff --git a/projects/packages/backup/src/rest/class-capabilities-bridge.php b/projects/packages/backup/src/rest/class-capabilities-bridge.php index 2d73e7ebcb44..ce527b6605d7 100644 --- a/projects/packages/backup/src/rest/class-capabilities-bridge.php +++ b/projects/packages/backup/src/rest/class-capabilities-bridge.php @@ -8,7 +8,6 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Connection\Client; -use Jetpack_Options; use WP_Error; use WP_REST_Server; @@ -53,13 +52,9 @@ public static function register_routes() { * @return \WP_REST_Response|WP_Error The decoded capabilities, or WP_Error on failure. */ public static function get_capabilities() { - $blog_id = Jetpack_Options::get_option( 'id' ); - if ( ! $blog_id ) { - return new WP_Error( - 'not_connected', - __( 'This site is not connected to Jetpack.', 'jetpack-backup-pkg' ), - array( 'status' => 412 ) - ); + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; } $response = Client::wpcom_json_api_request_as_user( diff --git a/projects/packages/backup/src/rest/class-download-bridge.php b/projects/packages/backup/src/rest/class-download-bridge.php index d2730e87e54e..5b8495c5f17a 100644 --- a/projects/packages/backup/src/rest/class-download-bridge.php +++ b/projects/packages/backup/src/rest/class-download-bridge.php @@ -9,7 +9,6 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Connection\Client; -use Jetpack_Options; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -81,7 +80,10 @@ public static function register_routes() { * @return \WP_REST_Response|WP_Error */ public static function initiate_download( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } $rewind_id = (string) $request->get_param( 'rewind_id' ); $response = Client::wpcom_json_api_request_as_user( @@ -133,7 +135,10 @@ public static function initiate_download( WP_REST_Request $request ) { * @return \WP_REST_Response|WP_Error */ public static function get_download_status( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } $rewind_id = (string) $request->get_param( 'rewind_id' ); $download_id = (int) $request->get_param( 'download_id' ); diff --git a/projects/packages/backup/src/rest/class-file-browser-bridge.php b/projects/packages/backup/src/rest/class-file-browser-bridge.php index a2c13f534c94..e9e5d6d25dc4 100644 --- a/projects/packages/backup/src/rest/class-file-browser-bridge.php +++ b/projects/packages/backup/src/rest/class-file-browser-bridge.php @@ -8,7 +8,6 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Connection\Client; -use Jetpack_Options; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -91,7 +90,10 @@ public static function register_routes() { * @return \WP_REST_Response|WP_Error */ public static function list_directory( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } $response = Client::wpcom_json_api_request_as_user( sprintf( '/sites/%d/rewind/backup/ls', $blog_id ), @@ -128,7 +130,10 @@ public static function list_directory( WP_REST_Request $request ) { * @return \WP_REST_Response|WP_Error */ public static function get_file_content( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } $file_period = (string) $request->get_param( 'file_period' ); $encoded_manifest_path = (string) $request->get_param( 'encoded_manifest_path' ); @@ -161,7 +166,10 @@ public static function get_file_content( WP_REST_Request $request ) { $url_body = json_decode( wp_remote_retrieve_body( $url_response ), true ); $signed_url = is_array( $url_body ) && isset( $url_body['url'] ) ? $url_body['url'] : null; - if ( ! $signed_url ) { + if ( ! $signed_url || ! wp_http_validate_url( $signed_url ) ) { + // Defense-in-depth: WPCOM is supposed to hand back an HTTPS + // URL, but a regression that returned `file://…` or another + // scheme would otherwise reach `wp_remote_get` below. return new WP_Error( 'backup_file_content_url_missing', __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ), @@ -170,7 +178,20 @@ public static function get_file_content( WP_REST_Request $request ) { } // Step 2: fetch the stream body server-side. - $stream_response = wp_remote_get( $signed_url, array( 'timeout' => 15 ) ); + // + // `limit_response_size` caps the body at the HTTP-transport + // layer so a multi-GB blob can't be buffered into PHP memory + // before truncation. The bridge enforces no mime check at + // all — the React layer's allowlist is advisory only — so any + // admin can address any blob the WPCOM signer is willing to + // hand a URL for. + $stream_response = wp_remote_get( + $signed_url, + array( + 'timeout' => 15, + 'limit_response_size' => self::PREVIEW_MAX_BYTES, + ) + ); if ( is_wp_error( $stream_response ) ) { return $stream_response; @@ -185,13 +206,7 @@ public static function get_file_content( WP_REST_Request $request ) { ); } - $content = wp_remote_retrieve_body( $stream_response ); - - if ( strlen( $content ) > self::PREVIEW_MAX_BYTES ) { - $content = substr( $content, 0, self::PREVIEW_MAX_BYTES ); - } - - return rest_ensure_response( array( 'content' => $content ) ); + return rest_ensure_response( array( 'content' => wp_remote_retrieve_body( $stream_response ) ) ); } /** diff --git a/projects/packages/backup/src/rest/class-rest-controller.php b/projects/packages/backup/src/rest/class-rest-controller.php index a117d1cecb3e..b2f87b8a1c18 100644 --- a/projects/packages/backup/src/rest/class-rest-controller.php +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -8,6 +8,9 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Backup\V0005\Jetpack_Backup; +use Automattic\Jetpack\Connection\Manager as Connection_Manager; +use Jetpack_Options; +use WP_Error; if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); @@ -44,9 +47,47 @@ public static function register_routes() { /** * Permission check shared by every modernized-dashboard route. * - * @return bool True when the current user can manage options. + * Mirrors the activity-log package's pattern: `manage_options` is + * necessary but not sufficient — every bridge eventually proxies a + * WPCOM endpoint that's user-gated, so a site admin who isn't + * personally WPCOM-linked needs a clearer error than the opaque + * "Only Administrators can query…" WPCOM returns. + * + * @return bool|WP_Error True when the current user can call the bridges, WP_Error otherwise. */ public static function permission_check() { - return current_user_can( 'manage_options' ); + if ( ! current_user_can( 'manage_options' ) ) { + return false; + } + + if ( ! ( new Connection_Manager() )->is_user_connected() ) { + return new WP_Error( + 'user_not_connected', + __( 'Your WordPress.com account is not connected to this site.', 'jetpack-backup-pkg' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Returns the site's WPCOM blog id, or a `not_connected` WP_Error + * when the site hasn't been registered yet. Shared across the bridges + * so the `sprintf( '/sites/%d/…', $blog_id )` upstream path is never + * built with an empty id. + * + * @return int|WP_Error Blog id, or WP_Error when not connected. + */ + public static function get_blog_id_or_error() { + $blog_id = (int) Jetpack_Options::get_option( 'id' ); + if ( ! $blog_id ) { + return new WP_Error( + 'not_connected', + __( 'This site is not connected to Jetpack.', 'jetpack-backup-pkg' ), + array( 'status' => 412 ) + ); + } + return $blog_id; } } diff --git a/projects/packages/backup/src/rest/class-restore-bridge.php b/projects/packages/backup/src/rest/class-restore-bridge.php index 36d3102194b8..0c74113990f2 100644 --- a/projects/packages/backup/src/rest/class-restore-bridge.php +++ b/projects/packages/backup/src/rest/class-restore-bridge.php @@ -8,7 +8,6 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Connection\Client; -use Jetpack_Options; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -82,7 +81,10 @@ public static function register_routes() { * @return \WP_REST_Response|WP_Error */ public static function initiate_restore( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } $rewind_id = (string) $request->get_param( 'rewind_id' ); $types = $request->get_param( 'types' ); @@ -108,14 +110,10 @@ public static function initiate_restore( WP_REST_Request $request ) { $status_code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $status_code ) { - $upstream = json_decode( wp_remote_retrieve_body( $response ), true ); return new WP_Error( 'restore_initiate_failed', __( 'Could not start the backup restore.', 'jetpack-backup-pkg' ), - array( - 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500, - 'upstream' => is_array( $upstream ) ? $upstream : null, - ) + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) ); } @@ -140,7 +138,10 @@ public static function initiate_restore( WP_REST_Request $request ) { * @return \WP_REST_Response|WP_Error */ public static function get_restore_status( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } $restore_id = (int) $request->get_param( 'restore_id' ); $response = Client::wpcom_json_api_request_as_user( diff --git a/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php index fcfc6d3a07a1..4200bfdfea85 100644 --- a/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php +++ b/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php @@ -22,7 +22,7 @@ use function wp_set_current_user; /** - * Tests for the GET /jetpack/v4/activity-log route. + * Tests for the GET /jetpack/v4/site/rewindable-activity route. * * @covers \Automattic\Jetpack\Backup\V0005\REST\Activity_Log_Bridge */ @@ -69,7 +69,7 @@ public function tearDown(): void { */ public function test_route_registers_when_modernized() { $routes = $this->server->get_routes(); - $this->assertArrayHasKey( '/jetpack/v4/activity-log', $routes ); + $this->assertArrayHasKey( '/jetpack/v4/site/rewindable-activity', $routes ); } /** @@ -85,7 +85,7 @@ public function test_route_requires_manage_options() { ); wp_set_current_user( $subscriber_id ); - $request = new WP_REST_Request( 'GET', '/jetpack/v4/activity-log' ); + $request = new WP_REST_Request( 'GET', '/jetpack/v4/site/rewindable-activity' ); $response = $this->server->dispatch( $request ); $this->assertSame( 403, $response->get_status() ); diff --git a/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer index 5108082a2f28..8373feffce9e 100644 --- a/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer +++ b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer @@ -1,3 +1,3 @@ Significance: patch -Type: added +Type: other Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability/connection checks, drop the mock-mode dev banner. No-op when the modernization filter is off. From 964f9b02ea26abbf7bb7fa0094313ddfd40bd350 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sun, 17 May 2026 03:24:09 -0300 Subject: [PATCH 10/11] Backup: wire RestoreItems checklist through to Restore/Download bridges + review cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RestoreItemsChecklist selection was collected into local state on the Restore and Download screens but never sent to the bridge, so an unchecked "Plugins" or "Themes" was silently restored anyway. Thread `RestoreItems` through `useRestore` / `useDownload` → `initiateRestore` / `initiateDownload` as the mutation variable, and surface it to the bridges as `data: { types }` (the keys already match WPCOM's `types` payload). Also clears the review suggestions from the same pass: * `useFileContents` browser path now UTF-8 encodes via TextEncoder before btoa, so non-ASCII manifest paths (accented or CJK upload filenames) no longer throw InvalidCharacterError. * `useConnection` drops its empty-deps useMemo and reads JP_CONNECTION_INITIAL_STATE on every render — single property lookup, removes any risk of capturing a pre-emit empty snapshot if load order ever shifts. * `query-client.ts` docstring corrected: each wp-build route is its own bundle so the cache is per-route, not shared across navigation. The design doesn't depend on cross-route reuse, but the comment shouldn't claim otherwise. * `Restore_Bridge::initiate_restore` returns the same user-facing "Could not start the backup restore." string in both failure branches instead of the diagnostic "Restore response missing restore id." * `Activity_Log_Bridge` drops the declared-but-unused `aggregate`, `after`, `before` args (and mirrors the cleanup in `data/api/activity-log.ts`'s FetchArgs) — the hook only ever passes `number`. * Download success link gains `download` + `rel="noreferrer"` so the signed URL triggers a download instead of navigating away when WPCOM omits Content-Disposition. PHP + JS tests pass; phan unchanged (only the pre-existing build/ artifact warning); eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/dashboard/data/api/activity-log.ts | 12 ++----- .../backup/src/dashboard/data/api/download.ts | 11 ++++++- .../backup/src/dashboard/data/api/restore.ts | 11 ++++++- .../backup/src/dashboard/data/query-client.ts | 11 ++++--- .../src/dashboard/hooks/use-connection.ts | 33 +++++++++---------- .../src/dashboard/hooks/use-download.ts | 10 ++++-- .../backup/src/dashboard/hooks/use-restore.ts | 8 ++--- .../backup/src/dashboard/screens/download.tsx | 12 +++++-- .../backup/src/dashboard/screens/restore.tsx | 5 +-- .../src/rest/class-activity-log-bridge.php | 12 ++----- .../backup/src/rest/class-restore-bridge.php | 2 +- 11 files changed, 71 insertions(+), 56 deletions(-) diff --git a/projects/packages/backup/src/dashboard/data/api/activity-log.ts b/projects/packages/backup/src/dashboard/data/api/activity-log.ts index 9ce53e0b91c8..28a71417fbb5 100644 --- a/projects/packages/backup/src/dashboard/data/api/activity-log.ts +++ b/projects/packages/backup/src/dashboard/data/api/activity-log.ts @@ -22,26 +22,18 @@ export type WpcomActivityLogResponse = { type FetchArgs = { number?: number; - aggregate?: boolean; - after?: string; - before?: string; }; /** * Fetch the rewindable activity log entries from the bridge. * - * @param args - Optional pagination and filter args. + * @param args - Optional pagination args. * @return The raw WPCOM-shaped response. */ export async function fetchActivityLog( args: FetchArgs = {} ): Promise< WpcomActivityLogResponse > { return apiCall< WpcomActivityLogResponse >( { - path: apiPath( '/site/rewindable-activity', { - number: args.number, - aggregate: args.aggregate, - after: args.after, - before: args.before, - } ), + path: apiPath( '/site/rewindable-activity', { number: args.number } ), } ); } diff --git a/projects/packages/backup/src/dashboard/data/api/download.ts b/projects/packages/backup/src/dashboard/data/api/download.ts index e494b4d30a30..b8a01ddc0bda 100644 --- a/projects/packages/backup/src/dashboard/data/api/download.ts +++ b/projects/packages/backup/src/dashboard/data/api/download.ts @@ -1,4 +1,5 @@ import { apiCall, apiPath, toIntRewindId } from './_helpers'; +import type { RestoreItems } from '../../types/restore'; export type InitiateDownloadResponse = { id: number; @@ -14,13 +15,21 @@ export type DownloadStatusResponse = { /** * Initiate a backup download. * + * `types` maps directly to WPCOM's `types` payload (the keys match the + * `RestoreItems` checklist). Sending `{}` means "include everything". + * * @param rewindId - The backup's rewind id. + * @param types - Which categories to include in the download. * @return The download id. */ -export async function initiateDownload( rewindId: string ): Promise< InitiateDownloadResponse > { +export async function initiateDownload( + rewindId: string, + types: RestoreItems +): Promise< InitiateDownloadResponse > { return apiCall< InitiateDownloadResponse >( { path: apiPath( `/backups/download/${ toIntRewindId( rewindId ) }` ), method: 'POST', + data: { types }, } ); } diff --git a/projects/packages/backup/src/dashboard/data/api/restore.ts b/projects/packages/backup/src/dashboard/data/api/restore.ts index 1b29f13e01ff..13debe4a7968 100644 --- a/projects/packages/backup/src/dashboard/data/api/restore.ts +++ b/projects/packages/backup/src/dashboard/data/api/restore.ts @@ -1,4 +1,5 @@ import { apiCall, apiPath, toIntRewindId } from './_helpers'; +import type { RestoreItems } from '../../types/restore'; export type InitiateRestoreResponse = { id: number; @@ -16,13 +17,21 @@ export type RestoreStatusResponse = { /** * Start a restore for the given backup point. * + * `types` maps directly to WPCOM's `types` payload (the keys match the + * `RestoreItems` checklist). Sending `{}` means "restore everything". + * * @param rewindId - The backup's rewind id. + * @param types - Which categories to restore (themes/plugins/roots/contents/sqls/uploads). * @return The restore id. */ -export async function initiateRestore( rewindId: string ): Promise< InitiateRestoreResponse > { +export async function initiateRestore( + rewindId: string, + types: RestoreItems +): Promise< InitiateRestoreResponse > { return apiCall< InitiateRestoreResponse >( { path: apiPath( `/rewind/to/${ toIntRewindId( rewindId ) }` ), method: 'POST', + data: { types }, } ); } diff --git a/projects/packages/backup/src/dashboard/data/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts index 3ccbd328bda8..0995c703b696 100644 --- a/projects/packages/backup/src/dashboard/data/query-client.ts +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -4,11 +4,14 @@ const STALE_TIME_DEFAULT_MS = 30_000; const GC_TIME_DEFAULT_MS = 5 * 60_000; /** - * Module-scope QueryClient shared across the modernized Backup dashboard. + * Module-scope QueryClient for the modernized Backup dashboard. * - * Each wp-build route mounts its own `` (via - * ``), but the client itself is created once so the - * cache survives the per-route bundle re-mount on navigation. + * Each wp-build route is its own JS bundle and ends up with its own + * copy of this module, so the cache is per-route — it does NOT survive + * Overview ↔ Restore ↔ Download navigation. The screens don't depend on + * cross-route cache reuse today (each derives its rewindId from the + * URL), so this is fine; revisit if a future screen needs to read cache + * a sibling route populated. */ export const queryClient = new QueryClient( { defaultOptions: { diff --git a/projects/packages/backup/src/dashboard/hooks/use-connection.ts b/projects/packages/backup/src/dashboard/hooks/use-connection.ts index b6487f7404a3..758d4d40ac9d 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-connection.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-connection.ts @@ -1,5 +1,3 @@ -import { useMemo } from '@wordpress/element'; - type Result = { isLoaded: boolean; isFullyConnected: boolean; @@ -19,8 +17,11 @@ type ConnectionStatus = { * * We deliberately avoid `@automattic/jetpack-connection`'s store / hooks * because the package's barrel pulls in SCSS that wp-build's bundler - * can't resolve, and the connection state is page-load static anyway — - * a single `window` read at hook-mount time covers every consumer. + * can't resolve. The global is set inline on `admin_print_scripts` + * priority 1, well before any React bundle runs, so reading on every + * render is a single property lookup — no need to memoize, and not + * memoizing avoids any chance of capturing a pre-emit empty snapshot + * if load order ever drifts. * * `@automattic/jetpack-connection`'s `declarations.d.ts` already types * the global ambiently — and declares it non-optional — so we don't @@ -30,19 +31,17 @@ type ConnectionStatus = { * @return Connection state. */ export function useConnection(): Result { - return useMemo( () => { - const state = typeof window !== 'undefined' ? window.JP_CONNECTION_INITIAL_STATE : undefined; - const status: ConnectionStatus = state?.connectionStatus ?? {}; + const state = typeof window !== 'undefined' ? window.JP_CONNECTION_INITIAL_STATE : undefined; + const status: ConnectionStatus = state?.connectionStatus ?? {}; - const isLoaded = Object.keys( status ).length > 0; - const isFullyConnected = Boolean( isLoaded && status.isRegistered && status.hasConnectedOwner ); - const isSecondaryAdminNotConnected = Boolean( isFullyConnected && ! status.isUserConnected ); + const isLoaded = Object.keys( status ).length > 0; + const isFullyConnected = Boolean( isLoaded && status.isRegistered && status.hasConnectedOwner ); + const isSecondaryAdminNotConnected = Boolean( isFullyConnected && ! status.isUserConnected ); - return { - isLoaded, - isFullyConnected, - isSecondaryAdminNotConnected, - hasConnectionError: false, - }; - }, [] ); + return { + isLoaded, + isFullyConnected, + isSecondaryAdminNotConnected, + hasConnectionError: false, + }; } diff --git a/projects/packages/backup/src/dashboard/hooks/use-download.ts b/projects/packages/backup/src/dashboard/hooks/use-download.ts index a959fe95e57f..84c475cb546f 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-download.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-download.ts @@ -3,6 +3,7 @@ import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { fetchDownloadStatus, initiateDownload } from '../data/api/download'; import { keys } from '../data/query-client'; +import type { RestoreItems } from '../types/restore'; type DownloadState = | { phase: 'idle' } @@ -13,7 +14,7 @@ type DownloadState = type Result = { state: DownloadState; - submit: () => void; + submit: ( items: RestoreItems ) => void; reset: () => void; }; @@ -39,7 +40,7 @@ export function useDownload( rewindId: string ): Result { reset: resetMutation, isPending: isInitiating, } = useMutation( { - mutationFn: () => initiateDownload( rewindId ), + mutationFn: ( items: RestoreItems ) => initiateDownload( rewindId, items ), onSuccess: result => { setDownloadId( result.id ); setErrorMessage( null ); @@ -58,7 +59,10 @@ export function useDownload( rewindId: string ): Result { query.state.data?.status === 'in-progress' ? POLL_INTERVAL_MS : false, } ); - const submit = useCallback( () => mutateInitiate(), [ mutateInitiate ] ); + const submit = useCallback( + ( items: RestoreItems ) => mutateInitiate( items ), + [ mutateInitiate ] + ); const reset = useCallback( () => { setDownloadId( null ); setErrorMessage( null ); diff --git a/projects/packages/backup/src/dashboard/hooks/use-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-restore.ts index 0cc30fce687e..c8dca8e6c780 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-restore.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -3,11 +3,11 @@ import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { fetchRestoreStatus, initiateRestore } from '../data/api/restore'; import { keys } from '../data/query-client'; -import type { RestoreState } from '../types/restore'; +import type { RestoreItems, RestoreState } from '../types/restore'; type Result = { state: RestoreState; - submit: () => void; + submit: ( items: RestoreItems ) => void; reset: () => void; }; @@ -36,7 +36,7 @@ export function useRestore( rewindId: string ): Result { reset: resetMutation, isPending, } = useMutation( { - mutationFn: () => initiateRestore( rewindId ), + mutationFn: ( items: RestoreItems ) => initiateRestore( rewindId, items ), onSuccess: result => { setRestoreId( result.id ); setErrorMessage( null ); @@ -59,7 +59,7 @@ export function useRestore( rewindId: string ): Result { : false, } ); - const submit = useCallback( () => kickOff(), [ kickOff ] ); + const submit = useCallback( ( items: RestoreItems ) => kickOff( items ), [ kickOff ] ); const reset = useCallback( () => { setRestoreId( null ); setErrorMessage( null ); diff --git a/projects/packages/backup/src/dashboard/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx index 182051d8bd44..d2319a1e0f6f 100644 --- a/projects/packages/backup/src/dashboard/screens/download.tsx +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -1,6 +1,6 @@ import { Notice, ProgressBar, Spinner } from '@wordpress/components'; import { dateI18n } from '@wordpress/date'; -import { useState } from '@wordpress/element'; +import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Icon, cloud, download as downloadIcon, arrowLeft } from '@wordpress/icons'; import { Link, useParams } from '@wordpress/route'; @@ -38,6 +38,7 @@ export default function DownloadScreen() { const downloadPoint = rewindIdToIso( rewindId ); const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); const { state, submit, reset } = useDownload( rewindId ); + const handleGenerate = useCallback( () => submit( items ), [ submit, items ] ); return ( @@ -74,7 +75,7 @@ export default function DownloadScreen() { className="jpb-download__confirm" variant="solid" disabled={ state.phase === 'submitting' } - onClick={ submit } + onClick={ handleGenerate } > { state.phase === 'submitting' ? ( @@ -96,7 +97,12 @@ export default function DownloadScreen() { { __( 'Your download is ready.', 'jetpack-backup-pkg' ) } - + { __( 'Download the file', 'jetpack-backup-pkg' ) } diff --git a/projects/packages/backup/src/dashboard/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx index 0ad62504c473..ae4b31345770 100644 --- a/projects/packages/backup/src/dashboard/screens/restore.tsx +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -1,6 +1,6 @@ import { Notice, ProgressBar, Spinner } from '@wordpress/components'; import { dateI18n } from '@wordpress/date'; -import { useState } from '@wordpress/element'; +import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Icon, backup as backupIcon, arrowLeft } from '@wordpress/icons'; import { Link, useParams } from '@wordpress/route'; @@ -39,6 +39,7 @@ export default function RestoreScreen() { const restorePoint = rewindIdToIso( rewindId ); const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); const { state, submit, reset } = useRestore( rewindId ); + const handleConfirm = useCallback( () => submit( items ), [ submit, items ] ); return ( @@ -76,7 +77,7 @@ export default function RestoreScreen() { className="jpb-restore__confirm" variant="solid" disabled={ state.phase === 'submitting' } - onClick={ submit } + onClick={ handleConfirm } > { state.phase === 'submitting' ? ( diff --git a/projects/packages/backup/src/rest/class-activity-log-bridge.php b/projects/packages/backup/src/rest/class-activity-log-bridge.php index 2b101e4e202d..14c5ea7aaefd 100644 --- a/projects/packages/backup/src/rest/class-activity-log-bridge.php +++ b/projects/packages/backup/src/rest/class-activity-log-bridge.php @@ -43,10 +43,7 @@ public static function register_routes() { 'callback' => array( __CLASS__, 'get_activity_log' ), 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), 'args' => array( - 'number' => array( 'type' => 'integer' ), - 'aggregate' => array( 'type' => 'boolean' ), - 'after' => array( 'type' => 'string' ), - 'before' => array( 'type' => 'string' ), + 'number' => array( 'type' => 'integer' ), ), ) ); @@ -65,12 +62,7 @@ public static function get_activity_log( WP_REST_Request $request ) { } $query = array_filter( - array( - 'number' => $request->get_param( 'number' ), - 'aggregate' => $request->get_param( 'aggregate' ) ? 'true' : null, - 'after' => $request->get_param( 'after' ), - 'before' => $request->get_param( 'before' ), - ), + array( 'number' => $request->get_param( 'number' ) ), static function ( $value ) { return null !== $value && '' !== $value; } diff --git a/projects/packages/backup/src/rest/class-restore-bridge.php b/projects/packages/backup/src/rest/class-restore-bridge.php index 0c74113990f2..6bd54a7786bc 100644 --- a/projects/packages/backup/src/rest/class-restore-bridge.php +++ b/projects/packages/backup/src/rest/class-restore-bridge.php @@ -122,7 +122,7 @@ public static function initiate_restore( WP_REST_Request $request ) { if ( ! $restore_id_in ) { return new WP_Error( 'restore_initiate_failed', - __( 'Restore response missing restore id.', 'jetpack-backup-pkg' ), + __( 'Could not start the backup restore.', 'jetpack-backup-pkg' ), array( 'status' => 500 ) ); } From c332789972deece026822ca4ee3b617214ffd114 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sun, 17 May 2026 16:39:31 -0300 Subject: [PATCH 11/11] Backup: address second review pass on the modernized data layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes raised in /jetpack-review-pr feedback: * Activity list pagination is now real server-side pagination instead of a 100-item client-side window. The bridge forwards `number` and `page` to WPCOM; `useActivityLog` uses `keepPreviousData` for smooth page transitions and reads `totalItems`/`totalPages` straight from the envelope. The cache key is per-page (`activityLogPage(page, pageSize)`); `activityLogRoot()` is the family prefix for cross-page lookup. Overview lifts view state so ``'s `useActivityById` subscribes to the same paginated query, falling through to other cached pages via `getQueriesData` if the row was loaded on a different page. The 100-item cap was a relic of the mocked-data phase. DataViews `search` is disabled — WPCOM's `/rewindable` endpoint doesn't accept a search param and the rewindable list is homogeneous enough that hand-rolling client-side search wasn't worth it. * `useConnection` no longer spins forever when `JP_CONNECTION_INITIAL_STATE` is missing. The "any key present" loaded heuristic is replaced with always-loaded + a dev-mode console warning, so `` falls through to `` instead of staying on the skeleton. * `` surfaces `useFileContents` errors with a distinct muted message ("Preview could not be loaded for this file. It may no longer be available in storage."). The mime-mismatch branch keeps its generic "Preview unavailable" copy. Drops the inert per-file Download/Restore buttons — per-file actions aren't on this phase's roadmap and ghost affordances are worse than no affordance. * `useRestore` and `useDownload` surface `statusQuery.error` as a terminal `error` phase ("Lost connection while restoring." / "Lost connection while preparing download.") instead of leaving the UI stuck on the last known progress percent with no message and no way out — important for Restore in particular, which is irreversible. Tests + lint + tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/activity-list/index.tsx | 57 ++---- .../components/file-info-card/index.tsx | 56 +++--- .../src/dashboard/data/api/activity-log.ts | 17 +- .../backup/src/dashboard/data/query-client.ts | 13 +- .../src/dashboard/hooks/use-activity-log.ts | 179 +++++++++++------- .../src/dashboard/hooks/use-connection.ts | 21 +- .../src/dashboard/hooks/use-download.ts | 9 + .../backup/src/dashboard/hooks/use-restore.ts | 8 + .../backup/src/dashboard/screens/overview.tsx | 65 +++++-- .../src/rest/class-activity-log-bridge.php | 12 +- 10 files changed, 270 insertions(+), 167 deletions(-) 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 7efbd6cc5c1d..fca01763f6d4 100644 --- a/projects/packages/backup/src/dashboard/components/activity-list/index.tsx +++ b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx @@ -1,10 +1,10 @@ import { DataViews } from '@wordpress/dataviews'; import { dateI18n } from '@wordpress/date'; -import { useCallback, useMemo, useState } from '@wordpress/element'; +import { useCallback, useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Icon, cloud, image, post, plugins as pluginsIcon, color } from '@wordpress/icons'; import { Card, Stack, Text } from '@wordpress/ui'; -import { useActivityLog } from '../../hooks/use-activity-log'; +import { ACTIVITY_LOG_DEFAULT_PER_PAGE, useActivityLog } from '../../hooks/use-activity-log'; import { isBackupItem } from '../../types/activity'; import './style.scss'; import type { ActivityItem, ActivityKind } from '../../types/activity'; @@ -21,6 +21,8 @@ const ICON_BY_KIND: Record< ActivityKind, typeof cloud > = { type Props = { selectedId: string | null; onSelect: ( id: string ) => void; + view: View; + onChangeView: ( next: View ) => void; }; /** @@ -76,53 +78,31 @@ function DescriptionCell( { item }: { item: ActivityItem } ) { ); } -const DEFAULT_PER_PAGE = 10; - /** * Left pane of the modernized Overview, rendered as a DataViews list. * * DataViews gives us the cog/filter affordance, pagination controls, - * zebra striping, row separators, and search box for free — the legacy - * affordances `` was hand-rolling get swapped out for the + * zebra striping, and row separators for free — the legacy affordances + * `` was hand-rolling get swapped out for the * design-system primitive. * - * Selection lives in the parent (URL-persisted via `?selected=`), - * with `` driven through its `selection` + `onChangeSelection` - * props so the row click is the single source of truth for "which row - * is highlighted". + * Controlled: view state (page, perPage) lives in the parent so the + * right-pane row lookup can subscribe to the same paginated query the + * list reads from. Selection is similarly URL-persisted in the parent. * - * @param props - Component props. - * @param props.selectedId - Currently selected row id, or null when nothing is selected. - * @param props.onSelect - Callback invoked with the new selection id when a row is activated. + * @param props - Component props. + * @param props.selectedId - Currently selected row id, or null when nothing is selected. + * @param props.onSelect - Callback invoked with the new selection id when a row is activated. + * @param props.view - DataViews view state. + * @param props.onChangeView - Callback invoked when the view state changes. * @return The rendered list. */ -export default function ActivityList( { selectedId, onSelect }: Props ) { - // In DataViews' list layout, the `titleField`, `mediaField` and - // `descriptionField` fields are rendered implicitly — anything else - // in `fields` would render a second time as a generic row. Leave - // `fields` empty so the title + media + description are the only - // things shown per row. - const [ view, setView ] = useState< View >( { - type: 'list', - page: 1, - perPage: DEFAULT_PER_PAGE, - search: '', - filters: [], - titleField: 'title', - mediaField: 'icon', - descriptionField: 'description', - fields: [], - } ); - - // Drive the hook with DataViews' own view state so the hook is the - // single source of truth for the visible slice + counts. +export default function ActivityList( { selectedId, onSelect, view, onChangeView }: Props ) { const page = view.page ?? 1; - const perPage = view.perPage ?? DEFAULT_PER_PAGE; - const search = view.search ?? ''; + const perPage = view.perPage ?? ACTIVITY_LOG_DEFAULT_PER_PAGE; const { items, totalItems, totalPages, isLoading } = useActivityLog( { page, pageSize: perPage, - search, } ); const fields: Field< ActivityItem >[] = useMemo( @@ -179,15 +159,14 @@ export default function ActivityList( { selectedId, onSelect }: Props ) { data={ items } fields={ fields } view={ view } - onChangeView={ setView } + onChangeView={ onChangeView } paginationInfo={ { totalItems, totalPages } } defaultLayouts={ { list: {} } } getItemId={ getRowId } selection={ selection } onChangeSelection={ onChangeSelection } isLoading={ isLoading } - search={ true } - searchLabel={ __( 'Search backups', 'jetpack-backup-pkg' ) } + search={ false } /> ); diff --git a/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx index e47ee16835ae..aeac859b0bb2 100644 --- a/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx +++ b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx @@ -1,6 +1,6 @@ import { Spinner } from '@wordpress/components'; import { dateI18n } from '@wordpress/date'; -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { closeSmall } from '@wordpress/icons'; import { Button, Card, Stack, Text } from '@wordpress/ui'; import { useFileContents } from '../../hooks/use-file-contents'; @@ -61,8 +61,11 @@ type Props = { /** * Renders the preview slot's body: a spinner while loading, the file - * contents in a `
` when available, or a "preview unavailable" muted
- * line when the mime type isn't text or the fetch returned nothing.
+ * contents in a `
` when available, an error-specific muted line
+ * when the fetch failed (most commonly because the file's `period`
+ * predates available content blobs — VaultPress retains manifest
+ * entries longer than blob storage), or a generic "preview unavailable"
+ * muted line for non-text mime types.
  *
  * Pulled out as a standalone component to keep `FileInfoCard`'s JSX flat
  * (no nested ternaries) and to give the loading / error / empty branches
@@ -72,16 +75,19 @@ type Props = {
  * @param props.showPreview - Whether the file's mime type is renderable as text.
  * @param props.isLoading   - Whether the file-contents query is in flight.
  * @param props.content     - The fetched body, or null when not yet resolved.
+ * @param props.error       - The fetch error, or null on success.
  * @return The preview body.
  */
 function PreviewBody( {
 	showPreview,
 	isLoading,
 	content,
+	error,
 }: {
 	showPreview: boolean;
 	isLoading: boolean;
 	content: string | null;
+	error: Error | null;
 } ) {
 	if ( ! showPreview ) {
 		return (
@@ -93,6 +99,16 @@ function PreviewBody( {
 	if ( isLoading ) {
 		return ;
 	}
+	if ( error ) {
+		return (
+			
+				{ __(
+					'Preview could not be loaded for this file. It may no longer be available in storage.',
+					'jetpack-backup-pkg'
+				) }
+			
+		);
+	}
 	if ( content === null ) {
 		return (
 			
@@ -137,11 +153,11 @@ function isTextual( mime: string ): boolean {
 export default function FileInfoCard( { file, onClose }: Props ) {
 	const mimeType = mimeFromName( file.name );
 	const showPreview = mimeType ? isTextual( mimeType ) : false;
-	const { content, isLoading: contentsLoading } = useFileContents(
-		file.period,
-		file.manifestPath,
-		showPreview
-	);
+	const {
+		content,
+		isLoading: contentsLoading,
+		error: contentsError,
+	} = useFileContents( file.period, file.manifestPath, showPreview );
 
 	return (
 		
@@ -183,31 +199,9 @@ export default function FileInfoCard( { file, onClose }: Props ) {
 					showPreview={ showPreview }
 					isLoading={ contentsLoading }
 					content={ content }
+					error={ contentsError }
 				/>
 			
- - - - ); } diff --git a/projects/packages/backup/src/dashboard/data/api/activity-log.ts b/projects/packages/backup/src/dashboard/data/api/activity-log.ts index 28a71417fbb5..2799fe25fdd9 100644 --- a/projects/packages/backup/src/dashboard/data/api/activity-log.ts +++ b/projects/packages/backup/src/dashboard/data/api/activity-log.ts @@ -17,23 +17,32 @@ export type WpcomActivityLogResponse = { current?: { orderedItems?: WpcomActivityEntry[]; }; - total_items?: number; + totalItems?: number; + totalPages?: number; + itemsPerPage?: number; }; type FetchArgs = { number?: number; + page?: number; }; /** - * Fetch the rewindable activity log entries from the bridge. + * Fetch a page of the rewindable activity log from the bridge. * - * @param args - Optional pagination args. + * `number` is the page size; `page` is 1-indexed. The bridge forwards + * both to WPCOM, which returns `totalItems` / `totalPages` in the + * envelope — those values drive DataViews' pagination footer. + * + * @param args - Pagination args. + * @param args.page - 1-indexed page number. + * @param args.number - Items per page. * @return The raw WPCOM-shaped response. */ export async function fetchActivityLog( args: FetchArgs = {} ): Promise< WpcomActivityLogResponse > { return apiCall< WpcomActivityLogResponse >( { - path: apiPath( '/site/rewindable-activity', { number: args.number } ), + path: apiPath( '/site/rewindable-activity', { number: args.number, page: args.page } ), } ); } diff --git a/projects/packages/backup/src/dashboard/data/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts index 0995c703b696..4457986ad72e 100644 --- a/projects/packages/backup/src/dashboard/data/query-client.ts +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -32,11 +32,14 @@ export const queryClient = new QueryClient( { */ export const keys = { capabilities: () => [ 'backup', 'capabilities' ] as const, - // Single shared key: the hook always fetches one fixed window of - // rewindable activity and filters/paginates client-side, so every - // consumer (list, default-selection, by-id lookup) subscribes to - // the same entry. - activityLogWindow: () => [ 'backup', 'activity-log-window' ] as const, + // Family prefix for any rewindable-activity-log page. Use as a + // query-filter root to scan all cached pages (e.g. when looking up + // a row by id across pages). + activityLogRoot: () => [ 'backup', 'activity-log' ] as const, + // Per-page key. `(page, pageSize)` is the only thing that + // distinguishes one fetch from another, so both must be in the key. + activityLogPage: ( page: number, pageSize: number ) => + [ 'backup', 'activity-log', { page, pageSize } ] as const, fileTree: ( rewindId: string, folderPath: string | null ) => [ 'backup', 'file-tree', rewindId, folderPath ] as const, fileContents: ( rewindId: string, path: string ) => diff --git a/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts index 08a6d554ece8..6f6d5fd2e79f 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts @@ -1,6 +1,10 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo } from '@wordpress/element'; -import { fetchActivityLog } from '../data/api/activity-log'; +import { + fetchActivityLog, + type WpcomActivityEntry, + type WpcomActivityLogResponse, +} from '../data/api/activity-log'; import { normalizeActivityLog } from '../data/normalize/activity-log'; import { keys } from '../data/query-client'; import type { ActivityItem } from '../types/activity'; @@ -8,7 +12,6 @@ import type { ActivityItem } from '../types/activity'; type Args = { page: number; pageSize: number; - search: string; }; type Result = { @@ -19,119 +22,131 @@ type Result = { error: Error | null; }; -const MAX_FETCH_ITEMS = 100; - /** - * Shared `useQuery` for the single rewindable-activity window the - * dashboard fetches. Centralizing the query options means every - * consumer (`useActivityLog`, `useActivityById`, - * `useDefaultBackupRewindId`) subscribes to the same cache entry, so - * TanStack dedups the fetch and any consumer mounted after the window - * resolves re-renders without a refetch. - * - * @return The cached window query. + * Default per-page size for the rewindable activity list. Mirrors + * `ActivityList`'s `DEFAULT_PER_PAGE` so the default-selection lookup + * (`useDefaultBackupRewindId`) shares a cache entry with the list's + * first fetch when the user hasn't changed the per-page setting. */ -function useActivityLogWindowQuery() { - return useQuery( { - queryKey: keys.activityLogWindow(), - queryFn: () => fetchActivityLog( { number: MAX_FETCH_ITEMS } ), - } ); -} +export const ACTIVITY_LOG_DEFAULT_PER_PAGE = 10; /** - * Case-insensitive predicate: returns true when the activity item's title - * or summary contains the search query. An empty query matches every item. + * Shared `useQuery` for a single page of the rewindable activity log. * - * WPCOM's `/sites/{id}/activity/rewindable` endpoint doesn't accept a - * search parameter, so the hook fetches a single window of recent - * activity (capped at `MAX_FETCH_ITEMS`) and filters client-side. This - * matches what DataViews expects: server returns a fixed window, the - * hook hands DataViews the filtered + paginated slice plus the totals - * it needs for its pagination footer. + * `keepPreviousData` keeps the previous page visible while the next + * page's request is in flight — DataViews' pagination feels smooth + * instead of flashing a spinner over the list on every page change. * - * @param item - Activity item to test. - * @param q - Search query (raw, untrimmed). - * @return True when the item matches the query. + * @param page - 1-indexed page number. + * @param pageSize - Items per page. + * @return The page query. */ -function matchesSearch( item: ActivityItem, q: string ): boolean { - if ( ! q ) { - return true; - } - const haystack = `${ item.title } ${ item.summary ?? '' }`.toLowerCase(); - return haystack.includes( q.toLowerCase() ); +function useActivityPageQuery( page: number, pageSize: number ) { + return useQuery( { + queryKey: keys.activityLogPage( page, pageSize ), + queryFn: () => fetchActivityLog( { page, number: pageSize } ), + placeholderData: keepPreviousData, + } ); } /** - * React Query hook returning a paginated, search-filtered slice of the + * React Query hook returning a single server-paginated page of the * site's rewindable activity log. * + * Mirrors the trunk `jetpack-activity-log` package's pattern: WPCOM + * does the paging, `totalItems` / `totalPages` come back in the + * envelope, and DataViews owns the footer. + * * @param args - Query args. * @param args.page - 1-indexed page number. * @param args.pageSize - Items per page. - * @param args.search - Search query (title + summary, case-insensitive). * @return Items, total items, total pages, loading, error. */ -export function useActivityLog( { page, pageSize, search }: Args ): Result { - const query = useActivityLogWindowQuery(); +export function useActivityLog( { page, pageSize }: Args ): Result { + const query = useActivityPageQuery( page, pageSize ); - const allItems = useMemo( + const items = useMemo( () => normalizeActivityLog( query.data?.current?.orderedItems ), [ query.data ] ); - const { items, totalItems, totalPages } = useMemo( () => { - const filtered = allItems.filter( item => matchesSearch( item, search ) ); - const total = Math.max( 1, Math.ceil( filtered.length / pageSize ) ); - const start = ( page - 1 ) * pageSize; - return { - items: filtered.slice( start, start + pageSize ), - totalItems: filtered.length, - totalPages: total, - }; - }, [ allItems, page, pageSize, search ] ); - return { items, - totalItems, - totalPages, + totalItems: query.data?.totalItems ?? items.length, + totalPages: query.data?.totalPages ?? Math.max( 1, Math.ceil( items.length / pageSize ) ), isLoading: query.isLoading, error: query.error ?? null, }; } /** - * Look up a single activity item from the cached rewindable-activity - * window. Reactive: subscribes via `useQuery`, so when the window - * fetch resolves the calling component re-renders with the item. + * Look up a single activity item from the currently-loaded page of + * rewindable activity, falling through to any other cached pages of + * the same family if not found. * - * @param id - Selection id: `rewindId` for backup items, `activity_id` otherwise. - * @return The matching item, or null when the cache hasn't been populated yet or the id doesn't match anything in the cached page. + * Selection happens by clicking a row, so the item is guaranteed to be + * in the page that's currently rendered. When the user lands via a + * bookmarked `?selected=` URL on a different page than the row, this + * hook returns null and the right pane shows the "Item not found" + * fallback until the user paginates to that page. + * + * @param id - Selection id: `rewindId` for backup items, `activity_id` otherwise. + * @param page - The page currently shown in the list. + * @param pageSize - The per-page setting currently shown in the list. + * @return The matching item, or null when nothing in the cached page(s) matches. */ -export function useActivityById( id: string | null ): ActivityItem | null { - const query = useActivityLogWindowQuery(); +export function useActivityById( + id: string | null, + page: number, + pageSize: number +): ActivityItem | null { + // Subscribe to the same page query the list uses so this hook + // re-renders the moment the list's data resolves. + const query = useActivityPageQuery( page, pageSize ); + const queryClient = useQueryClient(); + return useMemo( () => { if ( ! id ) { return null; } - const items = normalizeActivityLog( query.data?.current?.orderedItems ); - return ( - items.find( item => ( item.kind === 'backup' ? item.rewindId === id : item.id === id ) ) ?? - null - ); - }, [ query.data, id ] ); + const found = findById( query.data?.current?.orderedItems, id ); + if ( found ) { + return found; + } + // Fall through: scan any other cached pages of the rewindable + // family for the id (e.g. the user paginated away from the + // page that originally loaded their selection). + const cached = queryClient.getQueriesData< WpcomActivityLogResponse >( { + queryKey: keys.activityLogRoot(), + } ); + for ( const [ , data ] of cached ) { + const hit = findById( data?.current?.orderedItems, id ); + if ( hit ) { + return hit; + } + } + return null; + // `queryClient` is stable; the cache scan re-runs whenever the + // active page query resolves (covered by `query.data`). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ id, query.data ] ); } /** - * Returns the newest backup row in the cached rewindable-activity - * window, or null when the cache isn't populated or holds no backup + * Returns the newest backup row in the first page of rewindable + * activity, or null when the cache isn't populated or holds no backup * rows. Reactive: subscribes via `useQuery`, so Overview's first-load * default selection reconciles to the newest backup the moment the - * window fetch resolves. + * page-1 fetch resolves. + * + * Always reads page 1 with the default per-page size, regardless of + * which page the list is currently on. When the list is also on page 1 + * with the default size, TanStack dedupes — no extra fetch. * * @return The newest backup item's rewindId, or null. */ export function useDefaultBackupRewindId(): string | null { - const query = useActivityLogWindowQuery(); + const query = useActivityPageQuery( 1, ACTIVITY_LOG_DEFAULT_PER_PAGE ); return useMemo( () => { const items = normalizeActivityLog( query.data?.current?.orderedItems ); for ( const item of items ) { @@ -142,3 +157,25 @@ export function useDefaultBackupRewindId(): string | null { return null; }, [ query.data ] ); } + +/** + * Scan a raw WPCOM `orderedItems` array for a row matching the given + * selection id. Backup rows are addressed by `rewindId`, others by + * `activity_id` — `normalizeActivityLog` is run on the matched slice + * so the caller gets the same `ActivityItem` shape the rest of the + * UI consumes. + * + * @param entries - Raw WPCOM entries to scan, or undefined. + * @param id - Selection id to match. + * @return The matching activity item, or null. + */ +function findById( entries: WpcomActivityEntry[] | undefined, id: string ): ActivityItem | null { + if ( ! entries ) { + return null; + } + const normalized = normalizeActivityLog( entries ); + return ( + normalized.find( item => ( item.kind === 'backup' ? item.rewindId === id : item.id === id ) ) ?? + null + ); +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-connection.ts b/projects/packages/backup/src/dashboard/hooks/use-connection.ts index 758d4d40ac9d..12d7a336b7f5 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-connection.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-connection.ts @@ -23,9 +23,12 @@ type ConnectionStatus = { * memoizing avoids any chance of capturing a pre-emit empty snapshot * if load order ever drifts. * + * If the global is missing entirely (PHP failed to emit it), we treat + * the connection as "loaded but not connected" so `` falls + * through to the not-connected screen instead of spinning forever. * `@automattic/jetpack-connection`'s `declarations.d.ts` already types * the global ambiently — and declares it non-optional — so we don't - * redeclare it here, but we do keep the runtime guard because nothing + * redeclare it here, but we do keep that runtime guard because nothing * guarantees PHP emitted it. * * @return Connection state. @@ -33,13 +36,23 @@ type ConnectionStatus = { export function useConnection(): Result { const state = typeof window !== 'undefined' ? window.JP_CONNECTION_INITIAL_STATE : undefined; const status: ConnectionStatus = state?.connectionStatus ?? {}; + const hasGlobal = Boolean( state ); - const isLoaded = Object.keys( status ).length > 0; - const isFullyConnected = Boolean( isLoaded && status.isRegistered && status.hasConnectedOwner ); + if ( ! hasGlobal && typeof window !== 'undefined' ) { + // eslint-disable-next-line no-console + console.warn( + '[Jetpack Backup] JP_CONNECTION_INITIAL_STATE is missing — treating site as not connected.' + ); + } + + const isFullyConnected = Boolean( status.isRegistered && status.hasConnectedOwner ); const isSecondaryAdminNotConnected = Boolean( isFullyConnected && ! status.isUserConnected ); return { - isLoaded, + // Always loaded: either the global is present (with whatever + // keys it has, including `{}` for a disconnected site) or it's + // missing and we've decided to render the not-connected screen. + isLoaded: true, isFullyConnected, isSecondaryAdminNotConnected, hasConnectionError: false, diff --git a/projects/packages/backup/src/dashboard/hooks/use-download.ts b/projects/packages/backup/src/dashboard/hooks/use-download.ts index 84c475cb546f..c96d2359f22d 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-download.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-download.ts @@ -78,6 +78,15 @@ export function useDownload( rewindId: string ): Result { state = { phase: 'success', downloadUrl: statusQuery.data.url }; } else if ( statusQuery.data?.status === 'failed' ) { state = { phase: 'error', message: __( 'Download failed.', 'jetpack-backup-pkg' ) }; + } 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. + state = { + phase: 'error', + message: + statusQuery.error.message || + __( 'Lost connection while preparing download.', 'jetpack-backup-pkg' ), + }; } else if ( downloadId !== null && statusQuery.data ) { state = { phase: 'progress', diff --git a/projects/packages/backup/src/dashboard/hooks/use-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-restore.ts index c8dca8e6c780..bd27fdc00025 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-restore.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -81,6 +81,14 @@ export function useRestore( rewindId: string ): Result { phase: 'error', message: statusQuery.data.message || __( 'Restore failed.', 'jetpack-backup-pkg' ), }; + } else if ( restoreId !== 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. + state = { + phase: 'error', + message: + statusQuery.error.message || __( 'Lost connection while restoring.', 'jetpack-backup-pkg' ), + }; } else if ( restoreId !== null && statusQuery.data ) { state = { phase: 'progress', diff --git a/projects/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx index 9b9811b6e1d3..c19d44887df8 100644 --- a/projects/packages/backup/src/dashboard/screens/overview.tsx +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -1,4 +1,4 @@ -import { useCallback } from '@wordpress/element'; +import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { calendar } from '@wordpress/icons'; import { useNavigate, useSearch } from '@wordpress/route'; @@ -7,16 +7,37 @@ import ActivityDetail from '../components/activity-detail'; import ActivityList from '../components/activity-list'; import BackupDetail from '../components/backup-detail'; import DashboardLayout from '../components/dashboard-layout'; -import { useActivityById, useDefaultBackupRewindId } from '../hooks/use-activity-log'; +import { + ACTIVITY_LOG_DEFAULT_PER_PAGE, + useActivityById, + useDefaultBackupRewindId, +} from '../hooks/use-activity-log'; import { isBackupItem } from '../types/activity'; +import type { View } from '@wordpress/dataviews'; type OverviewSearch = Record< string, unknown > & { selected?: string }; +const INITIAL_VIEW: View = { + type: 'list', + page: 1, + perPage: ACTIVITY_LOG_DEFAULT_PER_PAGE, + filters: [], + titleField: 'title', + mediaField: 'icon', + descriptionField: 'description', + // In DataViews' list layout, the `titleField`, `mediaField` and + // `descriptionField` fields are rendered implicitly — anything else + // in `fields` would render a second time as a generic row. Leave + // `fields` empty so the title + media + description are the only + // things shown per row. + fields: [], +}; + /** * Overview screen for the modernized Backup dashboard. * * Renders the shared `` chrome around a two-pane body: the - * left pane is the searchable activity list; the right pane resolves the + * left pane is a paginated activity list; the right pane resolves the * selected row to a detail card. Selection is persisted in the URL via * `?selected=` so a refresh preserves it; on first visit the newest * backup is preselected so the right pane mirrors Calypso's behaviour. @@ -29,11 +50,16 @@ export default function OverviewScreen() { strict: false, } ) as OverviewSearch; const navigate = useNavigate(); - // Subscribe to the activity-log window so the right pane reconciles - // to the newest backup the moment the first fetch resolves. Until - // then, `defaultSelectedId` is null and the empty-state placeholder - // renders. The hook shares its query key with ActivityList's - // `useActivityLog`, so this doesn't issue an extra fetch. + // View state lives here so RightPane's `useActivityById` can + // subscribe to the same paginated query the list reads from. + const [ view, setView ] = useState< View >( INITIAL_VIEW ); + const page = view.page ?? 1; + const perPage = view.perPage ?? ACTIVITY_LOG_DEFAULT_PER_PAGE; + // Subscribe to page 1 of the activity log so the right pane + // reconciles to the newest backup the moment that page resolves. + // Until then, `defaultSelectedId` is null and the empty-state + // placeholder renders. Page 1 dedupes with the list's first + // fetch when the list is on page 1 with the default per-page. const defaultSelectedId = useDefaultBackupRewindId(); const selectedId = typeof search.selected === 'string' ? search.selected : defaultSelectedId; @@ -63,8 +89,13 @@ export default function OverviewScreen() { } >
- - + +
); @@ -80,10 +111,20 @@ export default function OverviewScreen() { * * @param props - Component props. * @param props.selectedId - Currently selected row id, or null when nothing is selected. + * @param props.page - The page currently shown in the list. + * @param props.pageSize - The per-page setting currently shown in the list. * @return The rendered detail card or an empty-state placeholder. */ -function RightPane( { selectedId }: { selectedId: string | null } ) { - const item = useActivityById( selectedId ); +function RightPane( { + selectedId, + page, + pageSize, +}: { + selectedId: string | null; + page: number; + pageSize: number; +} ) { + const item = useActivityById( selectedId, page, pageSize ); if ( ! selectedId ) { return (
diff --git a/projects/packages/backup/src/rest/class-activity-log-bridge.php b/projects/packages/backup/src/rest/class-activity-log-bridge.php index 14c5ea7aaefd..9ed18ef1d76b 100644 --- a/projects/packages/backup/src/rest/class-activity-log-bridge.php +++ b/projects/packages/backup/src/rest/class-activity-log-bridge.php @@ -44,6 +44,7 @@ public static function register_routes() { 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), 'args' => array( 'number' => array( 'type' => 'integer' ), + 'page' => array( 'type' => 'integer' ), ), ) ); @@ -52,6 +53,12 @@ public static function register_routes() { /** * Proxy the WPCOM rewindable activity log. * + * Forwards `number` (per page) and `page` (1-indexed) so the dashboard + * paginates against the real server response — `totalItems` / + * `totalPages` come back in the WPCOM envelope and drive DataViews' + * pagination footer. Same param shape the `automattic/jetpack-activity-log` + * package uses against the parent `/sites/{id}/activity` endpoint. + * * @param WP_REST_Request $request The REST request. * @return \WP_REST_Response|WP_Error */ @@ -62,7 +69,10 @@ public static function get_activity_log( WP_REST_Request $request ) { } $query = array_filter( - array( 'number' => $request->get_param( 'number' ) ), + array( + 'number' => $request->get_param( 'number' ), + 'page' => $request->get_param( 'page' ), + ), static function ( $value ) { return null !== $value && '' !== $value; }