Skip to content
35 changes: 32 additions & 3 deletions src/components/content-tab-import-export.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { speak } from '@wordpress/a11y';
import { Notice } from '@wordpress/components';
import { createInterpolateElement } from '@wordpress/element';
import { sprintf, __ } from '@wordpress/i18n';
import { Icon, download } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
import { useRef } from 'react';
import { ACCEPTED_IMPORT_FILE_TYPES, STUDIO_DOCS_URL_IMPORT_EXPORT } from '../constants';
import { useEffect, useRef, useState } from 'react';
import { STUDIO_DOCS_URL_IMPORT_EXPORT, ACCEPTED_IMPORT_FILE_TYPES } from '../constants';
import { useSyncSites } from '../hooks/sync-sites/sync-sites-context';
import { useConfirmationDialog } from '../hooks/use-confirmation-dialog';
import { useDragAndDropFile } from '../hooks/use-drag-and-drop-file';
Expand Down Expand Up @@ -247,8 +248,36 @@ const ImportSite = ( props: { selectedSite: SiteDetails } ) => {
};

export function ContentTabImportExport( { selectedSite }: ContentTabImportExportProps ) {
const [ isSupported, setIsSupported ] = useState< boolean | null >( null );

useEffect( () => {
getIpcApi()
.isImportExportSupported( selectedSite.id )
.then( ( result ) => {
setIsSupported( result );
} );
}, [ selectedSite.id, selectedSite.running ] );

if ( isSupported === null ) {
return null;
}

if ( ! isSupported ) {
return (
<div className="flex flex-col p-8">
<Notice status="warning" isDismissible={ false }>
<span className="font-bold">
{ __( 'Import / Export is not available for this site' ) }
</span>
<br />
{ __( 'This feature is only available for sites using the default SQLite integration.' ) }
</Notice>
</div>
);
}

return (
<div className="flex flex-col p-8 gap-8">
<div className="flex flex-col p-8 gap-8" data-testid="import-export-supported">
<ImportSite selectedSite={ selectedSite } />
<ExportSite selectedSite={ selectedSite } />
</div>
Expand Down
56 changes: 51 additions & 5 deletions src/components/tests/content-tab-import-export.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { render, fireEvent, waitFor, screen, createEvent, act } from '@testing-library/react';
import { render, fireEvent, waitFor, screen, createEvent } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { act } from 'react';
import { SyncSitesProvider } from '../../hooks/sync-sites/sync-sites-context';
import { useImportExport } from '../../hooks/use-import-export';
import { useSiteDetails } from '../../hooks/use-site-details';
Expand Down Expand Up @@ -28,6 +29,7 @@ beforeEach( () => {
} );
( getIpcApi as jest.Mock ).mockReturnValue( {
showMessageBox: jest.fn().mockResolvedValue( { response: 0, checkboxChecked: false } ), // Mock showMessageBox
isImportExportSupported: jest.fn().mockResolvedValue( true ),
} );
( useImportExport as jest.Mock ).mockReturnValue( {
importFile: jest.fn(),
Expand All @@ -43,18 +45,25 @@ const renderWithProvider = ( children: React.ReactElement ) => {
};

describe( 'ContentTabImportExport Import', () => {
test( 'should display drop text on file over', () => {
test( 'should display drop text on file over', async () => {
renderWithProvider( <ContentTabImportExport selectedSite={ selectedSite } /> );
await waitFor( () => {
expect( screen.getByTestId( 'import-export-supported' ) ).toBeVisible();
} );

const dropZone = screen.getByText( /Drag a file here, or click to select a file/i );
expect( dropZone ).toBeInTheDocument();

fireEvent.dragOver( dropZone );
act( () => {
fireEvent.dragOver( dropZone );
} );
expect( screen.getByText( /Drop file/i ) ).toBeInTheDocument();
} );

test( 'should display inital text on drop leave', () => {
test( 'should display inital text on drop leave', async () => {
renderWithProvider( <ContentTabImportExport selectedSite={ selectedSite } /> );
await waitFor( () => {
expect( screen.getByTestId( 'import-export-supported' ) ).toBeVisible();
} );

const dropZone = screen.getByText( /Drag a file here, or click to select a file/i );
expect( dropZone ).toBeInTheDocument();
Expand All @@ -77,6 +86,9 @@ describe( 'ContentTabImportExport Import', () => {

test( 'should import a site via drag-and-drop', async () => {
renderWithProvider( <ContentTabImportExport selectedSite={ selectedSite } /> );
await waitFor( () => {
expect( screen.getByTestId( 'import-export-supported' ) ).toBeVisible();
} );

const dropZone = screen.getByText( /Drag a file here, or click to select a file/i );
const file = new File( [ 'file contents' ], 'backup.zip', { type: 'application/zip' } );
Expand All @@ -93,7 +105,13 @@ describe( 'ContentTabImportExport Import', () => {

test( 'should import a site via file selection', async () => {
renderWithProvider( <ContentTabImportExport selectedSite={ selectedSite } /> );
await waitFor( () => {
expect( screen.getByTestId( 'import-export-supported' ) ).toBeVisible();
} );

const fileInput = screen.getByTestId( 'backup-file' );
expect( fileInput ).toBeInTheDocument();

const file = new File( [ 'file contents' ], 'backup.zip', { type: 'application/zip' } );

await userEvent.upload( fileInput, file );
Expand All @@ -110,6 +128,10 @@ describe( 'ContentTabImportExport Import', () => {
} );

renderWithProvider( <ContentTabImportExport selectedSite={ selectedSite } /> );
await waitFor( () => {
expect( screen.getByTestId( 'import-export-supported' ) ).toBeVisible();
} );

expect( screen.getByText( 'Extracting backup…' ) ).toBeVisible();
expect( screen.getByRole( 'progressbar', { value: { now: 5 } } ) ).toBeVisible();
} );
Expand All @@ -123,6 +145,9 @@ describe( 'ContentTabImportExport Export', () => {

test( 'should export full site', async () => {
renderWithProvider( <ContentTabImportExport selectedSite={ selectedSite } /> );
await waitFor( () => {
expect( screen.getByTestId( 'import-export-supported' ) ).toBeVisible();
} );

const exportButton = screen.getByRole( 'button', { name: /Export entire site/i } );
fireEvent.click( exportButton );
Expand All @@ -132,6 +157,9 @@ describe( 'ContentTabImportExport Export', () => {

test( 'should export database', async () => {
renderWithProvider( <ContentTabImportExport selectedSite={ selectedSite } /> );
await waitFor( () => {
expect( screen.getByTestId( 'import-export-supported' ) ).toBeVisible();
} );

const exportButton = screen.getByRole( 'button', { name: /Export database/i } );
fireEvent.click( exportButton );
Expand All @@ -146,7 +174,25 @@ describe( 'ContentTabImportExport Export', () => {
} );

renderWithProvider( <ContentTabImportExport selectedSite={ selectedSite } /> );
await waitFor( () => {
expect( screen.getByTestId( 'import-export-supported' ) ).toBeVisible();
} );

expect( screen.getByText( 'Starting export...' ) ).toBeVisible();
expect( screen.getByRole( 'progressbar', { value: { now: 5 } } ) ).toBeVisible();
} );

test( 'should be blocked', async () => {
( getIpcApi as jest.Mock ).mockReturnValue( {
isImportExportSupported: jest.fn().mockResolvedValue( false ),
} );

renderWithProvider( <ContentTabImportExport selectedSite={ selectedSite } /> );

await waitFor( () => {
expect( screen.getByText( 'Import / Export is not available for this site' ) ).toBeVisible();
} );
expect( screen.queryByRole( 'button', { name: /Export entire site/i } ) ).toBeNull();
expect( screen.queryByRole( 'button', { name: /Export database/i } ) ).toBeNull();
} );
} );
8 changes: 8 additions & 0 deletions src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -948,3 +948,11 @@ export async function removeSyncBackup( event: IpcMainInvokeEvent, remoteSiteId:
const filePath = getSyncBackupTempPath( remoteSiteId );
await fsPromises.unlink( filePath );
}

export async function isImportExportSupported( _event: IpcMainInvokeEvent, siteId: string ) {
const site = SiteServer.get( siteId );
if ( ! site ) {
throw new Error( 'Site not found.' );
}
return site.hasSQLitePlugin();
}
2 changes: 2 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ const api: IpcApi = {
ipcRenderer.invoke( 'getAbsolutePathFromSite', siteId, relativePath ),
openFileInIDE: ( relativePath: string, siteId: string ) =>
ipcRenderer.invoke( 'openFileInIDE', relativePath, siteId ),
isImportExportSupported: ( siteId: string ) =>
ipcRenderer.invoke( 'isImportExportSupported', siteId ),
downloadSyncBackup: ( remoteSiteId: number, downloadUrl: string ) =>
ipcRenderer.invoke( 'downloadSyncBackup', remoteSiteId, downloadUrl ),
removeSyncBackup: ( remoteSiteId: number ) =>
Expand Down
33 changes: 32 additions & 1 deletion src/site-server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import fs from 'fs';
import nodePath from 'path';
import * as Sentry from '@sentry/electron/main';
import fsExtra from 'fs-extra';
import { parse } from 'shell-quote';
import { getWpNowConfig } from '../vendor/wp-now/src';
import { WPNowMode } from '../vendor/wp-now/src/config';
import { DEFAULT_PHP_VERSION } from '../vendor/wp-now/src/constants';
import { DEFAULT_PHP_VERSION, SQLITE_FILENAME } from '../vendor/wp-now/src/constants';
import { getWordPressVersionPath } from '../vendor/wp-now/src/download';
import { pathExists, recursiveCopyDirectory, isEmptyDir } from './lib/fs-utils';
import { decodePassword } from './lib/passwords';
Expand Down Expand Up @@ -197,4 +198,34 @@ export class SiteServer {
return { stdout: '', stderr: 'error when executing wp-cli command', exitCode: 1 };
}
}

async hasSQLitePlugin(): Promise< boolean > {
const wpContentPath = nodePath.join( this.details.path, 'wp-content' );

const sqliteIntegrationPaths = {
muPlugin: nodePath.join( wpContentPath, 'mu-plugins', SQLITE_FILENAME ),
muPluginLegacy: nodePath.join( wpContentPath, 'mu-plugins', `${ SQLITE_FILENAME }-main` ),
regularPlugin: nodePath.join( wpContentPath, 'plugins', SQLITE_FILENAME ),
};

const requiredConfigPaths = {
wpConfig: nodePath.join( this.details.path, 'wp-config.php' ),
dbConfig: nodePath.join( wpContentPath, 'db.php' ),
dbSqlite: nodePath.join( wpContentPath, 'database', '.ht.sqlite' ),
};

const anyIntegrationExists = await Promise.all( [
fsExtra.pathExists( sqliteIntegrationPaths.muPlugin ),
fsExtra.pathExists( sqliteIntegrationPaths.muPluginLegacy ),
fsExtra.pathExists( sqliteIntegrationPaths.regularPlugin ),
] ).then( ( results ) => results.some( Boolean ) );

const configFilesExist = await Promise.all( [
fsExtra.pathExists( requiredConfigPaths.wpConfig ),
fsExtra.pathExists( requiredConfigPaths.dbConfig ),
fsExtra.pathExists( requiredConfigPaths.dbSqlite ),
] ).then( ( results ) => results.every( Boolean ) );

return anyIntegrationExists && configFilesExist;
}
}