From be62a33364f314a262455d1dc9e9cefec829c1e4 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 10 Aug 2026 21:01:09 +0100 Subject: [PATCH 01/14] Add backup import to the Agentic UI site overview --- .../components/import-site-dialog/index.tsx | 134 ++++++++++++++++++ .../import-site-dialog/style.module.css | 14 ++ .../site-overview-view/index.test.tsx | 42 ++++++ .../components/site-overview-view/index.tsx | 30 +++- .../src/hooks/use-site-management-actions.ts | 38 +++-- 5 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 apps/ui/src/components/import-site-dialog/index.tsx create mode 100644 apps/ui/src/components/import-site-dialog/style.module.css diff --git a/apps/ui/src/components/import-site-dialog/index.tsx b/apps/ui/src/components/import-site-dialog/index.tsx new file mode 100644 index 0000000000..23366a260c --- /dev/null +++ b/apps/ui/src/components/import-site-dialog/index.tsx @@ -0,0 +1,134 @@ +import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; +import { isSupportedBackupFilename } from '@studio/common/lib/backup-files'; +import { getErrorMessage } from '@studio/common/lib/error-formatting'; +import { getImportStatusMessage } from '@studio/common/lib/import-progress'; +import { __, sprintf } from '@wordpress/i18n'; +import { Button, Dialog } from '@wordpress/ui'; +import { useState } from 'react'; +import { dismissToast, toast } from '@/data/app-messages'; +import { useConnector } from '@/data/core'; +import { useImportSite } from '@/data/queries/use-import-site'; +import styles from './style.module.css'; +import type { SiteDetails } from '@/data/core'; + +export const IMPORT_FILE_ACCEPT = ACCEPTED_IMPORT_FILE_TYPES.join( ',' ); + +// A quiet stretch between progress events shouldn't drop the toast mid-import, +// and `confirm` always clears it explicitly once the import settles. +const PROGRESS_TOAST_TTL_MS = 10 * 60 * 1000; + +export function useSiteBackupImport( site: SiteDetails ) { + const connector = useConnector(); + const importSite = useImportSite(); + const [ file, setFile ] = useState< File | null >( null ); + // Tracked separately from `file` because the popup stays mounted through its + // closing animation — clearing the file to close would shrink it mid-fade. + const [ isConfirming, setIsConfirming ] = useState( false ); + const [ isImporting, setIsImporting ] = useState( false ); + + const selectFile = ( picked?: File ) => { + if ( ! picked ) { + return; + } + // The input's `accept` filter is advisory — a drag or an "All files" + // pick can still hand us something unsupported. + if ( ! isSupportedBackupFilename( picked.name ) ) { + toast.error( + __( + 'This file type is not supported. Please use a .zip, .gz, .gzip, .tar, .tar.gz, .wpress, .sql, or .xml file.' + ) + ); + return; + } + setFile( picked ); + setIsConfirming( true ); + }; + + const confirm = async () => { + if ( ! file || isImporting ) { + return; + } + setIsConfirming( false ); + setIsImporting( true ); + const toastId = `import-site-${ site.id }`; + try { + const backupPath = await connector.getFilePath( file ); + if ( ! backupPath ) { + throw new Error( __( 'Unable to access the selected backup. Please try again.' ) ); + } + await importSite.mutateAsync( { + siteId: site.id, + backupPath, + onProgress: ( event ) => { + const message = getImportStatusMessage( event ); + if ( message ) { + toast.info( message, { id: toastId, durationMs: PROGRESS_TOAST_TTL_MS } ); + } + }, + } ); + } catch ( error ) { + toast.error( __( 'Import failed' ), { description: getErrorMessage( error ) } ); + } finally { + dismissToast( toastId ); + setIsImporting( false ); + } + }; + + return { + file, + isConfirming, + selectFile, + cancel: () => setIsConfirming( false ), + confirm, + isImporting, + }; +} + +interface ImportSiteDialogProps { + site: SiteDetails; + file: File | null; + open: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function ImportSiteDialog( { + site, + file, + open, + onCancel, + onConfirm, +}: ImportSiteDialogProps ) { + return ( + { + if ( ! next ) { + onCancel(); + } + } } + > + + + { sprintf( __( 'Overwrite %s?' ), site.name ) } + + +

+ { __( + 'Importing a backup will replace the existing files and database for your site.' + ) } +

