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/onboarding.tsx b/src/components/onboarding.tsx index 4bd96dd5..f61bc955 100644 --- a/src/components/onboarding.tsx +++ b/src/components/onboarding.tsx @@ -34,7 +34,6 @@ export default function Onboarding() { const { __ } = useI18n(); const { setSiteName, - isAddingSite, setProposedSitePath, setSitePath, setError, @@ -114,13 +113,8 @@ export default function Onboarding() { onSubmit={ handleSubmit } >
-
diff --git a/src/components/site-content-tabs.tsx b/src/components/site-content-tabs.tsx index fd84f06c..aa15554a 100644 --- a/src/components/site-content-tabs.tsx +++ b/src/components/site-content-tabs.tsx @@ -9,6 +9,7 @@ import { ContentTabOverview } from './content-tab-overview'; import { ContentTabSettings } from './content-tab-settings'; import { ContentTabSnapshots } from './content-tab-snapshots'; import Header from './header'; +import { SiteLoadingIndicator } from './site-loading-indicator'; export function SiteContentTabs() { const { selectedSite } = useSiteDetails(); @@ -23,6 +24,10 @@ export function SiteContentTabs() { ); } + if ( selectedSite?.isAddingSite ) { + return ; + } + return (
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 {