diff --git a/packages/block-library/src/navigation/edit/index.js b/packages/block-library/src/navigation/edit/index.js index 6fa66ea1a1dcbe..709c82cb90825e 100644 --- a/packages/block-library/src/navigation/edit/index.js +++ b/packages/block-library/src/navigation/edit/index.js @@ -38,10 +38,12 @@ import { __ } from '@wordpress/i18n'; import useListViewModal from './use-list-view-modal'; import useNavigationMenu from '../use-navigation-menu'; import Placeholder from './placeholder'; +import PlaceholderPreview from './placeholder/placeholder-preview'; import ResponsiveWrapper from './responsive-wrapper'; import NavigationInnerBlocks from './inner-blocks'; import NavigationMenuSelector from './navigation-menu-selector'; import NavigationMenuNameControl from './navigation-menu-name-control'; +import NavigationMenuPublishButton from './navigation-menu-publish-button'; import UnsavedInnerBlocks from './unsaved-inner-blocks'; import NavigationMenuDeleteControl from './navigation-menu-delete-control'; @@ -75,8 +77,8 @@ function detectColors( colorsDetectionElement, setColor, setBackground ) { function Navigation( { attributes, setAttributes, - isSelected, clientId, + isSelected, className, backgroundColor, setBackgroundColor, @@ -108,13 +110,26 @@ function Navigation( { `navigationMenu/${ navigationMenuId }` ); - const innerBlocks = useSelect( - ( select ) => select( blockEditorStore ).getBlocks( clientId ), + const { innerBlocks, isInnerBlockSelected } = useSelect( + ( select ) => { + const { getBlocks, hasSelectedInnerBlock } = select( + blockEditorStore + ); + return { + innerBlocks: getBlocks( clientId ), + isInnerBlockSelected: hasSelectedInnerBlock( clientId, true ), + }; + }, [ clientId ] ); const hasExistingNavItems = !! innerBlocks.length; const { replaceInnerBlocks, selectBlock } = useDispatch( blockEditorStore ); + const [ + hasSavedUnsavedInnerBlocks, + setHasSavedUnsavedInnerBlocks, + ] = useState( false ); + const [ isPlaceholderShown, setIsPlaceholderShown ] = useState( ! hasExistingNavItems ); @@ -127,10 +142,13 @@ function Navigation( { isNavigationMenuResolved, isNavigationMenuMissing, canSwitchNavigationMenu, - hasResolvedNavigationMenu, + hasResolvedNavigationMenus, + navigationMenus, + navigationMenu, } = useNavigationMenu( navigationMenuId ); const navRef = useRef(); + const isDraftNavigationMenu = navigationMenu?.status === 'draft'; const { listViewToolbarButton, listViewModal } = useListViewModal( clientId @@ -203,19 +221,23 @@ function Navigation( { // If the block has inner blocks, but no menu id, this was an older // navigation block added before the block used a wp_navigation entity. + // Either this block was saved in the content or inserted by a pattern. // Consider this 'unsaved'. Offer an uncontrolled version of inner blocks, - // with a prompt to 'save'. - const hasUnsavedBlocks = - hasExistingNavItems && navigationMenuId === undefined; + // that automatically saves the menu. + const hasUnsavedBlocks = hasExistingNavItems && ! isEntityAvailable; if ( hasUnsavedBlocks ) { return ( - setAttributes( { navigationMenuId: post.id } ) - } + navigationMenus={ navigationMenus } + hasSelection={ isSelected || isInnerBlockSelected } + hasSavedUnsavedInnerBlocks={ hasSavedUnsavedInnerBlocks } + onSave={ ( post ) => { + setHasSavedUnsavedInnerBlocks( true ); + // Switch to using the wp_navigation entity. + setAttributes( { navigationMenuId: post.id } ); + } } /> ); } @@ -261,8 +283,8 @@ function Navigation( { > - - { isEntityAvailable && ( + { ! isDraftNavigationMenu && isEntityAvailable && ( + ) } - ) } - + + ) } { hasItemJustificationControls && ( ) } { listViewToolbarButton } + + { isDraftNavigationMenu && ( + + ) } + { listViewModal } @@ -420,11 +447,14 @@ function Navigation( { selectBlock( clientId ); } } canSwitchNavigationMenu={ canSwitchNavigationMenu } - hasResolvedNavigationMenu={ - hasResolvedNavigationMenu + hasResolvedNavigationMenus={ + hasResolvedNavigationMenus } /> ) } + { ! isEntityAvailable && ! isPlaceholderShown && ( + + ) } { ! isPlaceholderShown && ( - { __( 'Create' ) } + { finishButtonText } diff --git a/packages/block-library/src/navigation/edit/navigation-menu-publish-button.js b/packages/block-library/src/navigation/edit/navigation-menu-publish-button.js new file mode 100644 index 00000000000000..fd4a766d1f0935 --- /dev/null +++ b/packages/block-library/src/navigation/edit/navigation-menu-publish-button.js @@ -0,0 +1,57 @@ +/** + * WordPress dependencies + */ +import { ToolbarButton } from '@wordpress/components'; +import { + useEntityId, + useEntityProp, + store as coreStore, +} from '@wordpress/core-data'; +import { useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import NavigationMenuNameModal from './navigation-menu-name-modal'; + +export default function NavigationMenuPublishButton() { + const [ isNameModalVisible, setIsNameModalVisible ] = useState( false ); + const id = useEntityId( 'postType', 'wp_navigation' ); + const [ navigationMenuTitle ] = useEntityProp( + 'postType', + 'wp_navigation', + 'title' + ); + const { editEntityRecord, saveEditedEntityRecord } = useDispatch( + coreStore + ); + + return ( + <> + setIsNameModalVisible( true ) }> + { __( 'Save as' ) } + + { isNameModalVisible && ( + setIsNameModalVisible( false ) } + finishButtonText={ __( 'Save' ) } + onFinish={ ( updatedTitle ) => { + editEntityRecord( 'postType', 'wp_navigation', id, { + title: updatedTitle, + status: 'publish', + } ); + saveEditedEntityRecord( + 'postType', + 'wp_navigation', + id + ); + } } + /> + ) } + + ); +} diff --git a/packages/block-library/src/navigation/edit/placeholder/index.js b/packages/block-library/src/navigation/edit/placeholder/index.js index 9c19c88e309a16..aca458843150de 100644 --- a/packages/block-library/src/navigation/edit/placeholder/index.js +++ b/packages/block-library/src/navigation/edit/placeholder/index.js @@ -88,7 +88,7 @@ const ExistingMenusDropdown = ( { export default function NavigationPlaceholder( { onFinish, canSwitchNavigationMenu, - hasResolvedNavigationMenu, + hasResolvedNavigationMenus, } ) { const [ selectedMenu, setSelectedMenu ] = useState(); @@ -189,10 +189,10 @@ export default function NavigationPlaceholder( { return ( <> - { ( ! hasResolvedNavigationMenu || isStillLoading ) && ( + { ( ! hasResolvedNavigationMenus || isStillLoading ) && ( ) } - { hasResolvedNavigationMenu && ! isStillLoading && ( + { hasResolvedNavigationMenus && ! isStillLoading && (
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 23dd393dea8f6a..a2f8985ce002f7 100644 --- a/packages/block-library/src/navigation/edit/unsaved-inner-blocks.js +++ b/packages/block-library/src/navigation/edit/unsaved-inner-blocks.js @@ -1,38 +1,82 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ -import { useInnerBlocksProps, Warning } from '@wordpress/block-editor'; +import { useInnerBlocksProps } from '@wordpress/block-editor'; import { serialize } from '@wordpress/blocks'; -import { Button, Disabled } from '@wordpress/components'; +import { Disabled, Spinner } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; -import { useDispatch } from '@wordpress/data'; -import { useCallback, useState } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useCallback, useContext, useEffect, useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import NavigationMenuNameModal from './navigation-menu-name-modal'; +import useNavigationMenu from '../use-navigation-menu'; + +const NOOP = () => {}; +const DRAFT_MENU_PARAMS = [ + 'postType', + 'wp_navigation', + { status: 'draft', per_page: -1 }, +]; export default function UnsavedInnerBlocks( { blockProps, blocks, + hasSavedUnsavedInnerBlocks, onSave, - isSelected, + hasSelection, } ) { + const isDisabled = useContext( Disabled.Context ); + const savingLock = useRef( false ); + const innerBlocksProps = useInnerBlocksProps( blockProps, { - renderAppender: false, - } ); - const [ isModalVisible, setIsModalVisible ] = useState( false ); + renderAppender: hasSelection ? undefined : false, + // Make the inner blocks 'controlled'. This allows the block to always + // work with controlled inner blocks, smoothing out the switch to using + // an entity. + value: blocks, + onChange: NOOP, + onInput: NOOP, + } ); const { saveEntityRecord } = useDispatch( coreStore ); + const { + isSaving, + draftNavigationMenus, + hasResolvedDraftNavigationMenus, + } = useSelect( ( select ) => { + const { + getEntityRecords, + hasFinishedResolution, + isSavingEntityRecord, + } = select( coreStore ); + + return { + isSaving: isSavingEntityRecord( 'postType', 'wp_navigation' ), + draftNavigationMenus: getEntityRecords( ...DRAFT_MENU_PARAMS ), + hasResolvedDraftNavigationMenus: hasFinishedResolution( + 'getEntityRecords', + DRAFT_MENU_PARAMS + ), + }; + }, [] ); + + const { hasResolvedNavigationMenus, navigationMenus } = useNavigationMenu(); + const createNavigationMenu = useCallback( - async ( title = __( 'Untitled Navigation Menu' ) ) => { + async ( title ) => { const record = { title, content: serialize( blocks ), - status: 'publish', + status: 'draft', }; const navigationMenu = await saveEntityRecord( @@ -46,41 +90,83 @@ export default function UnsavedInnerBlocks( { [ blocks, serialize, saveEntityRecord ] ); + // Automatically save the uncontrolled blocks. + useEffect( async () => { + // The block will be disabled when used in a BlockPreview. + // In this case avoid automatic creation of a wp_navigation post. + // Otherwise the user will be spammed with lots of menus! + // + // Also ensure other navigation menus have loaded so an + // accurate name can be created. + // + // Don't try saving when another save is already + // in progress. + // + // And finally only create the menu when the block is selected, + // which is an indication they want to start editing. + if ( + hasSavedUnsavedInnerBlocks || + isDisabled || + isSaving || + savingLock.current || + ! hasResolvedDraftNavigationMenus || + ! hasResolvedNavigationMenus || + ! hasSelection + ) { + return; + } + + savingLock.current = true; + const title = __( 'Untitled menu' ); + + // Determine how many menus start with the untitled title. + const matchingMenuTitleCount = [ + ...draftNavigationMenus, + ...navigationMenus, + ].reduce( + ( count, menu ) => + menu?.title?.raw?.startsWith( title ) ? count + 1 : count, + 0 + ); + + // Append a number to the end of the title if a menu with + // the same name exists. + const titleWithCount = + matchingMenuTitleCount > 0 + ? `${ title } ${ matchingMenuTitleCount + 1 }` + : title; + + const menu = await createNavigationMenu( titleWithCount ); + onSave( menu ); + savingLock.current = false; + }, [ + isDisabled, + isSaving, + hasResolvedDraftNavigationMenus, + hasResolvedNavigationMenus, + draftNavigationMenus, + navigationMenus, + hasSelection, + createNavigationMenu, + ] ); + return ( <> - { isModalVisible && ( - { - setIsModalVisible( false ); - } } - onFinish={ async ( title ) => { - const menu = await createNavigationMenu( title ); - onSave( menu ); - } } - /> - ) } ); } diff --git a/packages/block-library/src/navigation/editor.scss b/packages/block-library/src/navigation/editor.scss index 0e18fd0a065742..2be4d048bec21b 100644 --- a/packages/block-library/src/navigation/editor.scss +++ b/packages/block-library/src/navigation/editor.scss @@ -522,12 +522,42 @@ body.editor-styles-wrapper } } -.wp-block-navigation__unsaved-changes-warning { - width: 100%; +@keyframes fadein { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} - .block-editor-warning__actions { - margin-top: 0; +.wp-block-navigation__unsaved-changes { + position: relative; + + .components-spinner { + position: absolute; + top: calc(50% - #{$spinner-size} / 2); + left: calc(50% - #{$spinner-size} / 2); + + // Delay showing the saving spinner until after 2 seconds. + // This should ensure it only shows for slow connections. + opacity: 0; + animation: 0.5s linear 2s normal forwards fadein; + } +} + +@keyframes fadeouthalf { + 0% { + opacity: 1; } + 100% { + opacity: 0.5; + } +} + +.wp-block-navigation__unsaved-changes-overlay.is-saving { + opacity: 1; + animation: 0.5s linear 2s normal forwards fadeouthalf; } .wp-block-navigation-delete-menu-button { diff --git a/packages/block-library/src/navigation/save.js b/packages/block-library/src/navigation/save.js index 17571d8f30d2de..c563ca923ddfdc 100644 --- a/packages/block-library/src/navigation/save.js +++ b/packages/block-library/src/navigation/save.js @@ -3,6 +3,12 @@ */ import { InnerBlocks } from '@wordpress/block-editor'; -export default function save() { +export default function save( { attributes } ) { + if ( attributes.navigationMenuId ) { + // Avoid rendering inner blocks when a navigationMenuId is defined. + // When this id is defined the inner blocks are loaded from the + // `wp_navigation` entity rather than the hard-coded block html. + return; + } return ; } diff --git a/packages/block-library/src/navigation/use-navigation-menu.js b/packages/block-library/src/navigation/use-navigation-menu.js index 49f751b39321f0..1bf42dd968f048 100644 --- a/packages/block-library/src/navigation/use-navigation-menu.js +++ b/packages/block-library/src/navigation/use-navigation-menu.js @@ -28,7 +28,11 @@ export default function useNavigationMenu( navigationMenuId ) { ) : false; - const navigationMenuMultipleArgs = [ 'postType', 'wp_navigation' ]; + const navigationMenuMultipleArgs = [ + 'postType', + 'wp_navigation', + { per_page: -1 }, + ]; const navigationMenus = getEntityRecords( ...navigationMenuMultipleArgs ); @@ -42,7 +46,7 @@ export default function useNavigationMenu( navigationMenuId ) { isNavigationMenuMissing: hasResolvedNavigationMenu && ! navigationMenu, canSwitchNavigationMenu, - hasResolvedNavigationMenu: hasFinishedResolution( + hasResolvedNavigationMenus: hasFinishedResolution( 'getEntityRecords', navigationMenuMultipleArgs ), diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js index 35a754b6df00ad..7e91e50b2a493b 100644 --- a/packages/editor/src/components/entities-saved-states/index.js +++ b/packages/editor/src/components/entities-saved-states/index.js @@ -27,6 +27,13 @@ const TRANSLATED_SITE_PROPERTIES = { page_on_front: __( 'Page on front' ), }; +const PUBLISH_ON_SAVE_ENTITIES = [ + { + kind: 'postType', + name: 'wp_navigation', + }, +]; + export default function EntitiesSavedStates( { close } ) { const saveButtonRef = useRef(); const { dirtyEntityRecords } = useSelect( ( select ) => { @@ -63,6 +70,7 @@ export default function EntitiesSavedStates( { close } ) { }; }, [] ); const { + editEntityRecord, saveEditedEntityRecord, __experimentalSaveSpecifiedEntityEdits: saveSpecifiedEntityEdits, } = useDispatch( coreStore ); @@ -130,6 +138,16 @@ export default function EntitiesSavedStates( { close } ) { if ( 'root' === kind && 'site' === name ) { siteItemsToSave.push( property ); } else { + if ( + PUBLISH_ON_SAVE_ENTITIES.some( + ( typeToPublish ) => + typeToPublish.kind === kind && + typeToPublish.name === name + ) + ) { + editEntityRecord( kind, name, key, { status: 'publish' } ); + } + saveEditedEntityRecord( kind, name, key ); } } );