From 734060111e84555a493b70bd813135ffbd970faa Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Tue, 16 Jun 2026 15:54:13 -0400 Subject: [PATCH] Agentic UI: move backup picking to onboarding home --- apps/ui/src/lib/backup-files.ts | 22 +++ apps/ui/src/lib/pending-backup.ts | 21 +++ apps/ui/src/lib/pending-slot.ts | 49 ++++++ .../router/layout-onboarding/style.module.css | 15 +- .../router/route-onboarding-home/index.tsx | 75 +++++++- .../router/route-onboarding-import/index.tsx | 164 +++--------------- .../route-onboarding-import/style.module.css | 8 - 7 files changed, 201 insertions(+), 153 deletions(-) create mode 100644 apps/ui/src/lib/backup-files.ts create mode 100644 apps/ui/src/lib/pending-backup.ts create mode 100644 apps/ui/src/lib/pending-slot.ts delete mode 100644 apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css diff --git a/apps/ui/src/lib/backup-files.ts b/apps/ui/src/lib/backup-files.ts new file mode 100644 index 0000000000..369327a160 --- /dev/null +++ b/apps/ui/src/lib/backup-files.ts @@ -0,0 +1,22 @@ +import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; + +export function isValidBackupFile( file: File ): boolean { + const lower = file.name.toLowerCase(); + return ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => lower.endsWith( ext ) ); +} + +/** + * Derives a friendly default site name from a backup filename. Strips the + * archive extension and common "site-backup-2024-01-01" date suffixes so the + * form can seed the site name without the user having to retype it. + */ +export function nameFromFilename( filename: string ): string { + const basename = filename.replace( /^.*[\\/]/, '' ); + const lower = basename.toLowerCase(); + const ext = ACCEPTED_IMPORT_FILE_TYPES.find( ( candidate ) => lower.endsWith( candidate ) ); + return ( ext ? basename.slice( 0, -ext.length ) : basename ) + .replace( /[-_](backup|export|wordpress|jetpack)(s)?$/i, '' ) + .replace( /[-_]\d{4}[-_]\d{2}[-_]\d{2}.*$/, '' ) + .replace( /[-_]+/g, ' ' ) + .trim(); +} diff --git a/apps/ui/src/lib/pending-backup.ts b/apps/ui/src/lib/pending-backup.ts new file mode 100644 index 0000000000..8b59d693c7 --- /dev/null +++ b/apps/ui/src/lib/pending-backup.ts @@ -0,0 +1,21 @@ +import { createPendingSlot } from './pending-slot'; + +/** + * One-slot handoff for a backup archive picked outside the import route's + * own UI — currently the drop-target card on the onboarding home screen. + * The picker stores the file here and navigates to the configure step; the + * route adopts it into component state on arrival and clears the slot. + */ + +export interface PendingBackup { + file: File; + // Resolved via `connector.getFilePath` at pick-time so the import route + // doesn't have to await the preload bridge again. + path: string; +} + +export const pendingBackupSlot = createPendingSlot< PendingBackup >(); + +export const setPendingBackup = pendingBackupSlot.set; +export const peekPendingBackup = pendingBackupSlot.peek; +export const clearPendingBackup = pendingBackupSlot.clear; diff --git a/apps/ui/src/lib/pending-slot.ts b/apps/ui/src/lib/pending-slot.ts new file mode 100644 index 0000000000..4ffdaf8aec --- /dev/null +++ b/apps/ui/src/lib/pending-slot.ts @@ -0,0 +1,49 @@ +/** + * A one-slot handoff between a producer outside a route's own UI (a deep + * link, the onboarding home screen) and the route that consumes the value. + * The producer `set`s the value and navigates; the route adopts it on + * arrival and `clear`s the slot. + * + * `subscribe` notifies on every set/clear so routes can read the slot via + * `useSyncExternalStore` and react to a new value arriving while already + * mounted (e.g. a second deep link mid-configure). Adoption and clearing + * stay split (rather than an atomic take) so React StrictMode's + * double-invoked effects can't consume the value on the first pass and + * bounce the user back on the second. + */ +export interface PendingSlot< T > { + set( value: T ): void; + peek(): T | null; + clear(): void; + subscribe( listener: () => void ): () => void; +} + +export function createPendingSlot< T >(): PendingSlot< T > { + let value: T | null = null; + const listeners = new Set< () => void >(); + const notify = () => { + for ( const listener of [ ...listeners ] ) { + listener(); + } + }; + return { + set( next: T ) { + value = next; + notify(); + }, + peek: () => value, + clear() { + if ( value === null ) { + return; + } + value = null; + notify(); + }, + subscribe( listener: () => void ) { + listeners.add( listener ); + return () => { + listeners.delete( listener ); + }; + }, + }; +} diff --git a/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css b/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css index 3bf06f28c5..a7a00665f9 100644 --- a/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css +++ b/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css @@ -31,9 +31,11 @@ color: inherit; text-decoration: none; background: var(--wpds-color-bg-surface-neutral-strong, #fff); + font: inherit; } -.card:hover { +.card:hover, +.cardDragging { border-color: var(--wpds-color-stroke-interactive-neutral, #bbb); } @@ -52,6 +54,13 @@ font-size: 0.875rem; } +.cardError { + display: block; + margin-top: 8px; + font-size: 0.75rem; + color: var(--wpds-color-fg-content-error, #b32d2e); +} + .cardBadge { position: absolute; top: 12px; @@ -61,3 +70,7 @@ letter-spacing: 0.04em; color: var(--wpds-color-fg-content-neutral-weak, #555); } + +.hiddenInput { + display: none; +} diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx index be5ebbe680..256869f218 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx @@ -1,9 +1,41 @@ -import { createRoute, Link } from '@tanstack/react-router'; +import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; +import { createRoute, Link, useNavigate } from '@tanstack/react-router'; import { __ } from '@wordpress/i18n'; +import { useCallback, useRef, useState } from 'react'; +import { useConnector } from '@/data/core'; +import { isValidBackupFile } from '@/lib/backup-files'; +import { setPendingBackup } from '@/lib/pending-backup'; import { onboardingLayoutRoute } from '../layout-onboarding'; import styles from '../layout-onboarding/style.module.css'; function OnboardingHomePage() { + const navigate = useNavigate(); + const connector = useConnector(); + const fileRef = useRef< HTMLInputElement >( null ); + const [ isDragging, setIsDragging ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + + const handleBackupFile = useCallback( + async ( file: File | undefined ) => { + if ( ! file ) { + return; + } + if ( ! isValidBackupFile( file ) ) { + setError( __( 'Unsupported file type.' ) ); + return; + } + const path = await connector.getFilePath( file ); + if ( ! path ) { + setError( __( 'Unable to read the file. Try clicking the card to browse instead.' ) ); + return; + } + setError( null ); + setPendingBackup( { file, path } ); + await navigate( { to: '/onboarding/import' } ); + }, + [ connector, navigate ] + ); + return (

{ __( 'Start a new site' ) }

@@ -25,12 +57,45 @@ function OnboardingHomePage() { ) }

