From 298d83e47d2c3129cfe5b2bc04af7bea7098d7f6 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 01:22:23 -0300 Subject: [PATCH 01/14] Backup: scaffold modernized dashboard route with shared chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the scaffold's empty body with a real wp-build entry that mounts (which itself uses from @wordpress/admin-ui) wrapping an empty Overview screen. Adds shared TypeScript types, the mock-mode detector behind `?jpb-mock=1`, and a dev banner that only renders when mock mode is active. No PHP changes — the existing modernization filter continues to gate the whole dashboard via the scaffold's is_modernized() switch. Phase 1 of the Backup modernization. UI only; data wiring is a follow-up PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../add-backup-modernization-dashboard-ui | 3 ++ .../backup/routes/dashboard/package.json | 7 +++- .../backup/routes/dashboard/stage.tsx | 18 ++------- .../backup/routes/dashboard/style.scss | 4 ++ .../components/dashboard-layout/index.tsx | 39 +++++++++++++++++++ .../components/dashboard-layout/style.scss | 7 ++++ .../components/dev-mode-banner/index.tsx | 26 +++++++++++++ .../components/dev-mode-banner/style.scss | 9 +++++ .../src/dashboard/hooks/use-is-mock-mode.ts | 34 ++++++++++++++++ .../backup/src/dashboard/screens/overview.tsx | 19 +++++++++ .../backup/src/dashboard/types/activity.ts | 30 ++++++++++++++ .../backup/src/dashboard/types/file-tree.ts | 19 +++++++++ .../backup/src/dashboard/types/restore.ts | 24 ++++++++++++ .../add-backup-modernization-dashboard-ui | 3 ++ .../add-backup-modernization-dashboard-ui | 3 ++ 15 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 projects/packages/backup/changelog/add-backup-modernization-dashboard-ui create mode 100644 projects/packages/backup/routes/dashboard/style.scss create mode 100644 projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx create mode 100644 projects/packages/backup/src/dashboard/components/dashboard-layout/style.scss create mode 100644 projects/packages/backup/src/dashboard/components/dev-mode-banner/index.tsx create mode 100644 projects/packages/backup/src/dashboard/components/dev-mode-banner/style.scss create mode 100644 projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts create mode 100644 projects/packages/backup/src/dashboard/screens/overview.tsx create mode 100644 projects/packages/backup/src/dashboard/types/activity.ts create mode 100644 projects/packages/backup/src/dashboard/types/file-tree.ts create mode 100644 projects/packages/backup/src/dashboard/types/restore.ts create mode 100644 projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui create mode 100644 projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui diff --git a/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui b/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui new file mode 100644 index 000000000000..f839094cce83 --- /dev/null +++ b/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui @@ -0,0 +1,3 @@ +Significance: minor +Type: added +Comment: Backup: scaffold the modernized dashboard route with shared chrome and mocked-mode banner. No-op when the modernization filter is off. diff --git a/projects/packages/backup/routes/dashboard/package.json b/projects/packages/backup/routes/dashboard/package.json index f89149985c3f..0805363db098 100644 --- a/projects/packages/backup/routes/dashboard/package.json +++ b/projects/packages/backup/routes/dashboard/package.json @@ -5,8 +5,13 @@ "dependencies": { "@types/react": "18.3.28", "@wordpress/admin-ui": "2.0.0", + "@wordpress/components": "32.6.0", + "@wordpress/date": "5.27.0", "@wordpress/element": "6.44.0", - "@wordpress/i18n": "6.17.0" + "@wordpress/i18n": "6.17.0", + "@wordpress/icons": "12.2.0", + "@wordpress/route": "0.10.0", + "@wordpress/ui": "0.11.0" }, "route": { "path": "/", diff --git a/projects/packages/backup/routes/dashboard/stage.tsx b/projects/packages/backup/routes/dashboard/stage.tsx index e29fe85c77d5..1eb92fdafa4b 100644 --- a/projects/packages/backup/routes/dashboard/stage.tsx +++ b/projects/packages/backup/routes/dashboard/stage.tsx @@ -1,18 +1,6 @@ -import { Page } from '@wordpress/admin-ui'; -import { __ } from '@wordpress/i18n'; +import OverviewScreen from '../../src/dashboard/screens/overview'; +import './style.scss'; -const Stage = () => { - // "VaultPress Backup" is a product name, do not translate. - return ( - - ); -}; +const Stage = () => ; export { Stage as stage }; diff --git a/projects/packages/backup/routes/dashboard/style.scss b/projects/packages/backup/routes/dashboard/style.scss new file mode 100644 index 000000000000..fd4e0d9830a5 --- /dev/null +++ b/projects/packages/backup/routes/dashboard/style.scss @@ -0,0 +1,4 @@ +// Route-level styles for the modernized Backup dashboard. +// Per-component styles live alongside each component under +// src/dashboard/components/*. This file is intentionally minimal for +// Task 1; later tasks add layout rules for the two-pane Overview here. diff --git a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx new file mode 100644 index 000000000000..1dfd04499bae --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -0,0 +1,39 @@ +import { Page } from '@wordpress/admin-ui'; +import { __ } from '@wordpress/i18n'; +import DevModeBanner from '../dev-mode-banner'; +import './style.scss'; +import type { ReactNode } from 'react'; + +type Props = { + children: ReactNode; + actions?: ReactNode; +}; + +/** + * Shared shell for every screen of the modernized Backup dashboard. + * + * Wraps a `` from `@wordpress/admin-ui` (which provides the standard + * wp-admin chrome) with the dev-mode banner and a centered, max-width body + * container that every screen renders into. + * + * @param props - Component props. + * @param props.children - Screen contents to render inside the page body. + * @param props.actions - Optional nodes rendered in the page header's top-right action slot. + * @return The rendered dashboard shell. + */ +export default function DashboardLayout( { children, actions }: Props ) { + return ( + + +
{ children }
+
+ ); +} diff --git a/projects/packages/backup/src/dashboard/components/dashboard-layout/style.scss b/projects/packages/backup/src/dashboard/components/dashboard-layout/style.scss new file mode 100644 index 000000000000..5d6b83fb1dbc --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/style.scss @@ -0,0 +1,7 @@ +.jpb-dashboard-body { + box-sizing: border-box; + width: 100%; + max-width: 1344px; + margin-inline: auto; + padding: 24px; +} 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 new file mode 100644 index 000000000000..78de887bae39 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/dev-mode-banner/index.tsx @@ -0,0 +1,26 @@ +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 new file mode 100644 index 000000000000..cf346aab01b9 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/dev-mode-banner/style.scss @@ -0,0 +1,9 @@ +.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/hooks/use-is-mock-mode.ts b/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts new file mode 100644 index 000000000000..e17f0348de85 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts @@ -0,0 +1,34 @@ +const MOCK_URL_PARAM = 'jpb-mock'; + +let cachedValue: boolean | null = null; + +/** + * Read the mock-mode flag from the current URL's query string. + * + * @return True when the `?jpb-mock` query parameter is present. + */ +function readFromUrl(): boolean { + if ( typeof window === 'undefined' ) { + return false; + } + try { + return new URLSearchParams( window.location.search ).has( MOCK_URL_PARAM ); + } catch { + return false; + } +} + +/** + * Hook returning whether the dashboard should use fixture data. + * + * The value is read from the URL once and cached for the lifetime of the page, + * so subsequent calls are cheap and stable across re-renders. + * + * @return True when mock mode is active (`?jpb-mock=1`). + */ +export function useIsMockMode(): boolean { + if ( cachedValue === null ) { + cachedValue = readFromUrl(); + } + return cachedValue; +} diff --git a/projects/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx new file mode 100644 index 000000000000..c9c14cfa5b76 --- /dev/null +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -0,0 +1,19 @@ +import { __ } from '@wordpress/i18n'; +import DashboardLayout from '../components/dashboard-layout'; + +/** + * Overview screen for the modernized Backup dashboard. + * + * Renders the shared `` chrome around the screen body. + * The body is intentionally a placeholder in this task; subsequent tasks + * replace it with the activity stream and details pane. + * + * @return The rendered Overview screen. + */ +export default function OverviewScreen() { + return ( + +

{ __( 'Overview placeholder.', 'jetpack-backup-pkg' ) }

+
+ ); +} diff --git a/projects/packages/backup/src/dashboard/types/activity.ts b/projects/packages/backup/src/dashboard/types/activity.ts new file mode 100644 index 000000000000..c5accd084b04 --- /dev/null +++ b/projects/packages/backup/src/dashboard/types/activity.ts @@ -0,0 +1,30 @@ +export type ActivityKind = 'backup' | 'post' | 'upload' | 'plugin-update' | 'theme-update'; + +export type ActivityActor = { + type: 'Application' | 'Person'; + name: string; +}; + +export type ActivityItemBase = { + id: string; + title: string; + publishedAt: string; + actor: ActivityActor; + summary?: string; +}; + +export type BackupActivityItem = ActivityItemBase & { + kind: 'backup'; + rewindId: string; + stats: string; + isComplete: boolean; +}; + +export type NonBackupActivityItem = ActivityItemBase & { + kind: Exclude< ActivityKind, 'backup' >; +}; + +export type ActivityItem = BackupActivityItem | NonBackupActivityItem; + +export const isBackupItem = ( item: ActivityItem ): item is BackupActivityItem => + item.kind === 'backup'; diff --git a/projects/packages/backup/src/dashboard/types/file-tree.ts b/projects/packages/backup/src/dashboard/types/file-tree.ts new file mode 100644 index 000000000000..2a8a3164d507 --- /dev/null +++ b/projects/packages/backup/src/dashboard/types/file-tree.ts @@ -0,0 +1,19 @@ +export type FileNodeBase = { + name: string; + path: string; +}; + +export type FolderNode = FileNodeBase & { + type: 'folder'; + children?: FileNode[]; +}; + +export type FileNodeFile = FileNodeBase & { + type: 'file'; + sizeBytes: number; + mimeType: string; +}; + +export type FileNode = FolderNode | FileNodeFile; + +export const isFolder = ( node: FileNode ): node is FolderNode => node.type === 'folder'; diff --git a/projects/packages/backup/src/dashboard/types/restore.ts b/projects/packages/backup/src/dashboard/types/restore.ts new file mode 100644 index 000000000000..78b211c96337 --- /dev/null +++ b/projects/packages/backup/src/dashboard/types/restore.ts @@ -0,0 +1,24 @@ +export type RestoreItems = { + themes: boolean; + plugins: boolean; + roots: boolean; + contents: boolean; + sqls: boolean; + uploads: boolean; +}; + +export const DEFAULT_RESTORE_ITEMS: RestoreItems = { + themes: true, + plugins: true, + roots: true, + contents: true, + sqls: true, + uploads: true, +}; + +export type RestoreState = + | { phase: 'idle' } + | { phase: 'submitting' } + | { phase: 'progress'; percent: number } + | { phase: 'success' } + | { phase: 'error'; message: string }; diff --git a/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui b/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui new file mode 100644 index 000000000000..f839094cce83 --- /dev/null +++ b/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui @@ -0,0 +1,3 @@ +Significance: minor +Type: added +Comment: Backup: scaffold the modernized dashboard route with shared chrome and mocked-mode banner. No-op when the modernization filter is off. diff --git a/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui b/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui new file mode 100644 index 000000000000..6aca6355b0f7 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui @@ -0,0 +1,3 @@ +Significance: patch +Type: other +Comment: Backup: scaffold the modernized dashboard route with shared chrome and mocked-mode banner. No-op when the modernization filter is off. From 28d25901607efad62d227d57c47a1e0e8a35718d Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 01:38:36 -0300 Subject: [PATCH 02/14] Backup: add Overview activity list with mocked data Fills the left pane of the modernized Overview with a paginated, searchable activity list backed by static fixtures + a synthetic-latency mock hook. Selection persists in the URL via `?selected=` so refresh keeps state. Right pane remains an empty-state card until the backup-detail commit lands. Adds `@wordpress/route` to the package root so the wordpress-externals plugin can resolve it from `src/dashboard/screens/overview.tsx`; the route sub-package already declared the same pin (mirrors Newsletter's setup, where the dep is listed in both root and routes/dashboard). Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 9 +- projects/packages/backup/package.json | 1 + .../backup/routes/dashboard/style.scss | 18 +++ .../components/activity-list/index.tsx | 113 ++++++++++++++++++ .../components/activity-list/style.scss | 34 ++++++ .../components/activity-row/index.tsx | 68 +++++++++++ .../components/activity-row/style.scss | 34 ++++++ .../src/dashboard/fixtures/activity-log.ts | 73 +++++++++++ .../dashboard/hooks/use-mock-activity-log.ts | 77 ++++++++++++ .../backup/src/dashboard/screens/overview.tsx | 52 +++++++- 10 files changed, 472 insertions(+), 7 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/components/activity-list/index.tsx create mode 100644 projects/packages/backup/src/dashboard/components/activity-list/style.scss create mode 100644 projects/packages/backup/src/dashboard/components/activity-row/index.tsx create mode 100644 projects/packages/backup/src/dashboard/components/activity-row/style.scss create mode 100644 projects/packages/backup/src/dashboard/fixtures/activity-log.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2eea6bc73a14..d80de3f174e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2054,6 +2054,9 @@ importers: '@wordpress/icons': specifier: 12.2.0 version: 12.2.0(react@18.3.1) + '@wordpress/route': + specifier: 0.10.0 + version: 0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/ui': specifier: 0.11.0 version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2108,7 +2111,7 @@ importers: version: 6.44.0 '@wordpress/build': specifier: 0.13.0 - version: 0.13.0(@babel/core@7.29.0)(browserslist@4.28.2) + version: 0.13.0(@babel/core@7.29.0)(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) browserslist: specifier: ^4.24.0 version: 4.28.2 @@ -24787,7 +24790,7 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.13.0(@babel/core@7.29.0)(browserslist@4.28.2)': + '@wordpress/build@0.13.0(@babel/core@7.29.0)(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 autoprefixer: 10.5.0(postcss@8.5.14) @@ -24804,6 +24807,8 @@ snapshots: postcss-modules: 6.0.1(postcss@8.5.14) rtlcss: 4.3.0 sass-embedded: 1.97.3 + optionalDependencies: + '@wordpress/route': 0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) transitivePeerDependencies: - '@babel/core' - browserslist diff --git a/projects/packages/backup/package.json b/projects/packages/backup/package.json index a05f0edefd32..9d6cd5a77da6 100644 --- a/projects/packages/backup/package.json +++ b/projects/packages/backup/package.json @@ -47,6 +47,7 @@ "@wordpress/element": "6.44.0", "@wordpress/i18n": "6.17.0", "@wordpress/icons": "12.2.0", + "@wordpress/route": "0.10.0", "@wordpress/ui": "0.11.0", "moment": "2.30.1", "prop-types": "^15.8.1", diff --git a/projects/packages/backup/routes/dashboard/style.scss b/projects/packages/backup/routes/dashboard/style.scss index fd4e0d9830a5..34b24961b99d 100644 --- a/projects/packages/backup/routes/dashboard/style.scss +++ b/projects/packages/backup/routes/dashboard/style.scss @@ -2,3 +2,21 @@ // Per-component styles live alongside each component under // src/dashboard/components/*. This file is intentionally minimal for // Task 1; later tasks add layout rules for the two-pane Overview here. + +.jpb-overview { + display: grid; + grid-template-columns: minmax(360px, 1fr) minmax(400px, 1.5fr); + gap: 16px; + width: 100%; + min-height: 600px; + + &__detail--empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + background-color: var(--wp-components-color-background-secondary, #f7f7f7); + border-radius: 8px; + padding: 24px; + } +} diff --git a/projects/packages/backup/src/dashboard/components/activity-list/index.tsx b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx new file mode 100644 index 000000000000..02153288eb08 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx @@ -0,0 +1,113 @@ +import { SearchControl, Spinner } from '@wordpress/components'; +import { useCallback, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { Icon, settings as settingsIcon } from '@wordpress/icons'; +import { Button, Card, Stack } from '@wordpress/ui'; +import { useMockActivityLog } from '../../hooks/use-mock-activity-log'; +import ActivityRow from '../activity-row'; +import './style.scss'; +import type { ActivityItem } from '../../types/activity'; + +const PAGE_SIZE = 10; + +type Props = { + selectedId: string | null; + onSelect: ( id: string ) => void; +}; + +/** + * Left pane of the modernized Overview: a paginated, searchable activity list. + * + * Owns its own search and pagination state and reads items from the mock hook. + * Selection is owned by the parent so it can be reflected in the URL and used + * by the right-hand detail pane. + * + * @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. + * @return The rendered activity list card. + */ +export default function ActivityList( { selectedId, onSelect }: Props ) { + const [ search, setSearch ] = useState( '' ); + const [ page, setPage ] = useState( 1 ); + const { items, totalPages, isLoading } = useMockActivityLog( { + page, + pageSize: PAGE_SIZE, + search, + } ); + + const handleSearchChange = useCallback( ( next: string ) => { + setSearch( next ); + setPage( 1 ); + }, [] ); + + const handlePrevPage = useCallback( () => { + setPage( p => Math.max( 1, p - 1 ) ); + }, [] ); + + const handleNextPage = useCallback( () => { + setPage( p => Math.min( totalPages, p + 1 ) ); + }, [ totalPages ] ); + + return ( + + + + + + + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/activity-list/style.scss b/projects/packages/backup/src/dashboard/components/activity-list/style.scss new file mode 100644 index 000000000000..3fdaf57a300c --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/activity-list/style.scss @@ -0,0 +1,34 @@ +.jpb-activity-list { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + + &__header, + &__footer { + padding: 12px 16px; + } + + &__header { + border-bottom: 1px solid var(--wp-components-color-gray-200, #e0e0e0); + } + + &__footer { + border-top: 1px solid var(--wp-components-color-gray-200, #e0e0e0); + font-size: 13px; + } + + &__rows { + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + padding: 8px; + } + + &__loading { + display: flex; + justify-content: center; + padding: 32px 0; + } +} diff --git a/projects/packages/backup/src/dashboard/components/activity-row/index.tsx b/projects/packages/backup/src/dashboard/components/activity-row/index.tsx new file mode 100644 index 000000000000..b23506d087c1 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/activity-row/index.tsx @@ -0,0 +1,68 @@ +import { dateI18n } from '@wordpress/date'; +import { useCallback } from '@wordpress/element'; +import { Icon, cloud, image, post, plugins as pluginsIcon, color } from '@wordpress/icons'; +import { Stack, Text } from '@wordpress/ui'; +import { isBackupItem } from '../../types/activity'; +import './style.scss'; +import type { ActivityItem, ActivityKind } from '../../types/activity'; + +const ICON_BY_KIND: Record< ActivityKind, typeof cloud > = { + backup: cloud, + upload: image, + post, + 'plugin-update': pluginsIcon, + 'theme-update': color, +}; + +type Props = { + item: ActivityItem; + isSelected: boolean; + onSelect: ( id: string ) => void; +}; + +/** + * One row in the Overview activity list. + * + * Renders as a ` + ); +} diff --git a/projects/packages/backup/src/dashboard/components/activity-row/style.scss b/projects/packages/backup/src/dashboard/components/activity-row/style.scss new file mode 100644 index 000000000000..6b159e5c9c2f --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/activity-row/style.scss @@ -0,0 +1,34 @@ +.jpb-activity-row { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 12px 16px; + background: transparent; + border: 0; + border-radius: 4px; + text-align: left; + cursor: pointer; + + &:hover { + background-color: var(--wp-admin-theme-color-background, #f0f6fc); + } + + &.is-selected { + background-color: var(--wp-admin-theme-color-background, #e0ecf7); + } + + &__icon { + flex: 0 0 auto; + margin-top: 2px; + } + + &__body { + flex: 1 1 auto; + min-width: 0; + } + + &__summary { + color: var(--wp-components-color-foreground-muted, #757575); + } +} diff --git a/projects/packages/backup/src/dashboard/fixtures/activity-log.ts b/projects/packages/backup/src/dashboard/fixtures/activity-log.ts new file mode 100644 index 000000000000..61e7cbd99eb1 --- /dev/null +++ b/projects/packages/backup/src/dashboard/fixtures/activity-log.ts @@ -0,0 +1,73 @@ +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 ), +]; 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 new file mode 100644 index 000000000000..22cd715054a2 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts @@ -0,0 +1,77 @@ +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[]; + totalPages: number; + isLoading: boolean; +}; + +/** + * Predicate that returns true when the given activity item matches the search query. + * + * Matching is case-insensitive against the item's title and optional summary. + * An empty query returns every item. + * + * @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. + * + * 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 or 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 page count, and loading flag. + */ +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, search ] ); + + const { items, 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; + const slice = filtered.slice( start, start + pageSize ); + return { items: slice, totalPages: total }; + }, [ page, pageSize, search ] ); + + return { items, totalPages, isLoading }; +} diff --git a/projects/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx index c9c14cfa5b76..361199c8c456 100644 --- a/projects/packages/backup/src/dashboard/screens/overview.tsx +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -1,19 +1,61 @@ +import { useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { useNavigate, useSearch } from '@wordpress/route'; +import { Button, Stack, Text } from '@wordpress/ui'; +import ActivityList from '../components/activity-list'; import DashboardLayout from '../components/dashboard-layout'; +type OverviewSearch = Record< string, unknown > & { selected?: string }; + /** * Overview screen for the modernized Backup dashboard. * - * Renders the shared `` chrome around the screen body. - * The body is intentionally a placeholder in this task; subsequent tasks - * replace it with the activity stream and details pane. + * Renders the shared `` chrome around a two-pane body: the + * left pane is the searchable, paginated activity list; the right pane is an + * empty-state placeholder until the backup-detail commit lands. Selection is + * persisted in the URL via `?selected=` so a refresh preserves it. * * @return The rendered Overview screen. */ export default function OverviewScreen() { + const search = useSearch( { + from: '/' as unknown as never, + strict: false, + } ) as OverviewSearch; + const navigate = useNavigate(); + const selectedId = typeof search.selected === 'string' ? search.selected : null; + + const setSelected = useCallback( + ( id: string ) => { + // Merge into existing search so future params (filters, range, etc.) aren't dropped. + navigate( { + search: { ...search, selected: id }, + } as unknown as Parameters< typeof navigate >[ 0 ] ); + }, + [ navigate, search ] + ); + return ( - -

{ __( 'Overview placeholder.', 'jetpack-backup-pkg' ) }

+ + + + + } + > +
+ +
+ + { __( 'Select an item from the list to see details.', 'jetpack-backup-pkg' ) } + +
+
); } From ec773fc12aaff49b932bf22d8274e0b59971aa67 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 01:55:38 -0300 Subject: [PATCH 03/14] Backup: add right-pane backup + activity detail to Overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires `?selected=` to BackupDetail (status header + Download/Restore actions + stats + files placeholder) for backup rows, and a lighter ActivityDetail card for non-backup rows. Download/Restore actions deep-link to `/download/$rewindId` and `/restore/$rewindId` which will 404 until the Restore + Download routes land in later commits. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/activity-detail/index.tsx | 34 ++++++++++ .../components/backup-detail/index.tsx | 67 +++++++++++++++++++ .../components/backup-detail/style.scss | 59 ++++++++++++++++ .../src/dashboard/fixtures/activity-log.ts | 16 +++++ .../backup/src/dashboard/screens/overview.tsx | 44 ++++++++++-- 5 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/components/activity-detail/index.tsx create mode 100644 projects/packages/backup/src/dashboard/components/backup-detail/index.tsx create mode 100644 projects/packages/backup/src/dashboard/components/backup-detail/style.scss diff --git a/projects/packages/backup/src/dashboard/components/activity-detail/index.tsx b/projects/packages/backup/src/dashboard/components/activity-detail/index.tsx new file mode 100644 index 000000000000..5c8007571500 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/activity-detail/index.tsx @@ -0,0 +1,34 @@ +import { dateI18n } from '@wordpress/date'; +import { Card, Stack, Text } from '@wordpress/ui'; +import type { NonBackupActivityItem } from '../../types/activity'; + +type Props = { + item: NonBackupActivityItem; +}; + +/** + * Right-pane detail card for non-backup activity rows (post publish, + * upload, plugin update, theme update). Lighter than `` — + * no Download/Restore actions, no file browser slot. + * + * @param props - Component props. + * @param props.item - The selected non-backup activity item. + * @return The rendered activity-detail card. + */ +export default function ActivityDetail( { item }: Props ) { + return ( + + + }> + { item.title } + + + { dateI18n( 'M j, Y, g:i A', item.publishedAt, undefined ) } + { ' ' } + { item.actor.name } + + { item.summary && { item.summary } } + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx b/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx new file mode 100644 index 000000000000..17982f11e6a8 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx @@ -0,0 +1,67 @@ +import { dateI18n } from '@wordpress/date'; +import { __, sprintf } from '@wordpress/i18n'; +import { Icon, cloud, download as downloadIcon, backup as backupIcon } from '@wordpress/icons'; +import { Link } from '@wordpress/route'; +import { Card, Stack, Text } from '@wordpress/ui'; +import './style.scss'; +import type { BackupActivityItem } from '../../types/activity'; + +type Props = { + item: BackupActivityItem; +}; + +/** + * Right-pane detail card for a selected backup activity item. + * + * Shows the status header with Download / Restore actions linking to the + * matching sibling routes, the backup's summary line, a timestamp by-line, + * and a `__files` slot reserved for the file browser (Task 4). + * + * @param props - Component props. + * @param props.item - The selected backup activity item. + * @return The rendered detail card. + */ +export default function BackupDetail( { item }: Props ) { + return ( + + + + + }> + { __( 'Backup and scan complete', 'jetpack-backup-pkg' ) } + + + + + + { __( 'Download backup', 'jetpack-backup-pkg' ) } + + + + { __( 'Restore to this point', 'jetpack-backup-pkg' ) } + + + + { item.stats } + + { sprintf( + /* translators: %1$s formatted date+time, %2$s actor name */ + __( '%1$s by %2$s', 'jetpack-backup-pkg' ), + dateI18n( 'M j, Y, g:i A', item.publishedAt, undefined ), + item.actor.name + ) } + +
+ { /* Task 4 replaces this slot with the real file-browser tree. */ } + + Files placeholder — Task 4 will replace this. + +
+
+ ); +} diff --git a/projects/packages/backup/src/dashboard/components/backup-detail/style.scss b/projects/packages/backup/src/dashboard/components/backup-detail/style.scss new file mode 100644 index 000000000000..01a754d00554 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/backup-detail/style.scss @@ -0,0 +1,59 @@ +.jpb-backup-detail { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + + &__header { + padding-bottom: 12px; + border-bottom: 1px solid var(--wp-components-color-gray-200, #e0e0e0); + } + + &__download { + display: inline-flex; + gap: 6px; + align-items: center; + text-decoration: none; + } + + // Styled-link "primary button" for the Restore action. The router's + // `` renders an ``, so we avoid nesting it inside a `` HTML). + &__restore { + display: inline-flex; + gap: 6px; + align-items: center; + padding: 6px 12px; + border-radius: 2px; + background-color: var(--wp-admin-theme-color, #007cba); + font-size: 13px; + line-height: 1.4; + text-decoration: none; + + // wp-admin's global `a { color: #2271b1 }` is unlayered and wins + // over `@layer wp-ui-components`; force white explicitly. + &, + &:hover, + &:focus, + &:visited { + color: #fff; + } + + &:hover { + background-color: var(--wp-admin-theme-color-darker-10, #005a87); + } + + &:focus { + outline: 2px solid transparent; + box-shadow: 0 0 0 2px var(--wp-admin-theme-color, #007cba); + } + } + + &__stats { + font-weight: 600; + } + + &__files { + margin-top: 8px; + } +} diff --git a/projects/packages/backup/src/dashboard/fixtures/activity-log.ts b/projects/packages/backup/src/dashboard/fixtures/activity-log.ts index 61e7cbd99eb1..613e998c270f 100644 --- a/projects/packages/backup/src/dashboard/fixtures/activity-log.ts +++ b/projects/packages/backup/src/dashboard/fixtures/activity-log.ts @@ -71,3 +71,19 @@ export const MOCK_ACTIVITY_LOG: ActivityItem[] = [ 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/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx index 361199c8c456..55c22c7724f1 100644 --- a/projects/packages/backup/src/dashboard/screens/overview.tsx +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -2,8 +2,12 @@ import { useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useNavigate, useSearch } from '@wordpress/route'; import { Button, Stack, Text } from '@wordpress/ui'; +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 { findActivityById } from '../fixtures/activity-log'; +import { isBackupItem } from '../types/activity'; type OverviewSearch = Record< string, unknown > & { selected?: string }; @@ -50,12 +54,42 @@ export default function OverviewScreen() { >
-
- - { __( 'Select an item from the list to see details.', 'jetpack-backup-pkg' ) } - -
+
); } + +/** + * Right-pane router for the Overview screen. + * + * Resolves the URL-driven `selectedId` to an activity item and renders the + * matching detail card: `` for backup rows and `` + * for everything else. Falls back to an empty/not-found state when the + * selection is missing or doesn't resolve. + * + * @param props - Component props. + * @param props.selectedId - Currently selected row id, or null when nothing is selected. + * @return The rendered detail card or an empty-state placeholder. + */ +function RightPane( { selectedId }: { selectedId: string | null } ) { + if ( ! selectedId ) { + return ( +
+ { __( 'Select an item from the list to see details.', 'jetpack-backup-pkg' ) } +
+ ); + } + const item = findActivityById( selectedId ); + if ( ! item ) { + return ( +
+ { __( 'Item not found.', 'jetpack-backup-pkg' ) } +
+ ); + } + if ( isBackupItem( item ) ) { + return ; + } + return ; +} From e58791cc2206f18d83fa3364ede46f8e4edb4193 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 02:13:32 -0300 Subject: [PATCH 04/14] Backup: add file browser + info card to Overview detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces BackupDetail's "Files placeholder" with a tree of folders + files driven by useMockFileTree. Folders expand lazily with synthetic latency so loading and empty states are exercised. Selecting a file opens a side info-card with size, mime, path, and a monospace text preview for text/PHP/SQL/CSS sources. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/backup-detail/index.tsx | 6 +- .../components/file-browser/index.tsx | 199 ++++++++++++++++++ .../components/file-browser/style.scss | 46 ++++ .../components/file-info-card/index.tsx | 88 ++++++++ .../components/file-info-card/style.scss | 23 ++ .../src/dashboard/fixtures/file-contents.ts | 47 +++++ .../src/dashboard/fixtures/file-tree.ts | 68 ++++++ .../src/dashboard/hooks/use-mock-file-tree.ts | 48 +++++ 8 files changed, 521 insertions(+), 4 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/components/file-browser/index.tsx create mode 100644 projects/packages/backup/src/dashboard/components/file-browser/style.scss create mode 100644 projects/packages/backup/src/dashboard/components/file-info-card/index.tsx create mode 100644 projects/packages/backup/src/dashboard/components/file-info-card/style.scss create mode 100644 projects/packages/backup/src/dashboard/fixtures/file-contents.ts create mode 100644 projects/packages/backup/src/dashboard/fixtures/file-tree.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts diff --git a/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx b/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx index 17982f11e6a8..0e3cae5bf7a8 100644 --- a/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx +++ b/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx @@ -3,6 +3,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { Icon, cloud, download as downloadIcon, backup as backupIcon } from '@wordpress/icons'; import { Link } from '@wordpress/route'; import { Card, Stack, Text } from '@wordpress/ui'; +import FileBrowser from '../file-browser'; import './style.scss'; import type { BackupActivityItem } from '../../types/activity'; @@ -57,10 +58,7 @@ export default function BackupDetail( { item }: Props ) { ) }
- { /* Task 4 replaces this slot with the real file-browser tree. */ } - - Files placeholder — Task 4 will replace this. - +
); diff --git a/projects/packages/backup/src/dashboard/components/file-browser/index.tsx b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx new file mode 100644 index 000000000000..32991cfb2b96 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx @@ -0,0 +1,199 @@ +import { CheckboxControl, Spinner } from '@wordpress/components'; +import { useCallback, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { + Icon, + chevronRight, + chevronDown, + file as fileIcon, + category as folderIcon, +} from '@wordpress/icons'; +import { Stack, Text } from '@wordpress/ui'; +import { useMockFileTree } from '../../hooks/use-mock-file-tree'; +import { isFolder } from '../../types/file-tree'; +import FileInfoCard from '../file-info-card'; +import './style.scss'; +import type { FileNode, FileNodeFile } from '../../types/file-tree'; + +type Props = { + rewindId: string; +}; + +/** + * Lazy file-tree browser for the selected backup. Folders fetch their + * children on first expand via `useMockFileTree`; selecting a file opens + * `` to the right of the tree with a text preview when the + * mime type is text-shaped. + * + * @param props - Component props. + * @param props.rewindId - The selected backup's rewindId. Surfaced as a data + * attribute today; the future REST hook will use it to + * scope its requests to a specific backup point. + * @return The rendered tree. + */ +export default function FileBrowser( { rewindId }: Props ) { + const [ selected, setSelected ] = useState< Set< string > >( () => new Set() ); + const [ openFilePath, setOpenFilePath ] = useState< string | null >( null ); + const { children: roots } = useMockFileTree( null ); + + const toggleSelected = useCallback( ( path: string ) => { + setSelected( prev => { + const next = new Set( prev ); + if ( next.has( path ) ) { + next.delete( path ); + } else { + next.add( path ); + } + return next; + } ); + }, [] ); + + const clearSelected = useCallback( () => setSelected( new Set() ), [] ); + const closeInfoCard = useCallback( () => setOpenFilePath( null ), [] ); + + const openFile = roots ? findFileInTree( roots, openFilePath ) : null; + + return ( +
+ + { __( 'FILES', 'jetpack-backup-pkg' ) } + + + 0 } + label={ sprintf( + /* translators: %d count of selected files */ + __( '%d files selected', 'jetpack-backup-pkg' ), + selected.size + ) } + onChange={ clearSelected } + __nextHasNoMarginBottom + /> + +
+
+ { ( roots ?? [] ).map( node => ( + + ) ) } +
+ { openFile && } +
+
+ ); +} + +type NodeRowProps = { + node: FileNode; + depth: number; + selected: Set< string >; + onToggleSelected: ( path: string ) => void; + onOpenFile: ( path: string ) => void; +}; + +/** + * 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). + * + * @param props - Component props. + * @param props.node - The node to render. + * @param props.depth - Indent depth (root = 0). + * @param props.selected - Set of selected paths shared across rows. + * @param props.onToggleSelected - Toggle selection for a path. + * @param props.onOpenFile - Open the info-card for a file path. + * @return The rendered row. + */ +function NodeRow( { node, depth, selected, onToggleSelected, onOpenFile }: NodeRowProps ) { + const [ open, setOpen ] = useState( false ); + const nodeIsFolder = isFolder( node ); + const { children, isLoading } = useMockFileTree( open && nodeIsFolder ? node.path : null ); + + const handleToggleSelected = useCallback( + () => onToggleSelected( node.path ), + [ onToggleSelected, node.path ] + ); + const handleToggleOpen = useCallback( () => setOpen( v => ! v ), [] ); + const handleOpenFile = useCallback( () => onOpenFile( node.path ), [ onOpenFile, node.path ] ); + + return ( +
+
+ + { nodeIsFolder ? ( + + ) : ( + + ) } +
+ { open && nodeIsFolder && ( +
+ { isLoading && ( +
+ +
+ ) } + { ! isLoading && ( children ?? [] ).length === 0 && ( +
+ { __( 'Empty', 'jetpack-backup-pkg' ) } +
+ ) } + { ! isLoading && + ( children ?? [] ).map( child => ( + + ) ) } +
+ ) } +
+ ); +} + +/** + * 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-browser/style.scss b/projects/packages/backup/src/dashboard/components/file-browser/style.scss new file mode 100644 index 000000000000..45f093775bc9 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/file-browser/style.scss @@ -0,0 +1,46 @@ +.jpb-file-browser { + display: flex; + flex-direction: column; + gap: 8px; + + &__header { + padding: 4px 0; + } + + &__layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 280px); + gap: 16px; + } + + &__row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + } + + &__toggle, + &__file { + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + border: 0; + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + color: inherit; + } + + &__empty { + font-style: italic; + color: var(--wp-components-color-foreground-muted, #757575); + padding: 4px 0; + } + + &__loading { + padding: 4px 0; + } +} 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 new file mode 100644 index 000000000000..1bb37f610707 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx @@ -0,0 +1,88 @@ +import { __ } from '@wordpress/i18n'; +import { Icon, closeSmall } from '@wordpress/icons'; +import { Button, Card, Stack, Text } from '@wordpress/ui'; +import { findContents } from '../../fixtures/file-contents'; +import './style.scss'; +import type { FileNodeFile } from '../../types/file-tree'; + +type Props = { + file: FileNodeFile; + onClose: () => void; +}; + +/** + * Returns true when the given mime type is renderable as plain text. + * + * @param mime - Mime type string. + * @return Whether the type is textual. + */ +function isTextual( mime: string ): boolean { + return ( + mime.startsWith( 'text/' ) || + mime === 'application/x-php' || + mime === 'application/sql' || + mime === 'application/json' + ); +} + +/** + * 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: name + path + + * size + mime, plus a monospace preview for recognized text mime types. + * + * @param props - Component props. + * @param props.file - The file to render. + * @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; + + return ( + + + }> + { file.name } + + + + ) } + { state.phase === 'progress' && ( + + { __( 'Restoring…', 'jetpack-backup-pkg' ) } + + + ) } + { state.phase === 'success' && ( + + + { __( 'Restore complete.', 'jetpack-backup-pkg' ) } + + { __( 'Back to overview', 'jetpack-backup-pkg' ) } + + ) } + { state.phase === 'error' && ( + + + { state.message } + + + + ) } + + +
+ ); +} From eaed666d992381ba1695538a687a21391ce09272 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 02:37:53 -0300 Subject: [PATCH 06/14] Backup: add Download route reusing the restore checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the Restore route minus the warning notice. The mocked submit flow cycles idle → submitting → progress → success → mock download URL, with the same ~10% stochastic error branch. Reuses the shared `` component. This completes Phase 1: the modernized Backup dashboard now renders the overview, file browser, restore, and download screens end-to-end with mocked data. Real REST integration is a follow-up PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../add-backup-modernization-dashboard-ui | 2 +- .../backup/routes/download/package.json | 20 ++++ .../packages/backup/routes/download/route.tsx | 1 + .../packages/backup/routes/download/stage.tsx | 6 + .../backup/routes/download/style.scss | 27 +++++ .../src/dashboard/hooks/use-mock-download.ts | 82 ++++++++++++++ .../backup/src/dashboard/screens/download.tsx | 103 ++++++++++++++++++ .../add-backup-modernization-dashboard-ui | 2 +- .../add-backup-modernization-dashboard-ui | 2 +- 9 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 projects/packages/backup/routes/download/package.json create mode 100644 projects/packages/backup/routes/download/route.tsx create mode 100644 projects/packages/backup/routes/download/stage.tsx create mode 100644 projects/packages/backup/routes/download/style.scss create mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-download.ts create mode 100644 projects/packages/backup/src/dashboard/screens/download.tsx diff --git a/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui b/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui index f839094cce83..5172587e5431 100644 --- a/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui +++ b/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui @@ -1,3 +1,3 @@ Significance: minor Type: added -Comment: Backup: scaffold the modernized dashboard route with shared chrome and mocked-mode banner. No-op when the modernization filter is off. +Comment: Backup: flesh out the modernized dashboard behind `rsm_jetpack_ui_modernization_backup` — overview list, two-pane backup detail with file browser + preview, and narrow Restore + Download forms. UI only with mocked data; no-op when the modernization filter is off. diff --git a/projects/packages/backup/routes/download/package.json b/projects/packages/backup/routes/download/package.json new file mode 100644 index 000000000000..324d9bd3fb2d --- /dev/null +++ b/projects/packages/backup/routes/download/package.json @@ -0,0 +1,20 @@ +{ + "name": "_@jetpack-backup/download-route", + "version": "1.0.0", + "private": true, + "dependencies": { + "@types/react": "18.3.28", + "@wordpress/admin-ui": "2.0.0", + "@wordpress/components": "32.6.0", + "@wordpress/date": "5.27.0", + "@wordpress/element": "6.44.0", + "@wordpress/i18n": "6.17.0", + "@wordpress/icons": "12.2.0", + "@wordpress/route": "0.10.0", + "@wordpress/ui": "0.11.0" + }, + "route": { + "path": "/download/$rewindId", + "page": "jetpack-backup-dashboard" + } +} diff --git a/projects/packages/backup/routes/download/route.tsx b/projects/packages/backup/routes/download/route.tsx new file mode 100644 index 000000000000..91499ada4b32 --- /dev/null +++ b/projects/packages/backup/routes/download/route.tsx @@ -0,0 +1 @@ +export const route = {}; diff --git a/projects/packages/backup/routes/download/stage.tsx b/projects/packages/backup/routes/download/stage.tsx new file mode 100644 index 000000000000..d44b7fd35af6 --- /dev/null +++ b/projects/packages/backup/routes/download/stage.tsx @@ -0,0 +1,6 @@ +import DownloadScreen from '../../src/dashboard/screens/download'; +import './style.scss'; + +const Stage = () => ; + +export { Stage as stage }; diff --git a/projects/packages/backup/routes/download/style.scss b/projects/packages/backup/routes/download/style.scss new file mode 100644 index 000000000000..220258cbceb6 --- /dev/null +++ b/projects/packages/backup/routes/download/style.scss @@ -0,0 +1,27 @@ +.jpb-download { + max-width: 700px; + margin-inline: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + + &__back { + display: inline-flex; + align-items: center; + gap: 6px; + text-decoration: none; + width: max-content; + } + + &__card { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + } + + &__link { + font-weight: 600; + } +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-mock-download.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-download.ts new file mode 100644 index 000000000000..2cab0fe7501b --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-mock-download.ts @@ -0,0 +1,82 @@ +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/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx new file mode 100644 index 000000000000..b6b59c875515 --- /dev/null +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -0,0 +1,103 @@ +import { Notice, ProgressBar, Spinner } from '@wordpress/components'; +import { dateI18n } from '@wordpress/date'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Icon, cloud, download as downloadIcon, arrowLeft } from '@wordpress/icons'; +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 { DEFAULT_RESTORE_ITEMS } from '../types/restore'; + +/** + * 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. + * + * @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 [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); + const { state, submit, reset } = useMockDownload(); + + return ( + +
+ + + { __( 'Back to overview', 'jetpack-backup-pkg' ) } + + + + + + }> + { __( 'Download backup', 'jetpack-backup-pkg' ) } + + { downloadPoint && ( + + { __( 'Download point:', 'jetpack-backup-pkg' ) }{ ' ' } + { dateI18n( 'M j, Y, g:i A', downloadPoint, undefined ) } + + ) } + + + { ( state.phase === 'idle' || state.phase === 'submitting' ) && ( + <> + + { __( + 'Choose the items you wish to include in the download:', + 'jetpack-backup-pkg' + ) } + + + + + ) } + { state.phase === 'progress' && ( + + { __( 'Preparing download…', 'jetpack-backup-pkg' ) } + + + ) } + { state.phase === 'success' && ( + + + { __( 'Your download is ready.', 'jetpack-backup-pkg' ) } + + + { __( 'Download the file', 'jetpack-backup-pkg' ) } + + + ) } + { state.phase === 'error' && ( + + + { state.message } + + + + ) } + +
+
+ ); +} diff --git a/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui b/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui index f839094cce83..5172587e5431 100644 --- a/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui +++ b/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui @@ -1,3 +1,3 @@ Significance: minor Type: added -Comment: Backup: scaffold the modernized dashboard route with shared chrome and mocked-mode banner. No-op when the modernization filter is off. +Comment: Backup: flesh out the modernized dashboard behind `rsm_jetpack_ui_modernization_backup` — overview list, two-pane backup detail with file browser + preview, and narrow Restore + Download forms. UI only with mocked data; no-op when the modernization filter is off. diff --git a/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui b/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui index 6aca6355b0f7..27171917b368 100644 --- a/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui +++ b/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui @@ -1,3 +1,3 @@ Significance: patch Type: other -Comment: Backup: scaffold the modernized dashboard route with shared chrome and mocked-mode banner. No-op when the modernization filter is off. +Comment: Backup: flesh out the modernized dashboard behind `rsm_jetpack_ui_modernization_backup` — overview list, two-pane backup detail with file browser + preview, and narrow Restore + Download forms. UI only with mocked data; no-op when the modernization filter is off. From 5846975adff464775ca99cb5ef6e9807e84f4b41 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 03:47:20 -0300 Subject: [PATCH 07/14] 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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wire-backup-modernization-data-layer | 3 ++ .../backup/routes/dashboard/package.json | 1 + .../backup/routes/download/package.json | 1 + .../backup/routes/restore/package.json | 1 + .../components/dashboard-layout/index.tsx | 5 ++- .../backup/src/dashboard/data/query-client.ts | 41 +++++++++++++++++++ .../providers/query-client-provider.tsx | 19 +++++++++ .../wire-backup-modernization-data-layer | 3 ++ .../wire-backup-modernization-data-layer | 3 ++ 9 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 projects/packages/backup/changelog/wire-backup-modernization-data-layer 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/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..6f44e75925a4 --- /dev/null +++ b/projects/packages/backup/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: minor +Type: added +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability checks, drop the mock-mode dev banner. No-op when the modernization filter is off. diff --git a/projects/packages/backup/routes/dashboard/package.json b/projects/packages/backup/routes/dashboard/package.json index 0805363db098..001b0c10dd25 100644 --- a/projects/packages/backup/routes/dashboard/package.json +++ b/projects/packages/backup/routes/dashboard/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { + "@tanstack/react-query": "5.90.8", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.0.0", "@wordpress/components": "32.6.0", diff --git a/projects/packages/backup/routes/download/package.json b/projects/packages/backup/routes/download/package.json index 324d9bd3fb2d..a96873ae17e3 100644 --- a/projects/packages/backup/routes/download/package.json +++ b/projects/packages/backup/routes/download/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { + "@tanstack/react-query": "5.90.8", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.0.0", "@wordpress/components": "32.6.0", diff --git a/projects/packages/backup/routes/restore/package.json b/projects/packages/backup/routes/restore/package.json index c2cacc926d25..4c9c78e92cd0 100644 --- a/projects/packages/backup/routes/restore/package.json +++ b/projects/packages/backup/routes/restore/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "private": true, "dependencies": { + "@tanstack/react-query": "5.90.8", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.0.0", "@wordpress/components": "32.6.0", 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 1dfd04499bae..f60c38626e5a 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -1,5 +1,6 @@ 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'; @@ -33,7 +34,9 @@ export default function DashboardLayout( { children, actions }: Props ) { actions={ actions } > -
{ children }
+ +
{ children }
+
); } 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..72aad9119dba --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -0,0 +1,41 @@ +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, + 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..6f44e75925a4 --- /dev/null +++ b/projects/plugins/backup/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: minor +Type: added +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability 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..f1160b77c714 --- /dev/null +++ b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: patch +Type: other +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability checks, drop the mock-mode dev banner. No-op when the modernization filter is off. From ccb998566e230c6706801a01860382c8718ffb89 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 04:11:46 -0300 Subject: [PATCH 08/14] 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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) 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 | 30 ++++++ .../src/dashboard/data/api/capabilities.ts | 16 ++++ .../src/dashboard/hooks/use-capabilities.ts | 29 ++++++ .../src/dashboard/hooks/use-connection.ts | 53 ++++++++++ .../src/rest/class-capabilities-bridge.php | 96 +++++++++++++++++++ .../backup/src/rest/class-rest-controller.php | 48 ++++++++++ .../php/Rest_Capabilities_Bridge_Test.php | 91 ++++++++++++++++++ 13 files changed, 542 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 create mode 100644 projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php diff --git a/projects/packages/backup/src/class-jetpack-backup.php b/projects/packages/backup/src/class-jetpack-backup.php index 3196bebcd2e5..e68097869ec7 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. @@ -876,6 +877,27 @@ public static function maybe_load_wp_build() { self::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 ''; } /** @@ -931,7 +953,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 f60c38626e5a..3a0d6fb1949e 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 { 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'; @@ -35,7 +36,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..72f820b22ef8 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/style.scss @@ -0,0 +1,30 @@ +.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..9e02ee7fe1a1 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/capabilities.ts @@ -0,0 +1,16 @@ +import apiFetch from '@wordpress/api-fetch'; + +export type Capabilities = { + hasBackupPlan: boolean; + hasScan: boolean; + planSlug: string | null; +}; + +/** + * Fetch the site's backup capabilities. + * + * @return The capabilities payload. + */ +export async function fetchCapabilities(): Promise< Capabilities > { + return apiFetch< Capabilities >( { path: '/jetpack/v4/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..80157a6e5bf6 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-connection.ts @@ -0,0 +1,53 @@ +import { useMemo } from '@wordpress/element'; + +type Result = { + isLoaded: boolean; + isFullyConnected: boolean; + isSecondaryAdminNotConnected: boolean; + hasConnectionError: boolean; +}; + +type ConnectionStatus = { + isRegistered?: boolean; + isUserConnected?: boolean; + hasConnectedOwner?: boolean; +}; + +type ConnectionInitialState = { + connectionStatus?: ConnectionStatus; +}; + +declare global { + interface Window { + JP_CONNECTION_INITIAL_STATE?: ConnectionInitialState; + } +} + +/** + * 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. + * + * @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..13e346e77897 --- /dev/null +++ b/projects/packages/backup/src/rest/class-capabilities-bridge.php @@ -0,0 +1,96 @@ +` 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` (v2, as_user) and project the response + * into the shape the React layer expects. + * + * @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?force=wpcom', $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 ), + 'planSlug' => isset( $body['plan_slug'] ) ? (string) $body['plan_slug'] : null, + ) + ); + } +} 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..33afff157b9d --- /dev/null +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -0,0 +1,48 @@ +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() ); + } +} From 412065fb06fd7706cb14171d5b62cbd435dc8cc6 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 04:25:19 -0300 Subject: [PATCH 09/14] Backup: wire the activity list to /jetpack/v4/activity-log 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. Fixture activity-log.ts is deleted; findActivityById's three callers swap to: getCachedActivityById (Overview, reads from React Query cache) for the selected-row right-pane, and a direct rewindId -> Unix timestamp derivation for Restore and Download (the only field they used was the publishedAt timestamp anyway). Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 3 + projects/packages/backup/package.json | 1 + .../components/activity-list/index.tsx | 4 +- .../backup/src/dashboard/data/api/_helpers.ts | 79 +++++++++++++++ .../src/dashboard/data/api/activity-log.ts | 47 +++++++++ .../dashboard/data/api/test/_helpers.test.ts | 15 +++ .../dashboard/data/normalize/activity-log.ts | 67 +++++++++++++ .../data/normalize/test/activity-log.test.ts | 73 ++++++++++++++ .../src/dashboard/fixtures/activity-log.ts | 89 ----------------- .../src/dashboard/hooks/use-activity-log.ts | 89 +++++++++++++++++ .../dashboard/hooks/use-mock-activity-log.ts | 77 --------------- .../backup/src/dashboard/screens/download.tsx | 8 +- .../backup/src/dashboard/screens/overview.tsx | 4 +- .../backup/src/dashboard/screens/restore.tsx | 8 +- .../src/rest/class-activity-log-bridge.php | 99 +++++++++++++++++++ .../backup/src/rest/class-rest-controller.php | 1 + .../php/Rest_Activity_Log_Bridge_Test.php | 91 +++++++++++++++++ 17 files changed, 579 insertions(+), 176 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/data/api/_helpers.ts create mode 100644 projects/packages/backup/src/dashboard/data/api/activity-log.ts create mode 100644 projects/packages/backup/src/dashboard/data/api/test/_helpers.test.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 delete mode 100644 projects/packages/backup/src/dashboard/fixtures/activity-log.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-activity-log.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts create mode 100644 projects/packages/backup/src/rest/class-activity-log-bridge.php create mode 100644 projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d80de3f174e0..37dcee2749d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2060,6 +2060,9 @@ importers: '@wordpress/ui': specifier: 0.11.0 version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/url': + specifier: 4.44.0 + version: 4.44.0 moment: specifier: 2.30.1 version: 2.30.1 diff --git a/projects/packages/backup/package.json b/projects/packages/backup/package.json index 9d6cd5a77da6..66bb0e4ff057 100644 --- a/projects/packages/backup/package.json +++ b/projects/packages/backup/package.json @@ -49,6 +49,7 @@ "@wordpress/icons": "12.2.0", "@wordpress/route": "0.10.0", "@wordpress/ui": "0.11.0", + "@wordpress/url": "4.44.0", "moment": "2.30.1", "prop-types": "^15.8.1", "react": "18.3.1", 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 02153288eb08..d04dfa590a84 100644 --- a/projects/packages/backup/src/dashboard/components/activity-list/index.tsx +++ b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx @@ -3,7 +3,7 @@ import { useCallback, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { Icon, settings as settingsIcon } from '@wordpress/icons'; import { Button, Card, Stack } from '@wordpress/ui'; -import { useMockActivityLog } from '../../hooks/use-mock-activity-log'; +import { useActivityLog } from '../../hooks/use-activity-log'; import ActivityRow from '../activity-row'; import './style.scss'; import type { ActivityItem } from '../../types/activity'; @@ -30,7 +30,7 @@ type Props = { export default function ActivityList( { selectedId, onSelect }: Props ) { const [ search, setSearch ] = useState( '' ); const [ page, setPage ] = useState( 1 ); - const { items, totalPages, isLoading } = useMockActivityLog( { + const { items, totalPages, isLoading } = useActivityLog( { page, pageSize: PAGE_SIZE, search, 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..458dabbbdb24 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/_helpers.ts @@ -0,0 +1,79 @@ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; + +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`. + * + * @param options - apiFetch options. + * @return The decoded JSON response. + */ +export async function apiCall< T >( options: Parameters< typeof apiFetch >[ 0 ] ): 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/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/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/normalize/activity-log.ts b/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts new file mode 100644 index 000000000000..c035229732c5 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts @@ -0,0 +1,67 @@ +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, + stats: entry.object?.backup_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..df4430fb8808 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts @@ -0,0 +1,73 @@ +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' }, + object: { backup_stats: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page' }, +}; + +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: '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/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/hooks/use-activity-log.ts b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts new file mode 100644 index 000000000000..0854dd0c6f25 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts @@ -0,0 +1,89 @@ +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[]; + 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. + * + * @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 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, 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 ), totalPages: total }; + }, [ allItems, page, pageSize, search ] ); + + return { + items, + totalPages, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} + +/** + * Look up a single activity item from the React Query cache. + * + * @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 + ); +} 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 22cd715054a2..000000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts +++ /dev/null @@ -1,77 +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[]; - totalPages: number; - isLoading: boolean; -}; - -/** - * Predicate that returns true when the given activity item matches the search query. - * - * Matching is case-insensitive against the item's title and optional summary. - * An empty query returns every item. - * - * @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. - * - * 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 or 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 page count, and loading flag. - */ -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, search ] ); - - const { items, 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; - const slice = filtered.slice( start, start + pageSize ); - return { items: slice, totalPages: total }; - }, [ page, pageSize, search ] ); - - return { items, totalPages, isLoading }; -} diff --git a/projects/packages/backup/src/dashboard/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx index b6b59c875515..de8540855e5a 100644 --- a/projects/packages/backup/src/dashboard/screens/download.tsx +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -7,7 +7,7 @@ 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 { toIntRewindId } from '../data/api/_helpers'; import { useMockDownload } from '../hooks/use-mock-download'; import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; @@ -20,8 +20,10 @@ import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; */ export default function DownloadScreen() { const { rewindId } = useParams( { from: '/download/$rewindId' } ); - const item = findActivityById( rewindId ); - const downloadPoint = item ? item.publishedAt : null; + const downloadPoint = ( () => { + const seconds = parseInt( toIntRewindId( rewindId ), 10 ); + return Number.isFinite( seconds ) ? new Date( seconds * 1000 ).toISOString() : null; + } )(); const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); const { state, submit, reset } = useMockDownload(); diff --git a/projects/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx index 55c22c7724f1..f3559a02a313 100644 --- a/projects/packages/backup/src/dashboard/screens/overview.tsx +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -6,7 +6,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 { findActivityById } from '../fixtures/activity-log'; +import { getCachedActivityById } from '../hooks/use-activity-log'; import { isBackupItem } from '../types/activity'; type OverviewSearch = Record< string, unknown > & { selected?: string }; @@ -80,7 +80,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/dashboard/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx index 256cb1dfd3fd..93394f1d9bf1 100644 --- a/projects/packages/backup/src/dashboard/screens/restore.tsx +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -7,7 +7,7 @@ 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 { toIntRewindId } from '../data/api/_helpers'; import { useMockRestore } from '../hooks/use-mock-restore'; import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; @@ -21,8 +21,10 @@ import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; */ export default function RestoreScreen() { const { rewindId } = useParams( { from: '/restore/$rewindId' } ); - const item = findActivityById( rewindId ); - const restorePoint = item ? item.publishedAt : null; + const restorePoint = ( () => { + const seconds = parseInt( toIntRewindId( rewindId ), 10 ); + return Number.isFinite( seconds ) ? new Date( seconds * 1000 ).toISOString() : null; + } )(); const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); const { state, submit, reset } = useMockRestore(); 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 ) ); + } +} diff --git a/projects/packages/backup/src/rest/class-rest-controller.php b/projects/packages/backup/src/rest/class-rest-controller.php index 33afff157b9d..fb0536e44738 100644 --- a/projects/packages/backup/src/rest/class-rest-controller.php +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -35,6 +35,7 @@ public static function register_routes() { } Capabilities_Bridge::register_routes(); + Activity_Log_Bridge::register_routes(); } /** 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..468e1de3b0fa --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php @@ -0,0 +1,91 @@ +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() ); + } +} From 1e43b2f7f450f5c7ad3d3c9f9502c70c82dbd0b6 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 04:33:43 -0300 Subject: [PATCH 10/14] 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 and FileInfoCard is now actually consumed and threaded down to the bridge call. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/file-browser/index.tsx | 82 +++--- .../components/file-info-card/index.tsx | 27 +- .../src/dashboard/data/api/file-contents.ts | 24 ++ .../src/dashboard/data/api/file-tree.ts | 59 ++++ .../src/dashboard/fixtures/file-contents.ts | 47 ---- .../src/dashboard/fixtures/file-tree.ts | 68 ----- .../src/dashboard/hooks/use-file-contents.ts | 50 ++++ .../src/dashboard/hooks/use-file-tree.ts | 73 +++++ .../src/dashboard/hooks/use-mock-file-tree.ts | 48 ---- .../src/dashboard/hooks/use-path-info.ts | 33 +++ .../src/rest/class-file-browser-bridge.php | 253 ++++++++++++++++++ .../backup/src/rest/class-rest-controller.php | 1 + .../php/Rest_File_Browser_Bridge_Test.php | 109 ++++++++ 13 files changed, 660 insertions(+), 214 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 delete mode 100644 projects/packages/backup/src/dashboard/fixtures/file-contents.ts delete mode 100644 projects/packages/backup/src/dashboard/fixtures/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 delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-path-info.ts create mode 100644 projects/packages/backup/src/rest/class-file-browser-bridge.php create mode 100644 projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.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 32991cfb2b96..4e3bfce83ab9 100644 --- a/projects/packages/backup/src/dashboard/components/file-browser/index.tsx +++ b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx @@ -9,7 +9,7 @@ import { category as folderIcon, } from '@wordpress/icons'; import { Stack, Text } from '@wordpress/ui'; -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'; @@ -19,22 +19,28 @@ type Props = { rewindId: string; }; +// File selections store the resolved `FileNodeFile` rather than just a +// path: the tree fetches children lazily per folder, so a path-only +// lookup walked across roots can't find files inside collapsed-then- +// re-expanded folders. Holding the resolved node directly side-steps +// that and keeps `` decoupled from where the click came from. + /** * 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. * * @param props - Component props. - * @param props.rewindId - The selected backup's rewindId. Surfaced as a data - * attribute today; the future REST hook will use it to - * scope its requests to a specific backup point. + * @param props.rewindId - The selected backup's rewindId. Threaded into the + * file-tree and file-contents bridge calls so each + * request is scoped to the chosen backup point. * @return The rendered tree. */ export default function FileBrowser( { rewindId }: Props ) { const [ selected, setSelected ] = useState< Set< string > >( () => new Set() ); - const [ openFilePath, setOpenFilePath ] = useState< string | null >( null ); - const { children: roots } = useMockFileTree( null ); + const [ openFile, setOpenFile ] = useState< FileNodeFile | null >( null ); + const { children: roots } = useFileTree( rewindId, null ); const toggleSelected = useCallback( ( path: string ) => { setSelected( prev => { @@ -49,12 +55,10 @@ export default function FileBrowser( { rewindId }: Props ) { }, [] ); const clearSelected = useCallback( () => setSelected( new Set() ), [] ); - const closeInfoCard = useCallback( () => setOpenFilePath( null ), [] ); - - const openFile = roots ? findFileInTree( roots, openFilePath ) : null; + const closeInfoCard = useCallback( () => setOpenFile( null ), [] ); return ( -
+
{ __( 'FILES', 'jetpack-backup-pkg' ) } @@ -77,13 +81,16 @@ export default function FileBrowser( { rewindId }: Props ) { key={ node.path } node={ node } depth={ 0 } + rewindId={ rewindId } selected={ selected } onToggleSelected={ toggleSelected } - onOpenFile={ setOpenFilePath } + onOpenFile={ setOpenFile } /> ) ) }
- { openFile && } + { openFile && ( + + ) }
); @@ -92,35 +99,48 @@ export default function FileBrowser( { rewindId }: Props ) { type NodeRowProps = { node: FileNode; depth: number; + rewindId: string; selected: Set< string >; onToggleSelected: ( path: string ) => void; - onOpenFile: ( path: string ) => void; + onOpenFile: ( file: FileNodeFile ) => void; }; /** * Recursive row inside the file-browser tree. Folders own their own - * expand state; while a folder is open, `useMockFileTree` keeps its + * expand state; while a folder is open, `useFileTree` keeps its * children resolved (re-collapsing and re-opening re-issues the fetch). * * @param props - Component props. * @param props.node - The node to render. * @param props.depth - Indent depth (root = 0). + * @param props.rewindId - Backup rewind id, threaded into the fetcher. * @param props.selected - Set of selected paths shared across rows. * @param props.onToggleSelected - Toggle selection for a path. * @param props.onOpenFile - Open the info-card for a file path. * @return The rendered row. */ -function NodeRow( { node, depth, selected, onToggleSelected, onOpenFile }: NodeRowProps ) { +function NodeRow( { + node, + depth, + rewindId, + selected, + onToggleSelected, + onOpenFile, +}: 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 handleToggleSelected = useCallback( () => onToggleSelected( node.path ), [ onToggleSelected, node.path ] ); const handleToggleOpen = useCallback( () => setOpen( v => ! v ), [] ); - const handleOpenFile = useCallback( () => onOpenFile( node.path ), [ onOpenFile, node.path ] ); + const handleOpenFile = useCallback( () => { + if ( ! isFolder( node ) ) { + onOpenFile( node ); + } + }, [ onOpenFile, node ] ); return (
@@ -162,6 +182,7 @@ function NodeRow( { node, depth, selected, onToggleSelected, onOpenFile }: NodeR key={ child.path } node={ child } depth={ depth + 1 } + rewindId={ rewindId } selected={ selected } onToggleSelected={ onToggleSelected } onOpenFile={ onOpenFile } @@ -172,28 +193,3 @@ function NodeRow( { node, depth, selected, onToggleSelected, onOpenFile }: NodeR
); } - -/** - * 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 1bb37f610707..7a8060695887 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,11 +1,13 @@ import { __ } from '@wordpress/i18n'; import { Icon, 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 { usePathInfo } from '../../hooks/use-path-info'; import './style.scss'; import type { FileNodeFile } from '../../types/file-tree'; type Props = { + rewindId: string; file: FileNodeFile; onClose: () => void; }; @@ -45,13 +47,20 @@ function humanSize( bytes: number ): string { * Side panel showing details for the currently-open file: name + path + * size + mime, plus a monospace preview for recognized text mime types. * - * @param props - Component props. - * @param props.file - The file to render. - * @param props.onClose - Callback to close the card. + * @param props - Component props. + * @param props.rewindId - Backup rewind id, threaded into the file-contents fetcher. + * @param props.file - The file to render. + * @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; +export default function FileInfoCard( { rewindId, file, onClose }: Props ) { + // The tree response only carries names + types — `mime_type` and `size` + // come from path-info, which we fetch once per opened file. + const { data: pathInfo, isLoading: pathInfoLoading } = usePathInfo( rewindId, file.path ); + const mimeType = pathInfo?.mime_type ?? file.mimeType; + const sizeBytes = pathInfo?.size ?? file.sizeBytes; + const canPreview = ! pathInfoLoading && isTextual( mimeType ); + const { content: contents } = useFileContents( rewindId, file.path, canPreview ); return ( @@ -71,7 +80,7 @@ export default function FileInfoCard( { file, onClose }: Props ) { { file.path } - { humanSize( file.sizeBytes ) } { file.mimeType } + { humanSize( sizeBytes ) } { mimeType }
@@ -79,7 +88,9 @@ export default function FileInfoCard( { file, onClose }: Props ) {
{ contents }
) : ( - { __( 'No preview available for this file type.', 'jetpack-backup-pkg' ) } + { canPreview + ? __( 'Loading preview…', 'jetpack-backup-pkg' ) + : __( 'No preview available for this file type.', 'jetpack-backup-pkg' ) } ) }
diff --git a/projects/packages/backup/src/dashboard/data/api/file-contents.ts b/projects/packages/backup/src/dashboard/data/api/file-contents.ts new file mode 100644 index 000000000000..36789d4904f0 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/file-contents.ts @@ -0,0 +1,24 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +export type FileContentsResponse = { + content: string; +}; + +/** + * Fetch a text file's contents via the proxy (WPCOM signed URL + 64KB cap). + * + * @param rewindId - The backup's rewind id. + * @param encodedManifestPath - Base64-encoded manifest path. + * @return The decoded file content. + */ +export async function fetchFileContents( + rewindId: string, + encodedManifestPath: string +): Promise< FileContentsResponse > { + return apiCall< FileContentsResponse >( { + path: apiPath( '/rewind/backup/file-content', { + rewind_id: toIntRewindId( rewindId ), + 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..14ae35bbbe15 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/file-tree.ts @@ -0,0 +1,59 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +export type WpcomFileNode = { + name: string; + type: 'd' | 'f' | 'archive'; + manifest_path?: string; +}; + +export type WpcomLsResponse = { + contents?: 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, + }, + } ); +} + +export type WpcomPathInfo = { + size?: number; + last_modified?: string; + mime_type?: string; +}; + +/** + * Fetch metadata for a single file inside a backup. + * + * @param rewindId - The backup's rewind id. + * @param manifestPath - The file's manifest path. + * @return The decoded WPCOM path-info response. + */ +export async function fetchPathInfo( + rewindId: string, + manifestPath: string +): Promise< WpcomPathInfo > { + return apiCall< WpcomPathInfo >( { + path: apiPath( '/rewind/backup/path-info' ), + method: 'POST', + data: { + rewind_id: toIntRewindId( rewindId ), + manifest_path: manifestPath, + }, + } ); +} 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 ffd42ec8619e..000000000000 --- a/projects/packages/backup/src/dashboard/fixtures/file-tree.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { isFolder } from '../types/file-tree'; -import type { FileNode } from '../types/file-tree'; - -const file = ( name: string, path: string, sizeBytes: number, mimeType: string ): FileNode => ( { - type: 'file', - name, - path, - sizeBytes, - mimeType, -} ); - -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-file-contents.ts b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts new file mode 100644 index 000000000000..a5cf363facd2 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts @@ -0,0 +1,50 @@ +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. Uses `window.btoa` in the browser and falls back to Node's + * `Buffer` for tests/SSR. + * + * @param manifestPath - The raw manifest path. + * @return Base64-encoded path. + */ +function encodeManifestPath( manifestPath: string ): string { + if ( typeof window !== 'undefined' && typeof window.btoa === 'function' ) { + return window.btoa( manifestPath ); + } + return Buffer.from( manifestPath, 'utf-8' ).toString( 'base64' ); +} + +/** + * Hook fetching a text file's contents for the preview pane. + * + * @param rewindId - The backup's rewind id. + * @param manifestPath - The file's manifest path (NOT base64-encoded — encoded here). + * @param enabled - When false, the query is skipped (e.g. binary mime types). + * @return Content + loading state. + */ +export function useFileContents( + rewindId: string, + manifestPath: string | null, + enabled: boolean +): Result { + const query = useQuery( { + queryKey: keys.fileContents( rewindId, manifestPath ?? '' ), + queryFn: () => fetchFileContents( rewindId, encodeManifestPath( manifestPath ?? '' ) ), + enabled: enabled && Boolean( rewindId ) && Boolean( manifestPath ), + } ); + + 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..10d4454c4bde --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-file-tree.ts @@ -0,0 +1,73 @@ +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 raw WPCOM ls node into the dashboard's `FileNode` shape, joining + * the child's name onto the parent path so callers see absolute paths. + * + * @param raw - The WPCOM ls entry. + * @param parentPath - Path of the folder the entry lives in. + * @return The mapped `FileNode`. + */ +function toFileNode( raw: WpcomFileNode, parentPath: string ): FileNode { + const fullPath = + parentPath === BASE_FOLDER_PATH ? `/${ raw.name }` : `${ parentPath }/${ raw.name }`; + + if ( raw.type === 'd' || raw.type === 'archive' ) { + return { + type: 'folder', + name: raw.name, + path: fullPath, + }; + } + return { + type: 'file', + name: raw.name, + path: fullPath, + sizeBytes: 0, + mimeType: 'application/octet-stream', + }; +} + +/** + * 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; + } + return ( query.data.contents ?? [] ).map( child => toFileNode( child, path ) ); + }, [ query.data, path ] ); + + return { + children, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} 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 383bb573a499..000000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useState } from '@wordpress/element'; -import { findNodeByPath, MOCK_FILE_TREE } 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 children of a folder in the mock file tree. - * - * Passing `null` returns the root nodes synchronously (no latency); passing - * a folder path resolves the children after a 300ms synthetic delay so the - * tree exercises its per-folder loading state. Identical signature to what - * the future REST-driven hook will expose. - * - * @param folderPath - Folder path to load, or null for the root. - * @return Loaded children + loading flag. - */ -export function useMockFileTree( folderPath: string | null ): Result { - // Seed state synchronously when asked for the root so the tree paints - // fully on first render. Non-root paths still flip through isLoading. - const [ isLoading, setIsLoading ] = useState( false ); - const [ children, setChildren ] = useState< FileNode[] | null >( - folderPath === null ? MOCK_FILE_TREE : null - ); - - useEffect( () => { - if ( folderPath === null ) { - setChildren( MOCK_FILE_TREE ); - 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-path-info.ts b/projects/packages/backup/src/dashboard/hooks/use-path-info.ts new file mode 100644 index 000000000000..1311b21ab3a4 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-path-info.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchPathInfo, type WpcomPathInfo } from '../data/api/file-tree'; + +type Result = { + data: WpcomPathInfo | undefined; + isLoading: boolean; + error: Error | null; +}; + +/** + * Hook fetching path-info metadata (size, mime type, last modified) for + * a single file inside a backup. + * + * `useFileTree` doesn't return mime + size on its tree entries — WPCOM's + * `/rewind/backup/ls` only ships `name` + `type`. Path-info is a + * per-file follow-up call that the FileInfoCard issues on open. + * + * @param rewindId - The backup's rewind id. + * @param manifestPath - The file's manifest path, or null when no file is open. + * @return Path-info query state. + */ +export function usePathInfo( rewindId: string, manifestPath: string | null ): Result { + const query = useQuery( { + queryKey: [ 'backup', 'path-info', rewindId, manifestPath ] as const, + queryFn: () => fetchPathInfo( rewindId, manifestPath as string ), + enabled: Boolean( rewindId ) && Boolean( manifestPath ), + } ); + return { + data: query.data, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} 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..6159cf2aecd8 --- /dev/null +++ b/projects/packages/backup/src/rest/class-file-browser-bridge.php @@ -0,0 +1,253 @@ + 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/path-info', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'get_path_info' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'manifest_path' => array( + 'type' => 'string', + 'required' => true, + ), + 'extension_type' => array( + 'type' => 'string', + ), + ), + ) + ); + + 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( + 'rewind_id' => 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' ) ); + } + + /** + * Get metadata for a single file. Proxies POST wpcom/v2 /sites/{id}/rewind/backup/path-info. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_path_info( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/rewind/backup/path-info', $blog_id ), + 'v2', + array( 'method' => 'POST' ), + array( + 'backup_id' => $request->get_param( 'rewind_id' ), + 'manifest_path' => $request->get_param( 'manifest_path' ), + 'extension_type' => $request->get_param( 'extension_type' ), + ), + 'wpcom' + ); + + return self::forward_response( $response, 'backup_path_info_fetch_failed', __( 'Could not fetch file metadata.', '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. + * + * @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' ); + $rewind_id = (string) $request->get_param( 'rewind_id' ); + $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( $rewind_id ), + 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 ) ); + } +} diff --git a/projects/packages/backup/src/rest/class-rest-controller.php b/projects/packages/backup/src/rest/class-rest-controller.php index fb0536e44738..6a0e84f6a20b 100644 --- a/projects/packages/backup/src/rest/class-rest-controller.php +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -36,6 +36,7 @@ public static function register_routes() { Capabilities_Bridge::register_routes(); Activity_Log_Bridge::register_routes(); + File_Browser_Bridge::register_routes(); } /** 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..72f059852e79 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php @@ -0,0 +1,109 @@ +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 path-info route registers when the modernization filter is on. + */ + public function test_path_info_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/backup/path-info', $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( 'rewind_id', '123' ); + $request->set_param( 'encoded_manifest_path', 'abc' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } +} From e0ea337ba4586ed1452de259f7968699d454b14d Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 04:40:59 -0300 Subject: [PATCH 11/14] 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. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backup/src/dashboard/data/api/download.ts | 43 +++++ .../src/dashboard/hooks/use-download.ts | 86 +++++++++ .../src/dashboard/hooks/use-mock-download.ts | 82 --------- .../backup/src/dashboard/screens/download.tsx | 4 +- .../backup/src/rest/class-download-bridge.php | 168 ++++++++++++++++++ .../backup/src/rest/class-rest-controller.php | 1 + .../tests/php/Rest_Download_Bridge_Test.php | 103 +++++++++++ 7 files changed, 403 insertions(+), 84 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 delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-download.ts create mode 100644 projects/packages/backup/src/rest/class-download-bridge.php create mode 100644 projects/packages/backup/tests/php/Rest_Download_Bridge_Test.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..e46769a48d90 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-download.ts @@ -0,0 +1,86 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback, useState } from '@wordpress/element'; +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.' }; + } 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/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/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx index de8540855e5a..4e51301316f2 100644 --- a/projects/packages/backup/src/dashboard/screens/download.tsx +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -8,7 +8,7 @@ import { Button, Card, Stack, Text } from '@wordpress/ui'; import DashboardLayout from '../components/dashboard-layout'; import RestoreItemsChecklist from '../components/restore-items-checklist'; import { toIntRewindId } from '../data/api/_helpers'; -import { useMockDownload } from '../hooks/use-mock-download'; +import { useDownload } from '../hooks/use-download'; import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; /** @@ -25,7 +25,7 @@ export default function DownloadScreen() { return Number.isFinite( seconds ) ? new Date( seconds * 1000 ).toISOString() : null; } )(); 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 ) ); + } +} diff --git a/projects/packages/backup/src/rest/class-rest-controller.php b/projects/packages/backup/src/rest/class-rest-controller.php index 6a0e84f6a20b..74940d841f4b 100644 --- a/projects/packages/backup/src/rest/class-rest-controller.php +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -37,6 +37,7 @@ public static function register_routes() { Capabilities_Bridge::register_routes(); Activity_Log_Bridge::register_routes(); File_Browser_Bridge::register_routes(); + Download_Bridge::register_routes(); } /** 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..bef5a0892364 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Download_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 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() ); + } +} From 207e36a635d5b6a2ef31048189d6a7f74a0e8ac2 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 04:45:49 -0300 Subject: [PATCH 12/14] 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 from `d3f8b8da7e` 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"). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backup/src/dashboard/data/api/restore.ts | 39 ++++ .../src/dashboard/hooks/use-mock-restore.ts | 75 ------- .../backup/src/dashboard/hooks/use-restore.ts | 90 +++++++++ .../backup/src/dashboard/screens/restore.tsx | 4 +- .../backup/src/rest/class-rest-controller.php | 1 + .../backup/src/rest/class-restore-bridge.php | 183 ++++++++++++++++++ .../tests/php/Rest_Restore_Bridge_Test.php | 102 ++++++++++ 7 files changed, 417 insertions(+), 77 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/data/api/restore.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-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 create mode 100644 projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.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-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 }; -} 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..1e5ca5b3107a --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -0,0 +1,90 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback, useState } from '@wordpress/element'; +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.', + }; + } 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 93394f1d9bf1..cbcc543de0cb 100644 --- a/projects/packages/backup/src/dashboard/screens/restore.tsx +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -8,7 +8,7 @@ import { Button, Card, Stack, Text } from '@wordpress/ui'; import DashboardLayout from '../components/dashboard-layout'; import RestoreItemsChecklist from '../components/restore-items-checklist'; import { toIntRewindId } from '../data/api/_helpers'; -import { useMockRestore } from '../hooks/use-mock-restore'; +import { useRestore } from '../hooks/use-restore'; import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; /** @@ -26,7 +26,7 @@ export default function RestoreScreen() { return Number.isFinite( seconds ) ? new Date( seconds * 1000 ).toISOString() : null; } )(); 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-rest-controller.php b/projects/packages/backup/src/rest/class-rest-controller.php index 74940d841f4b..a117d1cecb3e 100644 --- a/projects/packages/backup/src/rest/class-rest-controller.php +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -38,6 +38,7 @@ public static function register_routes() { Activity_Log_Bridge::register_routes(); File_Browser_Bridge::register_routes(); Download_Bridge::register_routes(); + Restore_Bridge::register_routes(); } /** 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'] : '', + ) + ); + } +} 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..ef68697686d7 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php @@ -0,0 +1,102 @@ +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 b638b9913936ede886be492d5625f5fdbeb9764d Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 04:46:58 -0300 Subject: [PATCH 13/14] Backup: drop the ?jpb-mock=1 dev-mode banner now that everything is real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the last mock-mode artifacts: useIsMockMode, the dev banner component, 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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/dashboard-layout/index.tsx | 6 ++-- .../components/dev-mode-banner/index.tsx | 26 -------------- .../components/dev-mode-banner/style.scss | 9 ----- .../src/dashboard/hooks/use-is-mock-mode.ts | 34 ------------------- 4 files changed, 2 insertions(+), 73 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/hooks/use-is-mock-mode.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 3a0d6fb1949e..bde9fdd0f480 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -1,7 +1,6 @@ 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'; @@ -15,8 +14,8 @@ type Props = { * Shared shell for every screen of the modernized Backup dashboard. * * Wraps a `` from `@wordpress/admin-ui` (which provides the standard - * wp-admin chrome) with the dev-mode banner and a centered, max-width body - * container that every screen renders into. + * wp-admin chrome) with the dashboard's QueryClient + Gates and a centered, + * max-width body container that every screen renders into. * * @param props - Component props. * @param props.children - Screen contents to render inside the page body. @@ -34,7 +33,6 @@ export default function DashboardLayout( { children, actions }: Props ) { hasPadding={ false } 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/hooks/use-is-mock-mode.ts b/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts deleted file mode 100644 index e17f0348de85..000000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts +++ /dev/null @@ -1,34 +0,0 @@ -const MOCK_URL_PARAM = 'jpb-mock'; - -let cachedValue: boolean | null = null; - -/** - * Read the mock-mode flag from the current URL's query string. - * - * @return True when the `?jpb-mock` query parameter is present. - */ -function readFromUrl(): boolean { - if ( typeof window === 'undefined' ) { - return false; - } - try { - return new URLSearchParams( window.location.search ).has( MOCK_URL_PARAM ); - } catch { - return false; - } -} - -/** - * Hook returning whether the dashboard should use fixture data. - * - * The value is read from the URL once and cached for the lifetime of the page, - * so subsequent calls are cheap and stable across re-renders. - * - * @return True when mock mode is active (`?jpb-mock=1`). - */ -export function useIsMockMode(): boolean { - if ( cachedValue === null ) { - cachedValue = readFromUrl(); - } - return cachedValue; -} From 4ba4ea270f2c421c44441e53e331acc6cee528f9 Mon Sep 17 00:00:00 2001 From: Douglas Date: Fri, 15 May 2026 04:54:38 -0300 Subject: [PATCH 14/14] Backup: post-review polish on Phase 2 data layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from the final code review: - Wrap the bare 'Download failed.' / 'Restore failed.' fallbacks in __() with the jetpack-backup-pkg textdomain (the strings flow into user-facing copy). - Route fetchCapabilities through apiCall + apiPath so a 4xx from /jetpack/v4/site/capabilities throws an ApiError with `code`, matching every other fetcher in the data layer. - Update Restore/Download screen JSDoc — "mocked state machine" was true in Phase 1; Phase 2 made it real. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backup/src/dashboard/data/api/capabilities.ts | 8 ++++++-- .../packages/backup/src/dashboard/hooks/use-download.ts | 3 ++- .../packages/backup/src/dashboard/hooks/use-restore.ts | 6 +++++- .../packages/backup/src/dashboard/screens/download.tsx | 4 ++-- .../packages/backup/src/dashboard/screens/restore.tsx | 5 ++--- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/projects/packages/backup/src/dashboard/data/api/capabilities.ts b/projects/packages/backup/src/dashboard/data/api/capabilities.ts index 9e02ee7fe1a1..42587393f986 100644 --- a/projects/packages/backup/src/dashboard/data/api/capabilities.ts +++ b/projects/packages/backup/src/dashboard/data/api/capabilities.ts @@ -1,4 +1,4 @@ -import apiFetch from '@wordpress/api-fetch'; +import { apiCall, apiPath } from './_helpers'; export type Capabilities = { hasBackupPlan: boolean; @@ -9,8 +9,12 @@ export type Capabilities = { /** * 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 apiFetch< Capabilities >( { path: '/jetpack/v4/site/capabilities' } ); + return apiCall< Capabilities >( { path: apiPath( '/site/capabilities' ) } ); } diff --git a/projects/packages/backup/src/dashboard/hooks/use-download.ts b/projects/packages/backup/src/dashboard/hooks/use-download.ts index e46769a48d90..a959fe95e57f 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-download.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-download.ts @@ -1,5 +1,6 @@ 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'; @@ -72,7 +73,7 @@ export function useDownload( rewindId: string ): Result { } 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.' }; + state = { phase: 'error', message: __( 'Download failed.', '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 1e5ca5b3107a..526477241e17 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-restore.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -1,5 +1,6 @@ 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'; @@ -75,7 +76,10 @@ export function useRestore( rewindId: string ): Result { } else if ( statusQuery.data?.status === 'failed' ) { state = { phase: 'error', - message: statusQuery.data.message || statusQuery.data.error_code || 'Restore failed.', + message: + statusQuery.data.message || + statusQuery.data.error_code || + __( 'Restore failed.', 'jetpack-backup-pkg' ), }; } else if ( restoreId !== null && statusQuery.data ) { state = { diff --git a/projects/packages/backup/src/dashboard/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx index 4e51301316f2..80894980ed65 100644 --- a/projects/packages/backup/src/dashboard/screens/download.tsx +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -13,8 +13,8 @@ import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; /** * 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 initiates a WPCOM-side download prep; the + * success branch surfaces the signed download URL as a link. * * @return The rendered Download screen. */ diff --git a/projects/packages/backup/src/dashboard/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx index cbcc543de0cb..1dbe525bad74 100644 --- a/projects/packages/backup/src/dashboard/screens/restore.tsx +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -13,9 +13,8 @@ import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; /** * 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 kicks off a + * WPCOM rewind and polls until it lands on success, failure, or error. * * @return The rendered Restore screen. */