From d64302c8f4563e5614f10130d236409116b23f08 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Tue, 16 Jun 2026 15:56:59 -0400 Subject: [PATCH] Agentic UI: reuse classic onboarding in Desks --- .../router/layout-onboarding/index.tsx | 2 +- .../route-onboarding-blueprint/index.tsx | 22 +- .../router/route-onboarding-create/index.tsx | 2 +- .../router/route-onboarding-home/index.tsx | 2 +- .../router/route-onboarding-import/index.tsx | 22 +- apps/ui/src/ui-desks/app.tsx | 15 +- apps/ui/src/ui-desks/onboarding/index.tsx | 516 ++---------------- .../src/ui-desks/onboarding/style.module.css | 56 -- 8 files changed, 84 insertions(+), 553 deletions(-) delete mode 100644 apps/ui/src/ui-desks/onboarding/style.module.css diff --git a/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx b/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx index 0db88f49b7..1fb5acd486 100644 --- a/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx +++ b/apps/ui/src/ui-classic/router/layout-onboarding/index.tsx @@ -3,7 +3,7 @@ import { OnboardingLayout } from '@/components/onboarding-layout'; import { useSites } from '@/data/queries/use-sites'; import { rootRoute } from '../layout-root'; -function OnboardingShell() { +export function OnboardingShell() { const navigate = useNavigate(); const { data: sites } = useSites(); const hasSites = ( sites?.length ?? 0 ) > 0; diff --git a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx index 099c5b8c0c..4793ce7e2a 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-blueprint/index.tsx @@ -2,7 +2,7 @@ import { extractFormValuesFromBlueprint, updateBlueprintWithFormValues, } from '@studio/common/lib/blueprint-settings'; -import { createRoute, useNavigate } from '@tanstack/react-router'; +import { createRoute, useNavigate, useSearch } from '@tanstack/react-router'; import { __ } from '@wordpress/i18n'; import { arrowLeft } from '@wordpress/icons'; import { Button, Icon } from '@wordpress/ui'; @@ -24,8 +24,8 @@ interface BlueprintSearch { step?: Step; } -function OnboardingBlueprintPage() { - const { step } = onboardingBlueprintRoute.useSearch(); +export function OnboardingBlueprintPage() { + const { step } = useSearch( { strict: false } ) as BlueprintSearch; const navigate = useNavigate(); const activeStep: Step = step === 'configure' ? 'configure' : 'select'; @@ -193,15 +193,17 @@ function mapBlueprintSettingsToFormValues( }; } +export function validateBlueprintSearch( search: Record< string, unknown > ): BlueprintSearch { + const value = search.step; + if ( value === 'configure' || value === 'select' ) { + return { step: value }; + } + return {}; +} + export const onboardingBlueprintRoute = createRoute( { getParentRoute: () => onboardingLayoutRoute, path: '/onboarding/blueprint', - validateSearch: ( search: Record< string, unknown > ): BlueprintSearch => { - const value = search.step; - if ( value === 'configure' || value === 'select' ) { - return { step: value }; - } - return {}; - }, + validateSearch: validateBlueprintSearch, component: OnboardingBlueprintPage, } ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx index bbb98abf50..8ab501c498 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-create/index.tsx @@ -11,7 +11,7 @@ import { onboardingLayoutRoute } from '../layout-onboarding'; import styles from '../layout-onboarding/style.module.css'; import type { CreateSiteFormValues } from '@/components/create-site-form'; -function CreateSitePage() { +export function CreateSitePage() { const navigate = useNavigate(); const { data: sites } = useSites(); const { data: existingDomainNames } = useExistingCustomDomains(); 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..5c5aef12e2 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 @@ -3,7 +3,7 @@ import { __ } from '@wordpress/i18n'; import { onboardingLayoutRoute } from '../layout-onboarding'; import styles from '../layout-onboarding/style.module.css'; -function OnboardingHomePage() { +export function OnboardingHomePage() { return (

{ __( 'Start a new site' ) }

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..21b8f6c112 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,5 +1,5 @@ import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; -import { createRoute, useNavigate } from '@tanstack/react-router'; +import { createRoute, useNavigate, useSearch } from '@tanstack/react-router'; import { __ } from '@wordpress/i18n'; import { arrowLeft, download } from '@wordpress/icons'; import { Button, Icon } from '@wordpress/ui'; @@ -50,8 +50,8 @@ function nameFromFilename( filename: string ): string { .trim(); } -function OnboardingImportPage() { - const { step } = onboardingImportRoute.useSearch(); +export function OnboardingImportPage() { + const { step } = useSearch( { strict: false } ) as ImportSearch; const navigate = useNavigate(); const connector = useConnector(); const activeStep: Step = step === 'configure' ? 'configure' : 'select'; @@ -211,15 +211,17 @@ function OnboardingImportPage() { ); } +export function validateImportSearch( search: Record< string, unknown > ): ImportSearch { + const value = search.step; + if ( value === 'configure' || value === 'select' ) { + return { step: value }; + } + return {}; +} + 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 {}; - }, + validateSearch: validateImportSearch, component: OnboardingImportPage, } ); diff --git a/apps/ui/src/ui-desks/app.tsx b/apps/ui/src/ui-desks/app.tsx index f8403f2c0a..1371301708 100644 --- a/apps/ui/src/ui-desks/app.tsx +++ b/apps/ui/src/ui-desks/app.tsx @@ -2,10 +2,13 @@ import { RouterProvider, createRouter } from '@tanstack/react-router'; import { useMemo } from 'react'; import { createPackagedRouterHistory } from '@/app/router-history'; import { + desksDashboardRedirectRoute, + desksNewSiteRedirectRoute, desksOnboardingBlueprintRoute, desksOnboardingCreateRoute, desksOnboardingHomeRoute, desksOnboardingImportRoute, + desksOnboardingLayoutRoute, } from './onboarding'; import { desksRootRoute } from './router/root'; import { siteDeskRoute } from './site-desk'; @@ -16,10 +19,14 @@ const routeTree = desksRootRoute.addChildren( [ userDeskRoute, desksSiteSettingsRoute, siteDeskRoute, - desksOnboardingHomeRoute, - desksOnboardingCreateRoute, - desksOnboardingBlueprintRoute, - desksOnboardingImportRoute, + desksOnboardingLayoutRoute.addChildren( [ + desksOnboardingHomeRoute, + desksOnboardingCreateRoute, + desksOnboardingBlueprintRoute, + desksOnboardingImportRoute, + ] ), + desksDashboardRedirectRoute, + desksNewSiteRedirectRoute, ] ); export function createDesksRouter() { diff --git a/apps/ui/src/ui-desks/onboarding/index.tsx b/apps/ui/src/ui-desks/onboarding/index.tsx index 21bb55e602..2466cd4429 100644 --- a/apps/ui/src/ui-desks/onboarding/index.tsx +++ b/apps/ui/src/ui-desks/onboarding/index.tsx @@ -1,491 +1,67 @@ -import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; +import { createRoute, redirect } from '@tanstack/react-router'; +import { OnboardingShell } from '@/ui-classic/router/layout-onboarding'; import { - extractFormValuesFromBlueprint, - updateBlueprintWithFormValues, -} from '@studio/common/lib/blueprint-settings'; -import { createRoute, useNavigate } from '@tanstack/react-router'; -import { __ } from '@wordpress/i18n'; -import { arrowLeft, download } from '@wordpress/icons'; -import { useCallback, useEffect, useState } from 'react'; -import { flushSync } from 'react-dom'; -import { BlueprintSelector, type PickedBlueprint } from '@/components/blueprint-selector'; -import { CreateSiteForm } from '@/components/create-site-form'; -import { FileDropzone } from '@/components/file-dropzone'; -import { OnboardingLayout } from '@/components/onboarding-layout'; -import { useConnector } from '@/data/core'; + OnboardingBlueprintPage, + validateBlueprintSearch, +} from '@/ui-classic/router/route-onboarding-blueprint'; +import { CreateSitePage } from '@/ui-classic/router/route-onboarding-create'; +import { OnboardingHomePage } from '@/ui-classic/router/route-onboarding-home'; import { - useExistingCustomDomains, - useProposedSiteName, -} from '@/data/queries/use-create-site-helpers'; -import { useFeaturedBlueprints } from '@/data/queries/use-featured-blueprints'; -import { useImportSite } from '@/data/queries/use-import-site'; -import { useCreateSite, useSites } from '@/data/queries/use-sites'; -import { Button } from '@/ui-desks/components'; + OnboardingImportPage, + validateImportSearch, +} from '@/ui-classic/router/route-onboarding-import'; import { desksRootRoute } from '../router/root'; -import styles from './style.module.css'; -import type { CreateSiteFormValues } from '@/components/create-site-form'; -type Step = 'select' | 'configure'; +/** + * The desks surface has no site-creation flow of its own. It registers the + * classic onboarding pages under its own route tree, then adapts the flow's + * exit URLs to their desks equivalents. + */ -interface StepSearch { - step?: Step; -} - -interface PickedBackup { - file: File; - path: string; -} - -export function DeskOnboardingHome() { - const navigate = useNavigate(); - - return ( - void navigate( { to: '/' } ) } width="wide"> -
-

{ __( 'Start a new site' ) }

-

- { __( 'WordPress can power anything. What are you building?' ) } -

-
- - - -
-
-
- ); -} - -export function DeskOnboardingCreate() { - const navigate = useNavigate(); - const { data: sites } = useSites(); - const { data: existingDomainNames } = useExistingCustomDomains(); - const { data: proposedName } = useProposedSiteName( sites ); - const createSite = useCreateSite(); - const [ submitError, setSubmitError ] = useState( '' ); - - const handleSubmit = async ( values: CreateSiteFormValues ) => { - setSubmitError( '' ); - try { - const site = await createSite.mutateAsync( { - name: values.name, - path: values.path, - phpVersion: values.phpVersion, - wpVersion: values.wpVersion, - customDomain: values.customDomain, - enableHttps: values.enableHttps, - adminUsername: values.adminUsername || undefined, - adminPassword: values.adminPassword || undefined, - adminEmail: values.adminEmail || undefined, - } ); - await navigate( { to: '/sites/$siteId', params: { siteId: site.id } } ); - } catch ( error ) { - setSubmitError( - error instanceof Error ? error.message : __( 'Failed to create site. Please try again.' ) - ); - } - }; - - return ( - void navigate( { to: '/' } ) }> -
-

{ __( 'Create a new site' ) }

-

- { __( 'Choose a name and we\u2019ll scaffold a fresh WordPress site locally.' ) } -

- void navigate( { to: '/onboarding' } ) } - isSubmitting={ createSite.isPending } - submitError={ submitError } - /> -
-
- ); -} - -export function DeskOnboardingBlueprint() { - const { step } = desksOnboardingBlueprintRoute.useSearch() as StepSearch; - const navigate = useNavigate(); - const activeStep: Step = step === 'configure' ? 'configure' : 'select'; - const featured = useFeaturedBlueprints(); - const { data: existingDomainNames } = useExistingCustomDomains(); - const createSite = useCreateSite(); - const [ picked, setPicked ] = useState< PickedBlueprint | null >( null ); - const [ submitError, setSubmitError ] = useState( '' ); - - useEffect( () => { - if ( activeStep === 'configure' && ! picked ) { - void navigate( { - to: '/onboarding/blueprint', - search: { step: 'select' }, - replace: true, - } ); - } - }, [ activeStep, picked, navigate ] ); - - const handlePick = useCallback( - ( blueprint: PickedBlueprint ) => { - flushSync( () => { - setPicked( blueprint ); - setSubmitError( '' ); - } ); - void navigate( { - to: '/onboarding/blueprint', - search: { step: 'configure' }, - } ); - }, - [ navigate ] - ); - - const handleSubmit = async ( values: CreateSiteFormValues ) => { - if ( ! picked ) return; - setSubmitError( '' ); - const mergedBlueprint = updateBlueprintWithFormValues( picked.blueprint, { - phpVersion: values.phpVersion, - wpVersion: values.wpVersion, - customDomain: values.customDomain, - enableHttps: values.enableHttps, - adminUsername: values.adminUsername, - adminPassword: values.adminPassword, - siteName: values.name, - } ); - try { - const site = await createSite.mutateAsync( { - name: values.name, - path: values.path, - phpVersion: values.phpVersion, - wpVersion: values.wpVersion, - customDomain: values.customDomain, - enableHttps: values.enableHttps, - adminUsername: values.adminUsername || undefined, - adminPassword: values.adminPassword || undefined, - adminEmail: values.adminEmail || undefined, - blueprint: { - blueprint: mergedBlueprint, - slug: picked.slug, - filePath: picked.filePath, - }, - } ); - await navigate( { to: '/sites/$siteId', params: { siteId: site.id } } ); - } catch ( error ) { - setSubmitError( - error instanceof Error - ? error.message - : __( 'Failed to create site from Blueprint. Please try again.' ) - ); - } - }; - - if ( activeStep === 'select' ) { - return ( - void navigate( { to: '/' } ) } width="wide"> -
-

{ __( 'Start from a Blueprint' ) }

-

- { __( - 'Pick a featured Blueprint or drop in your own to provision plugins, content, and settings.' - ) } -

- -
-
- ); - } - - if ( ! picked ) { - return null; - } - - const initialValues = mapBlueprintSettingsToFormValues( - extractFormValuesFromBlueprint( picked.blueprint ), - picked.title - ); - - return ( - void navigate( { to: '/' } ) }> -
- -

{ picked.title }

- { picked.excerpt &&

{ picked.excerpt }

} - void navigate( { to: '/onboarding' } ) } - isSubmitting={ createSite.isPending } - submitError={ submitError } - submitLabel={ __( 'Create site from Blueprint' ) } - /> -
-
- ); -} - -export function DeskOnboardingImport() { - const { step } = desksOnboardingImportRoute.useSearch() as StepSearch; - const navigate = useNavigate(); - const connector = useConnector(); - const activeStep: Step = step === 'configure' ? 'configure' : 'select'; - const { data: existingDomainNames } = useExistingCustomDomains(); - const createSite = useCreateSite(); - const importSite = useImportSite(); - const [ picked, setPicked ] = useState< PickedBackup | null >( null ); - const [ pickError, setPickError ] = useState< string | null >( null ); - const [ submitError, setSubmitError ] = useState( '' ); - - useEffect( () => { - if ( activeStep === 'configure' && ! picked ) { - void navigate( { - to: '/onboarding/import', - search: { step: 'select' }, - replace: true, - } ); - } - }, [ activeStep, picked, navigate ] ); - - 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( () => { - setPickError( null ); - setPicked( { file, path } ); - } ); - void navigate( { - to: '/onboarding/import', - search: { step: 'configure' }, - } ); - }, - [ connector, navigate ] - ); - - const handleClearPick = useCallback( () => { - setPicked( null ); - setPickError( null ); - }, [] ); - - const handleSubmit = async ( values: CreateSiteFormValues ) => { - if ( ! picked ) return; - setSubmitError( '' ); - try { - const site = await createSite.mutateAsync( { - name: values.name, - path: values.path, - phpVersion: values.phpVersion, - wpVersion: values.wpVersion, - customDomain: values.customDomain, - enableHttps: values.enableHttps, - adminUsername: values.adminUsername || undefined, - adminPassword: values.adminPassword || undefined, - adminEmail: values.adminEmail || undefined, - } ); - await importSite.mutateAsync( { - siteId: site.id, - backup: { path: picked.path, type: picked.file.type }, - } ); - await navigate( { to: '/sites/$siteId', params: { siteId: site.id } } ); - } catch ( error ) { - setSubmitError( - error instanceof Error ? error.message : __( 'Failed to import site. Please try again.' ) - ); - } - }; - - if ( activeStep === 'select' ) { - return ( - void navigate( { to: '/' } ) }> -
-

{ __( '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 } - /> -
-
- ); - } - - if ( ! picked ) { - return null; - } - - const initialValues: Partial< CreateSiteFormValues > = { - name: nameFromFilename( picked.file.name ), - }; - const isSubmitting = createSite.isPending || importSite.isPending; - - return ( - void navigate( { to: '/' } ) }> -
- -

{ __( 'Configure the imported site' ) }

-

- { __( 'Pick a name and local folder. The backup will restore on top of this new site.' ) } -

- void navigate( { to: '/onboarding' } ) } - isSubmitting={ isSubmitting } - submitError={ submitError } - submitLabel={ __( 'Import site' ) } - /> -
-
- ); -} - -function mapBlueprintSettingsToFormValues( - settings: ReturnType< typeof extractFormValuesFromBlueprint >, - fallbackName: string -): Partial< CreateSiteFormValues > { - return { - name: settings.siteName || fallbackName, - phpVersion: settings.phpVersion, - wpVersion: settings.wpVersion, - customDomain: settings.customDomain, - enableHttps: settings.enableHttps, - adminUsername: settings.adminUsername, - adminPassword: settings.adminPassword, - }; -} - -function isValidBackupFile( file: File ): boolean { - const lower = file.name.toLowerCase(); - return ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => lower.endsWith( ext ) ); -} - -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 validateStepSearch( search: Record< string, unknown > ): StepSearch { - const value = search.step; - if ( value === 'configure' || value === 'select' ) { - return { step: value }; - } - return {}; -} +export const desksOnboardingLayoutRoute = createRoute( { + getParentRoute: () => desksRootRoute, + id: 'desks-onboarding-layout', + component: OnboardingShell, +} ); export const desksOnboardingHomeRoute = createRoute( { - getParentRoute: () => desksRootRoute, + getParentRoute: () => desksOnboardingLayoutRoute, path: '/onboarding', - component: DeskOnboardingHome, + component: OnboardingHomePage, } ); export const desksOnboardingCreateRoute = createRoute( { - getParentRoute: () => desksRootRoute, + getParentRoute: () => desksOnboardingLayoutRoute, path: '/onboarding/create', - component: DeskOnboardingCreate, + component: CreateSitePage, } ); export const desksOnboardingBlueprintRoute = createRoute( { - getParentRoute: () => desksRootRoute, + getParentRoute: () => desksOnboardingLayoutRoute, path: '/onboarding/blueprint', - validateSearch: validateStepSearch, - component: DeskOnboardingBlueprint, + validateSearch: validateBlueprintSearch, + component: OnboardingBlueprintPage, } ); export const desksOnboardingImportRoute = createRoute( { - getParentRoute: () => desksRootRoute, + getParentRoute: () => desksOnboardingLayoutRoute, path: '/onboarding/import', - validateSearch: validateStepSearch, - component: DeskOnboardingImport, + validateSearch: validateImportSearch, + component: OnboardingImportPage, +} ); + +export const desksDashboardRedirectRoute = createRoute( { + getParentRoute: () => desksRootRoute, + path: '/dashboard', + beforeLoad: () => { + throw redirect( { to: '/' } ); + }, +} ); + +export const desksNewSiteRedirectRoute = createRoute( { + getParentRoute: () => desksRootRoute, + path: '/sites/$siteId/new', + beforeLoad: ( { params } ) => { + throw redirect( { to: '/sites/$siteId', params } ); + }, } ); diff --git a/apps/ui/src/ui-desks/onboarding/style.module.css b/apps/ui/src/ui-desks/onboarding/style.module.css deleted file mode 100644 index f6c171e58b..0000000000 --- a/apps/ui/src/ui-desks/onboarding/style.module.css +++ /dev/null @@ -1,56 +0,0 @@ -.page { - width: 100%; - text-align: left; -} - -.title { - margin-bottom: 8px; -} - -.subtitle { - color: var(--wpds-color-fg-content-neutral-weak, #666); - margin: 0 0 32px; -} - -.cards { - display: flex; - gap: 16px; -} - -.card { - position: relative; - display: block; - text-align: left; - padding: 24px; - width: 240px; - border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd); - border-radius: 8px; - cursor: pointer; - color: inherit; - text-decoration: none; - background: var(--wpds-color-bg-surface-neutral-strong, #fff); - font: inherit; -} - -.card:hover { - border-color: var(--wpds-color-stroke-interactive-neutral, #bbb); -} - -.cardTitle { - font-weight: 600; - margin-bottom: 8px; -} - -.cardBody { - color: var(--wpds-color-fg-content-neutral-weak, #666); - font-size: 0.875rem; -} - -.backLink { - display: inline-flex; - align-items: center; - gap: 4px; - align-self: flex-start; - margin-bottom: 16px; - padding: 4px 8px 4px 4px; -}