diff --git a/packages/block-library/src/navigation/edit.js b/packages/block-library/src/navigation/edit.js index 9019aec823f98..c52ba5b9581cf 100644 --- a/packages/block-library/src/navigation/edit.js +++ b/packages/block-library/src/navigation/edit.js @@ -1,13 +1,13 @@ /** * External dependencies */ -import { escape, upperFirst } from 'lodash'; +import { upperFirst } from 'lodash'; import classnames from 'classnames'; /** * WordPress dependencies */ -import { useMemo, useState, useRef, useCallback } from '@wordpress/element'; +import { useRef } from '@wordpress/element'; import { InnerBlocks, InspectorControls, @@ -17,8 +17,6 @@ import { __experimentalUseColors, __experimentalBlock as Block, } from '@wordpress/block-editor'; - -import { createBlock } from '@wordpress/blocks'; import { useSelect, useDispatch, @@ -26,18 +24,13 @@ import { withDispatch, } from '@wordpress/data'; import { - Button, PanelBody, - Placeholder, - Spinner, ToggleControl, Toolbar, ToolbarGroup, - CustomSelectControl, } from '@wordpress/components'; import { compose } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; -import { navigation as icon } from '@wordpress/icons'; /** * Internal dependencies @@ -45,20 +38,7 @@ import { navigation as icon } from '@wordpress/icons'; import useBlockNavigator from './use-block-navigator'; import BlockColorsStyleSelector from './block-colors-selector'; import * as navIcons from './icons'; -import createDataTree from './create-data-tree'; - -// Constants -const CREATE_EMPTY_OPTION_VALUE = '__CREATE_EMPTY__'; -const CREATE_FROM_PAGES_OPTION_VALUE = '__CREATE_FROM_PAGES__'; -const CREATE_PLACEHOLDER_VALUE = '__CREATE_PLACEHOLDER__'; - -function LoadingSpinner() { - return ( - <> - { __( 'Loading…' ) } - - ); -} +import NavigationPlaceholder from './placeholder'; function Navigation( { selectedBlockHasDescendants, @@ -66,29 +46,18 @@ function Navigation( { clientId, fontSize, hasExistingNavItems, - hasResolvedPages, isImmediateParentOfSelectedBlock, - isRequestingPages, - getHasResolvedMenuItems, - hasResolvedMenus, - isRequestingMenus, isSelected, - pages, - menus, - getMenuItems, setAttributes, setFontSize, - updateNavItemBlocks, + updateInnerBlocks, className, } ) { // // HOOKS // const ref = useRef(); - const [ - selectedCreateActionOption, - setSelectedCreateActionOption, - ] = useState( null ); + const { selectBlock } = useDispatch( 'core/block-editor' ); const { TextColor, BackgroundColor, ColorPanel } = __experimentalUseColors( [ @@ -120,69 +89,6 @@ function Navigation( { clientId ); - const isRequestingEntities = isRequestingPages || isRequestingMenus; - const selectedCreateActionOptionKey = selectedCreateActionOption?.key; - - // Builds navigation links from default Pages. - const buildNavLinkBlocksFromPages = useMemo( () => { - if ( ! pages ) { - return null; - } - - return pages.map( ( { title, type, link: url, id } ) => - createBlock( 'core/navigation-link', { - type, - id, - url, - label: ! title.rendered - ? __( '(no title)' ) - : escape( title.rendered ), - opensInNewTab: false, - } ) - ); - }, [ pages ] ); - - const menuItems = getMenuItems( selectedCreateActionOptionKey ); - - // Builds navigation links from selected Menu's items. - const buildNavLinkBlocksFromMenuItems = useMemo( () => { - if ( ! menuItems ) { - return null; - } - - function initialiseBlocks( nodes ) { - return nodes.map( ( { title, type, link: url, id, children } ) => { - const innerBlocks = - children && children.length - ? initialiseBlocks( children ) - : []; - - return createBlock( - 'core/navigation-link', - { - type, - id, - url, - label: ! title.rendered - ? __( '(no title)' ) - : escape( title.rendered ), - opensInNewTab: false, - }, - innerBlocks - ); - } ); - } - - const menuTree = createDataTree( menuItems ); - - const menuBlocksTree = initialiseBlocks( menuTree ); - - return menuBlocksTree; - }, [ menuItems ] ); - - const hasPages = !! ( hasResolvedPages && pages?.length ); - const hasMenus = !! ( hasResolvedMenus && menus?.length ); - // // HANDLERS // @@ -196,215 +102,19 @@ function Navigation( { }; } - function handleCreateEmpty() { - const emptyNavLinkBlock = createBlock( 'core/navigation-link' ); - updateNavItemBlocks( [ emptyNavLinkBlock ] ); - } - - function handleCreateFromExistingPages() { - updateNavItemBlocks( buildNavLinkBlocksFromPages ); - selectBlock( clientId ); - } - - function handleCreateFromExistingMenu() { - updateNavItemBlocks( buildNavLinkBlocksFromMenuItems ); - selectBlock( clientId ); - } - - function handleCreate() { - const { key } = selectedCreateActionOption; - - // Explicity request to create empty. - if ( key === CREATE_EMPTY_OPTION_VALUE ) { - return handleCreateEmpty(); - } - - // Create from Pages. - if ( hasPages && key === CREATE_FROM_PAGES_OPTION_VALUE ) { - return handleCreateFromExistingPages(); - } - - // Create from WP Menu (if exists and not empty). - if ( - hasMenus && - selectedCreateActionOption && - buildNavLinkBlocksFromMenuItems?.length - ) { - return handleCreateFromExistingMenu(); - } - - // Default to empty menu - return handleCreateEmpty(); - } - - const buildPlaceholderInstructionText = useCallback( () => { - if ( isRequestingEntities ) { - return ''; - } - - if ( hasMenus && hasPages ) { - return __( - 'Create a navigation from all existing pages, or choose a menu.' - ); - } - - if ( ! hasMenus && ! hasPages ) { - return __( 'Create an empty navigation.' ); - } - - if ( hasMenus && ! hasPages ) { - return __( 'Create a navigation from a menu or create empty.' ); - } - - if ( ! hasMenus && hasPages ) { - return __( - 'Create a navigation from all existing pages, or create empty.' - ); - } - }, [ isRequestingEntities, hasMenus, hasPages ] ); - - const createActionOptions = useMemo( - () => [ - { - id: CREATE_PLACEHOLDER_VALUE, - name: __( 'Select where to start from…' ), - }, - ...( hasMenus ? menus : [] ), - { - id: CREATE_EMPTY_OPTION_VALUE, - name: __( 'Create empty menu' ), - className: 'is-create-empty-option', - }, - ...( hasPages - ? [ - { - id: CREATE_FROM_PAGES_OPTION_VALUE, - name: __( 'New from all top-level pages' ), - }, - ] - : [] ), - ], - [ - CREATE_PLACEHOLDER_VALUE, - CREATE_EMPTY_OPTION_VALUE, - CREATE_FROM_PAGES_OPTION_VALUE, - hasMenus, - menus, - hasPages, - ] - ); - - const shouldDisableCreateButton = useCallback( () => { - // If there is no key at all then disable. - if ( ! selectedCreateActionOptionKey ) { - return true; - } - - // Always disable if the default "placeholder" option is selected. - if ( selectedCreateActionOptionKey === CREATE_PLACEHOLDER_VALUE ) { - return true; - } - - // Always enable if Create Empty is selected. - if ( selectedCreateActionOptionKey === CREATE_EMPTY_OPTION_VALUE ) { - return false; - } - - // Enable if Pages option selected and we have Pages available. - if ( - selectedCreateActionOptionKey === CREATE_FROM_PAGES_OPTION_VALUE && - hasResolvedPages - ) { - return false; - } - - // Only "menu" options use an integer based key. - const selectedOptionIsMenu = Number.isInteger( - selectedCreateActionOptionKey - ); - - const menuItemsResolved = - selectedOptionIsMenu && - getHasResolvedMenuItems( selectedCreateActionOptionKey ); - - return ! menuItemsResolved; - }, [ - selectedCreateActionOptionKey, - hasResolvedPages, - CREATE_PLACEHOLDER_VALUE, - CREATE_EMPTY_OPTION_VALUE, - CREATE_FROM_PAGES_OPTION_VALUE, - ] ); - // If we don't have existing items then show the Placeholder if ( ! hasExistingNavItems ) { return ( - - { isRequestingEntities ? ( -
- -
- ) : ( -
- <> - { - if ( - selectedItem?.key === - selectedCreateActionOptionKey - ) { - return; - } - setSelectedCreateActionOption( - selectedItem - ); - } } - options={ createActionOptions.map( - ( option ) => { - return { - ...option, - key: option.id, - }; - } - ) } - /> - - -
- ) } -
+ { + updateInnerBlocks( blocks ); + if ( selectNavigationBlock ) { + selectBlock( clientId ); + } + } } + />
); } @@ -494,9 +204,6 @@ function Navigation( { className={ blockClassNames } style={ blockInlineStyles } > - { ! hasExistingNavItems && isRequestingEntities && ( - - ) } { - if ( ! menuId ) { - return false; - } - - // If the option is a placeholder or doesn't have a valid - // id then reject - if ( ! Number.isInteger( menuId ) ) { - return false; - } - - return select( 'core' ).getMenuItems( { - menus: menuId, - per_page: -1, - } ); - }, - getIsRequestingMenuItems: ( menuId ) => { - return select( 'core' ).isResolving( 'getMenuItems', [ - { - menus: menuId, - per_page: -1, - }, - ] ); - }, - getHasResolvedMenuItems: ( menuId ) => { - return select( 'core' ).hasFinishedResolution( 'getMenuItems', [ - { - menus: menuId, - per_page: -1, - }, - ] ); - }, - - isRequestingPages: select( 'core/data' ).isResolving( - ...pagesSelect - ), - - hasResolvedPages: select( 'core/data' ).hasFinishedResolution( - ...pagesSelect - ), }; } ), withDispatch( ( dispatch, { clientId } ) => { return { - updateNavItemBlocks( blocks ) { + updateInnerBlocks( blocks ) { if ( blocks?.length === 0 ) { return false; } diff --git a/packages/block-library/src/navigation/placeholder.js b/packages/block-library/src/navigation/placeholder.js new file mode 100644 index 0000000000000..5dbc53f7e04bf --- /dev/null +++ b/packages/block-library/src/navigation/placeholder.js @@ -0,0 +1,379 @@ +/** + * External dependencies + */ + +import { escape } from 'lodash'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { createBlock } from '@wordpress/blocks'; +import { + Button, + CustomSelectControl, + Spinner, + Placeholder, +} from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { forwardRef, useCallback, useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { navigation as icon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import createDataTree from './create-data-tree'; + +const CREATE_EMPTY_OPTION_VALUE = '__CREATE_EMPTY__'; +const CREATE_FROM_PAGES_OPTION_VALUE = '__CREATE_FROM_PAGES__'; +const CREATE_PLACEHOLDER_VALUE = '__CREATE_PLACEHOLDER__'; + +/** + * Get instruction text for the Placeholder component. + * + * @param {boolean} hasMenus Flag that indicates if there are menus. + * @param {boolean} hasPages Flag that indicates if there are pages. + * + * @return {string} Text to display as the placeholder instructions. + */ +function getPlaceholderInstructions( hasMenus, hasPages ) { + if ( hasMenus && hasPages ) { + return __( + 'Create a navigation from all existing pages, or choose a menu.' + ); + } else if ( hasMenus && ! hasPages ) { + return __( 'Create a navigation from a menu or create empty.' ); + } else if ( ! hasMenus && hasPages ) { + return __( + 'Create a navigation from all existing pages, or create empty.' + ); + } + + return __( 'Create an empty navigation.' ); +} + +/** + * Return the menu id if the user has one selected. + * + * @param {Object} selectedCreateOption An object containing details of + * the selected create option. + * + * @return {number|undefined} The menu id. + */ +function getSelectedMenu( selectedCreateOption ) { + const optionKey = selectedCreateOption?.key; + return optionKey !== undefined && Number.isInteger( optionKey ) + ? optionKey + : undefined; +} + +/** + * A recursive function that maps menu item nodes to blocks. + * + * @param {Object[]} nodes An array of menu items. + * + * @return {WPBlock[]} An array of blocks. + */ +function mapMenuItemsToBlocks( nodes ) { + return nodes.map( ( { title, type, link: url, id, children } ) => { + const innerBlocks = + children && children.length ? mapMenuItemsToBlocks( children ) : []; + + return createBlock( + 'core/navigation-link', + { + type, + id, + url, + label: ! title.rendered + ? __( '(no title)' ) + : escape( title.rendered ), + opensInNewTab: false, + }, + innerBlocks + ); + } ); +} + +/** + * 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. + */ +function convertMenuItemsToBlocks( menuItems ) { + if ( ! menuItems ) { + return null; + } + + const menuTree = createDataTree( menuItems ); + return mapMenuItemsToBlocks( menuTree ); +} + +/** + * Convert pages to blocks. + * + * @param {Object[]} pages An array of pages. + * + * @return {WPBlock[]} An array of blocks. + */ +function convertPagesToBlocks( pages ) { + if ( ! pages ) { + return null; + } + + return pages.map( ( { title, type, link: url, id } ) => + createBlock( 'core/navigation-link', { + type, + id, + url, + label: ! title.rendered + ? __( '(no title)' ) + : escape( title.rendered ), + opensInNewTab: false, + } ) + ); +} + +/** + * Returns a value that indicates whether the create button should be disabled. + * + * @param {Object} selectedCreateOption An object containing details of + * the selected create option. + * @param {boolean} hasResolvedPages Indicates whether pages have loaded. + * @param {boolean} hasResolvedMenuItems Indicates whether menu items have loaded. + * + * @return {boolean} A value that indicates whether the create button is disabled. + */ +function getIsCreateButtonDisabled( + selectedCreateOption, + hasResolvedPages, + hasResolvedMenuItems +) { + // If there is no key at all then disable. + if ( ! selectedCreateOption ) { + return true; + } + + const optionKey = selectedCreateOption?.key; + + // Always disable if the default "placeholder" option is selected. + if ( optionKey === CREATE_PLACEHOLDER_VALUE ) { + return true; + } + + // Always enable if Create Empty is selected. + if ( optionKey === CREATE_EMPTY_OPTION_VALUE ) { + return false; + } + + // Enable if Pages option selected and we have Pages available. + if ( optionKey === CREATE_FROM_PAGES_OPTION_VALUE && hasResolvedPages ) { + return false; + } + + // Enable if a menu is selected and menu items have loaded. + const selectedMenu = getSelectedMenu( selectedCreateOption ); + return selectedMenu === undefined || ! hasResolvedMenuItems; +} + +function NavigationPlaceholder( { onCreate }, ref ) { + const [ selectedCreateOption, setSelectedCreateOption ] = useState(); + + const { + pages, + isResolvingPages, + hasResolvedPages, + menus, + isResolvingMenus, + hasResolvedMenus, + menuItems, + hasResolvedMenuItems, + } = useSelect( + ( select ) => { + const { + getEntityRecords, + getMenus, + getMenuItems, + isResolving, + hasFinishedResolution, + } = select( 'core' ); + const pagesParameters = [ + 'postType', + 'page', + { + parent: 0, + order: 'asc', + orderby: 'id', + }, + ]; + const menusParameters = [ { per_page: -1 } ]; + const selectedMenu = getSelectedMenu( selectedCreateOption ); + const hasSelectedMenu = selectedMenu !== undefined; + const menuItemsParameters = hasSelectedMenu + ? [ + { + menus: selectedMenu, + per_page: -1, + }, + ] + : undefined; + + return { + pages: getEntityRecords( ...pagesParameters ), + isResolvingPages: isResolving( + 'getEntityRecords', + pagesParameters + ), + hasResolvedPages: hasFinishedResolution( + 'getEntityRecords', + pagesParameters + ), + menus: getMenus( ...menusParameters ), + isResolvingMenus: isResolving( 'getMenus', menusParameters ), + hasResolvedMenus: hasFinishedResolution( + 'getMenus', + menusParameters + ), + menuItems: hasSelectedMenu + ? getMenuItems( ...menuItemsParameters ) + : undefined, + hasResolvedMenuItems: hasSelectedMenu + ? hasFinishedResolution( + 'getMenuItems', + menuItemsParameters + ) + : false, + }; + }, + [ selectedCreateOption ] + ); + + const hasPages = !! ( hasResolvedPages && pages?.length ); + const hasMenus = !! ( hasResolvedMenus && menus?.length ); + const isLoading = isResolvingPages || isResolvingMenus; + + const createOptions = useMemo( + () => [ + { + id: CREATE_PLACEHOLDER_VALUE, + name: __( 'Select where to start from…' ), + }, + ...( hasMenus ? menus : [] ), + { + id: CREATE_EMPTY_OPTION_VALUE, + name: __( 'Create empty menu' ), + className: 'is-create-empty-option', + }, + ...( hasPages + ? [ + { + id: CREATE_FROM_PAGES_OPTION_VALUE, + name: __( 'New from all top-level pages' ), + }, + ] + : [] ), + ], + [ menus, hasMenus, hasPages ] + ); + + const onCreateButtonClick = useCallback( () => { + if ( ! selectedCreateOption ) { + return; + } + + const { key } = selectedCreateOption; + + if ( key === CREATE_FROM_PAGES_OPTION_VALUE && hasPages ) { + const blocks = convertPagesToBlocks( pages ); + const selectNavigationBlock = true; + onCreate( blocks, selectNavigationBlock ); + return; + } + + if ( key === CREATE_EMPTY_OPTION_VALUE || ! menuItems?.length ) { + const blocks = [ createBlock( 'core/navigation-link' ) ]; + onCreate( blocks ); + } + + // Infer that the user selected a menu to create from. + // If either there's no selected menu or menu items are undefined + // this is undefined behavior, do nothing. + const selectedMenu = getSelectedMenu( selectedCreateOption ); + if ( selectedMenu === undefined || menuItems === undefined ) { + return; + } + + const blocks = convertMenuItemsToBlocks( menuItems ); + const selectNavigationBlock = true; + onCreate( blocks, selectNavigationBlock ); + } ); + + return ( + + { isLoading && ( +
+ { __( 'Loading…' ) } +
+ ) } + { ! isLoading && ( +
+ <> + { + if ( + selectedItem?.key === selectedCreateOption + ) { + return; + } + setSelectedCreateOption( selectedItem ); + } } + options={ createOptions.map( ( option ) => { + return { + ...option, + key: option.id, + }; + } ) } + /> + + +
+ ) } +
+ ); +} + +export default forwardRef( NavigationPlaceholder );