diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2eea6bc73a14..a786037b5b4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2036,12 +2036,18 @@ importers: '@wordpress/api-fetch': specifier: 7.44.0 version: 7.44.0 + '@wordpress/base-styles': + specifier: 6.20.0 + version: 6.20.0 '@wordpress/components': specifier: 32.6.0 version: 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/data': specifier: 10.44.0 version: 10.44.0(react@18.3.1) + '@wordpress/dataviews': + specifier: 14.1.0 + version: 14.1.0(@types/react@18.3.28)(react@18.3.1) '@wordpress/date': specifier: 5.44.0 version: 5.44.0 @@ -2054,6 +2060,12 @@ 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/theme': + specifier: 0.11.0 + version: 0.11.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 +2120,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))(@wordpress/theme@0.11.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,28 +24799,6 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.13.0(@babel/core@7.29.0)(browserslist@4.28.2)': - dependencies: - '@emotion/babel-plugin': 11.13.5 - autoprefixer: 10.5.0(postcss@8.5.14) - browserslist-to-esbuild: 2.1.1(browserslist@4.28.2) - change-case: 4.1.2 - chokidar: 4.0.3 - cssnano: 7.1.9(postcss@8.5.14) - esbuild: 0.27.4 - esbuild-plugin-babel: 0.2.3(@babel/core@7.29.0) - esbuild-sass-plugin: 3.3.1(esbuild@0.27.4)(sass-embedded@1.97.3) - fast-glob: 3.3.3 - moment-timezone: 0.5.48 - postcss: 8.5.14 - postcss-modules: 6.0.1(postcss@8.5.14) - rtlcss: 4.3.0 - sass-embedded: 1.97.3 - transitivePeerDependencies: - - '@babel/core' - - browserslist - - supports-color - '@wordpress/commands@1.44.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/base-styles': 6.20.0 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..5172587e5431 --- /dev/null +++ b/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui @@ -0,0 +1,3 @@ +Significance: minor +Type: added +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/package.json b/projects/packages/backup/package.json index a05f0edefd32..c6f96414c1ad 100644 --- a/projects/packages/backup/package.json +++ b/projects/packages/backup/package.json @@ -41,12 +41,16 @@ "@tanstack/react-query": "5.90.8", "@wordpress/admin-ui": "2.0.0", "@wordpress/api-fetch": "7.44.0", + "@wordpress/base-styles": "6.20.0", "@wordpress/components": "32.6.0", "@wordpress/data": "10.44.0", + "@wordpress/dataviews": "14.1.0", "@wordpress/date": "5.44.0", "@wordpress/element": "6.44.0", "@wordpress/i18n": "6.17.0", "@wordpress/icons": "12.2.0", + "@wordpress/route": "0.10.0", + "@wordpress/theme": "0.11.0", "@wordpress/ui": "0.11.0", "moment": "2.30.1", "prop-types": "^15.8.1", diff --git a/projects/packages/backup/routes/dashboard/package.json b/projects/packages/backup/routes/dashboard/package.json index f89149985c3f..017db6d29c43 100644 --- a/projects/packages/backup/routes/dashboard/package.json +++ b/projects/packages/backup/routes/dashboard/package.json @@ -3,10 +3,18 @@ "version": "1.0.0", "private": true, "dependencies": { + "@automattic/jetpack-components": "workspace:*", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.0.0", + "@wordpress/components": "32.6.0", + "@wordpress/dataviews": "14.1.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/theme": "0.11.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..2d21c6b299d0 --- /dev/null +++ b/projects/packages/backup/routes/dashboard/style.scss @@ -0,0 +1,74 @@ +// Route-level styles for the modernized Backup dashboard. +// Per-component styles live alongside each component under +// src/dashboard/components/*. + +@use "sass:meta"; +@use "@automattic/jetpack-base-styles/admin-page-layout" as *; + +// Pull in the bundled DataViews stylesheet. `@wordpress/dataviews` ships +// its CSS in `build-style/style.css` rather than auto-injecting it from +// the JS entry, so we explicitly @include it once at the route level +// (mirrors VideoPress's `routes/library/style.scss`). Without this the +// list-layout primary action button falls back to wp-admin's default +// browser button styling and shows as a visible artifact in every row. +@include meta.load-css("@wordpress/dataviews/build-style/style.css"); + +// Pin the wp-admin content column to the viewport and turn its inner +// flex chain into "fixed header + scrollable middle + pinned footer" +// — matches the modernized VideoPress / Forms dashboards (see +// `projects/packages/videopress/src/dashboard/admin-shell.scss`). +// Without this, JetpackFooter scrolls with the body when the activity +// list grows past the viewport. Scoped to the backup admin body so it +// can't leak onto other Jetpack pages. +body.jetpack_page_jetpack-backup { + @include jetpack-admin-page-layout-wp-build; + + // The shared mixin assumes `` from `@automattic/jetpack-components` + // (which stamps `.jp-admin-page` AND admin-ui's `.admin-ui-page` / + // `.admin-ui-page__header` classes verbatim). Our DashboardLayout uses + // `` from `@wordpress/admin-ui` 2.x, whose root + header classes + // are emotion-hashed (`___page`, `___header`) and don't + // match the mixin's selectors. We add `.jp-admin-page` to the Page so + // the mixin's outer flex chain still attaches, and reproduce the + // page-internal "header / middle / footer" sticky layout here using + // our own stable class names (`.jpb-dev-mode-banner`, + // `.jpb-dashboard-body`, `.jetpack-footer`). + .jp-admin-page { + + > header { + flex-shrink: 0; + } + + > .jpb-dev-mode-banner { + flex-shrink: 0; + } + + > .jpb-dashboard-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + } + + > .jetpack-footer { + flex-shrink: 0; + } + } +} + +.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/routes/download/package.json b/projects/packages/backup/routes/download/package.json new file mode 100644 index 000000000000..bb646ac133d8 --- /dev/null +++ b/projects/packages/backup/routes/download/package.json @@ -0,0 +1,22 @@ +{ + "name": "_@jetpack-backup/download-route", + "version": "1.0.0", + "private": true, + "dependencies": { + "@automattic/jetpack-components": "workspace:*", + "@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/theme": "0.11.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..a725145b8241 --- /dev/null +++ b/projects/packages/backup/routes/download/style.scss @@ -0,0 +1,42 @@ +.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; + } + + // Card.Root lays its children out as a flex column, so 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; + } + + // The `` SVG defaults to `fill: currentColor` but wp-admin's + // global `svg { fill: ... }` rules can override it for links. + // Force the icon to inherit the white link color. + svg { + fill: currentColor; + } + + &:hover { + background-color: var(--wp-admin-theme-color-darker-10, #005a87); + } + + &:focus { + outline: 2px solid transparent; + box-shadow: 0 0 0 2px var(--wp-admin-theme-color, #007cba); + } + } + + &__stats { + font-weight: 600; + } + + // "Files" section inside the body. Sits below the meta row and holds + // the FileBrowser tree. A subtle top border separates it from the + // summary, matching the legacy admin's section divider. + &__files { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--wp-components-color-gray-200, #dcdcde); + } + + &__files-title { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--wp-components-color-foreground-muted, #757575); + margin-bottom: 8px; + } +} 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..3e43ce035dfb --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -0,0 +1,54 @@ +import JetpackFooter from '@automattic/jetpack-components/jetpack-footer'; +import JetpackLogo from '@automattic/jetpack-components/jetpack-logo'; +import { Page } from '@wordpress/admin-ui'; +import { __ } from '@wordpress/i18n'; +import DevModeBanner from '../dev-mode-banner'; +import './style.scss'; +import type { ReactNode } from 'react'; + +type Props = { + children: ReactNode; + actions?: ReactNode; +}; + +const PRODUCT_NAME = 'VaultPress Backup'; // Product name; do not translate. + +/** + * Shared shell for every screen of the modernized Backup dashboard. + * + * Wraps a `` from `@wordpress/admin-ui` (the standard wp-admin + * chrome) with a Jetpack logo in the `visual` slot, the dev-mode banner, + * the page body, and `` at the bottom — matching every + * other modernized Jetpack dashboard (Newsletter, VideoPress, Forms). + * + * The page is laid out so the body grows to fill the viewport and the + * footer stays parked at the bottom when content is short, but scrolls + * naturally with the body when it overflows. + * + * @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 ( + } + title={ PRODUCT_NAME } + ariaLabel={ PRODUCT_NAME } + subTitle={ __( + 'Save changes and restore quickly with one-click recovery.', + 'jetpack-backup-pkg' + ) } + hasPadding={ false } + actions={ actions } + > + +
+
{ 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..e4857c099270 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/style.scss @@ -0,0 +1,17 @@ +// The body is the scrollable middle slot: it spans the full width of +// the Page content area so the scrollbar lines up with the right edge +// of the page (matching VideoPress / Forms). The inner wrapper holds +// the centering + padding so screen content keeps to the dashboard's +// 1344px ribbon without dragging the scrollbar inward. +.jpb-dashboard-body { + box-sizing: border-box; + width: 100%; + + &__inner { + 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/components/file-browser/index.tsx b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx new file mode 100644 index 000000000000..f3e252c029e4 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx @@ -0,0 +1,347 @@ +import { CheckboxControl, Spinner } from '@wordpress/components'; +import { useCallback, useMemo, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { + Icon, + chevronRight, + chevronDown, + file as fileIcon, + category as folderIcon, +} from '@wordpress/icons'; +import { Stack } 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'; + +/** + * Tree-checkbox selection state. + * + * `selected` holds paths the visitor explicitly checked; `deselected` + * holds the exception paths they unchecked while inside a selected + * ancestor's subtree. A path is "effectively selected" when its closest + * own-set entry (or, falling back, an ancestor's `selected` entry) is + * positive — `selected` beats `deselected` at the same row, and a row's + * own entry beats any ancestor. + */ +export type FileSelection = { + selected: ReadonlySet< string >; + deselected: ReadonlySet< string >; +}; + +export const EMPTY_FILE_SELECTION: FileSelection = { + selected: new Set(), + deselected: new Set(), +}; + +type Props = { + rewindId: string; + selection: FileSelection; + onSelectionChange: ( next: FileSelection ) => void; +}; + +/** + * Returns true when any path in `paths` is a descendant of `prefix`. + * + * @param paths - Set of paths to scan. + * @param prefix - Ancestor path (without trailing slash). + * @return True when at least one path starts with `prefix + "/"`. + */ +function hasDescendant( paths: ReadonlySet< string >, prefix: string ): boolean { + const needle = `${ prefix }/`; + for ( const p of paths ) { + if ( p.startsWith( needle ) ) { + return true; + } + } + return false; +} + +/** + * 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. + * + * Selection state lives in the parent (``) so its header + * buttons can swap between "Download backup" and "Download N selected + * files" using the same `FileSelection` shape that this tree drives. + * + * @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. + * @param props.selection - Current selection state (selected + deselected sets). + * @param props.onSelectionChange - Called with the next state when any row toggles. + * @return The rendered tree. + */ +export default function FileBrowser( { rewindId, selection, onSelectionChange }: Props ) { + const [ openFilePath, setOpenFilePath ] = useState< string | null >( null ); + const { children: roots } = useMockFileTree( null ); + const { selected, deselected } = selection; + + // Toggle a row given its current effective state. The caller passes + // `effectiveBefore` so the row can resolve "I see myself as checked" + // without re-deriving the inherited state here. + // + // Effective-checked → unchecked: + // - own entry in `selected`: drop it AND prune any descendant + // entries from both sets (they no longer have a positive parent + // to qualify against). + // - otherwise (checked via an ancestor): add this path to + // `deselected` as an exception. + // + // Effective-unchecked → checked: + // - own entry in `deselected`: drop it so the ancestor's positive + // state takes over again. + // - otherwise: add this path to `selected`. + const toggleAt = useCallback( + ( path: string, effectiveBefore: boolean ) => { + const nextSelected = new Set( selected ); + const nextDeselected = new Set( deselected ); + + if ( effectiveBefore ) { + if ( selected.has( path ) ) { + nextSelected.delete( path ); + const needle = `${ path }/`; + for ( const s of selected ) { + if ( s.startsWith( needle ) ) { + nextSelected.delete( s ); + } + } + for ( const d of deselected ) { + if ( d.startsWith( needle ) ) { + nextDeselected.delete( d ); + } + } + } else { + nextDeselected.add( path ); + } + } else if ( deselected.has( path ) ) { + nextDeselected.delete( path ); + } else { + nextSelected.add( path ); + } + + onSelectionChange( { selected: nextSelected, deselected: nextDeselected } ); + }, + [ selected, deselected, onSelectionChange ] + ); + + // The selection summary's checkbox doubles as a "select all / clear" + // toggle: clicking it with anything selected clears both sets, + // clicking with nothing selected seeds every top-level root path as + // a positive selection. Mirrors the legacy backup-contents header + // — selecting a folder includes its whole subtree on the server side, + // so we don't need to recurse the lazy-loaded child paths here. + const toggleSelectAll = useCallback( () => { + if ( selected.size > 0 ) { + onSelectionChange( EMPTY_FILE_SELECTION ); + return; + } + onSelectionChange( { + selected: new Set( ( roots ?? [] ).map( node => node.path ) ), + deselected: new Set(), + } ); + }, [ selected.size, roots, onSelectionChange ] ); + + const closeInfoCard = useCallback( () => setOpenFilePath( null ), [] ); + + const openFile = roots ? findFileInTree( roots, openFilePath ) : null; + + return ( +
+ + 0 } + label={ sprintf( + /* translators: %d count of selected files */ + __( '%d files selected', 'jetpack-backup-pkg' ), + selected.size + ) } + onChange={ toggleSelectAll } + __nextHasNoMarginBottom + /> + +
+
+ { ( roots ?? [] ).map( ( node, index ) => ( + + ) ) } +
+ { openFile && } +
+
+ ); +} + +type NodeRowProps = { + node: FileNode; + depth: number; + isAlternate: boolean; + ancestorSelected: boolean; + selection: FileSelection; + onToggle: ( path: string, effectiveBefore: boolean ) => 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). + * + * Two pieces of state propagate top-down: + * + * - `ancestorSelected`: the *effective* checked state of this row's + * nearest ancestor. The row resolves its own effective state with + * "own selected beats own deselected beats ancestor" and passes the + * result down to its children. + * - Zebra parity (`isAlternate`): toggled before each child so the + * stripe runs continuously through nested branches. + * + * A folder renders the indeterminate "—" dash when it's effectively + * checked AND any descendant path lives in `selection.deselected`. + * + * @param props - Component props. + * @param props.node - The node to render. + * @param props.depth - Indent depth (root = 0). + * @param props.isAlternate - Whether this row gets the alt (gray) background. + * @param props.ancestorSelected - True when this row inherits a checked state from + * a selected ancestor (modulo its own deselection). + * @param props.selection - Current selection state (selected + deselected sets). + * @param props.onToggle - Called with the row's path and current effective + * state when the checkbox toggles. + * @param props.onOpenFile - Open the info-card for a file path. + * @return The rendered row. + */ +function NodeRow( { + node, + depth, + isAlternate, + ancestorSelected, + selection, + onToggle, + onOpenFile, +}: NodeRowProps ) { + const [ open, setOpen ] = useState( false ); + const nodeIsFolder = isFolder( node ); + const { children, isLoading } = useMockFileTree( open && nodeIsFolder ? node.path : null ); + const { selected, deselected } = selection; + + // Effective check: own positive > own negative > inherited positive. + const ownSelected = selected.has( node.path ); + const ownDeselected = deselected.has( node.path ); + const isEffectivelySelected = ownSelected || ( ! ownDeselected && ancestorSelected ); + + // Indeterminate when effectively checked AND some descendant has an + // exception entry. Memoized because `hasDescendant` is O(deselected), + // and the deselected set can change without flipping the parents' + // own state. + const isIndeterminate = useMemo( + () => + isEffectivelySelected && + nodeIsFolder && + hasDescendant( deselected, node.path ), + [ isEffectivelySelected, nodeIsFolder, deselected, node.path ] + ); + + const handleToggleSelected = useCallback( + () => onToggle( node.path, isEffectivelySelected ), + [ onToggle, node.path, isEffectivelySelected ] + ); + const handleToggleOpen = useCallback( () => setOpen( v => ! v ), [] ); + const handleOpenFile = useCallback( () => onOpenFile( node.path ), [ onOpenFile, node.path ] ); + + const rowClassName = isAlternate + ? 'jpb-file-browser__row jpb-file-browser__row--alt' + : 'jpb-file-browser__row'; + + return ( +
+
+ + { nodeIsFolder ? ( + + ) : ( + + ) } +
+ { open && nodeIsFolder && ( +
+ { isLoading && ( +
+ +
+ ) } + { ! isLoading && ( children ?? [] ).length === 0 && ( +
+ { __( 'Empty', 'jetpack-backup-pkg' ) } +
+ ) } + { ! isLoading && + ( children ?? [] ).map( ( child, index ) => ( + + ) ) } +
+ ) } +
+ ); +} + +/** + * 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..8111937c8020 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/file-browser/style.scss @@ -0,0 +1,68 @@ +.jpb-file-browser { + display: flex; + flex-direction: column; + gap: 8px; + + &__selection { + padding: 8px 0; + border-bottom: 1px solid #dcdcde; + } + + &__layout { + // Single-column by default — the tree owns the full panel width so + // zebra-stripe rows can run edge-to-edge. The 280px sidebar slot is + // only carved out when a `FileInfoCard` is actually rendered. + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + + &:has(> .jpb-file-info-card) { + grid-template-columns: minmax(0, 1fr) minmax(0, 280px); + } + } + + &__row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + + // Alt-row tint. Applied via a class set by `` so the + // stripe respects the legacy backup-contents component's parity + // rule — a white parent's first child renders gray, a gray + // parent's first child renders white. Using a literal hex + // rather than `--wp-components-color-gray-100` because the + // token is locally re-mapped to white inside `Card.Content`. + // The row is a block-level flex container that fills its + // wrapper's full width, so the tint spans the panel edge to + // edge — no inner border-radius (which would have looked like + // a "pill" cropped from the surrounding bg). + &--alt { + background-color: #f6f7f7; + } + } + + &__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..b821eb825b94 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx @@ -0,0 +1,127 @@ +import { dateI18n } from '@wordpress/date'; +import { __, sprintf } from '@wordpress/i18n'; +import { closeSmall } from '@wordpress/icons'; +import { Button, Card, Stack, Text } from '@wordpress/ui'; +import { findContents } from '../../fixtures/file-contents'; +import './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: size, modified + * timestamp, hash, monospace text preview for recognized text mime types, + * plus per-file Download and Restore buttons. + * + * @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 } + + + +
+
+
{ __( 'Size:', 'jetpack-backup-pkg' ) }
+
{ humanSize( file.sizeBytes ) }
+
+
+
{ __( 'Modified:', 'jetpack-backup-pkg' ) }
+
{ dateI18n( 'M j, Y, g:i A', file.lastModified, undefined ) }
+
+
+
{ __( 'Hash:', 'jetpack-backup-pkg' ) }
+
{ file.hash }
+
+
+
+ { contents !== null ? ( +
{ contents }
+ ) : ( + + { __( 'Preview unavailable for this file.', 'jetpack-backup-pkg' ) } + + ) } +
+ + + + +
+ ); +} diff --git a/projects/packages/backup/src/dashboard/components/file-info-card/style.scss b/projects/packages/backup/src/dashboard/components/file-info-card/style.scss new file mode 100644 index 000000000000..19e8221957ed --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/file-info-card/style.scss @@ -0,0 +1,63 @@ +.jpb-file-info-card { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + + &__header { + padding-bottom: 8px; + border-bottom: 1px solid var(--wp-components-color-gray-200, #e0e0e0); + } + + &__meta { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + font-size: 13px; + + div { + display: flex; + gap: 6px; + } + + dt { + margin: 0; + font-weight: 600; + flex: 0 0 auto; + } + + dd { + margin: 0; + color: var(--wp-components-color-foreground, #1e1e1e); + } + } + + &__hash { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + word-break: break-all; + } + + &__preview { + max-height: 320px; + overflow: auto; + background-color: var(--wp-components-color-background-secondary, #f7f7f7); + border-radius: 4px; + padding: 12px; + + pre { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre; + color: var(--wp-components-color-foreground, #1e1e1e); + } + } + + &__actions { + padding-top: 8px; + border-top: 1px solid var(--wp-components-color-gray-200, #e0e0e0); + } +} diff --git a/projects/packages/backup/src/dashboard/components/restore-items-checklist/index.tsx b/projects/packages/backup/src/dashboard/components/restore-items-checklist/index.tsx new file mode 100644 index 000000000000..83d3b5992029 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/restore-items-checklist/index.tsx @@ -0,0 +1,109 @@ +import { CheckboxControl } from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; +import './style.scss'; +import type { RestoreItems } from '../../types/restore'; + +type Props = { + value: RestoreItems; + onChange: ( next: RestoreItems ) => void; +}; + +type ItemKey = keyof RestoreItems; + +type ItemDef = { + key: ItemKey; + label: string; + description?: string; +}; + +const ITEMS: ItemDef[] = [ + { key: 'themes', label: __( 'WordPress themes', 'jetpack-backup-pkg' ) }, + { key: 'plugins', label: __( 'WordPress plugins', 'jetpack-backup-pkg' ) }, + { + key: 'roots', + label: __( 'WordPress root', 'jetpack-backup-pkg' ), + description: __( 'Includes wp-config.php and any non WordPress files.', 'jetpack-backup-pkg' ), + }, + { + key: 'contents', + label: __( 'WP-content directory', 'jetpack-backup-pkg' ), + description: __( 'Excludes themes, plugins, and uploads.', 'jetpack-backup-pkg' ), + }, + { + key: 'sqls', + label: __( 'Site database', 'jetpack-backup-pkg' ), + description: __( 'Includes pages, and posts.', 'jetpack-backup-pkg' ), + }, + { + key: 'uploads', + label: __( 'Media uploads', 'jetpack-backup-pkg' ), + description: __( + 'You must also select Site database for restored media uploads to appear.', + 'jetpack-backup-pkg' + ), + }, +]; + +type RowProps = { + item: ItemDef; + value: RestoreItems; + onChange: ( next: RestoreItems ) => void; +}; + +/** + * Single row of the restore checklist: a labeled checkbox plus an optional + * muted description below it. Lives in its own component so the per-item + * `onChange` handler can be memoized via `useCallback` and satisfy + * `react/jsx-no-bind`. + * + * @param props - Component props. + * @param props.item - The item definition (key, label, optional description). + * @param props.value - Current state of every toggle in the parent checklist. + * @param props.onChange - Called with the next state when this row's toggle flips. + * @return The rendered row. + */ +function ChecklistRow( { item, value, onChange }: RowProps ) { + const handleChange = useCallback( + ( next: boolean ) => onChange( { ...value, [ item.key ]: next } ), + [ onChange, value, item.key ] + ); + + return ( + + + { item.description && ( + + { item.description } + + ) } + + ); +} + +/** + * Six-checkbox toggle list shared by the Restore and Download screens. + * + * The keys map to `RestoreItems` (themes/plugins/roots/contents/sqls/uploads); + * descriptions render as small muted text directly beneath their checkbox. + * + * @param props - Component props. + * @param props.value - Current state of each toggle. + * @param props.onChange - Called with the next state when any toggle flips. + * @return The rendered checklist. + */ +export default function RestoreItemsChecklist( { value, onChange }: Props ) { + return ( + + { ITEMS.map( item => ( + + ) ) } + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/restore-items-checklist/style.scss b/projects/packages/backup/src/dashboard/components/restore-items-checklist/style.scss new file mode 100644 index 000000000000..fd95bbe20cb8 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/restore-items-checklist/style.scss @@ -0,0 +1,12 @@ +.jpb-restore-checklist { + + // Helper text under a row: indented to line up with the checkbox label + // and rendered in a muted gray. `@wordpress/ui` Text's `variant="muted"` + // doesn't take inside our Card.Root (wp-admin's unlayered global + // `color: #1e1e1e` wins over the layered token), so set the color + // explicitly here. + &__desc { + padding-left: 28px; + 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..613e998c270f --- /dev/null +++ b/projects/packages/backup/src/dashboard/fixtures/activity-log.ts @@ -0,0 +1,89 @@ +import type { ActivityItem } from '../types/activity'; + +const BASE_DATE = new Date( '2026-05-15T12:26:00.000Z' ); +const DAY_MS = 24 * 60 * 60 * 1000; +const HOUR_MS = 60 * 60 * 1000; + +const at = ( daysAgo: number, hoursAgo = 0 ): string => + new Date( BASE_DATE.getTime() - daysAgo * DAY_MS - hoursAgo * HOUR_MS ).toISOString(); + +const jetpack = { type: 'Application' as const, name: 'Jetpack' }; +const totoro = { type: 'Person' as const, name: 'Totoro' }; + +const backup = ( offsetDays: number ): ActivityItem => ( { + id: `backup-${ offsetDays }`, + kind: 'backup', + title: 'Backup and scan complete by Jetpack', + publishedAt: at( offsetDays ), + actor: jetpack, + summary: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page', + rewindId: String( Math.floor( ( BASE_DATE.getTime() - offsetDays * DAY_MS ) / 1000 ) ), + stats: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page', + isComplete: true, +} ); + +export const MOCK_ACTIVITY_LOG: ActivityItem[] = [ + backup( 0 ), + { + id: 'upload-1', + kind: 'upload', + title: '1 image uploaded by Totoro', + publishedAt: at( 0.95 ), + actor: totoro, + summary: 'cat.png', + }, + { + id: 'post-1', + kind: 'post', + title: 'Post published by Totoro', + publishedAt: at( 1 ), + actor: totoro, + summary: 'The Perks of Having a Cat', + }, + backup( 1 ), + backup( 2 ), + backup( 3 ), + { + id: 'plugin-1', + kind: 'plugin-update', + title: 'Jetpack 15.2 plugin updated by Jetpack', + publishedAt: at( 4, 10 ), + actor: jetpack, + summary: 'Jetpack 15.2', + }, + backup( 4 ), + backup( 5 ), + backup( 6 ), + backup( 7 ), + backup( 8 ), + backup( 9 ), + { + id: 'theme-1', + kind: 'theme-update', + title: 'Twenty Twenty-Five theme updated by Totoro', + publishedAt: at( 10, 6 ), + actor: totoro, + summary: 'Twenty Twenty-Five 1.2', + }, + backup( 10 ), + backup( 11 ), + backup( 12 ), + backup( 13 ), + backup( 14 ), +]; + +/** + * Look up an activity item by the id used in the URL's `?selected=` param. + * + * Backup items match on `rewindId`; non-backup items match on `id`. + * + * @param id - Selection id from the URL. + * @return The matching activity item, or `null` if no row matches. + */ +export function findActivityById( id: string ): ActivityItem | null { + return ( + MOCK_ACTIVITY_LOG.find( item => + item.kind === 'backup' ? item.rewindId === id : item.id === id + ) ?? null + ); +} diff --git a/projects/packages/backup/src/dashboard/fixtures/file-contents.ts b/projects/packages/backup/src/dashboard/fixtures/file-contents.ts new file mode 100644 index 000000000000..91bbe567a4e4 --- /dev/null +++ b/projects/packages/backup/src/dashboard/fixtures/file-contents.ts @@ -0,0 +1,47 @@ +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 new file mode 100644 index 000000000000..cfb137093626 --- /dev/null +++ b/projects/packages/backup/src/dashboard/fixtures/file-tree.ts @@ -0,0 +1,81 @@ +import { isFolder } from '../types/file-tree'; +import type { FileNode } from '../types/file-tree'; + +// Stable timestamps + hashes so snapshot tests can't flake on +// import-time clock reads. The exact values mirror the kind of +// metadata the future REST path-info call will return. +const FIXTURE_LAST_MODIFIED = '2026-02-16T20:42:00.000Z'; + +const file = ( + name: string, + path: string, + sizeBytes: number, + mimeType: string, + hash = 'ce4fc8526a348484a88bba63b08c0976' +): FileNode => ( { + type: 'file', + name, + path, + sizeBytes, + mimeType, + lastModified: FIXTURE_LAST_MODIFIED, + hash, +} ); + +const folder = ( name: string, path: string, children?: FileNode[] ): FileNode => ( { + type: 'folder', + name, + path, + children, +} ); + +export const MOCK_FILE_TREE: FileNode[] = [ + folder( 'wp-content', '/wp-content', [ + folder( 'themes', '/wp-content/themes', [ + folder( 'twentytwentyfive', '/wp-content/themes/twentytwentyfive', [ + file( 'style.css', '/wp-content/themes/twentytwentyfive/style.css', 4_812, 'text/css' ), + ] ), + ] ), + folder( 'plugins', '/wp-content/plugins', [ + folder( 'jetpack', '/wp-content/plugins/jetpack', [ + file( + 'jetpack.php', + '/wp-content/plugins/jetpack/jetpack.php', + 12_034, + 'application/x-php' + ), + ] ), + ] ), + folder( 'uploads', '/wp-content/uploads', [ + file( 'cat.png', '/wp-content/uploads/cat.png', 188_240, 'image/png' ), + ] ), + ] ), + folder( 'sql', '/sql', [ + file( 'database.sql', '/sql/database.sql', 2_485_120, 'application/sql' ), + ] ), + file( 'wp-config.php', '/wp-config.php', 3_312, 'application/x-php' ), +]; + +/** + * Recursively searches `MOCK_FILE_TREE` for a node at the given path. + * + * @param path - Absolute path under the backup (e.g. `'/wp-content/uploads'`). + * @return The matching node, or null when no node is found. + */ +export function findNodeByPath( path: string ): FileNode | null { + const walk = ( nodes: FileNode[] ): FileNode | null => { + for ( const node of nodes ) { + if ( node.path === path ) { + return node; + } + if ( isFolder( node ) && node.children ) { + const found = walk( node.children ); + if ( found ) { + return found; + } + } + } + return null; + }; + return walk( MOCK_FILE_TREE ); +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts b/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts 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/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/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/hooks/use-mock-file-tree.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts new file mode 100644 index 000000000000..383bb573a499 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts @@ -0,0 +1,48 @@ +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-mock-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-restore.ts new file mode 100644 index 000000000000..429d0a6ba930 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-mock-restore.ts @@ -0,0 +1,75 @@ +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/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx new file mode 100644 index 000000000000..c36dff42f6b2 --- /dev/null +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -0,0 +1,108 @@ +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/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx new file mode 100644 index 000000000000..37a0e4fdc9c2 --- /dev/null +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -0,0 +1,116 @@ +import { useCallback, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { calendar } from '@wordpress/icons'; +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 { MOCK_ACTIVITY_LOG, findActivityById } from '../fixtures/activity-log'; +import { isBackupItem } from '../types/activity'; +import type { ActivityItem } from '../types/activity'; + +type OverviewSearch = Record< string, unknown > & { selected?: string }; + +/** + * Returns the selection id of the newest backup row in the fixture, so the + * Overview can preselect it on first load and keep the right pane populated. + * + * @param items - Activity items to scan. + * @return The newest backup's rewindId, or null when none exists. + */ +function findDefaultSelection( items: readonly ActivityItem[] ): string | null { + for ( const item of items ) { + if ( isBackupItem( item ) ) { + return item.rewindId; + } + } + return null; +} + +/** + * Overview screen for the modernized Backup dashboard. + * + * Renders the shared `` chrome around a two-pane body: the + * left pane is the searchable activity list; the right pane resolves the + * selected row to a detail card. Selection is persisted in the URL via + * `?selected=` so a refresh preserves it; on first visit the newest + * backup is preselected so the right pane mirrors Calypso's behaviour. + * + * @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 defaultSelectedId = useMemo( () => findDefaultSelection( MOCK_ACTIVITY_LOG ), [] ); + const selectedId = typeof search.selected === 'string' ? search.selected : defaultSelectedId; + + 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 ( + + + + + } + > +
+ + +
+
+ ); +} + +/** + * 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 ; +} diff --git a/projects/packages/backup/src/dashboard/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx new file mode 100644 index 000000000000..b833e270f2c6 --- /dev/null +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -0,0 +1,108 @@ +import { Notice, ProgressBar, Spinner } from '@wordpress/components'; +import { dateI18n } from '@wordpress/date'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Icon, backup as backupIcon, 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 { useMockRestore } from '../hooks/use-mock-restore'; +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. + * + * @return The rendered Restore screen. + */ +export default function RestoreScreen() { + const { rewindId } = useParams( { from: '/restore/$rewindId' } ); + const item = findActivityById( rewindId ); + const restorePoint = item ? item.publishedAt : null; + const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); + const { state, submit, reset } = useMockRestore(); + + return ( + +
+ + + { __( 'Back to overview', 'jetpack-backup-pkg' ) } + + + + + + }> + { __( 'Restore backup', 'jetpack-backup-pkg' ) } + + { restorePoint && ( + + { __( 'Restore point:', 'jetpack-backup-pkg' ) }{ ' ' } + { dateI18n( 'M j, Y, g:i A', restorePoint, undefined ) } + + ) } + + + { ( state.phase === 'idle' || state.phase === 'submitting' ) && ( + <> + + { __( + 'Restoring will overwrite the matching parts of your live site with the contents of this backup. This cannot be undone.', + 'jetpack-backup-pkg' + ) } + + { __( 'Choose the items you wish to restore:', 'jetpack-backup-pkg' ) } + + + + ) } + { 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 } + + + + ) } + +
+
+ ); +} 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..031fecd4e9a7 --- /dev/null +++ b/projects/packages/backup/src/dashboard/types/file-tree.ts @@ -0,0 +1,21 @@ +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; + lastModified: string; + hash: 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..5172587e5431 --- /dev/null +++ b/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui @@ -0,0 +1,3 @@ +Significance: minor +Type: added +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 new file mode 100644 index 000000000000..27171917b368 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui @@ -0,0 +1,3 @@ +Significance: patch +Type: other +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.