diff --git a/src/components/no-studio-sites/index.tsx b/src/components/no-studio-sites/index.tsx
index 6cb6cc72db..c2c03ac8f4 100644
--- a/src/components/no-studio-sites/index.tsx
+++ b/src/components/no-studio-sites/index.tsx
@@ -1,13 +1,10 @@
-import { useAddSite } from 'src/hooks/use-add-site';
import { AddSiteModalContent } from 'src/modules/add-site';
export function NoStudioSites() {
- const addSiteProps = useAddSite();
-
return (
);
diff --git a/src/hooks/tests/use-add-site.test.tsx b/src/hooks/tests/use-add-site.test.tsx
index 1034d4fff1..db883b5b0f 100644
--- a/src/hooks/tests/use-add-site.test.tsx
+++ b/src/hooks/tests/use-add-site.test.tsx
@@ -3,10 +3,9 @@ import { renderHook, act } from '@testing-library/react';
import nock from 'nock';
import { Provider } from 'react-redux';
import { useSyncSites } from 'src/hooks/sync-sites';
-import { useAddSite } from 'src/hooks/use-add-site';
+import { useAddSite, CreateSiteFormValues } from 'src/hooks/use-add-site';
import { useContentTabs } from 'src/hooks/use-content-tabs';
import { useSiteDetails } from 'src/hooks/use-site-details';
-import { getWordPressProvider } from 'src/lib/wordpress-provider';
import { store } from 'src/stores';
import { setProviderConstants } from 'src/stores/provider-constants-slice';
import type { SyncSitesContextType } from 'src/hooks/sync-sites/sync-sites-context';
@@ -20,22 +19,24 @@ jest.mock( 'src/hooks/use-import-export', () => ( {
useImportExport: () => ( {
importFile: jest.fn(),
clearImportState: jest.fn(),
+ importState: {},
} ),
} ) );
const mockConnectWpcomSites = jest.fn().mockResolvedValue( undefined );
+const mockShowOpenFolderDialog = jest.fn();
const mockGenerateProposedSitePath = jest.fn().mockResolvedValue( {
path: '/default/path',
name: 'Default Site',
isEmpty: true,
isWordPress: false,
} );
-
const mockComparePaths = jest.fn().mockResolvedValue( false );
jest.mock( 'src/lib/get-ipc-api', () => ( {
getIpcApi: () => ( {
generateProposedSitePath: mockGenerateProposedSitePath,
+ showOpenFolderDialog: mockShowOpenFolderDialog,
showNotification: jest.fn(),
getAllCustomDomains: jest.fn().mockResolvedValue( [] ),
connectWpcomSites: mockConnectWpcomSites,
@@ -70,6 +71,13 @@ describe( 'useAddSite', () => {
} )
);
+ mockGenerateProposedSitePath.mockResolvedValue( {
+ path: '/default/path',
+ name: 'Default Site',
+ isEmpty: true,
+ isWordPress: false,
+ } );
+
( useSiteDetails as jest.Mock ).mockReturnValue( {
createSite: mockCreateSite,
updateSite: mockUpdateSite,
@@ -130,39 +138,19 @@ describe( 'useAddSite', () => {
nock.cleanAll();
} );
- it( 'should initialize with default WordPress version', () => {
- const { result } = renderHookWithProvider( () => useAddSite() );
-
- expect( result.current.wpVersion ).toBe( getWordPressProvider().DEFAULT_WORDPRESS_VERSION );
- } );
-
- it( 'should initialize with default PHP version', () => {
+ it( 'should provide default PHP version', () => {
const { result } = renderHookWithProvider( () => useAddSite() );
- expect( result.current.phpVersion ).toBe( '8.3' );
- } );
-
- it( 'should update WordPress version when setWpVersion is called', () => {
- const { result } = renderHookWithProvider( () => useAddSite() );
-
- act( () => {
- result.current.setWpVersion( '6.1.7' );
- } );
-
- expect( result.current.wpVersion ).toBe( '6.1.7' );
+ expect( result.current.defaultPhpVersion ).toBe( '8.3' );
} );
- it( 'should update PHP version when setPhpVersion is called', () => {
+ it( 'should provide default WordPress version', () => {
const { result } = renderHookWithProvider( () => useAddSite() );
- act( () => {
- result.current.setPhpVersion( '8.2' );
- } );
-
- expect( result.current.phpVersion ).toBe( '8.2' );
+ expect( result.current.defaultWpVersion ).toBe( 'latest' );
} );
- it( 'should pass WordPress version to createSite when handleAddSiteClick is called', async () => {
+ it( 'should create site with provided form values', async () => {
mockCreateSite.mockImplementation(
( path, name, wpVersion, customDomain, enableHttps, blueprint, phpVersion, callback ) => {
callback( {
@@ -178,27 +166,54 @@ describe( 'useAddSite', () => {
const { result } = renderHookWithProvider( () => useAddSite() );
- act( () => {
- result.current.setWpVersion( '6.1.7' );
- result.current.setSitePath( '/test/path' );
- } );
+ const formValues: CreateSiteFormValues = {
+ siteName: 'My Test Site',
+ sitePath: '/test/path',
+ phpVersion: '8.2',
+ wpVersion: '6.1.7',
+ useCustomDomain: false,
+ customDomain: null,
+ enableHttps: false,
+ };
await act( async () => {
- await result.current.handleAddSiteClick();
+ await result.current.handleCreateSite( formValues );
} );
expect( mockCreateSite ).toHaveBeenCalledWith(
'/test/path',
- '',
+ 'My Test Site',
'6.1.7',
undefined,
false,
undefined, // blueprint parameter
- '8.3',
+ '8.2',
expect.any( Function )
);
} );
+ it( 'should generate proposed path for site name', async () => {
+ mockGenerateProposedSitePath.mockResolvedValue( {
+ path: '/studio/my-site',
+ isEmpty: true,
+ isWordPress: false,
+ } );
+
+ const { result } = renderHookWithProvider( () => useAddSite() );
+
+ let pathResult;
+ await act( async () => {
+ pathResult = await result.current.generateProposedPath( 'My Site' );
+ } );
+
+ expect( mockGenerateProposedSitePath ).toHaveBeenCalledWith( 'My Site' );
+ expect( pathResult ).toEqual( {
+ path: '/studio/my-site',
+ isEmpty: true,
+ isWordPress: false,
+ } );
+ } );
+
it( 'should connect and start pulling when a remote site is selected', async () => {
const remoteSite: SyncSite = {
id: 123,
@@ -232,11 +247,20 @@ describe( 'useAddSite', () => {
act( () => {
result.current.setSelectedRemoteSite( remoteSite );
- result.current.setSitePath( createdSite.path );
} );
+ const formValues: CreateSiteFormValues = {
+ siteName: createdSite.name,
+ sitePath: createdSite.path,
+ phpVersion: '8.3',
+ wpVersion: 'latest',
+ useCustomDomain: false,
+ customDomain: null,
+ enableHttps: false,
+ };
+
await act( async () => {
- await result.current.handleAddSiteClick();
+ await result.current.handleCreateSite( formValues );
} );
expect( mockConnectWpcomSites ).toHaveBeenCalledWith( [
@@ -250,56 +274,4 @@ describe( 'useAddSite', () => {
} );
expect( mockSetSelectedTab ).toHaveBeenCalledWith( 'sync' );
} );
-
- describe( 'handleSiteNameChange', () => {
- beforeEach( () => {
- mockGenerateProposedSitePath.mockReset();
- mockGenerateProposedSitePath.mockResolvedValue( {
- path: '/default/path',
- name: 'Default Site',
- isEmpty: true,
- isWordPress: false,
- } );
- mockComparePaths.mockReset();
- mockComparePaths.mockResolvedValue( false );
- } );
-
- it( 'should set user-friendly error when site name causes ENAMETOOLONG error', async () => {
- mockGenerateProposedSitePath.mockResolvedValueOnce( {
- path: '/default/path/very-long-name',
- name: 'a'.repeat( 300 ),
- isEmpty: false,
- isWordPress: false,
- isNameTooLong: true,
- } );
-
- const { result } = renderHookWithProvider( () => useAddSite() );
-
- await act( async () => {
- await result.current.handleSiteNameChange( 'a'.repeat( 300 ) );
- } );
-
- expect( result.current.error ).toBe(
- 'The site name is too long. Please choose a shorter site name.'
- );
- } );
-
- it( 'should successfully update site name when path is valid', async () => {
- mockGenerateProposedSitePath.mockResolvedValueOnce( {
- path: '/default/path/my-site',
- name: 'my-site',
- isEmpty: true,
- isWordPress: false,
- } );
-
- const { result } = renderHookWithProvider( () => useAddSite() );
-
- await act( async () => {
- await result.current.handleSiteNameChange( 'my-site' );
- } );
-
- expect( result.current.siteName ).toBe( 'my-site' );
- expect( result.current.error ).toBe( '' );
- } );
- } );
} );
diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts
index a71fa23dc7..d795acbefd 100644
--- a/src/hooks/use-add-site.ts
+++ b/src/hooks/use-add-site.ts
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/electron/renderer';
import { useI18n } from '@wordpress/react-i18n';
import { useCallback, useMemo, useState } from 'react';
import { BlueprintValidationWarning } from 'common/lib/blueprint-validation';
-import { generateCustomDomainFromSiteName, getDomainNameValidationError } from 'common/lib/domains';
+import { generateCustomDomainFromSiteName } from 'common/lib/domains';
import { useSyncSites } from 'src/hooks/sync-sites';
import { useContentTabs } from 'src/hooks/use-content-tabs';
import { useImportExport } from 'src/hooks/use-import-export';
@@ -20,12 +20,31 @@ import type { SyncSite } from 'src/modules/sync/types';
import type { Blueprint } from 'src/stores/wpcom-api';
import type { SyncOption } from 'src/types';
-interface UseAddSiteOptions {
- openModal?: () => void;
+/**
+ * Form values passed when creating a site
+ */
+export interface CreateSiteFormValues {
+ siteName: string;
+ sitePath: string;
+ phpVersion: AllowedPHPVersion;
+ wpVersion: string;
+ useCustomDomain: boolean;
+ customDomain: string | null;
+ enableHttps: boolean;
}
-export function useAddSite( options: UseAddSiteOptions = {} ) {
- const { openModal = () => {} } = options;
+/**
+ * Result from path selection or site name change validation
+ */
+export interface PathValidationResult {
+ path: string;
+ name?: string;
+ isEmpty: boolean;
+ isWordPress: boolean;
+ error?: string;
+}
+
+export function useAddSite() {
const { __ } = useI18n();
const { createSite, sites, startServer } = useSiteDetails();
const { importFile, clearImportState, importState } = useImportExport();
@@ -34,21 +53,8 @@ export function useAddSite( options: UseAddSiteOptions = {} ) {
const { setSelectedTab } = useContentTabs();
const defaultPhpVersion = useRootSelector( selectDefaultPhpVersion );
const defaultWordPressVersion = useRootSelector( selectDefaultWordPressVersion );
- const [ error, setError ] = useState( '' );
- const [ siteName, setSiteName ] = useState< string | null >( null );
- const [ sitePath, setSitePath ] = useState( '' );
- const [ proposedSitePath, setProposedSitePath ] = useState( '' );
- const [ doesPathContainWordPress, setDoesPathContainWordPress ] = useState( false );
+
const [ fileForImport, setFileForImport ] = useState< File | null >( null );
- const [ phpVersion, setPhpVersion ] = useState< AllowedPHPVersion >(
- defaultPhpVersion as AllowedPHPVersion
- );
- const [ wpVersion, setWpVersion ] = useState( defaultWordPressVersion );
- const [ useCustomDomain, setUseCustomDomain ] = useState( false );
- const [ customDomain, setCustomDomain ] = useState< string | null >( null );
- const [ customDomainError, setCustomDomainError ] = useState( '' );
- const [ existingDomainNames, setExistingDomainNames ] = useState< string[] >( [] );
- const [ enableHttps, setEnableHttps ] = useState( false );
const [ selectedBlueprint, setSelectedBlueprint ] = useState< Blueprint | undefined >();
const [ selectedRemoteSite, setSelectedRemoteSite ] = useState< SyncSite | undefined >();
const [ blueprintPreferredVersions, setBlueprintPreferredVersions ] = useState<
@@ -58,6 +64,7 @@ export function useAddSite( options: UseAddSiteOptions = {} ) {
BlueprintValidationWarning[] | undefined
>();
const [ isDeeplinkFlow, setIsDeeplinkFlow ] = useState( false );
+ const [ existingDomainNames, setExistingDomainNames ] = useState< string[] >( [] );
const isAnySiteProcessing = sites.some(
( site ) => site.isAddingSite || importState[ site.id ]?.isNewSite
@@ -70,32 +77,30 @@ export function useAddSite( options: UseAddSiteOptions = {} ) {
setBlueprintDeeplinkWarnings( undefined );
}, [] );
+ // For blueprint deeplinks - we need temporary state for PHP/WP versions
+ const [ deeplinkPhpVersion, setDeeplinkPhpVersion ] = useState< AllowedPHPVersion >(
+ defaultPhpVersion as AllowedPHPVersion
+ );
+ const [ deeplinkWpVersion, setDeeplinkWpVersion ] = useState( defaultWordPressVersion );
+
useBlueprintDeeplink( {
isAnySiteProcessing,
- openModal,
setSelectedBlueprint,
- setPhpVersion,
- setWpVersion,
+ setPhpVersion: setDeeplinkPhpVersion,
+ setWpVersion: setDeeplinkWpVersion,
setBlueprintPreferredVersions,
setBlueprintDeeplinkWarnings,
navigateToBlueprintDeeplink: () => setIsDeeplinkFlow( true ),
} );
const resetForm = useCallback( () => {
- setSitePath( '' );
- setError( '' );
- setDoesPathContainWordPress( false );
- setWpVersion( defaultWordPressVersion );
- setPhpVersion( defaultPhpVersion );
- setUseCustomDomain( false );
- setCustomDomain( null );
- setCustomDomainError( '' );
- setEnableHttps( false );
setFileForImport( null );
setSelectedBlueprint( undefined );
setBlueprintPreferredVersions( undefined );
setBlueprintDeeplinkWarnings( undefined );
setSelectedRemoteSite( undefined );
+ setDeeplinkPhpVersion( defaultPhpVersion as AllowedPHPVersion );
+ setDeeplinkWpVersion( defaultWordPressVersion );
clearDeeplinkState();
}, [ clearDeeplinkState, defaultPhpVersion, defaultWordPressVersion ] );
@@ -110,8 +115,11 @@ export function useAddSite( options: UseAddSiteOptions = {} ) {
} );
}, [] );
- const siteWithPathAlreadyExists = useCallback(
- async ( path: string ) => {
+ /**
+ * Check if a path is already associated with an existing site
+ */
+ const checkPathExists = useCallback(
+ async ( path: string ): Promise< boolean > => {
const results = await Promise.all(
sites.map( ( site ) => getIpcApi().comparePaths( site.path, path ) )
);
@@ -120,239 +128,218 @@ export function useAddSite( options: UseAddSiteOptions = {} ) {
[ sites ]
);
- const handleCustomDomainChange = useCallback(
- ( value: string | null ) => {
- setCustomDomain( value );
- setCustomDomainError(
- getDomainNameValidationError( useCustomDomain, value, existingDomainNames )
+ /**
+ * Open folder picker and validate the selected path
+ * Returns the result for the form to use
+ */
+ const selectPath = useCallback(
+ async ( currentPath: string ): Promise< PathValidationResult | null > => {
+ const response = await getIpcApi().showOpenFolderDialog(
+ __( 'Choose folder for site' ),
+ currentPath
);
- },
- [ useCustomDomain, setCustomDomain, setCustomDomainError, existingDomainNames ]
- );
- const handlePathSelectorClick = useCallback( async () => {
- const response = await getIpcApi().showOpenFolderDialog(
- __( 'Choose folder for site' ),
- sitePath
- );
- if ( response?.path ) {
+ if ( ! response?.path ) {
+ return null;
+ }
+
const { path, name, isEmpty, isWordPress } = response;
- setDoesPathContainWordPress( false );
- setError( '' );
- const pathResetToDefaultSitePath =
- path === proposedSitePath.substring( 0, proposedSitePath.lastIndexOf( '/' ) );
- setSitePath( pathResetToDefaultSitePath ? '' : path );
- if ( await siteWithPathAlreadyExists( path ) ) {
- setError(
- __(
+ if ( await checkPathExists( path ) ) {
+ return {
+ path,
+ name: name ?? undefined,
+ isEmpty,
+ isWordPress,
+ error: __(
'The directory is already associated with another Studio site. Please choose a different custom local path.'
- )
- );
- return;
+ ),
+ };
}
- if ( ! isEmpty && ! isWordPress && ! pathResetToDefaultSitePath ) {
- setError(
- __(
+
+ if ( ! isEmpty && ! isWordPress ) {
+ return {
+ path,
+ name: name ?? undefined,
+ isEmpty,
+ isWordPress,
+ error: __(
'This directory is not empty. Please select an empty directory or an existing WordPress folder.'
- )
- );
- return;
+ ),
+ };
}
- setDoesPathContainWordPress( ! isEmpty && isWordPress );
- if ( ! siteName ) {
- setSiteName( name ?? null );
- }
- }
- }, [ __, siteWithPathAlreadyExists, siteName, proposedSitePath, sitePath ] );
- const handleAddSiteClick = useCallback( async () => {
- try {
- const path = sitePath ? sitePath : proposedSitePath;
- let usedCustomDomain = useCustomDomain && customDomain ? customDomain : undefined;
- if ( useCustomDomain && ! customDomain ) {
- usedCustomDomain = generateCustomDomainFromSiteName( siteName ?? '' );
- }
- await createSite(
+ return {
path,
- siteName ?? '',
- wpVersion,
- usedCustomDomain,
- useCustomDomain ? enableHttps : false,
- selectedBlueprint,
- phpVersion,
- async ( newSite ) => {
- if ( fileForImport ) {
- await importFile( fileForImport, newSite, {
- showImportNotification: false,
- isNewSite: true,
- } );
- clearImportState( newSite.id );
-
- getIpcApi().showNotification( {
- title: newSite.name,
- body: __( 'Your new site was imported' ),
- } );
- } else {
- if ( selectedRemoteSite ) {
- await connectSite( { site: selectedRemoteSite, localSiteId: newSite.id } );
- const pullOptions: SyncOption[] = [ 'all' ];
- pullSite( selectedRemoteSite, newSite, {
- optionsToSync: pullOptions,
- } );
- setSelectedTab( 'sync' );
- } else {
- await startServer( newSite.id );
-
- getIpcApi().showNotification( {
- title: newSite.name,
- body: __( 'Your new site was created' ),
- } );
- }
- }
- }
- );
- } catch ( e ) {
- Sentry.captureException( e );
- }
- }, [
- __,
- clearImportState,
- createSite,
- fileForImport,
- importFile,
- proposedSitePath,
- siteName,
- sitePath,
- startServer,
- wpVersion,
- phpVersion,
- customDomain,
- useCustomDomain,
- enableHttps,
- selectedBlueprint,
- selectedRemoteSite,
- pullSite,
- connectSite,
- setSelectedTab,
- ] );
-
- const handleSiteNameChange = useCallback(
- async ( name: string ) => {
- setSiteName( name );
- if ( sitePath ) {
- return;
- }
- setError( '' );
-
- const {
- path: proposedPath,
+ name: name ?? undefined,
isEmpty,
isWordPress,
- isNameTooLong,
- } = await getIpcApi().generateProposedSitePath( name );
- setProposedSitePath( proposedPath );
+ };
+ },
+ [ __, checkPathExists ]
+ );
+
+ /**
+ * Generate a proposed path for a site name and validate it
+ */
+ const generateProposedPath = useCallback(
+ async ( siteName: string ): Promise< PathValidationResult > => {
+ const { path, isEmpty, isWordPress, isNameTooLong } =
+ await getIpcApi().generateProposedSitePath( siteName );
if ( isNameTooLong ) {
- setError( __( 'The site name is too long. Please choose a shorter site name.' ) );
- return;
+ return {
+ path,
+ isEmpty,
+ isWordPress,
+ error: __( 'The site name is too long. Please choose a shorter site name.' ),
+ };
}
- if ( await siteWithPathAlreadyExists( proposedPath ) ) {
- setError(
- __(
+ if ( await checkPathExists( path ) ) {
+ return {
+ path,
+ isEmpty,
+ isWordPress,
+ error: __(
'The directory is already associated with another Studio site. Please choose a different site name or a custom local path.'
- )
- );
- return;
+ ),
+ };
}
+
if ( ! isEmpty && ! isWordPress ) {
- setError(
- __(
+ return {
+ path,
+ isEmpty,
+ isWordPress,
+ error: __(
'This directory is not empty. Please select an empty directory or an existing WordPress folder.'
- )
+ ),
+ };
+ }
+
+ return { path, isEmpty, isWordPress };
+ },
+ [ __, checkPathExists ]
+ );
+
+ /**
+ * Create a new site with the given form values
+ */
+ const handleCreateSite = useCallback(
+ async ( formValues: CreateSiteFormValues ) => {
+ try {
+ let usedCustomDomain =
+ formValues.useCustomDomain && formValues.customDomain
+ ? formValues.customDomain
+ : undefined;
+ if ( formValues.useCustomDomain && ! formValues.customDomain ) {
+ usedCustomDomain = generateCustomDomainFromSiteName( formValues.siteName );
+ }
+
+ await createSite(
+ formValues.sitePath,
+ formValues.siteName,
+ formValues.wpVersion,
+ usedCustomDomain,
+ formValues.useCustomDomain ? formValues.enableHttps : false,
+ selectedBlueprint,
+ formValues.phpVersion,
+ async ( newSite ) => {
+ if ( fileForImport ) {
+ await importFile( fileForImport, newSite, {
+ showImportNotification: false,
+ isNewSite: true,
+ } );
+ clearImportState( newSite.id );
+
+ getIpcApi().showNotification( {
+ title: newSite.name,
+ body: __( 'Your new site was imported' ),
+ } );
+ } else {
+ if ( selectedRemoteSite ) {
+ await connectSite( { site: selectedRemoteSite, localSiteId: newSite.id } );
+ const pullOptions: SyncOption[] = [ 'all' ];
+ pullSite( selectedRemoteSite, newSite, {
+ optionsToSync: pullOptions,
+ } );
+ setSelectedTab( 'sync' );
+ } else {
+ await startServer( newSite.id );
+
+ getIpcApi().showNotification( {
+ title: newSite.name,
+ body: __( 'Your new site was created' ),
+ } );
+ }
+ }
+ }
);
- return;
+ } catch ( e ) {
+ Sentry.captureException( e );
}
- setDoesPathContainWordPress( ! isEmpty && isWordPress );
},
- [ __, sitePath, siteWithPathAlreadyExists ]
+ [
+ __,
+ clearImportState,
+ createSite,
+ fileForImport,
+ importFile,
+ startServer,
+ selectedBlueprint,
+ selectedRemoteSite,
+ pullSite,
+ connectSite,
+ setSelectedTab,
+ ]
);
- return useMemo( () => {
- return {
- resetForm,
- handleAddSiteClick,
- handlePathSelectorClick,
- handleSiteNameChange,
- error,
- sitePath: sitePath ? sitePath : proposedSitePath,
- siteName,
- doesPathContainWordPress,
- setSiteName,
- proposedSitePath,
- setProposedSitePath,
- setSitePath,
- setError,
- setDoesPathContainWordPress,
+ return useMemo(
+ () => ( {
+ handleCreateSite,
+ selectPath,
+ generateProposedPath,
+ defaultPhpVersion: defaultPhpVersion as AllowedPHPVersion,
+ defaultWpVersion: defaultWordPressVersion,
+ deeplinkPhpVersion,
+ deeplinkWpVersion,
fileForImport,
setFileForImport,
- phpVersion,
- setPhpVersion,
- wpVersion,
- setWpVersion,
- useCustomDomain,
- setUseCustomDomain,
- customDomain,
- setCustomDomain: handleCustomDomainChange,
- customDomainError,
- setCustomDomainError,
- enableHttps,
- setEnableHttps,
- loadAllCustomDomains,
selectedBlueprint,
setSelectedBlueprint,
- selectedRemoteSite,
- setSelectedRemoteSite,
blueprintPreferredVersions,
setBlueprintPreferredVersions,
blueprintDeeplinkWarnings,
- setBlueprintDeeplinkWarnings,
+ selectedRemoteSite,
+ setSelectedRemoteSite,
+ existingDomainNames,
+ loadAllCustomDomains,
isDeeplinkFlow,
setIsDeeplinkFlow,
isAnySiteProcessing,
+ resetForm,
clearDeeplinkState,
- };
- }, [
- resetForm,
- doesPathContainWordPress,
- error,
- handleAddSiteClick,
- handlePathSelectorClick,
- handleSiteNameChange,
- siteName,
- sitePath,
- proposedSitePath,
- fileForImport,
- phpVersion,
- wpVersion,
- useCustomDomain,
- setUseCustomDomain,
- customDomain,
- handleCustomDomainChange,
- customDomainError,
- setCustomDomainError,
- enableHttps,
- setEnableHttps,
- loadAllCustomDomains,
- selectedBlueprint,
- setSelectedBlueprint,
- selectedRemoteSite,
- setSelectedRemoteSite,
- blueprintPreferredVersions,
- blueprintDeeplinkWarnings,
- isDeeplinkFlow,
- isAnySiteProcessing,
- clearDeeplinkState,
- ] );
+ } ),
+ [
+ handleCreateSite,
+ selectPath,
+ generateProposedPath,
+ defaultPhpVersion,
+ defaultWordPressVersion,
+ deeplinkPhpVersion,
+ deeplinkWpVersion,
+ fileForImport,
+ selectedBlueprint,
+ blueprintPreferredVersions,
+ blueprintDeeplinkWarnings,
+ selectedRemoteSite,
+ existingDomainNames,
+ loadAllCustomDomains,
+ isDeeplinkFlow,
+ isAnySiteProcessing,
+ resetForm,
+ clearDeeplinkState,
+ ]
+ );
}
diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx
index 64dd3a9271..81a8fa638c 100644
--- a/src/modules/add-site/components/create-site-form.tsx
+++ b/src/modules/add-site/components/create-site-form.tsx
@@ -3,8 +3,8 @@ import { createInterpolateElement } from '@wordpress/element';
import { __, sprintf, _n } from '@wordpress/i18n';
import { tip, cautionFilled, chevronRight, chevronDown, chevronLeft } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
-import { FormEvent, useState, useEffect } from 'react';
-import { generateCustomDomainFromSiteName } from 'common/lib/domains';
+import { FormEvent, useState, useEffect, useCallback, useMemo, useRef, RefObject } from 'react';
+import { generateCustomDomainFromSiteName, getDomainNameValidationError } from 'common/lib/domains';
import Button from 'src/components/button';
import FolderIcon from 'src/components/folder-icon';
import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more';
@@ -18,6 +18,31 @@ import {
selectDefaultWordPressVersion,
selectAllowedPhpVersions,
} from 'src/stores/provider-constants-slice';
+import type { CreateSiteFormValues, PathValidationResult } from 'src/hooks/use-add-site';
+
+export interface CreateSiteFormProps {
+ /** Initial values and async updates (syncs before user interaction) */
+ defaultValues?: {
+ siteName?: string;
+ sitePath?: string;
+ phpVersion?: AllowedPHPVersion;
+ wpVersion?: string;
+ };
+ /** Opens folder picker to select site path */
+ onSelectPath?: ( currentPath: string ) => Promise< PathValidationResult | null >;
+ /** Generates proposed path when site name changes */
+ onSiteNameChange?: ( name: string ) => Promise< PathValidationResult >;
+ /** Existing domain names for validation */
+ existingDomainNames?: string[];
+ /** Blueprint preferred versions for warning display */
+ blueprintPreferredVersions?: { php?: string; wp?: string };
+ /** Called when form is submitted */
+ onSubmit: ( values: CreateSiteFormValues ) => void;
+ /** Called when form validity changes */
+ onValidityChange?: ( isValid: boolean ) => void;
+ /** Ref to form element for programmatic submission */
+ formRef?: RefObject< HTMLFormElement >;
+}
interface FormPathInputComponentProps {
value: string;
@@ -33,30 +58,6 @@ interface SiteFormErrorProps {
className?: string;
}
-interface CreateSiteFormProps {
- siteName: string;
- setSiteName: ( name: string ) => void;
- sitePath?: string;
- onSelectPath?: () => void;
- error: string;
- doesPathContainWordPress?: boolean;
- onSubmit: ( event: FormEvent ) => void;
- useCustomDomain?: boolean;
- setUseCustomDomain?: ( use: boolean ) => void;
- customDomain?: string | null;
- setCustomDomain?: ( domain: string ) => void;
- customDomainError?: string;
- phpVersion: AllowedPHPVersion;
- setPhpVersion: ( version: AllowedPHPVersion ) => void;
- useHttps?: boolean;
- setUseHttps?: ( use: boolean ) => void;
- enableHttps?: boolean;
- setEnableHttps?: ( use: boolean ) => void;
- wpVersion: string;
- setWpVersion: ( version: string ) => void;
- blueprintPreferredVersions?: { php?: string; wp?: string };
-}
-
const SiteFormError = ( { error, tipMessage = '', className = '' }: SiteFormErrorProps ) => {
return (
( error || tipMessage ) && (
@@ -81,6 +82,7 @@ const SiteFormError = ( { error, tipMessage = '', className = '' }: SiteFormErro
)
);
};
+
function FormPathInputComponent( {
value,
onClick,
@@ -141,49 +143,197 @@ function FormPathInputComponent( {
}
export const CreateSiteForm = ( {
- siteName,
- setSiteName,
- phpVersion,
- setPhpVersion,
- wpVersion,
- setWpVersion,
- sitePath = '',
+ defaultValues = {},
onSelectPath,
- error,
- onSubmit,
- doesPathContainWordPress = false,
- useCustomDomain,
- setUseCustomDomain,
- customDomain = null,
- setCustomDomain,
- customDomainError,
- enableHttps,
- setEnableHttps,
+ onSiteNameChange,
+ existingDomainNames = [],
blueprintPreferredVersions,
+ onSubmit,
+ onValidityChange,
+ formRef,
}: CreateSiteFormProps ) => {
const { __, isRTL } = useI18n();
const { data: isCertificateTrusted } = useCheckCertificateTrustQuery();
const defaultWordPressVersion = useRootSelector( selectDefaultWordPressVersion );
const allowedPhpVersions = useRootSelector( selectAllowedPhpVersions );
- // If the custom domain is enabled and the root certificate is trusted, enable HTTPS
+ const [ siteName, setSiteName ] = useState( defaultValues.siteName ?? '' );
+ const [ sitePath, setSitePath ] = useState( defaultValues.sitePath ?? '' );
+ const [ phpVersion, setPhpVersion ] = useState< AllowedPHPVersion >(
+ defaultValues.phpVersion ?? ( allowedPhpVersions[ 0 ] as AllowedPHPVersion ) ?? '8.2'
+ );
+ const [ wpVersion, setWpVersion ] = useState(
+ defaultValues.wpVersion ?? defaultWordPressVersion
+ );
+ const [ useCustomDomain, setUseCustomDomain ] = useState( false );
+ const [ customDomain, setCustomDomain ] = useState< string | null >( null );
+ const [ enableHttps, setEnableHttps ] = useState( false );
+
+ const [ pathError, setPathError ] = useState( '' );
+ const [ doesPathContainWordPress, setDoesPathContainWordPress ] = useState( false );
+ const [ customDomainError, setCustomDomainError ] = useState( '' );
+ const [ hasCustomPath, setHasCustomPath ] = useState( false );
+
+ const [ isAdvancedSettingsVisible, setAdvancedSettingsVisible ] = useState( false );
+
+ // Prevent overwriting user input when defaultValues change asynchronously
+ const hasUserInteracted = useRef( false );
+
+ // Sync name/path only before user interaction (allows async loading)
useEffect( () => {
- if ( useCustomDomain && isCertificateTrusted && setEnableHttps ) {
+ if ( hasUserInteracted.current ) {
+ return;
+ }
+
+ if ( defaultValues.siteName !== undefined ) {
+ setSiteName( defaultValues.siteName );
+ }
+ if ( defaultValues.sitePath !== undefined ) {
+ setSitePath( defaultValues.sitePath );
+ }
+ }, [ defaultValues.siteName, defaultValues.sitePath ] );
+
+ // Sync versions from defaultValues (initial load and deeplink flows)
+ useEffect( () => {
+ if ( defaultValues.phpVersion !== undefined ) {
+ setPhpVersion( defaultValues.phpVersion );
+ }
+ if ( defaultValues.wpVersion !== undefined ) {
+ setWpVersion( defaultValues.wpVersion );
+ }
+ }, [ defaultValues.phpVersion, defaultValues.wpVersion ] );
+
+ useEffect( () => {
+ if ( useCustomDomain && isCertificateTrusted ) {
setEnableHttps( true );
}
- }, [ useCustomDomain, isCertificateTrusted, setEnableHttps ] );
+ }, [ useCustomDomain, isCertificateTrusted ] );
- const shouldShowCustomDomainError = useCustomDomain && customDomainError;
- const errorCount = [ error, shouldShowCustomDomainError ].filter( Boolean ).length;
+ // Validate custom domain when useCustomDomain or customDomain changes
+ // Note: existingDomainNames is intentionally not in deps to avoid re-validation when the list loads
+ useEffect( () => {
+ if ( useCustomDomain ) {
+ const generatedDomainName = generateCustomDomainFromSiteName( siteName );
+ const domainToValidate = customDomain ?? generatedDomainName;
+ setCustomDomainError(
+ getDomainNameValidationError( useCustomDomain, domainToValidate, existingDomainNames )
+ );
+ } else {
+ setCustomDomainError( '' );
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ useCustomDomain, customDomain, siteName ] );
- const [ isAdvancedSettingsVisible, setAdvancedSettingsVisible ] = useState( false );
+ // Notify parent of form validity changes
+ const previousIsValid = useRef< boolean | undefined >( undefined );
+ useEffect( () => {
+ if ( ! onValidityChange ) {
+ return;
+ }
+
+ const hasErrors = !! pathError || ( useCustomDomain && !! customDomainError );
+ const isValid = ! hasErrors;
+
+ // Only notify if validity has actually changed
+ if ( previousIsValid.current !== isValid ) {
+ previousIsValid.current = isValid;
+ onValidityChange( isValid );
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ pathError, customDomainError, useCustomDomain ] );
+
+ const handleSiteNameChange = useCallback(
+ async ( name: string ) => {
+ hasUserInteracted.current = true;
+ setSiteName( name );
+
+ // Only generate path if user hasn't manually selected a custom path
+ if ( onSiteNameChange && ! hasCustomPath ) {
+ const result = await onSiteNameChange( name );
+ if ( result.error ) {
+ setPathError( result.error );
+ } else {
+ setPathError( '' );
+ }
+ setDoesPathContainWordPress( ! result.isEmpty && result.isWordPress );
+ setSitePath( result.path );
+ }
+ },
+ [ onSiteNameChange, hasCustomPath ]
+ );
+
+ const handleSelectPath = useCallback( async () => {
+ if ( ! onSelectPath || ! onSiteNameChange ) return;
+
+ hasUserInteracted.current = true;
+ // Pass the current path to the dialog (empty if no custom path yet)
+ const currentPath = hasCustomPath ? sitePath : '';
+ const result = await onSelectPath( currentPath );
+ if ( ! result ) return;
+
+ // Check if user selected the default directory (parent of the proposed path)
+ // We need to calculate what the proposed path WOULD BE for the current site name
+ const proposedPathResult = await onSiteNameChange( siteName );
+ const proposedPath = proposedPathResult.path;
+ const pathResetToDefault =
+ !! proposedPath &&
+ result.path === proposedPath.substring( 0, proposedPath.lastIndexOf( '/' ) );
+
+ setHasCustomPath( ! pathResetToDefault );
+ // Clear path on reset to trigger regeneration when site name changes
+ setSitePath( pathResetToDefault ? '' : result.path );
+
+ if ( result.error ) {
+ setPathError( result.error );
+ } else {
+ setPathError( '' );
+ }
+ setDoesPathContainWordPress( ! result.isEmpty && result.isWordPress );
+
+ if ( result.name && ! siteName ) {
+ setSiteName( result.name );
+ }
+ }, [ onSelectPath, onSiteNameChange, sitePath, siteName, hasCustomPath ] );
+
+ const handleCustomDomainChange = useCallback(
+ ( value: string ) => {
+ setCustomDomain( value || null );
+ setCustomDomainError(
+ getDomainNameValidationError( useCustomDomain, value, existingDomainNames )
+ );
+ },
+ [ useCustomDomain, existingDomainNames ]
+ );
+
+ const formValues = useMemo< CreateSiteFormValues >(
+ () => ( {
+ siteName,
+ sitePath,
+ phpVersion,
+ wpVersion,
+ useCustomDomain,
+ customDomain,
+ enableHttps,
+ } ),
+ [ siteName, sitePath, phpVersion, wpVersion, useCustomDomain, customDomain, enableHttps ]
+ );
+
+ const handleFormSubmit = useCallback(
+ ( event: FormEvent ) => {
+ event.preventDefault();
+ onSubmit( formValues );
+ },
+ [ onSubmit, formValues ]
+ );
+
+ const shouldShowCustomDomainError = useCustomDomain && customDomainError;
+ const errorCount = [ pathError, shouldShowCustomDomainError ].filter( Boolean ).length;
const handleAdvancedSettingsClick = () => {
setAdvancedSettingsVisible( ! isAdvancedSettingsVisible );
};
let chevronIcon;
-
if ( isAdvancedSettingsVisible ) {
chevronIcon = chevronDown;
} else if ( isRTL() ) {
@@ -191,6 +341,7 @@ export const CreateSiteForm = ( {
} else {
chevronIcon = chevronRight;
}
+
const generatedDomainName = generateCustomDomainFromSiteName( siteName );
// Check if current versions differ from blueprint recommendations
@@ -199,24 +350,27 @@ export const CreateSiteForm = ( {
( ( blueprintPreferredVersions.php && blueprintPreferredVersions.php !== phpVersion ) ||
( blueprintPreferredVersions.wp && blueprintPreferredVersions.wp !== wpVersion ) );
+ const showAdvancedSettings = onSelectPath !== undefined;
+
return (
-