diff --git a/packages/block-library/src/navigation/edit/index.js b/packages/block-library/src/navigation/edit/index.js index 7b15d775f281a3..0c60ff4993aa56 100644 --- a/packages/block-library/src/navigation/edit/index.js +++ b/packages/block-library/src/navigation/edit/index.js @@ -61,6 +61,11 @@ import useConvertClassicToBlockMenu, { CLASSIC_MENU_CONVERSION_PENDING, CLASSIC_MENU_CONVERSION_SUCCESS, } from './use-convert-classic-menu-to-block-menu'; +import useCreateNavigationMenu, { + CREATE_NAVIGATION_MENU_ERROR, + CREATE_NAVIGATION_MENU_PENDING, + CREATE_NAVIGATION_MENU_SUCCESS, +} from './use-create-navigation-menu'; const EMPTY_ARRAY = []; @@ -162,6 +167,51 @@ function Navigation( { // the Select Menu dropdown. useNavigationEntities(); + const [ + showNavigationMenuCreateNotice, + hideNavigationMenuCreateNotice, + ] = useNavigationNotice( { + name: 'block-library/core/navigation/create', + } ); + + const { + create: createNavigationMenu, + status: createNavigationMenuStatus, + error: createNavigationMenuError, + value: createNavigationMenuPost, + } = useCreateNavigationMenu( clientId ); + + const isCreatingNavigationMenu = + createNavigationMenuStatus === CREATE_NAVIGATION_MENU_PENDING; + + useEffect( () => { + hideNavigationMenuCreateNotice(); + + if ( createNavigationMenuStatus === CREATE_NAVIGATION_MENU_PENDING ) { + speak( __( `Creating Navigation Menu.` ) ); + } + + if ( createNavigationMenuStatus === CREATE_NAVIGATION_MENU_SUCCESS ) { + setRef( createNavigationMenuPost.id ); + selectBlock( clientId ); + + showNavigationMenuCreateNotice( + __( `Navigation Menu successfully created.` ) + ); + } + + if ( createNavigationMenuStatus === CREATE_NAVIGATION_MENU_ERROR ) { + showNavigationMenuCreateNotice( + __( 'Failed to create Navigation Menu.' ) + ); + } + }, [ + createNavigationMenu, + createNavigationMenuStatus, + createNavigationMenuError, + createNavigationMenuPost, + ] ); + const { hasUncontrolledInnerBlocks, uncontrolledInnerBlocks, @@ -251,12 +301,14 @@ function Navigation( { const TagName = 'nav'; // "placeholder" shown if: - // - we don't have a ref attribute pointing to a Navigation Post. - // - we are not running a menu conversion process. - // - we don't have uncontrolled blocks. - // - (legacy) we have a Navigation Area without a ref attribute pointing to a Navigation Post. + // - there is no ref attribute pointing to a Navigation Post. + // - there is no classic menu conversion process in progress. + // - there is no menu creation process in progress. + // - there are no uncontrolled blocks. + // - (legacy) there is a Navigation Area without a ref attribute pointing to a Navigation Post. const isPlaceholder = ! ref && + ! isCreatingNavigationMenu && ! isConvertingClassicMenu && ( ! hasUncontrolledInnerBlocks || isWithinUnassignedArea ); @@ -264,11 +316,13 @@ function Navigation( { ! isNavigationMenuMissing && isNavigationMenuResolved; // "loading" state: - // - we are running the Classic Menu conversion process. + // - there is a menu creation process in progress. + // - there is a classic menu conversion process in progress. // OR // - there is a ref attribute pointing to a Navigation Post // - the Navigation Post isn't available (hasn't resolved) yet. const isLoading = + isCreatingNavigationMenu || isConvertingClassicMenu || !! ( ref && ! isEntityAvailable && ! isConvertingClassicMenu ); @@ -466,7 +520,7 @@ function Navigation( { [ convert, handleUpdateMenu ] ); - const startWithEmptyMenu = useCallback( () => { + const resetToEmptyBlock = useCallback( () => { registry.batch( () => { if ( navigationArea ) { setAreaMenu( 0 ); @@ -528,7 +582,7 @@ function Navigation( { { __( 'Navigation menu has been deleted or is unavailable. ' ) } - @@ -569,6 +623,7 @@ function Navigation( { isResolvingCanUserCreateNavigationMenu } onFinish={ handleSelectNavigation } + onCreateEmpty={ () => createNavigationMenu( '', [] ) } /> ); @@ -584,7 +639,7 @@ function Navigation( { currentMenuId={ ref } clientId={ clientId } onSelect={ handleSelectNavigation } - onCreateNew={ startWithEmptyMenu } + onCreateNew={ resetToEmptyBlock } /* translators: %s: The name of a menu. */ actionLabel={ __( "Switch to '%s'" ) } showManageActions @@ -739,7 +794,7 @@ function Navigation( { { hasResolvedCanUserDeleteNavigationMenu && canUserDeleteNavigationMenu && ( ) } diff --git a/packages/block-library/src/navigation/edit/placeholder/index.js b/packages/block-library/src/navigation/edit/placeholder/index.js index 9906e37244eed4..e1b50e253a292f 100644 --- a/packages/block-library/src/navigation/edit/placeholder/index.js +++ b/packages/block-library/src/navigation/edit/placeholder/index.js @@ -12,7 +12,6 @@ import { useEffect } from '@wordpress/element'; */ import useNavigationEntities from '../../use-navigation-entities'; import PlaceholderPreview from './placeholder-preview'; -import useCreateNavigationMenu from '../use-create-navigation-menu'; import NavigationMenuSelector from '../navigation-menu-selector'; export default function NavigationPlaceholder( { @@ -22,26 +21,10 @@ export default function NavigationPlaceholder( { canUserCreateNavigationMenu = false, isResolvingCanUserCreateNavigationMenu, onFinish, + onCreateEmpty, } ) { - const createNavigationMenu = useCreateNavigationMenu( clientId ); - - const onFinishMenuCreation = async ( - blocks, - navigationMenuTitle = null - ) => { - const navigationMenu = await createNavigationMenu( - navigationMenuTitle, - blocks - ); - onFinish( navigationMenu, blocks ); - }; - const { isResolvingMenus, hasResolvedMenus } = useNavigationEntities(); - const onCreateEmptyMenu = () => { - onFinishMenuCreation( [] ); - }; - useEffect( () => { if ( ! isSelected ) { return; @@ -98,7 +81,7 @@ export default function NavigationPlaceholder( { { canUserCreateNavigationMenu && ( diff --git a/packages/block-library/src/navigation/edit/unsaved-inner-blocks.js b/packages/block-library/src/navigation/edit/unsaved-inner-blocks.js index 030a7dbbfdd37e..bf8edc12eac1fb 100644 --- a/packages/block-library/src/navigation/edit/unsaved-inner-blocks.js +++ b/packages/block-library/src/navigation/edit/unsaved-inner-blocks.js @@ -72,7 +72,9 @@ export default function UnsavedInnerBlocks( { const { hasResolvedNavigationMenus, navigationMenus } = useNavigationMenu(); - const createNavigationMenu = useCreateNavigationMenu( clientId ); + const { create: createNavigationMenu } = useCreateNavigationMenu( + clientId + ); // Automatically save the uncontrolled blocks. useEffect( async () => { diff --git a/packages/block-library/src/navigation/edit/use-convert-classic-menu-to-block-menu.js b/packages/block-library/src/navigation/edit/use-convert-classic-menu-to-block-menu.js index 475d232b4735f6..569c142ae9bb4c 100644 --- a/packages/block-library/src/navigation/edit/use-convert-classic-menu-to-block-menu.js +++ b/packages/block-library/src/navigation/edit/use-convert-classic-menu-to-block-menu.js @@ -18,7 +18,9 @@ export const CLASSIC_MENU_CONVERSION_PENDING = 'pending'; export const CLASSIC_MENU_CONVERSION_IDLE = 'idle'; function useConvertClassicToBlockMenu( clientId ) { - const createNavigationMenu = useCreateNavigationMenu( clientId ); + const { create: createNavigationMenu } = useCreateNavigationMenu( + clientId + ); const registry = useRegistry(); const [ status, setStatus ] = useState( CLASSIC_MENU_CONVERSION_IDLE ); diff --git a/packages/block-library/src/navigation/edit/use-create-navigation-menu.js b/packages/block-library/src/navigation/edit/use-create-navigation-menu.js index 39d54953587f06..14135c84c8d94c 100644 --- a/packages/block-library/src/navigation/edit/use-create-navigation-menu.js +++ b/packages/block-library/src/navigation/edit/use-create-navigation-menu.js @@ -4,23 +4,58 @@ import { serialize } from '@wordpress/blocks'; import { store as coreStore } from '@wordpress/core-data'; import { useDispatch } from '@wordpress/data'; -import { useCallback } from '@wordpress/element'; +import { useState, useCallback } from '@wordpress/element'; /** * Internal dependencies */ import useGenerateDefaultNavigationTitle from './use-generate-default-navigation-title'; +export const CREATE_NAVIGATION_MENU_SUCCESS = 'success'; +export const CREATE_NAVIGATION_MENU_ERROR = 'error'; +export const CREATE_NAVIGATION_MENU_PENDING = 'pending'; +export const CREATE_NAVIGATION_MENU_IDLE = 'idle'; + export default function useCreateNavigationMenu( clientId ) { + const [ status, setStatus ] = useState( CREATE_NAVIGATION_MENU_IDLE ); + const [ value, setValue ] = useState( null ); + const [ error, setError ] = useState( null ); + const { saveEntityRecord } = useDispatch( coreStore ); const generateDefaultTitle = useGenerateDefaultNavigationTitle( clientId ); // This callback uses data from the two placeholder steps and only creates // a new navigation menu when the user completes the final step. - return useCallback( + const create = useCallback( async ( title = null, blocks = [] ) => { + // Guard against creating Navigations without a title. + // Note you can pass no title, but if one is passed it must be + // a string otherwise the title may end up being empty. + if ( title && typeof title !== 'string' ) { + setError( + 'Invalid title supplied when creating Navigation Menu.' + ); + setStatus( CREATE_NAVIGATION_MENU_ERROR ); + throw new Error( + `Value of supplied title argument was not a string.` + ); + } + + setStatus( CREATE_NAVIGATION_MENU_PENDING ); + setValue( null ); + setError( null ); + if ( ! title ) { - title = await generateDefaultTitle(); + title = await generateDefaultTitle().catch( ( err ) => { + setError( err?.message ); + setStatus( CREATE_NAVIGATION_MENU_ERROR ); + throw new Error( + 'Failed to create title when saving new Navigation Menu.', + { + cause: err, + } + ); + } ); } const record = { title, @@ -28,12 +63,28 @@ export default function useCreateNavigationMenu( clientId ) { status: 'publish', }; - return await saveEntityRecord( - 'postType', - 'wp_navigation', - record - ); + // Return affords ability to await on this function directly + return saveEntityRecord( 'postType', 'wp_navigation', record ) + .then( ( response ) => { + setValue( response ); + setStatus( CREATE_NAVIGATION_MENU_SUCCESS ); + return response; + } ) + .catch( ( err ) => { + setError( err?.message ); + setStatus( CREATE_NAVIGATION_MENU_ERROR ); + throw new Error( 'Unable to save new Navigation Menu', { + cause: err, + } ); + } ); }, [ serialize, saveEntityRecord ] ); + + return { + create, + status, + value, + error, + }; } diff --git a/packages/e2e-tests/specs/editor/blocks/navigation.test.js b/packages/e2e-tests/specs/editor/blocks/navigation.test.js index e3d8f8739a7378..5a6fdfd7c34c6c 100644 --- a/packages/e2e-tests/specs/editor/blocks/navigation.test.js +++ b/packages/e2e-tests/specs/editor/blocks/navigation.test.js @@ -340,6 +340,53 @@ describe( 'Navigation', () => { // Resolve the controlled mocked API request. resolveNavigationRequest(); } ); + + it( 'shows a loading indicator whilst empty Navigation menu is being created', async () => { + const testNavId = 1; + + let resolveNavigationRequest; + + // Mock the request for the single Navigation post in order to fully + // control the resolution of the request. This will enable the ability + // to assert on how the UI responds during the API resolution without + // relying on variable factors such as network conditions. + await setUpResponseMocking( [ + { + match: ( request ) => + request.url().includes( `rest_route` ) && + request.url().includes( `navigation` ) && + request.url().includes( testNavId ), + onRequestMatch: () => { + // The Promise simulates a REST API request whose resolultion + // the test has full control over. + return new Promise( ( resolve ) => { + // Assign the resolution function to the var in the + // upper scope to afford control over resolution. + resolveNavigationRequest = resolve; + } ); + }, + }, + ] ); + + await createNewPost(); + await insertBlock( 'Navigation' ); + + let navBlock = await waitForBlock( 'Navigation' ); + + // Create empty Navigation block with no items + const startEmptyButton = await page.waitForXPath( + START_EMPTY_XPATH + ); + await startEmptyButton.click(); + + navBlock = await waitForBlock( 'Navigation' ); + + // Check for the spinner to be present whilst loading. + await navBlock.waitForSelector( '.components-spinner' ); + + // Resolve the controlled mocked API request. + resolveNavigationRequest(); + } ); } ); describe( 'Placeholder', () => { @@ -357,9 +404,9 @@ describe( 'Navigation', () => { // Check for unconfigured Placeholder state to display await page.waitForXPath( START_EMPTY_XPATH ); - // Deselect the Nav block. - await page.keyboard.press( 'Escape' ); - await page.keyboard.press( 'Escape' ); + // Deselect the Nav block by inserting a new block at the root level + // outside of the Nav block. + await insertBlock( 'Paragraph' ); const navBlock = await waitForBlock( 'Navigation' ); @@ -386,11 +433,15 @@ describe( 'Navigation', () => { ); await startEmptyButton.click(); - const navBlock = await waitForBlock( 'Navigation' ); + // Wait for block to resolve + let navBlock = await waitForBlock( 'Navigation' ); + + // Deselect the Nav block by inserting a new block at the root level + // outside of the Nav block. + await insertBlock( 'Paragraph' ); - // Deselect the Nav block. - await page.keyboard.press( 'Escape' ); - await page.keyboard.press( 'Escape' ); + // Aquire fresh reference to block + navBlock = await waitForBlock( 'Navigation' ); // Check Placeholder Preview is visible. await navBlock.waitForSelector( @@ -462,6 +513,11 @@ describe( 'Navigation', () => { const startEmptyButton = await page.waitForXPath( START_EMPTY_XPATH ); await startEmptyButton.click(); + // Await "success" notice. + await page.waitForXPath( + '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' + ); + const appender = await page.waitForSelector( '.wp-block-navigation .block-list-appender' );