diff --git a/e2e/sites.test.ts b/e2e/sites.test.ts
index a09247c4..11e469b1 100644
--- a/e2e/sites.test.ts
+++ b/e2e/sites.test.ts
@@ -37,18 +37,19 @@ test.describe( 'Servers', () => {
await modal.siteNameInput.fill( siteName );
await modal.addSiteButton.click();
- const sidebarButton = sidebar.getSiteNavButton( siteName );
- await expect( sidebarButton ).toBeAttached( { timeout: 30_000 } );
+ const siteTitle = sidebar.getSiteNavButton( siteName );
+ await expect( siteTitle ).toHaveText( siteName );
+
+ // Check the site is running
+ const siteContent = new SiteContent( session.mainWindow, siteName );
+ await expect( siteContent.siteNameHeading ).toBeAttached( { timeout: 30_000 } );
+ expect( await siteContent.siteNameHeading ).toHaveText( siteName );
// Check a WordPress site has been created
expect(
await pathExists( path.join( session.homePath, 'Studio', siteName, 'wp-config.php' ) )
).toBe( true );
- // Check the site is running
- const siteContent = new SiteContent( session.mainWindow, siteName );
- expect( await siteContent.siteNameHeading ).toHaveText( siteName );
-
await siteContent.navigateToTab( 'Settings' );
expect( await siteContent.frontendButton ).toBeVisible();
diff --git a/src/components/add-site.tsx b/src/components/add-site.tsx
index fd31b6a6..ec88c019 100644
--- a/src/components/add-site.tsx
+++ b/src/components/add-site.tsx
@@ -4,6 +4,7 @@ import { useI18n } from '@wordpress/react-i18n';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { useAddSite } from '../hooks/use-add-site';
import { useIpcListener } from '../hooks/use-ipc-listener';
+import { useSiteDetails } from '../hooks/use-site-details';
import { generateSiteName } from '../lib/generate-site-name';
import { getIpcApi } from '../lib/get-ipc-api';
import Button from './button';
@@ -18,10 +19,10 @@ export default function AddSite( { className }: AddSiteProps ) {
const { __ } = useI18n();
const [ showModal, setShowModal ] = useState( false );
const [ nameSuggested, setNameSuggested ] = useState( false );
+ const { data } = useSiteDetails();
const {
handleAddSiteClick,
- isAddingSite,
siteName,
setSiteName,
setProposedSitePath,
@@ -37,6 +38,8 @@ export default function AddSite( { className }: AddSiteProps ) {
loadingSites,
} = useAddSite();
+ const isSiteAdding = data.some( ( site ) => site.isAddingSite );
+
const siteAddedMessage = sprintf(
// translators: %s is the site name.
__( '%s site added.' ),
@@ -82,10 +85,10 @@ export default function AddSite( { className }: AddSiteProps ) {
async ( event: FormEvent ) => {
event.preventDefault();
try {
+ closeModal();
await handleAddSiteClick();
speak( siteAddedMessage );
setNameSuggested( false );
- closeModal();
} catch {
// No need to handle error here, it's already handled in handleAddSiteClick
}
@@ -117,16 +120,16 @@ export default function AddSite( { className }: AddSiteProps ) {
doesPathContainWordPress={ doesPathContainWordPress }
>
diff --git a/src/components/site-loading-indicator.tsx b/src/components/site-loading-indicator.tsx
new file mode 100644
index 00000000..dd3fe444
--- /dev/null
+++ b/src/components/site-loading-indicator.tsx
@@ -0,0 +1,38 @@
+import { useI18n } from '@wordpress/react-i18n';
+import { useEffect } from 'react';
+import { useProgressTimer } from '../hooks/use-progress-timer';
+import ProgressBar from './progress-bar';
+
+export function SiteLoadingIndicator( { selectedSiteName }: { selectedSiteName?: string } ) {
+ const { __ } = useI18n();
+
+ const { progress, setProgress } = useProgressTimer( {
+ initialProgress: 20,
+ interval: 1500,
+ maxValue: 95,
+ } );
+
+ useEffect( () => {
+ const updateProgress = () => {
+ setProgress( ( prev ) => {
+ const increment = Math.random() * 10 + 5;
+ return Math.min( prev + increment, 95 );
+ } );
+ };
+
+ setProgress( 50 );
+ const interval = setInterval( updateProgress, 1000 );
+
+ return () => clearInterval( interval );
+ }, [ setProgress ] );
+
+ return (
+
+
+
{ selectedSiteName }
+
+
{ __( 'Creating site...' ) }
+
+
+ );
+}
diff --git a/src/components/site-menu.tsx b/src/components/site-menu.tsx
index 5a19d3f9..1d5c3139 100644
--- a/src/components/site-menu.tsx
+++ b/src/components/site-menu.tsx
@@ -1,4 +1,5 @@
import { speak } from '@wordpress/a11y';
+import { Spinner } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import { useEffect } from 'react';
import { useSiteDetails } from '../hooks/use-site-details';
@@ -113,7 +114,11 @@ function SiteItem( { site }: { site: SiteDetails } ) {
>
{ site.name }
-
+ { site.isAddingSite ? (
+
+ ) : (
+
+ ) }
);
}
diff --git a/src/components/tests/add-site.test.tsx b/src/components/tests/add-site.test.tsx
index 885f3107..e88a6f13 100644
--- a/src/components/tests/add-site.test.tsx
+++ b/src/components/tests/add-site.test.tsx
@@ -205,48 +205,4 @@ describe( 'AddSite', () => {
screen.getByDisplayValue( '/default_path/my-wordpress-website-mutated' )
).toBeVisible();
} );
-
- it( 'should display a helpful error message when an error occurs while creating the site', async () => {
- const user = userEvent.setup();
- mockGenerateProposedSitePath.mockResolvedValue( {
- path: '/default_path/my-wordpress-website',
- name: 'My WordPress Website',
- isEmpty: true,
- isWordPress: false,
- } );
- mockCreateSite.mockImplementation( () => {
- throw new Error( 'Failed to create site' );
- } );
- render(
);
-
- await user.click( screen.getByRole( 'button', { name: 'Add site' } ) );
- await user.click( screen.getByRole( 'button', { name: 'Add site' } ) );
-
- await waitFor( () => {
- expect( screen.getByRole( 'alert' ) ).toHaveTextContent(
- 'An error occurred while creating the site. Verify your selected local path is an empty directory or an existing WordPress folder and try again. If this problem persists, please contact support.'
- );
- } );
- } );
-
- it( 'should disable submissions while the site is being added', async () => {
- const user = userEvent.setup();
- mockGenerateProposedSitePath.mockResolvedValue( {
- path: '/default_path/my-wordpress-website',
- name: 'My WordPress Website',
- isEmpty: true,
- isWordPress: false,
- } );
- mockCreateSite.mockImplementationOnce( () => {
- return new Promise( () => {
- // no-op
- } );
- } );
- render(
);
-
- await user.click( screen.getByRole( 'button', { name: 'Add site' } ) );
- await user.click( screen.getByRole( 'button', { name: 'Add site' } ) );
-
- expect( screen.getByRole( 'button', { name: 'Adding site…' } ) ).toBeDisabled();
- } );
} );
diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts
index 40cbd873..2cf47dc3 100644
--- a/src/hooks/use-add-site.ts
+++ b/src/hooks/use-add-site.ts
@@ -8,7 +8,6 @@ export function useAddSite() {
const { __ } = useI18n();
const { createSite, data: sites, loadingSites } = useSiteDetails();
const [ error, setError ] = useState( '' );
- const [ isAddingSite, setIsAddingSite ] = useState( false );
const [ siteName, setSiteName ] = useState< string | null >( null );
const [ sitePath, setSitePath ] = useState( '' );
const [ proposedSitePath, setProposedSitePath ] = useState( '' );
@@ -51,22 +50,13 @@ export function useAddSite() {
}, [ __, siteWithPathAlreadyExists, siteName, proposedSitePath ] );
const handleAddSiteClick = useCallback( async () => {
- setIsAddingSite( true );
try {
const path = sitePath ? sitePath : proposedSitePath;
await createSite( path, siteName ?? '' );
} catch ( e ) {
Sentry.captureException( e );
- setError(
- __(
- 'An error occurred while creating the site. Verify your selected local path is an empty directory or an existing WordPress folder and try again. If this problem persists, please contact support.'
- )
- );
- setIsAddingSite( false );
- throw e;
}
- setIsAddingSite( false );
- }, [ createSite, proposedSitePath, siteName, sitePath, __ ] );
+ }, [ createSite, proposedSitePath, siteName, sitePath ] );
const handleSiteNameChange = useCallback(
async ( name: string ) => {
@@ -103,7 +93,6 @@ export function useAddSite() {
handleAddSiteClick,
handlePathSelectorClick,
handleSiteNameChange,
- isAddingSite,
error: siteWithPathAlreadyExists( sitePath ? sitePath : proposedSitePath )
? __(
'Another site already exists at this path. Please select an empty directory to create a site.'
@@ -130,7 +119,6 @@ export function useAddSite() {
handlePathSelectorClick,
siteWithPathAlreadyExists,
handleSiteNameChange,
- isAddingSite,
siteName,
sitePath,
proposedSitePath,
diff --git a/src/hooks/use-site-details.tsx b/src/hooks/use-site-details.tsx
index ae73b29e..7e37f23b 100644
--- a/src/hooks/use-site-details.tsx
+++ b/src/hooks/use-site-details.tsx
@@ -10,6 +10,7 @@ import {
useState,
} from 'react';
import { getIpcApi } from '../lib/get-ipc-api';
+import { sortSites } from '../lib/sort-sites';
import { useSnapshots } from './use-snapshots';
interface SiteDetailsContext {
@@ -64,12 +65,15 @@ function useSelectedSite( firstSiteId: string | null ) {
const [ selectedSiteId, setSelectedSiteId ] = useState< string | null >(
selectedSiteIdFromLocal
);
+ useEffect( () => {
+ if ( selectedSiteId ) {
+ localStorage.setItem( SELECTED_SITE_ID_KEY, selectedSiteId );
+ }
+ } );
+
return {
selectedSiteId: selectedSiteId || firstSiteId,
- setSelectedSiteId: ( id: string ) => {
- setSelectedSiteId( id );
- localStorage.setItem( SELECTED_SITE_ID_KEY, id );
- },
+ setSelectedSiteId,
};
}
@@ -159,11 +163,61 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) {
const createSite = useCallback(
async ( path: string, siteName?: string ) => {
- const data = await getIpcApi().createSite( path, siteName );
- setData( data );
- const newSite = data.find( ( site ) => site.path === path );
- if ( newSite?.id ) {
- setSelectedSiteId( newSite.id );
+ // Function to handle error messages and cleanup
+ const showError = () => {
+ console.error( 'Failed to create site' );
+ getIpcApi().showMessageBox( {
+ type: 'error',
+ message: __( 'Failed to create site' ),
+ detail: __(
+ 'An error occurred while creating the site. Verify your selected local path is an empty directory or an existing WordPress folder and try again. If this problem persists, please contact support.'
+ ),
+ buttons: [ __( 'OK' ) ],
+ } );
+
+ // Remove the temporary site immediately, but with a minor delay to ensure state updates properly
+ setTimeout( () => {
+ setData( ( prevData ) =>
+ sortSites( prevData.filter( ( site ) => site.id !== tempSiteId ) )
+ );
+ }, 2000 );
+ };
+
+ const tempSiteId = crypto.randomUUID();
+ setData( ( prevData ) =>
+ sortSites( [
+ ...prevData,
+ {
+ id: tempSiteId,
+ name: siteName || path,
+ path,
+ running: false,
+ isAddingSite: true,
+ phpVersion: '',
+ },
+ ] )
+ );
+ setSelectedSiteId( tempSiteId ); // Set the temporary ID as the selected site
+
+ try {
+ const data = await getIpcApi().createSite( path, siteName );
+ const newSite = data.find( ( site ) => site.path === path );
+ if ( ! newSite ) {
+ showError();
+ return;
+ }
+ // Update the selected site to the new site's ID if the user didn't change it
+ setSelectedSiteId( ( prevSelectedSiteId ) => {
+ if ( prevSelectedSiteId === tempSiteId ) {
+ return newSite.id;
+ }
+ return prevSelectedSiteId;
+ } );
+ setData( ( prevData ) =>
+ prevData.map( ( site ) => ( site.id === tempSiteId ? newSite : site ) )
+ );
+ } catch ( error ) {
+ showError();
}
},
[ setSelectedSiteId ]
diff --git a/src/ipc-types.d.ts b/src/ipc-types.d.ts
index c0092b8c..a10b76e6 100644
--- a/src/ipc-types.d.ts
+++ b/src/ipc-types.d.ts
@@ -28,6 +28,7 @@ interface StoppedSiteDetails {
supportsWidgets: boolean;
supportsMenus: boolean;
};
+ isAddingSite?: boolean;
}
interface StartedSiteDetails extends StoppedSiteDetails {