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 ( + { + setSelectedMenu( + menu.id + ); + onCreateFromMenu(); + } } + onClose={ onClose } + key={ menu.id } + > + { menu.name } + + ); + } ) } + + ) } + + ) : 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";