diff --git a/packages/block-library/src/navigation/edit.js b/packages/block-library/src/navigation/edit.js
index 79e06d370460a..f5d7a1c44f656 100644
--- a/packages/block-library/src/navigation/edit.js
+++ b/packages/block-library/src/navigation/edit.js
@@ -103,6 +103,7 @@ function Navigation( {
hasSubmenuIndicatorSetting = true,
hasItemJustificationControls = true,
hasColorSettings = true,
+ customPlaceholder: CustomPlaceholder = null,
} ) {
const [ isPlaceholderShown, setIsPlaceholderShown ] = useState(
! hasExistingNavItems
@@ -163,7 +164,7 @@ function Navigation( {
// inherit templateLock={ 'all' }.
templateLock: false,
__experimentalLayout: LAYOUT,
- placeholder,
+ placeholder: ! CustomPlaceholder ? placeholder : undefined,
}
);
@@ -200,9 +201,13 @@ function Navigation( {
} );
if ( isPlaceholderShown ) {
+ const PlaceholderComponent = CustomPlaceholder
+ ? CustomPlaceholder
+ : NavigationPlaceholder;
+
return (
-
{
setIsPlaceholderShown( false );
updateInnerBlocks( blocks );
diff --git a/packages/block-library/src/navigation/placeholder.js b/packages/block-library/src/navigation/placeholder.js
index a9e0e589910ce..ce1c14533894f 100644
--- a/packages/block-library/src/navigation/placeholder.js
+++ b/packages/block-library/src/navigation/placeholder.js
@@ -48,7 +48,7 @@ function NavigationPlaceholder( { onCreate }, ref ) {
const { innerBlocks: blocks } = menuItemsToBlocks( menuItems );
const selectNavigationBlock = true;
onCreate( blocks, selectNavigationBlock );
- } );
+ }, [ menuItems, menuItemsToBlocks, onCreate ] );
const onCreateFromMenu = () => {
// If we have menu items, create the block right away.
diff --git a/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap b/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap
index 89f22e7ad5236..82c208896c7dc 100644
--- a/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap
+++ b/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap
@@ -4,7 +4,7 @@ exports[`Navigation editor allows creation of a menu when there are existing men
exports[`Navigation editor allows creation of a menu when there are no current menu items 1`] = `
"
-
+
"
`;
diff --git a/packages/e2e-tests/specs/experiments/navigation-editor.test.js b/packages/e2e-tests/specs/experiments/navigation-editor.test.js
index fa8bdf21d852a..972ba27eb834b 100644
--- a/packages/e2e-tests/specs/experiments/navigation-editor.test.js
+++ b/packages/e2e-tests/specs/experiments/navigation-editor.test.js
@@ -193,7 +193,18 @@ describe( 'Navigation editor', () => {
POST: menuPostResponse,
} ),
...getMenuItemMocks( { GET: [] } ),
- ...getPagesMocks( { GET: [ {} ] } ), // mock a single page
+ ...getPagesMocks( {
+ GET: [
+ {
+ type: 'page',
+ id: 1,
+ link: 'https://example.com/1',
+ title: {
+ rendered: 'My page',
+ },
+ },
+ ],
+ } ),
] );
await page.keyboard.type( 'Main Menu' );
@@ -354,7 +365,7 @@ describe( 'Navigation editor', () => {
);
await navBlock.click();
const startEmptyButton = await page.waitForXPath(
- '//button[.="Start empty"]'
+ '//button[.="Start blank"]'
);
await startEmptyButton.click();
diff --git a/packages/edit-navigation/src/components/block-placeholder/index.js b/packages/edit-navigation/src/components/block-placeholder/index.js
new file mode 100644
index 0000000000000..f4e47fa6b83dc
--- /dev/null
+++ b/packages/edit-navigation/src/components/block-placeholder/index.js
@@ -0,0 +1,187 @@
+/**
+ * WordPress dependencies
+ */
+import { createBlock } from '@wordpress/blocks';
+import {
+ Placeholder,
+ Button,
+ DropdownMenu,
+ MenuGroup,
+ MenuItem,
+ Spinner,
+} from '@wordpress/components';
+import {
+ forwardRef,
+ useCallback,
+ useState,
+ useEffect,
+} from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { chevronDown } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import { useMenuEntityProp, useSelectedMenuId } from '../../hooks';
+import useNavigationEntities from './use-navigation-entities';
+import menuItemsToBlocks from './menu-items-to-blocks';
+
+/**
+ * Convert pages to blocks.
+ *
+ * @param {Object[]} pages An array of pages.
+ *
+ * @return {WPBlock[]} An array of blocks.
+ */
+function convertPagesToBlocks( pages ) {
+ if ( ! pages?.length ) {
+ return null;
+ }
+
+ return pages.map( ( { title, type, link: url, id } ) =>
+ createBlock( 'core/navigation-link', {
+ type,
+ id,
+ url,
+ label: ! title.rendered ? __( '(no title)' ) : title.rendered,
+ opensInNewTab: false,
+ } )
+ );
+}
+
+const TOGGLE_PROPS = { variant: 'tertiary' };
+const POPOVER_PROPS = { position: 'bottom center' };
+
+function BlockPlaceholder( { onCreate }, ref ) {
+ const [ selectedMenu, setSelectedMenu ] = useState();
+ const [ isCreatingFromMenu, setIsCreatingFromMenu ] = useState( false );
+
+ const [ selectedMenuId ] = useSelectedMenuId();
+ const [ menuName ] = useMenuEntityProp( 'name', selectedMenuId );
+
+ const {
+ isResolvingPages,
+ menus,
+ isResolvingMenus,
+ menuItems,
+ hasResolvedMenuItems,
+ pages,
+ hasPages,
+ hasMenus,
+ } = useNavigationEntities( selectedMenu );
+
+ const isLoading = isResolvingPages || isResolvingMenus;
+
+ const createFromMenu = useCallback( () => {
+ const { innerBlocks: blocks } = menuItemsToBlocks( menuItems );
+ const selectNavigationBlock = true;
+ onCreate( blocks, selectNavigationBlock );
+ }, [ menuItems, menuItemsToBlocks, onCreate ] );
+
+ const onCreateFromMenu = () => {
+ // If we have menu items, create the block right away.
+ if ( hasResolvedMenuItems ) {
+ createFromMenu();
+ return;
+ }
+
+ // Otherwise, create the block when resolution finishes.
+ setIsCreatingFromMenu( true );
+ };
+
+ const onCreateEmptyMenu = () => {
+ onCreate( [] );
+ };
+
+ const onCreateAllPages = () => {
+ const blocks = convertPagesToBlocks( pages );
+ const selectNavigationBlock = true;
+ onCreate( blocks, selectNavigationBlock );
+ };
+
+ useEffect( () => {
+ // If the user selected a menu but we had to wait for menu items to
+ // finish resolving, then create the block once resolution finishes.
+ if ( isCreatingFromMenu && hasResolvedMenuItems ) {
+ createFromMenu();
+ setIsCreatingFromMenu( false );
+ }
+ }, [ isCreatingFromMenu, hasResolvedMenuItems ] );
+
+ const selectableMenus = menus?.filter(
+ ( menu ) => menu.id !== selectedMenuId
+ );
+
+ const hasSelectableMenus = !! selectableMenus?.length;
+
+ return (
+
+
+ { isLoading && (
+
+
+
+ ) }
+ { ! isLoading && (
+
+
+ { hasPages ? (
+
+ ) : undefined }
+ { hasSelectableMenus ? (
+
+ { ( { onClose } ) => (
+
+ { selectableMenus.map( ( menu ) => {
+ return (
+
+ );
+ } ) }
+
+ ) }
+
+ ) : undefined }
+
+ ) }
+
+
+ );
+}
+
+export default forwardRef( BlockPlaceholder );
diff --git a/packages/edit-navigation/src/components/block-placeholder/menu-items-to-blocks.js b/packages/edit-navigation/src/components/block-placeholder/menu-items-to-blocks.js
new file mode 100644
index 0000000000000..db29190438447
--- /dev/null
+++ b/packages/edit-navigation/src/components/block-placeholder/menu-items-to-blocks.js
@@ -0,0 +1,118 @@
+/**
+ * External dependencies
+ */
+import { sortBy } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { createBlock } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import { menuItemToBlockAttributes } from '../../store/utils';
+
+/**
+ * Convert a flat menu item structure to a nested blocks structure.
+ *
+ * @param {Object[]} menuItems An array of menu items.
+ *
+ * @return {WPBlock[]} An array of blocks.
+ */
+export default function menuItemsToBlocks( menuItems ) {
+ if ( ! menuItems ) {
+ return null;
+ }
+
+ const menuTree = createDataTree( menuItems );
+ return mapMenuItemsToBlocks( menuTree );
+}
+
+/** @typedef {import('../..store/utils').WPNavMenuItem} WPNavMenuItem */
+
+/**
+ * A recursive function that maps menu item nodes to blocks.
+ *
+ * @param {WPNavMenuItem[]} menuItems An array of WPNavMenuItem items.
+ * @return {Object} Object containing innerBlocks and mapping.
+ */
+function mapMenuItemsToBlocks( menuItems ) {
+ let mapping = {};
+
+ // The menuItem should be in menu_order sort order.
+ const sortedItems = sortBy( menuItems, 'menu_order' );
+
+ const innerBlocks = sortedItems.map( ( menuItem ) => {
+ const attributes = menuItemToBlockAttributes( menuItem );
+
+ // If there are children recurse to build those nested blocks.
+ const {
+ innerBlocks: nestedBlocks = [], // alias to avoid shadowing
+ mapping: nestedMapping = {}, // alias to avoid shadowing
+ } = menuItem.children?.length
+ ? mapMenuItemsToBlocks( menuItem.children )
+ : {};
+
+ // Update parent mapping with nested mapping.
+ mapping = {
+ ...mapping,
+ ...nestedMapping,
+ };
+
+ // Create block with nested "innerBlocks".
+ const block = createBlock(
+ 'core/navigation-link',
+ attributes,
+ nestedBlocks
+ );
+
+ // Create mapping for menuItem -> block
+ mapping[ menuItem.id ] = block.clientId;
+
+ return block;
+ } );
+
+ return {
+ innerBlocks,
+ mapping,
+ };
+}
+
+/**
+ * Creates a nested, hierarchical tree representation from unstructured data that
+ * has an inherent relationship defined between individual items.
+ *
+ * For example, by default, each element in the dataset should have an `id` and
+ * `parent` property where the `parent` property indicates a relationship between
+ * the current item and another item with a matching `id` properties.
+ *
+ * This is useful for building linked lists of data from flat data structures.
+ *
+ * @param {Array} dataset linked data to be rearranged into a hierarchical tree based on relational fields.
+ * @param {string} id the property which uniquely identifies each entry within the array.
+ * @param {*} relation the property which identifies how the current item is related to other items in the data (if at all).
+ * @return {Array} a nested array of parent/child relationships
+ */
+function createDataTree( dataset, id = 'id', relation = 'parent' ) {
+ const hashTable = Object.create( null );
+ const dataTree = [];
+
+ for ( const data of dataset ) {
+ hashTable[ data[ id ] ] = {
+ ...data,
+ children: [],
+ };
+ }
+ for ( const data of dataset ) {
+ if ( data[ relation ] ) {
+ hashTable[ data[ relation ] ].children.push(
+ hashTable[ data[ id ] ]
+ );
+ } else {
+ dataTree.push( hashTable[ data[ id ] ] );
+ }
+ }
+
+ return dataTree;
+}
diff --git a/packages/edit-navigation/src/components/block-placeholder/style.scss b/packages/edit-navigation/src/components/block-placeholder/style.scss
new file mode 100644
index 0000000000000..bd9b6eab7c9a9
--- /dev/null
+++ b/packages/edit-navigation/src/components/block-placeholder/style.scss
@@ -0,0 +1,54 @@
+.edit-navigation-block-placeholder {
+ // The navigation editor already has a border around content.
+ // Hide the placeholder's border. Requires extra specificity.
+ &.edit-navigation-block-placeholder {
+ box-shadow: none;
+ background: transparent;
+
+ @include break-medium() {
+ margin: -$grid-unit-20 0;
+ }
+ }
+
+ // Show placeholder instructions when it's a medium size.
+ &.is-medium .components-placeholder__instructions {
+ display: block;
+ }
+
+ // Display buttons in a column when placeholder is small.
+ .edit-navigation-block-placeholder__actions {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+
+ .components-button {
+ margin-bottom: $grid-unit-05;
+ margin-right: 0;
+
+ // Avoid bottom margin on the dropdown since it makes the
+ // menu anchor itself too far away from the button.
+ &.components-dropdown-menu__toggle {
+ margin-bottom: 0;
+
+ svg {
+ // Make the spacing inside the left of the button match the
+ // spacing inside the right of the button.
+ margin-left: -6px;
+ }
+ }
+ }
+ }
+
+ @include break-medium() {
+ .edit-navigation-block-placeholder__actions {
+ flex-direction: row;
+ }
+
+ // Change the default button margin. Again use extra specificity.
+ &.edit-navigation-block-placeholder.is-medium .components-button {
+ margin-bottom: 0;
+ margin-right: $grid-unit-15;
+ }
+ }
+
+}
diff --git a/packages/edit-navigation/src/components/block-placeholder/use-navigation-entities.js b/packages/edit-navigation/src/components/block-placeholder/use-navigation-entities.js
new file mode 100644
index 0000000000000..17806deadd9a8
--- /dev/null
+++ b/packages/edit-navigation/src/components/block-placeholder/use-navigation-entities.js
@@ -0,0 +1,142 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * @typedef {Object} NavigationEntitiesData
+ * @property {Array|undefined} pages - a collection of WP Post entity objects of post type "Page".
+ * @property {boolean} isResolvingPages - indicates whether the request to fetch pages is currently resolving.
+ * @property {boolean} hasResolvedPages - indicates whether the request to fetch pages has finished resolving.
+ * @property {Array|undefined} menus - a collection of Menu entity objects.
+ * @property {boolean} isResolvingMenus - indicates whether the request to fetch menus is currently resolving.
+ * @property {boolean} hasResolvedMenus - indicates whether the request to fetch menus has finished resolving.
+ * @property {Array|undefined} menusItems - a collection of Menu Item entity objects for the current menuId.
+ * @property {boolean} hasResolvedMenuItems - indicates whether the request to fetch menuItems has finished resolving.
+ * @property {boolean} hasPages - indicates whether there is currently any data for pages.
+ * @property {boolean} hasMenus - indicates whether there is currently any data for menus.
+ */
+
+/**
+ * Manages fetching and resolution state for all entities required
+ * for the Navigation block.
+ *
+ * @param {number} menuId the menu for which to retrieve menuItem data.
+ * @return { NavigationEntitiesData } the entity data.
+ */
+export default function useNavigationEntities( menuId ) {
+ return {
+ ...usePageEntities(),
+ ...useMenuEntities(),
+ ...useMenuItemEntities( menuId ),
+ };
+}
+
+function useMenuEntities() {
+ const { menus, isResolvingMenus, hasResolvedMenus } = useSelect(
+ ( select ) => {
+ const { getMenus, isResolving, hasFinishedResolution } = select(
+ coreStore
+ );
+
+ const menusParameters = [ { per_page: -1 } ];
+
+ return {
+ menus: getMenus( ...menusParameters ),
+ isResolvingMenus: isResolving( 'getMenus', menusParameters ),
+ hasResolvedMenus: hasFinishedResolution(
+ 'getMenus',
+ menusParameters
+ ),
+ };
+ },
+ []
+ );
+
+ return {
+ menus,
+ isResolvingMenus,
+ hasResolvedMenus,
+ hasMenus: !! ( hasResolvedMenus && menus?.length ),
+ };
+}
+
+function useMenuItemEntities( menuId ) {
+ const { menuItems, hasResolvedMenuItems } = useSelect(
+ ( select ) => {
+ const { getMenuItems, hasFinishedResolution } = select( coreStore );
+
+ const hasSelectedMenu = menuId !== undefined;
+ const menuItemsParameters = hasSelectedMenu
+ ? [
+ {
+ menus: menuId,
+ per_page: -1,
+ },
+ ]
+ : undefined;
+
+ return {
+ menuItems: hasSelectedMenu
+ ? getMenuItems( ...menuItemsParameters )
+ : undefined,
+ hasResolvedMenuItems: hasSelectedMenu
+ ? hasFinishedResolution(
+ 'getMenuItems',
+ menuItemsParameters
+ )
+ : false,
+ };
+ },
+ [ menuId ]
+ );
+
+ return {
+ menuItems,
+ hasResolvedMenuItems,
+ };
+}
+
+function usePageEntities() {
+ const { pages, isResolvingPages, hasResolvedPages } = useSelect(
+ ( select ) => {
+ const {
+ getEntityRecords,
+ isResolving,
+ hasFinishedResolution,
+ } = select( coreStore );
+
+ const pagesParameters = [
+ 'postType',
+ 'page',
+ {
+ parent: 0,
+ order: 'asc',
+ orderby: 'id',
+ per_page: -1,
+ },
+ ];
+
+ return {
+ pages: getEntityRecords( ...pagesParameters ) || null,
+ isResolvingPages: isResolving(
+ 'getEntityRecords',
+ pagesParameters
+ ),
+ hasResolvedPages: hasFinishedResolution(
+ 'getEntityRecords',
+ pagesParameters
+ ),
+ };
+ },
+ []
+ );
+
+ return {
+ pages,
+ isResolvingPages,
+ hasResolvedPages,
+ hasPages: !! ( hasResolvedPages && pages?.length ),
+ };
+}
diff --git a/packages/edit-navigation/src/filters/add-navigation-editor-placeholder.js b/packages/edit-navigation/src/filters/add-navigation-editor-placeholder.js
new file mode 100644
index 0000000000000..263658d0ffc64
--- /dev/null
+++ b/packages/edit-navigation/src/filters/add-navigation-editor-placeholder.js
@@ -0,0 +1,28 @@
+/**
+ * WordPress dependencies
+ */
+/**
+ * Internal dependencies
+ */
+import { addFilter } from '@wordpress/hooks';
+import { createHigherOrderComponent } from '@wordpress/compose';
+import BlockPlaceholder from '../components/block-placeholder';
+
+const addNavigationEditorPlaceholder = createHigherOrderComponent(
+ ( BlockEdit ) => ( props ) => {
+ if ( props.name !== 'core/navigation' ) {
+ return ;
+ }
+ return (
+
+ );
+ },
+ 'withNavigationEditorPlaceholder'
+);
+
+export default () =>
+ addFilter(
+ 'editor.BlockEdit',
+ 'core/edit-navigation/with-navigation-editor-placeholder',
+ addNavigationEditorPlaceholder
+ );
diff --git a/packages/edit-navigation/src/filters/index.js b/packages/edit-navigation/src/filters/index.js
index 96315b46b7eb8..08ab87f0fe4b5 100644
--- a/packages/edit-navigation/src/filters/index.js
+++ b/packages/edit-navigation/src/filters/index.js
@@ -1,6 +1,7 @@
/**
* Internal dependencies
*/
+import addNavigationEditorPlaceholder from './add-navigation-editor-placeholder';
import addMenuNameEditor from './add-menu-name-editor';
import disableInsertingNonNavigationBlocks from './disable-inserting-non-navigation-blocks';
import removeEditUnsupportedFeatures from './remove-edit-unsupported-features';
@@ -9,6 +10,7 @@ import removeSettingsUnsupportedFeatures from './remove-settings-unsupported-fea
export const addFilters = (
shouldAddDisableInsertingNonNavigationBlocksFilter
) => {
+ addNavigationEditorPlaceholder();
addMenuNameEditor();
if ( shouldAddDisableInsertingNonNavigationBlocksFilter ) {
disableInsertingNonNavigationBlocks();
diff --git a/packages/edit-navigation/src/style.scss b/packages/edit-navigation/src/style.scss
index d3865a1dfff8e..ef8c1bdea6646 100644
--- a/packages/edit-navigation/src/style.scss
+++ b/packages/edit-navigation/src/style.scss
@@ -8,6 +8,7 @@ $navigation-editor-spacing-top: $grid-unit-50 * 2;
}
@import "./components/add-menu/style.scss";
+@import "./components/block-placeholder/style.scss";
@import "../../interface/src/style.scss";
@import "./components/editor/style.scss";
@import "./components/error-boundary/style.scss";