+ { file ?

{ file.name }

: null } +
+ + + { __( 'Cancel' ) } + + + +
+
+ ); +} diff --git a/apps/ui/src/components/import-site-dialog/style.module.css b/apps/ui/src/components/import-site-dialog/style.module.css new file mode 100644 index 0000000000..b2df3e0c21 --- /dev/null +++ b/apps/ui/src/components/import-site-dialog/style.module.css @@ -0,0 +1,14 @@ +.dialogText { + margin: 0; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.fileName { + margin: var(--wpds-dimension-padding-sm) 0 0; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + color: var(--wpds-color-fg-content-neutral); + overflow-wrap: anywhere; +} diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index a960dfd0a5..0f4d94ad72 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -32,6 +32,7 @@ import type { const navigateMock = vi.fn(); const siteDropdownMock = vi.hoisted( () => vi.fn() ); +const importSiteMock = vi.hoisted( () => vi.fn() ); const useSidebarCollapsedMock = vi.hoisted( () => vi.fn() ); const useTrafficLightSpaceMock = vi.hoisted( () => vi.fn() ); @@ -96,6 +97,10 @@ vi.mock( '@/data/queries/use-sites', () => ( { useXdebugEnabledSite: vi.fn(), } ) ); +vi.mock( '@/data/queries/use-import-site', () => ( { + useImportSite: () => ( { mutateAsync: importSiteMock } ), +} ) ); + vi.mock( '@/data/queries/use-site-thumbnail', () => ( { useSiteThumbnail: vi.fn(), } ) ); @@ -157,12 +162,15 @@ describe( 'SiteOverviewView', () => { const exportDatabase = vi.fn(); const onTabChange = vi.fn(); + const getFilePath = vi.fn().mockResolvedValue( '/tmp/backup.tar.gz' ); + const connectorStub = ( openInOS = true ) => ( { openSiteUrl, openSiteFolder, openSiteInEditor, openSiteInTerminal, trackEvent, + getFilePath, capabilities: { openInOS } as ConnectorCapabilities, } ); @@ -266,6 +274,7 @@ describe( 'SiteOverviewView', () => { expect( screen.getByText( 'Media Library' ) ).toBeVisible(); expect( screen.queryByText( 'Customizer' ) ).not.toBeInTheDocument(); expect( screen.getByText( 'Duplicate' ) ).toBeVisible(); + expect( screen.getByText( 'Import' ) ).toBeVisible(); expect( screen.getByText( 'Export entire site' ) ).toBeVisible(); expect( screen.getByText( 'Export database' ) ).toBeVisible(); expect( screen.getByText( 'Delete' ) ).toBeVisible(); @@ -664,6 +673,39 @@ describe( 'SiteOverviewView', () => { expect( screen.getByRole( 'dialog' ) ).toBeVisible(); } ); + function selectBackup( name: string ) { + const input = screen.getByTestId( 'import-backup-file' ); + Object.defineProperty( input, 'files', { + configurable: true, + value: [ new File( [ 'backup' ], name ) ], + } ); + fireEvent.change( input ); + } + + it( 'imports a backup after confirming the overwrite', async () => { + renderView(); + + selectBackup( 'demo-site.tar.gz' ); + + expect( screen.getByRole( 'dialog' ) ).toHaveTextContent( 'Overwrite Demo Site?' ); + fireEvent.click( screen.getByRole( 'button', { name: 'Import' } ) ); + + await waitFor( () => + expect( importSiteMock ).toHaveBeenCalledWith( + expect.objectContaining( { siteId: 'site-1', backupPath: '/tmp/backup.tar.gz' } ) + ) + ); + } ); + + it( 'rejects an unsupported backup file without opening the overwrite dialog', () => { + renderView(); + + selectBackup( 'notes.txt' ); + + expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument(); + expect( importSiteMock ).not.toHaveBeenCalled(); + } ); + it( 'shows a sign-in banner with a login action when signed out', () => { const loginMutate = vi.fn(); useAgenticFeaturesMock.mockReturnValue( { diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx index f20200d1f9..79ba3d18cb 100644 --- a/apps/ui/src/components/site-overview-view/index.tsx +++ b/apps/ui/src/components/site-overview-view/index.tsx @@ -17,9 +17,14 @@ import { widget, } from '@wordpress/icons'; import { Button } from '@wordpress/ui'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { AgenticSigninBanner } from '@/components/agentic-signin-banner'; import { DeleteSiteDialog } from '@/components/delete-site-dialog'; +import { + ImportSiteDialog, + IMPORT_FILE_ACCEPT, + useSiteBackupImport, +} from '@/components/import-site-dialog'; import { OfflineBanner } from '@/components/offline-banner'; import { useOpenInDestinations } from '@/components/open-in-menu/use-open-in-destinations'; import { PreviewToggleButton } from '@/components/preview-toggle-button'; @@ -233,8 +238,12 @@ function SiteOverviewBody( { const isStarting = useIsSiteStarting( site.id ); const isStopping = useIsSiteStopping( site.id ); const [ deleteOpen, setDeleteOpen ] = useState( false ); + const importInputRef = useRef< HTMLInputElement >( null ); + const backupImport = useSiteBackupImport( site ); const managementActions = useSiteManagementActions( site, { onDelete: () => setDeleteOpen( true ), + onImport: () => importInputRef.current?.click(), + isImporting: backupImport.isImporting, } ); const busy = isStarting || isStopping; @@ -416,6 +425,25 @@ function SiteOverviewBody( {
+ { + backupImport.selectFile( event.target.files?.[ 0 ] ); + // Lets the same file be picked again after a cancel or a failure. + event.target.value = ''; + } } + /> + void backupImport.confirm() } + /> void } + { + onDelete, + onImport, + isImporting, + }: { onDelete: () => void; onImport: () => void; isImporting: boolean } ): SiteManagementAction[] { const copySite = useCopySite(); const exportFullSite = useExportFullSite(); const exportDatabase = useExportDatabase(); // Full-site and database exports share one backend queue, so either - // running disables both. + // running disables both. An import rewrites the files being read, so it + // blocks exports too — and vice versa. const isExporting = exportFullSite.isPending || exportDatabase.isPending; + const isBusy = isExporting || isImporting; return [ { @@ -55,13 +63,23 @@ export function useSiteManagementActions( destructive: false, run: () => copySite.mutate( site.id ), }, + { + id: 'import', + icon: upload, + label: __( 'Import' ), + loading: isImporting, + loadingAnnouncement: __( 'Importing site' ), + disabled: isBusy, + destructive: false, + run: onImport, + }, { id: 'export', icon: download, label: __( 'Export entire site' ), loading: exportFullSite.isPending, loadingAnnouncement: __( 'Exporting site' ), - disabled: isExporting, + disabled: isBusy, destructive: false, run: () => exportFullSite.mutate( site.id ), }, @@ -71,7 +89,7 @@ export function useSiteManagementActions( label: __( 'Export database' ), loading: exportDatabase.isPending, loadingAnnouncement: __( 'Exporting database' ), - disabled: isExporting, + disabled: isBusy, destructive: false, run: () => exportDatabase.mutate( site.id ), }, From c4ca5660dbb078bf6e9deb33e99ece0e4925a2e1 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 10 Aug 2026 21:01:13 +0100 Subject: [PATCH 02/14] Exclude .sql dumps from Agentic UI site creation --- .../route-onboarding-home/index.test.tsx | 21 ++++++++++++++++--- .../router/route-onboarding-home/index.tsx | 10 +++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/index.test.tsx b/apps/ui/src/ui-classic/router/route-onboarding-home/index.test.tsx index 97711744b8..206df77702 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-home/index.test.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-home/index.test.tsx @@ -79,15 +79,30 @@ describe( 'OnboardingHomePage', () => { fireEvent.click( screen.getByRole( 'button', { name: /Import from a backup/ } ) ); expect( click ).toHaveBeenCalledOnce(); - expect( input.accept ).toContain( '.sql' ); + expect( input.accept ).toContain( '.zip' ); expect( input.accept ).toContain( '.xml' ); + // A database dump has no files to go with it, so it can only be imported + // over an existing site. + expect( input.accept ).not.toContain( '.sql' ); + } ); + + it( 'rejects a .sql dump, which can only be imported over an existing site', () => { + const { container } = render( ); + const input = container.querySelector< HTMLInputElement >( 'input[type="file"]' ); + if ( ! input ) throw new Error( 'Backup input not found' ); + + fireEvent.change( input, { target: { files: [ new File( [ 'dump' ], 'client-site.sql' ) ] } } ); + + expect( peekPendingBackup() ).toBeNull(); + expect( mocks.navigate ).not.toHaveBeenCalled(); + expect( screen.getByRole( 'alert' ) ).toHaveTextContent( 'This file type is not supported' ); } ); it( 'hands a selected backup File to the import form', () => { const { container } = render( ); const input = container.querySelector< HTMLInputElement >( 'input[type="file"]' ); if ( ! input ) throw new Error( 'Backup input not found' ); - const file = new File( [ 'backup' ], 'client-site.sql' ); + const file = new File( [ 'backup' ], 'client-site.tar.gz' ); fireEvent.change( input, { target: { files: [ file ] } } ); @@ -97,7 +112,7 @@ describe( 'OnboardingHomePage', () => { it( 'hands a dropped backup File to the import form', () => { render( ); - const file = new File( [ 'backup' ], 'client-site.sql' ); + const file = new File( [ 'backup' ], 'client-site.tar.gz' ); fireEvent.drop( screen.getByRole( 'button', { name: /Import from a backup/ } ), { dataTransfer: { files: [ file ] }, 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 7882af481c..67d8275ed8 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,4 +1,4 @@ -import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; +import { ACCEPTED_ADD_SITE_FILE_TYPES } from '@studio/common/constants'; import { isSupportedBackupFilename } from '@studio/common/lib/backup-files'; import { createRoute, Link, useNavigate } from '@tanstack/react-router'; import { __ } from '@wordpress/i18n'; @@ -30,10 +30,12 @@ function ImportBackupCard() { const handleFile = useCallback( ( file?: File ) => { if ( ! file ) return; - if ( ! isSupportedBackupFilename( file.name ) ) { + // A .sql dump is a database without the files that go with it, so it can + // only be imported over an existing site — not used to create one. + if ( ! isSupportedBackupFilename( file.name, ACCEPTED_ADD_SITE_FILE_TYPES ) ) { setError( __( - 'This file type is not supported. Please use a .zip, .gz, .gzip, .tar, .tar.gz, .wpress, .sql, or .xml file.' + 'This file type is not supported. Please use a .zip, .gz, .gzip, .tar, .tar.gz, .wpress, or .xml file.' ) ); return; @@ -51,7 +53,7 @@ function ImportBackupCard() { { handleFile( event.target.files?.[ 0 ] ); From 9050b9d744169e5cd367d3fe2527e44d9e1268c3 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Tue, 11 Aug 2026 10:30:13 +0100 Subject: [PATCH 03/14] Correct import comments that assumed a freshly created site --- apps/ui/src/data/core/types.ts | 2 +- apps/ui/src/data/queries/use-import-site.ts | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index ffa0b8c057..815beee2a9 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -239,7 +239,7 @@ export interface Connector { readBlueprintFile( filePath: string ): Promise< BlueprintV1Declaration >; // Imports a backup into an already-created site and starts the usable site. - // `backupPath` comes from `getFilePath` for the current submission. + // `backupPath` comes from `getFilePath` for the currently selected file. importSiteFromBackup( siteId: string, backupPath: string, diff --git a/apps/ui/src/data/queries/use-import-site.ts b/apps/ui/src/data/queries/use-import-site.ts index 4db7151606..8375afde17 100644 --- a/apps/ui/src/data/queries/use-import-site.ts +++ b/apps/ui/src/data/queries/use-import-site.ts @@ -11,11 +11,9 @@ export interface ImportSiteInput { onProgress?: ( event: ImportEventTuple ) => void; } -/** - * Runs a backup import against a site that has just been created. The - * The cached site list is invalidated so metadata changed by the importer is - * picked up. - */ +// Imports a backup over an existing site, whether it was just created by the +// import flow or has been around. The cached site list is invalidated so +// metadata changed by the importer is picked up. export function useImportSite() { const connector = useConnector(); const queryClient = useQueryClient(); From d0abc317f13f81d8d30ca8fa41fffaeddaf5cb83 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Tue, 11 Aug 2026 10:34:46 +0100 Subject: [PATCH 04/14] Use AlertDialog for the import overwrite confirmation --- .../components/import-site-dialog/index.tsx | 40 ++++++++----------- .../import-site-dialog/style.module.css | 7 ---- .../site-overview-view/index.test.tsx | 10 +++-- 3 files changed, 22 insertions(+), 35 deletions(-) diff --git a/apps/ui/src/components/import-site-dialog/index.tsx b/apps/ui/src/components/import-site-dialog/index.tsx index 23366a260c..3a490ada42 100644 --- a/apps/ui/src/components/import-site-dialog/index.tsx +++ b/apps/ui/src/components/import-site-dialog/index.tsx @@ -3,7 +3,7 @@ import { isSupportedBackupFilename } from '@studio/common/lib/backup-files'; import { getErrorMessage } from '@studio/common/lib/error-formatting'; import { getImportStatusMessage } from '@studio/common/lib/import-progress'; import { __, sprintf } from '@wordpress/i18n'; -import { Button, Dialog } from '@wordpress/ui'; +import { AlertDialog } from '@wordpress/ui'; import { useState } from 'react'; import { dismissToast, toast } from '@/data/app-messages'; import { useConnector } from '@/data/core'; @@ -100,35 +100,27 @@ export function ImportSiteDialog( { onConfirm, }: ImportSiteDialogProps ) { return ( - { if ( ! next ) { onCancel(); } } } + // Returns synchronously so the dialog closes and the import runs in the + // background — an async handler would hold it open for the whole import. + onConfirm={ onConfirm } > - - - { sprintf( __( 'Overwrite %s?' ), site.name ) } - - -

- { __( - 'Importing a backup will replace the existing files and database for your site.' - ) } -

- { file ?

{ file.name }

: null } -
- - - { __( 'Cancel' ) } - - - -
-
+ + { file ?

{ file.name }

: null } +
+ ); } diff --git a/apps/ui/src/components/import-site-dialog/style.module.css b/apps/ui/src/components/import-site-dialog/style.module.css index b2df3e0c21..5f03ebe65a 100644 --- a/apps/ui/src/components/import-site-dialog/style.module.css +++ b/apps/ui/src/components/import-site-dialog/style.module.css @@ -1,10 +1,3 @@ -.dialogText { - margin: 0; - font-size: var(--wpds-typography-font-size-sm); - line-height: var(--wpds-typography-line-height-sm); - color: var(--wpds-color-fg-content-neutral-weak); -} - .fileName { margin: var(--wpds-dimension-padding-sm) 0 0; font-size: var(--wpds-typography-font-size-sm); diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index 0f4d94ad72..a064c19624 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { Tooltip } from '@wordpress/ui'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useConnector } from '@/data/core'; @@ -687,8 +687,10 @@ describe( 'SiteOverviewView', () => { selectBackup( 'demo-site.tar.gz' ); - expect( screen.getByRole( 'dialog' ) ).toHaveTextContent( 'Overwrite Demo Site?' ); - fireEvent.click( screen.getByRole( 'button', { name: 'Import' } ) ); + const dialog = screen.getByRole( 'alertdialog' ); + expect( dialog ).toHaveTextContent( 'Overwrite Demo Site?' ); + expect( dialog ).toHaveTextContent( 'demo-site.tar.gz' ); + fireEvent.click( within( dialog ).getByRole( 'button', { name: 'Import' } ) ); await waitFor( () => expect( importSiteMock ).toHaveBeenCalledWith( @@ -702,7 +704,7 @@ describe( 'SiteOverviewView', () => { selectBackup( 'notes.txt' ); - expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'alertdialog' ) ).not.toBeInTheDocument(); expect( importSiteMock ).not.toHaveBeenCalled(); } ); From 9d27c4b5c259d0ed1f0eed2601a62969f6db58dc Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 12 Aug 2026 12:14:29 +0100 Subject: [PATCH 05/14] Throttle import progress toasts and soften the overwrite confirm button --- .../components/import-site-dialog/index.tsx | 11 +++++-- .../site-overview-view/index.test.tsx | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/import-site-dialog/index.tsx b/apps/ui/src/components/import-site-dialog/index.tsx index 3a490ada42..c948f1c9f7 100644 --- a/apps/ui/src/components/import-site-dialog/index.tsx +++ b/apps/ui/src/components/import-site-dialog/index.tsx @@ -51,6 +51,11 @@ export function useSiteBackupImport( site: SiteDetails ) { setIsConfirming( false ); setIsImporting( true ); const toastId = `import-site-${ site.id }`; + // Extraction reports progress once per stream chunk, so a large backup + // fires thousands of events a second. Only re-show the toast when the + // rendered text actually changes — otherwise the store notifies its + // subscribers that fast and the app stops responding to clicks. + let lastMessage = ''; try { const backupPath = await connector.getFilePath( file ); if ( ! backupPath ) { @@ -61,7 +66,8 @@ export function useSiteBackupImport( site: SiteDetails ) { backupPath, onProgress: ( event ) => { const message = getImportStatusMessage( event ); - if ( message ) { + if ( message && message !== lastMessage ) { + lastMessage = message; toast.info( message, { id: toastId, durationMs: PROGRESS_TOAST_TTL_MS } ); } }, @@ -111,8 +117,9 @@ export function ImportSiteDialog( { // background — an async handler would hold it open for the whole import. onConfirm={ onConfirm } > + { /* Deliberately not `intent="irreversible"`: the importer moves the site's + existing wp-content and database to the trash, not straight to deletion. */ } vi.fn() ); @@ -708,6 +711,36 @@ describe( 'SiteOverviewView', () => { expect( importSiteMock ).not.toHaveBeenCalled(); } ); + // Extraction emits one progress event per stream chunk, so a large backup + // would otherwise notify every toast subscriber thousands of times a second. + it( 'only re-shows the progress toast when the status text changes', async () => { + const info = vi.spyOn( toast, 'info' ); + let emitProgress: ( ( event: ImportEventTuple ) => void ) | undefined; + importSiteMock.mockImplementation( async ( { onProgress } ) => { + emitProgress = onProgress; + } ); + renderView(); + + selectBackup( 'demo-site.tar.gz' ); + fireEvent.click( + within( screen.getByRole( 'alertdialog' ) ).getByRole( 'button', { name: 'Import' } ) + ); + await waitFor( () => expect( emitProgress ).toBeDefined() ); + + // 500 chunks spanning two whole-percent steps of the same 10-file backup. + for ( let processedFiles = 1; processedFiles <= 500; processedFiles++ ) { + emitProgress?.( [ + BackupExtractEvents.BACKUP_EXTRACT_PROGRESS, + { processedFiles: processedFiles <= 250 ? 1 : 2, totalFiles: 10 }, + ] as ImportEventTuple ); + } + + const titles = info.mock.calls + .map( ( [ title ] ) => title ) + .filter( ( title ) => title.startsWith( 'Extracting backup…' ) ); + expect( titles ).toEqual( [ 'Extracting backup… (10%)', 'Extracting backup… (20%)' ] ); + } ); + it( 'shows a sign-in banner with a login action when signed out', () => { const loginMutate = vi.fn(); useAgenticFeaturesMock.mockReturnValue( { From b1a5fedc46837917c7aaa4dd11e795a525a15f77 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 12 Aug 2026 13:27:29 +0100 Subject: [PATCH 06/14] Track import progress per site so it doesn't follow navigation --- .../components/import-site-dialog/index.tsx | 64 +++++++--- .../site-overview-view/index.test.tsx | 111 ++++++++++++++---- apps/ui/src/data/queries/use-import-site.ts | 5 + 3 files changed, 139 insertions(+), 41 deletions(-) diff --git a/apps/ui/src/components/import-site-dialog/index.tsx b/apps/ui/src/components/import-site-dialog/index.tsx index c948f1c9f7..58f71ba983 100644 --- a/apps/ui/src/components/import-site-dialog/index.tsx +++ b/apps/ui/src/components/import-site-dialog/index.tsx @@ -2,14 +2,16 @@ import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; import { isSupportedBackupFilename } from '@studio/common/lib/backup-files'; import { getErrorMessage } from '@studio/common/lib/error-formatting'; import { getImportStatusMessage } from '@studio/common/lib/import-progress'; +import { useMutationState } from '@tanstack/react-query'; import { __, sprintf } from '@wordpress/i18n'; import { AlertDialog } from '@wordpress/ui'; import { useState } from 'react'; import { dismissToast, toast } from '@/data/app-messages'; import { useConnector } from '@/data/core'; -import { useImportSite } from '@/data/queries/use-import-site'; +import { IMPORT_SITE_MUTATION_KEY, useImportSite } from '@/data/queries/use-import-site'; import styles from './style.module.css'; import type { SiteDetails } from '@/data/core'; +import type { ImportSiteInput } from '@/data/queries/use-import-site'; export const IMPORT_FILE_ACCEPT = ACCEPTED_IMPORT_FILE_TYPES.join( ',' ); @@ -17,14 +19,36 @@ export const IMPORT_FILE_ACCEPT = ACCEPTED_IMPORT_FILE_TYPES.join( ',' ); // and `confirm` always clears it explicitly once the import settles. const PROGRESS_TOAST_TTL_MS = 10 * 60 * 1000; +// `confirming` is tracked alongside the file rather than derived from it because +// the popup stays mounted through its closing animation — dropping the file to +// close would shrink the dialog mid-fade. +interface PendingImport { + siteId: string; + file: File; + confirming: boolean; +} + export function useSiteBackupImport( site: SiteDetails ) { const connector = useConnector(); const importSite = useImportSite(); - const [ file, setFile ] = useState< File | null >( null ); - // Tracked separately from `file` because the popup stays mounted through its - // closing animation — clearing the file to close would shrink it mid-fade. - const [ isConfirming, setIsConfirming ] = useState( false ); - const [ isImporting, setIsImporting ] = useState( false ); + // Everything here is stamped with a site id: the overview stays mounted when + // the user switches sites (the route only swaps the `$siteId` param), so a + // plain boolean would follow them and light up the next site's Import button. + const [ pending, setPending ] = useState< PendingImport | null >( null ); + // Covers the upload that resolves the backup path, before the mutation — and + // so before `useMutationState` can see it. + const [ preparingSiteId, setPreparingSiteId ] = useState< string | null >( null ); + + // Read from the mutation cache rather than local state so the progress + // survives navigating away, and so an import started during onboarding is + // visible here too. + const importingSiteIds = useMutationState( { + filters: { mutationKey: IMPORT_SITE_MUTATION_KEY, status: 'pending' }, + select: ( mutation ) => ( mutation.state.variables as ImportSiteInput | undefined )?.siteId, + } ); + + const active = pending?.siteId === site.id ? pending : null; + const isImporting = preparingSiteId === site.id || importingSiteIds.includes( site.id ); const selectFile = ( picked?: File ) => { if ( ! picked ) { @@ -40,17 +64,23 @@ export function useSiteBackupImport( site: SiteDetails ) { ); return; } - setFile( picked ); - setIsConfirming( true ); + setPending( { siteId: site.id, file: picked, confirming: true } ); }; + const closeDialog = () => + setPending( ( current ) => + current?.siteId === site.id ? { ...current, confirming: false } : current + ); + const confirm = async () => { + const file = active?.file; if ( ! file || isImporting ) { return; } - setIsConfirming( false ); - setIsImporting( true ); - const toastId = `import-site-${ site.id }`; + const { id: siteId } = site; + closeDialog(); + setPreparingSiteId( siteId ); + const toastId = `import-site-${ siteId }`; // Extraction reports progress once per stream chunk, so a large backup // fires thousands of events a second. Only re-show the toast when the // rendered text actually changes — otherwise the store notifies its @@ -62,7 +92,7 @@ export function useSiteBackupImport( site: SiteDetails ) { throw new Error( __( 'Unable to access the selected backup. Please try again.' ) ); } await importSite.mutateAsync( { - siteId: site.id, + siteId, backupPath, onProgress: ( event ) => { const message = getImportStatusMessage( event ); @@ -76,15 +106,17 @@ export function useSiteBackupImport( site: SiteDetails ) { toast.error( __( 'Import failed' ), { description: getErrorMessage( error ) } ); } finally { dismissToast( toastId ); - setIsImporting( false ); + setPreparingSiteId( ( current ) => ( current === siteId ? null : current ) ); + // Drop the File so a large backup isn't held in memory for the session. + setPending( ( current ) => ( current?.siteId === siteId ? null : current ) ); } }; return { - file, - isConfirming, + file: active?.file ?? null, + isConfirming: active?.confirming ?? false, selectFile, - cancel: () => setIsConfirming( false ), + cancel: closeDialog, confirm, isImporting, }; diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index fde86105a4..8acaa9f968 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -1,4 +1,5 @@ import { BackupExtractEvents } from '@studio/common/lib/import-export-events'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { Tooltip } from '@wordpress/ui'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -35,7 +36,7 @@ import type { ImportEventTuple } from '@studio/common/lib/import-export-events'; const navigateMock = vi.fn(); const siteDropdownMock = vi.hoisted( () => vi.fn() ); -const importSiteMock = vi.hoisted( () => vi.fn() ); +const importSiteFromBackup = vi.hoisted( () => vi.fn() ); const useSidebarCollapsedMock = vi.hoisted( () => vi.fn() ); const useTrafficLightSpaceMock = vi.hoisted( () => vi.fn() ); @@ -89,6 +90,8 @@ vi.mock( '@/data/queries/use-create-site-helpers', () => ( { } ) ); vi.mock( '@/data/queries/use-sites', () => ( { + // The real `useImportSite` invalidates this key on success. + SITES_QUERY_KEY: [ 'sites' ], useCopySite: vi.fn(), useExportDatabase: vi.fn(), useExportFullSite: vi.fn(), @@ -100,10 +103,6 @@ vi.mock( '@/data/queries/use-sites', () => ( { useXdebugEnabledSite: vi.fn(), } ) ); -vi.mock( '@/data/queries/use-import-site', () => ( { - useImportSite: () => ( { mutateAsync: importSiteMock } ), -} ) ); - vi.mock( '@/data/queries/use-site-thumbnail', () => ( { useSiteThumbnail: vi.fn(), } ) ); @@ -174,14 +173,21 @@ describe( 'SiteOverviewView', () => { openSiteInTerminal, trackEvent, getFilePath, + importSiteFromBackup, capabilities: { openInOS } as ConnectorCapabilities, } ); const preferencesStub = ( editor: SupportedEditor | null ) => ( { editor, terminal: 'terminal' } ) as UserPreferences; + let queryClient: QueryClient; + beforeEach( () => { vi.clearAllMocks(); + queryClient = new QueryClient( { + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + } ); + importSiteFromBackup.mockResolvedValue( undefined ); useSidebarCollapsedMock.mockReturnValue( false ); useTrafficLightSpaceMock.mockReturnValue( { start: false, end: false } ); vi.stubGlobal( 'ResizeObserver', ResizeObserverMock ); @@ -246,18 +252,38 @@ describe( 'SiteOverviewView', () => { function renderView( activeTab: 'overview' | 'general' | 'debugging' = 'overview', - openSiteDropdown = false + openSiteDropdown = false, + siteId = 'site-1' ) { - return render( - - - + const view = ( + + + + + ); + const rendered = render( view ); + return { + ...rendered, + showSite: ( nextSiteId: string ) => + rendered.rerender( + + + + + + ), + }; } it( 'renders the tab strip with the about, customize, and manage sections', () => { @@ -458,7 +484,7 @@ describe( 'SiteOverviewView', () => { isLoading: false, } ); - const { rerender } = renderView( 'general' ); + const { showSite } = renderView( 'general' ); fireEvent.change( screen.getByLabelText( 'WordPress version' ), { target: { value: '6.7.2' }, @@ -469,11 +495,7 @@ describe( 'SiteOverviewView', () => { data: [ createSite( { running: false, isWpAutoUpdating: false } ) ], isLoading: false, } ); - rerender( - - - - ); + showSite( 'site-1' ); expect( screen.getByLabelText( 'WordPress version' ) ).toHaveValue( '6.7.2' ); } ); @@ -696,8 +718,10 @@ describe( 'SiteOverviewView', () => { fireEvent.click( within( dialog ).getByRole( 'button', { name: 'Import' } ) ); await waitFor( () => - expect( importSiteMock ).toHaveBeenCalledWith( - expect.objectContaining( { siteId: 'site-1', backupPath: '/tmp/backup.tar.gz' } ) + expect( importSiteFromBackup ).toHaveBeenCalledWith( + 'site-1', + '/tmp/backup.tar.gz', + expect.any( Function ) ) ); } ); @@ -708,7 +732,44 @@ describe( 'SiteOverviewView', () => { selectBackup( 'notes.txt' ); expect( screen.queryByRole( 'alertdialog' ) ).not.toBeInTheDocument(); - expect( importSiteMock ).not.toHaveBeenCalled(); + expect( importSiteFromBackup ).not.toHaveBeenCalled(); + } ); + + // The buttons mark themselves with `aria-disabled` rather than the native + // attribute, so they stay focusable. + function isManageButtonDisabled( label: string ) { + const heading = screen.getByRole( 'heading', { name: 'Manage' } ); + const button = within( heading.closest( 'section' )! ).getByText( label ).closest( 'button' )!; + return button.getAttribute( 'aria-disabled' ) === 'true'; + } + + // The overview stays mounted across sites — only the `$siteId` route param + // changes — so import progress must be tracked per site, not per component. + it( 'keeps the import indicator on the importing site when switching sites', async () => { + useSitesMock.mockReturnValue( { + data: [ + createSite( { running: true } ), + createSite( { id: 'site-2', name: 'Other Site', running: true } ), + ], + isLoading: false, + } ); + importSiteFromBackup.mockReturnValue( new Promise( () => {} ) ); + const { showSite } = renderView(); + + selectBackup( 'demo-site.tar.gz' ); + fireEvent.click( + within( screen.getByRole( 'alertdialog' ) ).getByRole( 'button', { name: 'Import' } ) + ); + await waitFor( () => expect( isManageButtonDisabled( 'Export entire site' ) ).toBe( true ) ); + + showSite( 'site-2' ); + + expect( isManageButtonDisabled( 'Import' ) ).toBe( false ); + expect( isManageButtonDisabled( 'Export entire site' ) ).toBe( false ); + + showSite( 'site-1' ); + + expect( isManageButtonDisabled( 'Export entire site' ) ).toBe( true ); } ); // Extraction emits one progress event per stream chunk, so a large backup @@ -716,7 +777,7 @@ describe( 'SiteOverviewView', () => { it( 'only re-shows the progress toast when the status text changes', async () => { const info = vi.spyOn( toast, 'info' ); let emitProgress: ( ( event: ImportEventTuple ) => void ) | undefined; - importSiteMock.mockImplementation( async ( { onProgress } ) => { + importSiteFromBackup.mockImplementation( async ( _siteId, _path, onProgress ) => { emitProgress = onProgress; } ); renderView(); diff --git a/apps/ui/src/data/queries/use-import-site.ts b/apps/ui/src/data/queries/use-import-site.ts index 8375afde17..579049d595 100644 --- a/apps/ui/src/data/queries/use-import-site.ts +++ b/apps/ui/src/data/queries/use-import-site.ts @@ -11,6 +11,10 @@ export interface ImportSiteInput { onProgress?: ( event: ImportEventTuple ) => void; } +// Keyed so any surface can ask which sites are mid-import, whichever surface +// started it — the mutation outlives the component that kicked it off. +export const IMPORT_SITE_MUTATION_KEY = [ 'import-site' ]; + // Imports a backup over an existing site, whether it was just created by the // import flow or has been around. The cached site list is invalidated so // metadata changed by the importer is picked up. @@ -18,6 +22,7 @@ export function useImportSite() { const connector = useConnector(); const queryClient = useQueryClient(); return useMutation< void, Error, ImportSiteInput >( { + mutationKey: IMPORT_SITE_MUTATION_KEY, mutationFn: ( { siteId, backupPath, onProgress } ) => connector.importSiteFromBackup( siteId, backupPath, onProgress ), onSuccess: async () => { From 221b5b11e998a7b2e6c9a5cdfa67e6313ed6b51b Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 12 Aug 2026 14:57:51 +0100 Subject: [PATCH 07/14] Shorten import status messages so the toast stays on one line --- .../site-overview-view/index.test.tsx | 4 +-- .../route-onboarding-import/index.test.tsx | 4 +-- packages/common/lib/import-progress.ts | 25 ++++++++------- .../common/lib/tests/import-progress.test.ts | 32 +++++++++++++++++-- 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index 8acaa9f968..94a7ddbbd5 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -798,8 +798,8 @@ describe( 'SiteOverviewView', () => { const titles = info.mock.calls .map( ( [ title ] ) => title ) - .filter( ( title ) => title.startsWith( 'Extracting backup…' ) ); - expect( titles ).toEqual( [ 'Extracting backup… (10%)', 'Extracting backup… (20%)' ] ); + .filter( ( title ) => title.startsWith( 'Extracting…' ) ); + expect( titles ).toEqual( [ 'Extracting… (10%)', 'Extracting… (20%)' ] ); } ); it( 'shows a sign-in banner with a login action when signed out', () => { diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx b/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx index 177b233375..56f705c240 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx @@ -197,8 +197,8 @@ describe( 'OnboardingImportPage', () => { fireEvent.click( screen.getByRole( 'button', { name: 'Import site' } ) ); await waitFor( () => { - expect( mocks.setProgress ).toHaveBeenCalledWith( 'Extracting backup… (25%)' ); - expect( mocks.setProgress ).toHaveBeenCalledWith( 'Importing database… (50%)' ); + expect( mocks.setProgress ).toHaveBeenCalledWith( 'Extracting… (25%)' ); + expect( mocks.setProgress ).toHaveBeenCalledWith( 'Database… (50%)' ); } ); } ); diff --git a/packages/common/lib/import-progress.ts b/packages/common/lib/import-progress.ts index 44c121cb04..3b3a53aea8 100644 --- a/packages/common/lib/import-progress.ts +++ b/packages/common/lib/import-progress.ts @@ -1,17 +1,20 @@ import { __, sprintf } from '@wordpress/i18n'; import { BackupExtractEvents, ImporterEvents, type ImportEventTuple } from './import-export-events'; +// These land in a toast pinned to the sidebar, which is only 240px wide at its +// narrowest — roughly 166px of text. Anything longer wraps to a second line, so +// the messages that carry a percentage drop the verb to make room for it. const getWpContentTypeLabels = (): Record< string, string > => ( { - plugins: __( 'Importing plugins…' ), - themes: __( 'Importing themes…' ), - uploads: __( 'Importing media uploads…' ), - other: __( 'Importing other files…' ), + plugins: __( 'Plugins…' ), + themes: __( 'Themes…' ), + uploads: __( 'Media uploads…' ), + other: __( 'Other files…' ), } ); export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): string | undefined { switch ( event ) { case BackupExtractEvents.BACKUP_EXTRACT_START: - return __( 'Extracting backup files…' ); + return __( 'Extracting backup…' ); case BackupExtractEvents.BACKUP_EXTRACT_PROGRESS: if ( data.processedFiles !== undefined && @@ -19,11 +22,11 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str data.totalFiles > 0 ) { return sprintf( - __( 'Extracting backup… (%d%%)' ), + __( 'Extracting… (%d%%)' ), Math.round( ( data.processedFiles / data.totalFiles ) * 100 ) ); } - return __( 'Extracting backup files…' ); + return __( 'Extracting backup…' ); case ImporterEvents.IMPORT_START: return __( 'Importing backup…' ); case ImporterEvents.IMPORT_DATABASE_START: @@ -35,13 +38,13 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str data.totalFiles > 0 ) { return sprintf( - __( 'Importing database… (%d%%)' ), + __( 'Database… (%d%%)' ), Math.round( ( data.processedFiles / data.totalFiles ) * 100 ) ); } return __( 'Importing database…' ); case ImporterEvents.IMPORT_WP_CONTENT_START: - return __( 'Importing WordPress content…' ); + return __( 'Importing content…' ); case ImporterEvents.IMPORT_WP_CONTENT_PROGRESS: if ( data.type && @@ -51,11 +54,11 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str ) { return sprintf( __( '%1$s (%2$d%%)' ), - getWpContentTypeLabels()[ data.type ] || __( 'Importing files…' ), + getWpContentTypeLabels()[ data.type ] || __( 'Files…' ), Math.round( ( data.processedItems / data.totalItems ) * 100 ) ); } - return __( 'Importing WordPress content…' ); + return __( 'Importing content…' ); case ImporterEvents.IMPORT_COMPLETE: return __( 'Importing completed' ); } diff --git a/packages/common/lib/tests/import-progress.test.ts b/packages/common/lib/tests/import-progress.test.ts index e89d0df1f2..dbcaa005e8 100644 --- a/packages/common/lib/tests/import-progress.test.ts +++ b/packages/common/lib/tests/import-progress.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { BackupExtractEvents, ImporterEvents } from '../import-export-events'; import { getImportStatusMessage } from '../import-progress'; +import type { ImportEventTuple } from '../import-export-events'; describe( 'getImportStatusMessage', () => { it( 'formats extraction progress', () => { @@ -9,7 +10,7 @@ describe( 'getImportStatusMessage', () => { BackupExtractEvents.BACKUP_EXTRACT_PROGRESS, { processedFiles: 1, totalFiles: 4 }, ] ) - ).toBe( 'Extracting backup… (25%)' ); + ).toBe( 'Extracting… (25%)' ); } ); it( 'formats database progress', () => { @@ -18,7 +19,7 @@ describe( 'getImportStatusMessage', () => { ImporterEvents.IMPORT_DATABASE_PROGRESS, { processedFiles: 3, totalFiles: 4 }, ] ) - ).toBe( 'Importing database… (75%)' ); + ).toBe( 'Database… (75%)' ); } ); it( 'formats WordPress content progress by type', () => { @@ -27,7 +28,32 @@ describe( 'getImportStatusMessage', () => { ImporterEvents.IMPORT_WP_CONTENT_PROGRESS, { type: 'uploads', processedItems: 1, totalItems: 2 }, ] ) - ).toBe( 'Importing media uploads… (50%)' ); + ).toBe( 'Media uploads… (50%)' ); + } ); + + // The sidebar toast is 240px at its narrowest, leaving ~166px of text before + // the title wraps to a second line — about 30 characters at 13px. + it( 'keeps every status message short enough for one line in the toast', () => { + const progress = { processedFiles: 1, totalFiles: 3 }; + const events: ImportEventTuple[] = [ + [ BackupExtractEvents.BACKUP_EXTRACT_START, undefined ], + [ BackupExtractEvents.BACKUP_EXTRACT_PROGRESS, progress ], + [ ImporterEvents.IMPORT_START, 'jetpack' ], + [ ImporterEvents.IMPORT_DATABASE_START, undefined ], + [ ImporterEvents.IMPORT_DATABASE_PROGRESS, progress ], + [ ImporterEvents.IMPORT_WP_CONTENT_START, undefined ], + [ ImporterEvents.IMPORT_COMPLETE, 'jetpack' ], + ...( [ 'plugins', 'themes', 'uploads', 'other', 'unknown' ].map( ( type ) => [ + ImporterEvents.IMPORT_WP_CONTENT_PROGRESS, + { type, processedItems: 1, totalItems: 3 }, + ] ) as ImportEventTuple[] ), + ]; + + for ( const event of events ) { + const message = getImportStatusMessage( event ); + expect( message ).toBeDefined(); + expect( message!.length ).toBeLessThanOrEqual( 30 ); + } } ); it( 'ignores events that do not change the status message', () => { From c08c2c76f8a3d6bb0255367a5e833f862d77144a Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 12 Aug 2026 15:36:00 +0100 Subject: [PATCH 08/14] Clamp the import progress toast to one line for longer translations --- apps/ui/src/components/app-toasts/index.tsx | 2 +- .../components/app-toasts/style.module.css | 8 ++++++++ .../components/import-site-dialog/index.tsx | 6 +++++- .../site-overview-view/index.test.tsx | 16 ++++++++++++---- apps/ui/src/data/app-messages.ts | 7 +++++++ .../route-onboarding-import/index.test.tsx | 4 ++-- packages/common/lib/import-progress.ts | 19 ++++++++++++------- .../common/lib/tests/import-progress.test.ts | 12 +++++++----- 8 files changed, 54 insertions(+), 20 deletions(-) diff --git a/apps/ui/src/components/app-toasts/index.tsx b/apps/ui/src/components/app-toasts/index.tsx index 5f5c32590b..4a6019cd2a 100644 --- a/apps/ui/src/components/app-toasts/index.tsx +++ b/apps/ui/src/components/app-toasts/index.tsx @@ -57,7 +57,7 @@ export function AppToasts( { { item.title } { item.description ? ( diff --git a/apps/ui/src/components/app-toasts/style.module.css b/apps/ui/src/components/app-toasts/style.module.css index a29ced9d59..bf14d83fc4 100644 --- a/apps/ui/src/components/app-toasts/style.module.css +++ b/apps/ui/src/components/app-toasts/style.module.css @@ -72,6 +72,14 @@ line-height: 16px; } +/* Status text that ticks in place: a longer translation ellipsizes rather than + growing the toast to two or three lines mid-import. */ +.singleLine [class*='heading'] { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .notice [class*='__description'] { font-size: var(--wpds-typography-font-size-xs); line-height: 16px; diff --git a/apps/ui/src/components/import-site-dialog/index.tsx b/apps/ui/src/components/import-site-dialog/index.tsx index 58f71ba983..5a29d9f7e1 100644 --- a/apps/ui/src/components/import-site-dialog/index.tsx +++ b/apps/ui/src/components/import-site-dialog/index.tsx @@ -98,7 +98,11 @@ export function useSiteBackupImport( site: SiteDetails ) { const message = getImportStatusMessage( event ); if ( message && message !== lastMessage ) { lastMessage = message; - toast.info( message, { id: toastId, durationMs: PROGRESS_TOAST_TTL_MS } ); + toast.info( message, { + id: toastId, + durationMs: PROGRESS_TOAST_TTL_MS, + singleLine: true, + } ); } }, } ); diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index 94a7ddbbd5..8c1048283f 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -796,10 +796,18 @@ describe( 'SiteOverviewView', () => { ] as ImportEventTuple ); } - const titles = info.mock.calls - .map( ( [ title ] ) => title ) - .filter( ( title ) => title.startsWith( 'Extracting…' ) ); - expect( titles ).toEqual( [ 'Extracting… (10%)', 'Extracting… (20%)' ] ); + const progressCalls = info.mock.calls.filter( ( [ title ] ) => + title.endsWith( '· Extracting…' ) + ); + expect( progressCalls.map( ( [ title ] ) => title ) ).toEqual( [ + '10% · Extracting…', + '20% · Extracting…', + ] ); + // Clamped to one line so a longer translation ellipsizes instead of + // growing the toast while the percentage ticks. + for ( const [ , options ] of progressCalls ) { + expect( options?.singleLine ).toBe( true ); + } } ); it( 'shows a sign-in banner with a login action when signed out', () => { diff --git a/apps/ui/src/data/app-messages.ts b/apps/ui/src/data/app-messages.ts index b4ea396b92..9a1d261673 100644 --- a/apps/ui/src/data/app-messages.ts +++ b/apps/ui/src/data/app-messages.ts @@ -17,6 +17,11 @@ export type ToastInput = { description?: string; action?: ToastAction; durationMs?: number; + // Clamps the title to one line, ellipsizing the overflow. For status text + // that updates in place (import progress), where a translation growing to + // two or three lines would make the toast jump as it ticks. Leave off for + // anything the user has to read in full, like an error explanation. + singleLine?: boolean; }; export type ToastMessage = { @@ -26,6 +31,7 @@ export type ToastMessage = { description?: string; action?: ToastAction; durationMs: number; + singleLine?: boolean; // True while the exit transition plays. The toast stays in the visible // list (so the renderer can animate it out) and is actually removed — // and the queue promoted — TOAST_EXIT_MS later. @@ -123,6 +129,7 @@ export function showToast( input: ToastInput ): string { title: input.title, description: input.description, action: input.action, + singleLine: input.singleLine, durationMs: input.durationMs ?? ( intent === 'error' ? ERROR_TOAST_TTL_MS : DEFAULT_TOAST_TTL_MS ), }; diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx b/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx index 56f705c240..fc3992c2a4 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-import/index.test.tsx @@ -197,8 +197,8 @@ describe( 'OnboardingImportPage', () => { fireEvent.click( screen.getByRole( 'button', { name: 'Import site' } ) ); await waitFor( () => { - expect( mocks.setProgress ).toHaveBeenCalledWith( 'Extracting… (25%)' ); - expect( mocks.setProgress ).toHaveBeenCalledWith( 'Database… (50%)' ); + expect( mocks.setProgress ).toHaveBeenCalledWith( '25% · Extracting…' ); + expect( mocks.setProgress ).toHaveBeenCalledWith( '50% · Database…' ); } ); } ); diff --git a/packages/common/lib/import-progress.ts b/packages/common/lib/import-progress.ts index 3b3a53aea8..5076ca1ae4 100644 --- a/packages/common/lib/import-progress.ts +++ b/packages/common/lib/import-progress.ts @@ -2,8 +2,10 @@ import { __, sprintf } from '@wordpress/i18n'; import { BackupExtractEvents, ImporterEvents, type ImportEventTuple } from './import-export-events'; // These land in a toast pinned to the sidebar, which is only 240px wide at its -// narrowest — roughly 166px of text. Anything longer wraps to a second line, so -// the messages that carry a percentage drop the verb to make room for it. +// narrowest — roughly 166px of text. The messages that carry a percentage drop +// the verb to make room for it, and lead with the number: the toast clamps the +// title to one line, so a translation that overflows loses the tail, and the +// percentage is the part that actually changes. const getWpContentTypeLabels = (): Record< string, string > => ( { plugins: __( 'Plugins…' ), themes: __( 'Themes…' ), @@ -22,7 +24,8 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str data.totalFiles > 0 ) { return sprintf( - __( 'Extracting… (%d%%)' ), + /* translators: %d: percentage complete. */ + __( '%d%% · Extracting…' ), Math.round( ( data.processedFiles / data.totalFiles ) * 100 ) ); } @@ -38,7 +41,8 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str data.totalFiles > 0 ) { return sprintf( - __( 'Database… (%d%%)' ), + /* translators: %d: percentage complete. */ + __( '%d%% · Database…' ), Math.round( ( data.processedFiles / data.totalFiles ) * 100 ) ); } @@ -53,9 +57,10 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str data.totalItems > 0 ) { return sprintf( - __( '%1$s (%2$d%%)' ), - getWpContentTypeLabels()[ data.type ] || __( 'Files…' ), - Math.round( ( data.processedItems / data.totalItems ) * 100 ) + /* translators: %1$d: percentage complete. %2$s: what is being imported. */ + __( '%1$d%% · %2$s' ), + Math.round( ( data.processedItems / data.totalItems ) * 100 ), + getWpContentTypeLabels()[ data.type ] || __( 'Files…' ) ); } return __( 'Importing content…' ); diff --git a/packages/common/lib/tests/import-progress.test.ts b/packages/common/lib/tests/import-progress.test.ts index dbcaa005e8..ac4d96ce96 100644 --- a/packages/common/lib/tests/import-progress.test.ts +++ b/packages/common/lib/tests/import-progress.test.ts @@ -10,7 +10,7 @@ describe( 'getImportStatusMessage', () => { BackupExtractEvents.BACKUP_EXTRACT_PROGRESS, { processedFiles: 1, totalFiles: 4 }, ] ) - ).toBe( 'Extracting… (25%)' ); + ).toBe( '25% · Extracting…' ); } ); it( 'formats database progress', () => { @@ -19,7 +19,7 @@ describe( 'getImportStatusMessage', () => { ImporterEvents.IMPORT_DATABASE_PROGRESS, { processedFiles: 3, totalFiles: 4 }, ] ) - ).toBe( 'Database… (75%)' ); + ).toBe( '75% · Database…' ); } ); it( 'formats WordPress content progress by type', () => { @@ -28,11 +28,13 @@ describe( 'getImportStatusMessage', () => { ImporterEvents.IMPORT_WP_CONTENT_PROGRESS, { type: 'uploads', processedItems: 1, totalItems: 2 }, ] ) - ).toBe( 'Media uploads… (50%)' ); + ).toBe( '50% · Media uploads…' ); } ); - // The sidebar toast is 240px at its narrowest, leaving ~166px of text before - // the title wraps to a second line — about 30 characters at 13px. + // The toast clamps the title to one line, so overflow ellipsizes rather than + // wrapping. This keeps English clear of that clamp altogether: the sidebar is + // 240px at its narrowest, leaving ~166px of text — about 30 characters at + // 13px. Translations can still overflow, which is what the clamp is for. it( 'keeps every status message short enough for one line in the toast', () => { const progress = { processedFiles: 1, totalFiles: 3 }; const events: ImportEventTuple[] = [ From ef6b27b4d7adfefc0774520a8c06c7b04d58bb38 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 12 Aug 2026 16:00:35 +0100 Subject: [PATCH 09/14] Pad the import percentage to two digits instead of clamping the toast --- apps/ui/src/components/app-toasts/index.tsx | 2 +- .../components/app-toasts/style.module.css | 8 ----- .../components/import-site-dialog/index.tsx | 6 +--- .../site-overview-view/index.test.tsx | 16 +++------- apps/ui/src/data/app-messages.ts | 7 ----- packages/common/lib/import-progress.ts | 31 +++++++++++-------- .../common/lib/tests/import-progress.test.ts | 23 +++++++++++--- 7 files changed, 43 insertions(+), 50 deletions(-) diff --git a/apps/ui/src/components/app-toasts/index.tsx b/apps/ui/src/components/app-toasts/index.tsx index 4a6019cd2a..5f5c32590b 100644 --- a/apps/ui/src/components/app-toasts/index.tsx +++ b/apps/ui/src/components/app-toasts/index.tsx @@ -57,7 +57,7 @@ export function AppToasts( { { item.title } { item.description ? ( diff --git a/apps/ui/src/components/app-toasts/style.module.css b/apps/ui/src/components/app-toasts/style.module.css index bf14d83fc4..a29ced9d59 100644 --- a/apps/ui/src/components/app-toasts/style.module.css +++ b/apps/ui/src/components/app-toasts/style.module.css @@ -72,14 +72,6 @@ line-height: 16px; } -/* Status text that ticks in place: a longer translation ellipsizes rather than - growing the toast to two or three lines mid-import. */ -.singleLine [class*='heading'] { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - .notice [class*='__description'] { font-size: var(--wpds-typography-font-size-xs); line-height: 16px; diff --git a/apps/ui/src/components/import-site-dialog/index.tsx b/apps/ui/src/components/import-site-dialog/index.tsx index 5a29d9f7e1..58f71ba983 100644 --- a/apps/ui/src/components/import-site-dialog/index.tsx +++ b/apps/ui/src/components/import-site-dialog/index.tsx @@ -98,11 +98,7 @@ export function useSiteBackupImport( site: SiteDetails ) { const message = getImportStatusMessage( event ); if ( message && message !== lastMessage ) { lastMessage = message; - toast.info( message, { - id: toastId, - durationMs: PROGRESS_TOAST_TTL_MS, - singleLine: true, - } ); + toast.info( message, { id: toastId, durationMs: PROGRESS_TOAST_TTL_MS } ); } }, } ); diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index 8c1048283f..3f2db35903 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -796,18 +796,10 @@ describe( 'SiteOverviewView', () => { ] as ImportEventTuple ); } - const progressCalls = info.mock.calls.filter( ( [ title ] ) => - title.endsWith( '· Extracting…' ) - ); - expect( progressCalls.map( ( [ title ] ) => title ) ).toEqual( [ - '10% · Extracting…', - '20% · Extracting…', - ] ); - // Clamped to one line so a longer translation ellipsizes instead of - // growing the toast while the percentage ticks. - for ( const [ , options ] of progressCalls ) { - expect( options?.singleLine ).toBe( true ); - } + const titles = info.mock.calls + .map( ( [ title ] ) => title ) + .filter( ( title ) => title.endsWith( '· Extracting…' ) ); + expect( titles ).toEqual( [ '10% · Extracting…', '20% · Extracting…' ] ); } ); it( 'shows a sign-in banner with a login action when signed out', () => { diff --git a/apps/ui/src/data/app-messages.ts b/apps/ui/src/data/app-messages.ts index 9a1d261673..b4ea396b92 100644 --- a/apps/ui/src/data/app-messages.ts +++ b/apps/ui/src/data/app-messages.ts @@ -17,11 +17,6 @@ export type ToastInput = { description?: string; action?: ToastAction; durationMs?: number; - // Clamps the title to one line, ellipsizing the overflow. For status text - // that updates in place (import progress), where a translation growing to - // two or three lines would make the toast jump as it ticks. Leave off for - // anything the user has to read in full, like an error explanation. - singleLine?: boolean; }; export type ToastMessage = { @@ -31,7 +26,6 @@ export type ToastMessage = { description?: string; action?: ToastAction; durationMs: number; - singleLine?: boolean; // True while the exit transition plays. The toast stays in the visible // list (so the renderer can animate it out) and is actually removed — // and the queue promoted — TOAST_EXIT_MS later. @@ -129,7 +123,6 @@ export function showToast( input: ToastInput ): string { title: input.title, description: input.description, action: input.action, - singleLine: input.singleLine, durationMs: input.durationMs ?? ( intent === 'error' ? ERROR_TOAST_TTL_MS : DEFAULT_TOAST_TTL_MS ), }; diff --git a/packages/common/lib/import-progress.ts b/packages/common/lib/import-progress.ts index 5076ca1ae4..582a527276 100644 --- a/packages/common/lib/import-progress.ts +++ b/packages/common/lib/import-progress.ts @@ -2,10 +2,10 @@ import { __, sprintf } from '@wordpress/i18n'; import { BackupExtractEvents, ImporterEvents, type ImportEventTuple } from './import-export-events'; // These land in a toast pinned to the sidebar, which is only 240px wide at its -// narrowest — roughly 166px of text. The messages that carry a percentage drop -// the verb to make room for it, and lead with the number: the toast clamps the -// title to one line, so a translation that overflows loses the tail, and the -// percentage is the part that actually changes. +// narrowest — roughly 166px of text — so the messages carrying a percentage drop +// the verb to make room for it and lead with the number, the part that actually +// changes. A longer translation may wrap to a second line; what it must not do +// is reflow on every tick, so the percentage is padded to a fixed two digits. const getWpContentTypeLabels = (): Record< string, string > => ( { plugins: __( 'Plugins…' ), themes: __( 'Themes…' ), @@ -13,6 +13,11 @@ const getWpContentTypeLabels = (): Record< string, string > => ( { other: __( 'Other files…' ), } ); +// `@wordpress/i18n`'s sprintf ignores width flags like `%02d`, so pad here and +// interpolate as a string. +const percent = ( done: number, total: number ): string => + String( Math.round( ( done / total ) * 100 ) ).padStart( 2, '0' ); + export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): string | undefined { switch ( event ) { case BackupExtractEvents.BACKUP_EXTRACT_START: @@ -24,9 +29,9 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str data.totalFiles > 0 ) { return sprintf( - /* translators: %d: percentage complete. */ - __( '%d%% · Extracting…' ), - Math.round( ( data.processedFiles / data.totalFiles ) * 100 ) + /* translators: %s: percentage complete, zero-padded to two digits. */ + __( '%s%% · Extracting…' ), + percent( data.processedFiles, data.totalFiles ) ); } return __( 'Extracting backup…' ); @@ -41,9 +46,9 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str data.totalFiles > 0 ) { return sprintf( - /* translators: %d: percentage complete. */ - __( '%d%% · Database…' ), - Math.round( ( data.processedFiles / data.totalFiles ) * 100 ) + /* translators: %s: percentage complete, zero-padded to two digits. */ + __( '%s%% · Database…' ), + percent( data.processedFiles, data.totalFiles ) ); } return __( 'Importing database…' ); @@ -57,9 +62,9 @@ export function getImportStatusMessage( [ event, data ]: ImportEventTuple ): str data.totalItems > 0 ) { return sprintf( - /* translators: %1$d: percentage complete. %2$s: what is being imported. */ - __( '%1$d%% · %2$s' ), - Math.round( ( data.processedItems / data.totalItems ) * 100 ), + /* translators: %1$s: percentage complete, zero-padded to two digits. %2$s: what is being imported. */ + __( '%1$s%% · %2$s' ), + percent( data.processedItems, data.totalItems ), getWpContentTypeLabels()[ data.type ] || __( 'Files…' ) ); } diff --git a/packages/common/lib/tests/import-progress.test.ts b/packages/common/lib/tests/import-progress.test.ts index ac4d96ce96..35ca347686 100644 --- a/packages/common/lib/tests/import-progress.test.ts +++ b/packages/common/lib/tests/import-progress.test.ts @@ -31,10 +31,25 @@ describe( 'getImportStatusMessage', () => { ).toBe( '50% · Media uploads…' ); } ); - // The toast clamps the title to one line, so overflow ellipsizes rather than - // wrapping. This keeps English clear of that clamp altogether: the sidebar is - // 240px at its narrowest, leaving ~166px of text — about 30 characters at - // 13px. Translations can still overflow, which is what the clamp is for. + // The toast rewrites this message in place as the import runs. Padding single + // digits keeps its width steady so it doesn't reflow on every tick. + it( 'pads the percentage to two digits', () => { + const at = ( done: number, total: number ) => + getImportStatusMessage( [ + BackupExtractEvents.BACKUP_EXTRACT_PROGRESS, + { processedFiles: done, totalFiles: total }, + ] ); + + expect( at( 0, 100 ) ).toBe( '00% · Extracting…' ); + expect( at( 5, 100 ) ).toBe( '05% · Extracting…' ); + expect( at( 9, 100 ) ).toBe( '09% · Extracting…' ); + expect( at( 10, 100 ) ).toBe( '10% · Extracting…' ); + expect( at( 100, 100 ) ).toBe( '100% · Extracting…' ); + } ); + + // The sidebar is 240px at its narrowest, leaving ~166px of text — about 30 + // characters at 13px. English stays on one line; a longer translation may + // wrap to a second, which is fine as long as the width doesn't jitter. it( 'keeps every status message short enough for one line in the toast', () => { const progress = { processedFiles: 1, totalFiles: 3 }; const events: ImportEventTuple[] = [ From 5004ebbe5cb9e40d9ba746236793d1b6046af904 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 12 Aug 2026 16:14:49 +0100 Subject: [PATCH 10/14] Use tabular figures in toasts so the import percentage doesn't shift --- apps/ui/src/components/app-toasts/style.module.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/ui/src/components/app-toasts/style.module.css b/apps/ui/src/components/app-toasts/style.module.css index a29ced9d59..2be7e2e0da 100644 --- a/apps/ui/src/components/app-toasts/style.module.css +++ b/apps/ui/src/components/app-toasts/style.module.css @@ -57,6 +57,10 @@ width: 100%; box-sizing: border-box; text-wrap: pretty; + /* Toasts rewrite themselves in place — import progress ticks through a + percentage — and this font's digits are not equal width, so a counter + would shift the text by a few pixels on nearly every update. */ + font-variant-numeric: tabular-nums; --wp-ui-notice-background-color: var(--wpds-color-bg-surface-neutral-strong); --wp-ui-notice-border-color: transparent; --wp-ui-notice-text-color: var(--wpds-color-fg-content-neutral); From eb9531526e4f0800b3a4cbe08089d015bbd32133 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 12 Aug 2026 17:48:44 +0100 Subject: [PATCH 11/14] Show import progress per site instead of in a global toast --- .../components/import-site-dialog/index.tsx | 53 +++++++++---------- .../site-dropdown/main-view.test.tsx | 19 +++++++ .../components/site-dropdown/main-view.tsx | 11 ++-- .../site-dropdown/trigger-secondary.ts | 9 ++++ .../src/components/site-list/index.test.tsx | 19 +++++++ apps/ui/src/components/site-list/index.tsx | 27 +++++++--- .../site-overview-view/index.test.tsx | 35 ++++++++---- apps/ui/src/data/queries/use-import-site.ts | 5 -- apps/ui/src/data/sync-activity.ts | 16 ++++-- 9 files changed, 138 insertions(+), 56 deletions(-) diff --git a/apps/ui/src/components/import-site-dialog/index.tsx b/apps/ui/src/components/import-site-dialog/index.tsx index 58f71ba983..4b09bb7b55 100644 --- a/apps/ui/src/components/import-site-dialog/index.tsx +++ b/apps/ui/src/components/import-site-dialog/index.tsx @@ -2,23 +2,24 @@ import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants'; import { isSupportedBackupFilename } from '@studio/common/lib/backup-files'; import { getErrorMessage } from '@studio/common/lib/error-formatting'; import { getImportStatusMessage } from '@studio/common/lib/import-progress'; -import { useMutationState } from '@tanstack/react-query'; import { __, sprintf } from '@wordpress/i18n'; import { AlertDialog } from '@wordpress/ui'; import { useState } from 'react'; -import { dismissToast, toast } from '@/data/app-messages'; +import { toast } from '@/data/app-messages'; import { useConnector } from '@/data/core'; -import { IMPORT_SITE_MUTATION_KEY, useImportSite } from '@/data/queries/use-import-site'; +import { useImportSite } from '@/data/queries/use-import-site'; +import { + reportSyncError, + reportSyncPending, + reportSyncProgress, + reportSyncSuccess, + useSiteSyncActivity, +} from '@/data/sync-activity'; import styles from './style.module.css'; import type { SiteDetails } from '@/data/core'; -import type { ImportSiteInput } from '@/data/queries/use-import-site'; export const IMPORT_FILE_ACCEPT = ACCEPTED_IMPORT_FILE_TYPES.join( ',' ); -// A quiet stretch between progress events shouldn't drop the toast mid-import, -// and `confirm` always clears it explicitly once the import settles. -const PROGRESS_TOAST_TTL_MS = 10 * 60 * 1000; - // `confirming` is tracked alongside the file rather than derived from it because // the popup stays mounted through its closing animation — dropping the file to // close would shrink the dialog mid-fade. @@ -35,20 +36,13 @@ export function useSiteBackupImport( site: SiteDetails ) { // the user switches sites (the route only swaps the `$siteId` param), so a // plain boolean would follow them and light up the next site's Import button. const [ pending, setPending ] = useState< PendingImport | null >( null ); - // Covers the upload that resolves the backup path, before the mutation — and - // so before `useMutationState` can see it. - const [ preparingSiteId, setPreparingSiteId ] = useState< string | null >( null ); - // Read from the mutation cache rather than local state so the progress - // survives navigating away, and so an import started during onboarding is - // visible here too. - const importingSiteIds = useMutationState( { - filters: { mutationKey: IMPORT_SITE_MUTATION_KEY, status: 'pending' }, - select: ( mutation ) => ( mutation.state.variables as ImportSiteInput | undefined )?.siteId, - } ); + // The activity store is keyed by site and lives outside React, so progress + // survives navigating away and shows on whichever surface renders this site. + const activity = useSiteSyncActivity( site.id ); const active = pending?.siteId === site.id ? pending : null; - const isImporting = preparingSiteId === site.id || importingSiteIds.includes( site.id ); + const isImporting = activity?.kind === 'pending' && activity.direction === 'import'; const selectFile = ( picked?: File ) => { if ( ! picked ) { @@ -79,12 +73,11 @@ export function useSiteBackupImport( site: SiteDetails ) { } const { id: siteId } = site; closeDialog(); - setPreparingSiteId( siteId ); - const toastId = `import-site-${ siteId }`; + reportSyncPending( siteId, 'import' ); // Extraction reports progress once per stream chunk, so a large backup - // fires thousands of events a second. Only re-show the toast when the - // rendered text actually changes — otherwise the store notifies its - // subscribers that fast and the app stops responding to clicks. + // fires thousands of events a second. Only report when the rendered text + // actually changes — otherwise the store notifies its subscribers that + // fast and the app stops responding to clicks. let lastMessage = ''; try { const backupPath = await connector.getFilePath( file ); @@ -98,15 +91,19 @@ export function useSiteBackupImport( site: SiteDetails ) { const message = getImportStatusMessage( event ); if ( message && message !== lastMessage ) { lastMessage = message; - toast.info( message, { id: toastId, durationMs: PROGRESS_TOAST_TTL_MS } ); + reportSyncProgress( siteId, 'import', { message } ); } }, } ); + reportSyncSuccess( siteId, 'import' ); } catch ( error ) { - toast.error( __( 'Import failed' ), { description: getErrorMessage( error ) } ); + // Matches push/pull: the activity store carries the detail on the site + // itself, and a toast says so wherever the user has navigated to. + const message = + getErrorMessage( error ) ?? __( 'Failed to import the backup. Please try again.' ); + reportSyncError( siteId, 'import', message ); + toast.error( __( "Import didn't complete" ), { description: message } ); } finally { - dismissToast( toastId ); - setPreparingSiteId( ( current ) => ( current === siteId ? null : current ) ); // Drop the File so a large backup isn't held in memory for the session. setPending( ( current ) => ( current?.siteId === siteId ? null : current ) ); } diff --git a/apps/ui/src/components/site-dropdown/main-view.test.tsx b/apps/ui/src/components/site-dropdown/main-view.test.tsx index bd306fba71..d87d52e983 100644 --- a/apps/ui/src/components/site-dropdown/main-view.test.tsx +++ b/apps/ui/src/components/site-dropdown/main-view.test.tsx @@ -217,6 +217,25 @@ describe( 'MainView', () => { expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Creating remote backup… (24%)' ); } ); + it( 'shows detailed import progress in the open site status', () => { + renderMainView( { + activity: { kind: 'pending', direction: 'import', message: '24% · Media uploads…' }, + } ); + + expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Importing backup…' ); + expect( screen.getByRole( 'status' ) ).toHaveTextContent( '24% · Media uploads…' ); + } ); + + // An import replaces the site's files and database, so letting a sync run + // alongside it would have them fighting over the same site. + it( 'blocks the live sync actions while an import is running', () => { + renderMainView( { activity: { kind: 'pending', direction: 'import' } } ); + + expect( + screen.getByRole( 'button', { name: 'Update preview site (sync in progress)' } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + } ); + it( 'updates the existing preview site while the snapshot is fresh', () => { renderMainView(); diff --git a/apps/ui/src/components/site-dropdown/main-view.tsx b/apps/ui/src/components/site-dropdown/main-view.tsx index 1ae760b35e..25c6d81303 100644 --- a/apps/ui/src/components/site-dropdown/main-view.tsx +++ b/apps/ui/src/components/site-dropdown/main-view.tsx @@ -128,8 +128,10 @@ export function MainView( { site, activity, onSetupClick, onDisconnectClick }: P const { push: isPushPending, pull: isPullPending } = useIsSiteSyncing( site.id ); const isPreviewPending = publishPreviewSite.isPending; // Preview / push / pull all mutate the same local site; running them - // concurrently would wedge the site runtime. - const isSyncing = isPreviewPending || isPushPending || isPullPending; + // concurrently would wedge the site runtime. An import replaces that site's + // files and database outright, so it locks them out too. + const isImporting = activity?.kind === 'pending' && activity.direction === 'import'; + const isSyncing = isPreviewPending || isPushPending || isPullPending || isImporting; const { localSublabel } = deriveSiteStatus( site, isStarting, isStopping ); const localSiteUrl = getSiteUrl( site ); @@ -462,7 +464,10 @@ function SyncActivityDetails( { >
{ getSyncActivityLabel( activity ) }
- { activity.message ?? __( 'Preparing the live site…' ) } + { activity.message ?? + ( activity.direction === 'import' + ? __( 'Preparing the backup…' ) + : __( 'Preparing the live site…' ) ) }
); diff --git a/apps/ui/src/components/site-dropdown/trigger-secondary.ts b/apps/ui/src/components/site-dropdown/trigger-secondary.ts index 658935bfed..d3ca87d9c9 100644 --- a/apps/ui/src/components/site-dropdown/trigger-secondary.ts +++ b/apps/ui/src/components/site-dropdown/trigger-secondary.ts @@ -25,6 +25,9 @@ export function getSyncActivityLabel( activity: SyncActivity ): string { if ( activity.direction === 'preview' ) { return __( 'Publishing preview…' ); } + if ( activity.direction === 'import' ) { + return __( 'Importing backup…' ); + } return activity.direction === 'push' ? __( 'Pushing to live…' ) : __( 'Pulling from live…' ); } @@ -32,12 +35,18 @@ export function getSyncActivityLabel( activity: SyncActivity ): string { if ( activity.direction === 'preview' ) { return __( 'Preview published' ); } + if ( activity.direction === 'import' ) { + return __( 'Backup imported' ); + } return activity.direction === 'push' ? __( 'Pushed to live' ) : __( 'Pulled from live' ); } if ( activity.direction === 'preview' ) { return __( 'Publishing preview failed' ); } + if ( activity.direction === 'import' ) { + return __( 'Importing backup failed' ); + } return activity.direction === 'push' ? __( 'Pushing to live failed' ) : __( 'Pulling from live failed' ); diff --git a/apps/ui/src/components/site-list/index.test.tsx b/apps/ui/src/components/site-list/index.test.tsx index 6a8c57bc57..4548dcb3c9 100644 --- a/apps/ui/src/components/site-list/index.test.tsx +++ b/apps/ui/src/components/site-list/index.test.tsx @@ -690,6 +690,25 @@ describe( 'SiteList', () => { ); } ); + // Without a per-row indicator there is nothing to tell two concurrent imports + // apart — the toast that used to carry this named no site. + it( 'shows activity on the importing row only', () => { + useSiteAgentActivityMock.mockReturnValue( 'idle' ); + useSiteSyncActivityMock.mockImplementation( ( siteId ) => + siteId === 'running-site' ? { kind: 'pending', direction: 'import' } : null + ); + + render( ); + + const importingRow = screen.getByText( 'Running Site' ).closest( 'section' )!; + const otherRow = screen.getByText( 'Stopped Site' ).closest( 'section' )!; + + expect( + within( importingRow ).getByRole( 'status', { name: 'Importing backup' } ) + ).toBeInTheDocument(); + expect( within( otherRow ).queryByRole( 'status' ) ).not.toBeInTheDocument(); + } ); + it( 'shows live sync activity before the site name while a site is syncing', () => { useSiteAgentActivityMock.mockReturnValue( 'working' ); useSiteSyncActivityMock.mockImplementation( ( siteId ) => diff --git a/apps/ui/src/components/site-list/index.tsx b/apps/ui/src/components/site-list/index.tsx index 85d53b68b0..82f7846d03 100644 --- a/apps/ui/src/components/site-list/index.tsx +++ b/apps/ui/src/components/site-list/index.tsx @@ -52,7 +52,7 @@ type SiteRow = { sessionIds: string[]; }; -type SiteRowActivity = SiteAgentActivity | 'new-message' | 'sync'; +type SiteRowActivity = SiteAgentActivity | 'new-message' | 'sync' | 'import'; const ACTIVITY_EXIT_DURATION_MS = 180; @@ -114,6 +114,7 @@ function SiteAgentActivityIndicator( { activity }: { activity: SiteRowActivity } const pendingQuestionAriaLabel = __( 'Studio needs an answer.' ); const newMessageLabel = __( 'New message' ); const syncLabel = __( 'Syncing live site' ); + const importLabel = __( 'Importing backup' ); return ( ) : null } - { renderedActivity === 'sync' ? ( - + { renderedActivity === 'sync' || renderedActivity === 'import' ? ( +