- -

{ __( 'Bring existing' ) }

+ { + void handleBackupFile( event.target.files?.[ 0 ] ); + event.target.value = ''; + } } + /> +
); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx index d210ce266b..df94d8214f 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-import/index.tsx @@ -1,127 +1,54 @@ -import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; import { createRoute, useNavigate } from '@tanstack/react-router'; import { __ } from '@wordpress/i18n'; -import { arrowLeft, download } from '@wordpress/icons'; -import { Button, Icon } from '@wordpress/ui'; -import { useCallback, useEffect, useState } from 'react'; -import { flushSync } from 'react-dom'; +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; import { CreateSiteForm } from '@/components/create-site-form'; -import { FileDropzone } from '@/components/file-dropzone'; -import { useConnector } from '@/data/core'; import { useExistingCustomDomains } from '@/data/queries/use-create-site-helpers'; import { useImportSite } from '@/data/queries/use-import-site'; import { useCreateSite } from '@/data/queries/use-sites'; +import { nameFromFilename } from '@/lib/backup-files'; +import { pendingBackupSlot } from '@/lib/pending-backup'; import { onboardingLayoutRoute } from '../layout-onboarding'; import sharedStyles from '../layout-onboarding/style.module.css'; -import styles from './style.module.css'; import type { CreateSiteFormValues } from '@/components/create-site-form'; -type Step = 'select' | 'configure'; - -interface ImportSearch { - step?: Step; -} - interface PickedBackup { file: File; // Resolved from the connector once at pick-time so the submit handler - // doesn't have to await the preload bridge again. + // can pass an absolute path to the main process import handler. path: string; } -function isValidBackupFile( file: File ): boolean { - const lower = file.name.toLowerCase(); - return ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => lower.endsWith( ext ) ); -} - -/** - * Derives a friendly default site name from a backup filename. Strips the - * archive extension and common "site-backup-2024-01-01" date suffixes so the - * form can seed the site name without the user having to retype it. - */ -function nameFromFilename( filename: string ): string { - const basename = filename.replace( /^.*[\\/]/, '' ); - const lower = basename.toLowerCase(); - const ext = ACCEPTED_IMPORT_FILE_TYPES.find( ( candidate ) => lower.endsWith( candidate ) ); - return ( ext ? basename.slice( 0, -ext.length ) : basename ) - .replace( /[-_](backup|export|wordpress|jetpack)(s)?$/i, '' ) - .replace( /[-_]\d{4}[-_]\d{2}[-_]\d{2}.*$/, '' ) - .replace( /[-_]+/g, ' ' ) - .trim(); -} - -function OnboardingImportPage() { - const { step } = onboardingImportRoute.useSearch(); +export function OnboardingImportPage() { const navigate = useNavigate(); - const connector = useConnector(); - const activeStep: Step = step === 'configure' ? 'configure' : 'select'; - const { data: existingDomainNames } = useExistingCustomDomains(); const createSite = useCreateSite(); const importSite = useImportSite(); - // Picked backup lives in component state — survives navigation between - // steps but not a hard refresh. If the user lands on `step=configure` - // with no picked backup, the effect below bounces them back to select. const [ picked, setPicked ] = useState< PickedBackup | null >( null ); - const [ pickError, setPickError ] = useState< string | null >( null ); const [ submitError, setSubmitError ] = useState( '' ); + const pending = useSyncExternalStore( pendingBackupSlot.subscribe, pendingBackupSlot.peek ); + // Adopt a backup selected from the onboarding home card. A later pick + // replaces the current one so repeated drops/clicks always win. useEffect( () => { - if ( activeStep === 'configure' && ! picked ) { - void navigate( { - to: '/onboarding/import', - search: { step: 'select' }, - replace: true, - } ); + if ( ! pending ) { + return; } - }, [ activeStep, picked, navigate ] ); + setPicked( pending ); + pendingBackupSlot.clear(); + }, [ pending ] ); - const handlePick = useCallback( - async ( file: File ) => { - if ( ! isValidBackupFile( file ) ) { - setPickError( - __( - 'This file type is not supported. Please use a .zip, .gz, .tar, .tar.gz, or .wpress file.' - ) - ); - return; - } - const path = await connector.getFilePath( file ); - if ( ! path ) { - setPickError( - __( 'Unable to resolve the backup file path. Try choosing the file via the button.' ) - ); - return; - } - // `flushSync` commits the state updates before `navigate` fires so - // the router's URL change and React's component state land in the - // same render pass. Without this, tanstack router's store update - // commits first, the component re-renders with `activeStep` already - // at `configure` but `picked` still null, and the hard-refresh - // guard effect below immediately bounces us back to `select`. - flushSync( () => { - setPickError( null ); - setPicked( { file, path } ); - } ); - void navigate( { - to: '/onboarding/import', - search: { step: 'configure' }, - } ); - }, - [ connector, navigate ] - ); - - const handleClearPick = useCallback( () => { - setPicked( null ); - setPickError( null ); - }, [] ); + // Direct visits and refreshes have no File object to import, so send the + // user back to the picker card on the home screen. + useEffect( () => { + if ( picked || pending ) { + return; + } + void navigate( { to: '/onboarding', replace: true } ); + }, [ picked, pending, navigate ] ); - const handleBackToSelect = useCallback( () => { - void navigate( { - to: '/onboarding/import', - search: { step: 'select' }, - } ); + const handleBack = useCallback( () => { + void navigate( { to: '/onboarding' } ); }, [ navigate ] ); const handleSubmit = async ( values: CreateSiteFormValues ) => { @@ -151,49 +78,15 @@ function OnboardingImportPage() { } }; - if ( activeStep === 'select' ) { - return ( -
-

{ __( 'Import from a backup' ) }

-

- { __( - 'Drop a backup archive to restore a site locally. Jetpack, All-in-One WP Migration, Local, and Playground exports are supported.' - ) } -

- void handlePick( file ) } - file={ picked?.file ?? null } - onClear={ handleClearPick } - error={ pickError } - /> -
- ); - } - - // `step=configure` with no picked backup is handled by the effect above; - // render nothing in the intermediate frame to avoid a flash. if ( ! picked ) return null; + const isSubmitting = createSite.isPending || importSite.isPending; const initialValues: Partial< CreateSiteFormValues > = { name: nameFromFilename( picked.file.name ), }; - const isSubmitting = createSite.isPending || importSite.isPending; return (
-

{ __( 'Configure the imported site' ) }

{ __( 'Pick a name and local folder. The backup will restore on top of this new site.' ) } @@ -202,7 +95,7 @@ function OnboardingImportPage() { initialValues={ initialValues } existingDomainNames={ existingDomainNames ?? [] } onSubmit={ handleSubmit } - onCancel={ () => void navigate( { to: '/onboarding' } ) } + onCancel={ handleBack } isSubmitting={ isSubmitting } submitError={ submitError } submitLabel={ __( 'Import site' ) } @@ -214,12 +107,5 @@ function OnboardingImportPage() { export const onboardingImportRoute = createRoute( { getParentRoute: () => onboardingLayoutRoute, path: '/onboarding/import', - validateSearch: ( search: Record< string, unknown > ): ImportSearch => { - const value = search.step; - if ( value === 'configure' || value === 'select' ) { - return { step: value }; - } - return {}; - }, component: OnboardingImportPage, } ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css deleted file mode 100644 index 56615bfa21..0000000000 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.backLink { - display: inline-flex; - align-items: center; - gap: 4px; - align-self: flex-start; - margin-bottom: 16px; - padding: 4px 8px 4px 4px; -}