Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
be62a33
Add backup import to the Agentic UI site overview
bcotrim Aug 10, 2026
c4ca566
Exclude .sql dumps from Agentic UI site creation
bcotrim Aug 10, 2026
01c1766
Merge branch 'trunk' into stu-2008-add-import-export-to-agentic-ui
bcotrim Aug 11, 2026
9050b9d
Correct import comments that assumed a freshly created site
bcotrim Aug 11, 2026
e0efb72
Merge remote-tracking branch 'origin/stu-2008-add-import-export-to-ag…
bcotrim Aug 11, 2026
d0abc31
Use AlertDialog for the import overwrite confirmation
bcotrim Aug 11, 2026
9d27c4b
Throttle import progress toasts and soften the overwrite confirm button
bcotrim Aug 12, 2026
b1a5fed
Track import progress per site so it doesn't follow navigation
bcotrim Aug 12, 2026
221b5b1
Shorten import status messages so the toast stays on one line
bcotrim Aug 12, 2026
c08c2c7
Clamp the import progress toast to one line for longer translations
bcotrim Aug 12, 2026
ef6b27b
Pad the import percentage to two digits instead of clamping the toast
bcotrim Aug 12, 2026
5004ebb
Use tabular figures in toasts so the import percentage doesn't shift
bcotrim Aug 12, 2026
eb95315
Show import progress per site instead of in a global toast
bcotrim Aug 12, 2026
2c2fb67
Lead sync and import progress labels with a padded percentage
bcotrim Aug 12, 2026
a92fa5c
Refetch site details invalidated by an import
bcotrim Aug 12, 2026
52f2d56
Merge remote-tracking branch 'origin/trunk' into stu-2008-add-import-…
bcotrim Aug 13, 2026
4ba390e
Drop redundant comments from the site overview tests
bcotrim Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions apps/ui/src/components/import-site-dialog/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog.Root
open={ open }
onOpenChange={ ( next ) => {
if ( ! next ) {
onCancel();
}
} }
>
<Dialog.Popup size="small">
<Dialog.Header>
<Dialog.Title>{ sprintf( __( 'Overwrite %s?' ), site.name ) }</Dialog.Title>
</Dialog.Header>
<Dialog.Content>
<p className={ styles.dialogText }>
{ __(
'Importing a backup will replace the existing files and database for your site.'
) }
</p>
{ file ? <p className={ styles.fileName }>{ file.name }</p> : null }
</Dialog.Content>
<Dialog.Footer>
<Dialog.Action variant="minimal" tone="neutral">
{ __( 'Cancel' ) }
</Dialog.Action>
<Button variant="solid" tone="brand" onClick={ onConfirm }>
{ __( 'Import' ) }
</Button>
</Dialog.Footer>
</Dialog.Popup>
</Dialog.Root>
);
}
14 changes: 14 additions & 0 deletions apps/ui/src/components/import-site-dialog/style.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
42 changes: 42 additions & 0 deletions apps/ui/src/components/site-overview-view/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() );

Expand Down Expand Up @@ -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(),
} ) );
Expand Down Expand Up @@ -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,
} );

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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( {
Expand Down
30 changes: 29 additions & 1 deletion apps/ui/src/components/site-overview-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -416,6 +425,25 @@ function SiteOverviewBody( {
<div className={ styles.footerBar }>
<PreviewToggleButton />
</div>
<input
ref={ importInputRef }
type="file"
hidden
accept={ IMPORT_FILE_ACCEPT }
data-testid="import-backup-file"
onChange={ ( event ) => {
backupImport.selectFile( event.target.files?.[ 0 ] );
// Lets the same file be picked again after a cancel or a failure.
event.target.value = '';
} }
/>
<ImportSiteDialog
site={ site }
file={ backupImport.file }
open={ backupImport.isConfirming }
onCancel={ backupImport.cancel }
onConfirm={ () => void backupImport.confirm() }
/>
<DeleteSiteDialog
site={ site }
open={ deleteOpen }
Expand Down
38 changes: 28 additions & 10 deletions apps/ui/src/hooks/use-site-management-actions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { __ } from '@wordpress/i18n';
import { copy, download, grid, trash } from '@wordpress/icons';
import { copy, download, grid, trash, upload } from '@wordpress/icons';
import { useCopySite, useExportDatabase, useExportFullSite } from '@/data/queries/use-sites';
import type { SiteDetails } from '@/data/core';
import type { ReactElement, SVGProps } from 'react';

export type SiteManagementActionId = 'duplicate' | 'export' | 'export-db' | 'delete';
export type SiteManagementActionId = 'duplicate' | 'import' | 'export' | 'export-db' | 'delete';

export interface SiteManagementAction {
id: SiteManagementActionId;
Expand All @@ -24,25 +24,33 @@ export interface SiteManagementAction {
}

/**
* The canonical "manage this site" actions — Duplicate, Export entire site,
* The canonical "manage this site" actions — Duplicate, Import, Export entire site,
* Export database, Delete — shared by every surface that offers them, so labels, icons,
* order, and disabled logic don't drift apart between surfaces.
*
* Delete needs a confirmation dialog whose "deleted" navigation differs per
* surface, so this hook doesn't own the dialog: pass `onDelete` to open your
* own `DeleteSiteDialog` and the Delete action's `run` calls it.
* Import and Delete both need surface-owned UI (a file picker plus an overwrite
* confirmation, and a confirmation dialog whose "deleted" navigation differs per
* surface), so this hook doesn't own either: pass `onImport` / `onDelete` and the
* matching action's `run` calls them. `isImporting` comes back in from the surface
* running the import so exports and imports can block each other.
*/
export function useSiteManagementActions(
site: SiteDetails,
{ onDelete }: { onDelete: () => 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 [
{
Expand All @@ -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 ),
},
Expand All @@ -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 ),
},
Expand Down
Loading