diff --git a/packages/block-library/src/page-list/convert-to-links-modal.js b/packages/block-library/src/page-list/convert-to-links-modal.js new file mode 100644 index 00000000000000..6686ba3081b745 --- /dev/null +++ b/packages/block-library/src/page-list/convert-to-links-modal.js @@ -0,0 +1,124 @@ +/** + * WordPress dependencies + */ +import { Button, Modal } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as coreDataStore } from '@wordpress/core-data'; +import { createBlock as create } from '@wordpress/blocks'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +const PAGE_FIELDS = [ 'id', 'title', 'link', 'type', 'parent' ]; +const MAX_PAGE_COUNT = 100; + +export const convertSelectedBlockToNavigationLinks = ( { + pages, + clientId, + replaceBlock, + createBlock, +} ) => () => { + if ( ! pages ) { + return; + } + + const linkMap = {}; + const navigationLinks = []; + pages.forEach( ( { id, title, link: url, type, parent } ) => { + // See if a placeholder exists. This is created if children appear before parents in list + const innerBlocks = linkMap[ id ]?.innerBlocks ?? []; + linkMap[ id ] = createBlock( + 'core/navigation-link', + { + id, + label: title.rendered, + url, + type, + kind: 'post-type', + }, + innerBlocks + ); + + if ( ! parent ) { + navigationLinks.push( linkMap[ id ] ); + } else { + if ( ! linkMap[ parent ] ) { + // Use a placeholder if the child appears before parent in list + linkMap[ parent ] = { innerBlocks: [] }; + } + const parentLinkInnerBlocks = linkMap[ parent ].innerBlocks; + parentLinkInnerBlocks.push( linkMap[ id ] ); + } + } ); + + replaceBlock( clientId, navigationLinks ); +}; + +export default function ConvertToLinksModal( { onClose, clientId } ) { + const { pages, pagesFinished } = useSelect( + ( select ) => { + const { getEntityRecords, hasFinishedResolution } = select( + coreDataStore + ); + const query = [ + 'postType', + 'page', + { + per_page: MAX_PAGE_COUNT, + _fields: PAGE_FIELDS, + // TODO: When https://core.trac.wordpress.org/ticket/39037 REST API support for multiple orderby + // values is resolved, update 'orderby' to [ 'menu_order', 'post_title' ] to provide a consistent + // sort. + orderby: 'menu_order', + order: 'asc', + }, + ]; + return { + pages: getEntityRecords( ...query ), + pagesFinished: hasFinishedResolution( + 'getEntityRecords', + query + ), + }; + }, + [ clientId ] + ); + const { replaceBlock } = useDispatch( blockEditorStore ); + + return ( + +

+ { __( + 'To edit this navigation menu, convert it to single page links. This allows you to add, re-order, remove items, or edit their labels.' + ) } +

+

+ { __( + "Note: if you add new pages to your site, you'll need to add them to your navigation menu." + ) } +

+
+ + +
+
+ ); +} diff --git a/packages/block-library/src/page-list/edit.js b/packages/block-library/src/page-list/edit.js index 8293a4c59d2e2d..4274b3d2d5b1e6 100644 --- a/packages/block-library/src/page-list/edit.js +++ b/packages/block-library/src/page-list/edit.js @@ -6,14 +6,34 @@ import classnames from 'classnames'; /** * WordPress dependencies */ - -import { useBlockProps } from '@wordpress/block-editor'; +import { + BlockControls, + useBlockProps, + store as blockEditorStore, +} from '@wordpress/block-editor'; import ServerSideRender from '@wordpress/server-side-render'; +import { ToolbarButton } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useEffect, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import ConvertToLinksModal from './convert-to-links-modal'; + +// We only show the edit option when page count is <= MAX_PAGE_COUNT +// Performance of Navigation Links is not good past this value. +const MAX_PAGE_COUNT = 100; -export default function PageListEdit( { context } ) { +export default function PageListEdit( { context, clientId } ) { const { textColor, backgroundColor, showSubmenuIcon, style } = context || {}; + const [ allowConvertToLinks, setAllowConvertToLinks ] = useState( false ); + const blockProps = useBlockProps( { className: classnames( { 'has-text-color': !! textColor, @@ -25,9 +45,57 @@ export default function PageListEdit( { context } ) { style: { ...style?.color }, } ); + const isParentNavigation = useSelect( + ( select ) => { + const { getBlockParentsByBlockName } = select( blockEditorStore ); + return ( + getBlockParentsByBlockName( clientId, 'core/navigation' ) + .length > 0 + ); + }, + [ clientId ] + ); + + useEffect( () => { + if ( isParentNavigation ) { + apiFetch( { + path: addQueryArgs( '/wp/v2/pages', { + per_page: 1, + _fields: [ 'id' ], + } ), + parse: false, + } ).then( ( res ) => { + setAllowConvertToLinks( + res.headers.get( 'X-WP-Total' ) <= MAX_PAGE_COUNT + ); + } ); + } else { + setAllowConvertToLinks( false ); + } + }, [ isParentNavigation ] ); + + const [ isOpen, setOpen ] = useState( false ); + const openModal = () => setOpen( true ); + const closeModal = () => setOpen( false ); + return ( -
- -
+ <> + { allowConvertToLinks && ( + + + { __( 'Edit' ) } + + + ) } + { allowConvertToLinks && isOpen && ( + + ) } +
+ +
+ ); } diff --git a/packages/block-library/src/page-list/editor.scss b/packages/block-library/src/page-list/editor.scss index 778691f11ebf6b..99b3ca55f3aa93 100644 --- a/packages/block-library/src/page-list/editor.scss +++ b/packages/block-library/src/page-list/editor.scss @@ -20,3 +20,17 @@ margin: 0.5em; } } + +// Modal that shows conversion option. +.wp-block-page-list-modal { + max-width: 400px; +} + +.wp-block-page-list-modal-buttons { + display: flex; + justify-content: flex-end; + + .components-button { + margin-left: $grid-unit-15; + } +} diff --git a/packages/block-library/src/page-list/index.php b/packages/block-library/src/page-list/index.php index c6810939d817a2..67c6543818116b 100644 --- a/packages/block-library/src/page-list/index.php +++ b/packages/block-library/src/page-list/index.php @@ -149,7 +149,17 @@ function render_block_core_page_list( $attributes, $content, $block ) { static $block_id = 0; $block_id++; - $all_pages = get_pages( array( 'sort_column' => 'menu_order' ) ); + // TODO: When https://core.trac.wordpress.org/ticket/39037 REST API support for multiple orderby values is resolved, + // update 'sort_column' to 'menu_order, post_title'. Sorting by both menu_order and post_title ensures a stable sort. + // Otherwise with pages that have the same menu_order value, we can see different ordering depending on how DB + // queries are constructed internally. For example we might see a different order when a limit is set to <499 + // versus >= 500. + $all_pages = get_pages( + array( + 'sort_column' => 'menu_order', + 'order' => 'asc', + ) + ); $top_level_pages = array(); diff --git a/packages/block-library/src/page-list/test/convert-to-links-modal.js b/packages/block-library/src/page-list/test/convert-to-links-modal.js new file mode 100644 index 00000000000000..930582e85aae4a --- /dev/null +++ b/packages/block-library/src/page-list/test/convert-to-links-modal.js @@ -0,0 +1,399 @@ +/** + * Internal dependencies + */ +import { convertSelectedBlockToNavigationLinks } from '../convert-to-links-modal'; + +describe( 'page list convert to links', () => { + describe( 'convertSelectedBlockToNavigationLinks', () => { + it( 'Can create submenus', () => { + const pages = [ + { + title: { + raw: 'Sample Page', + rendered: 'Sample Page', + }, + id: 2, + parent: 0, + link: 'http://wordpress.local/sample-page/', + type: 'page', + }, + { + title: { + raw: 'About', + rendered: 'About', + }, + id: 34, + parent: 0, + link: 'http://wordpress.local/about/', + type: 'page', + }, + { + title: { + raw: 'Contact Page', + rendered: 'Contact Page', + }, + id: 37, + parent: 0, + link: 'http://wordpress.local/contact-page/', + type: 'page', + }, + { + title: { + raw: 'Test', + rendered: 'Test', + }, + id: 229, + parent: 0, + link: 'http://wordpress.local/test/', + type: 'page', + }, + { + title: { + raw: 'About Sub 1', + rendered: 'About Sub 1', + }, + id: 738, + parent: 34, + link: 'http://wordpress.local/about/about-sub-1/', + type: 'page', + }, + { + title: { + raw: 'About Sub 2', + rendered: 'About Sub 2', + }, + id: 740, + parent: 34, + link: 'http://wordpress.local/about/about-sub-2/', + type: 'page', + }, + { + title: { + raw: 'Test Sub', + rendered: 'Test Sub', + }, + id: 742, + parent: 229, + link: 'http://wordpress.local/test/test-sub/', + type: 'page', + }, + { + title: { + raw: 'Test Sub Sub', + rendered: 'Test Sub Sub', + }, + id: 744, + parent: 742, + link: 'http://wordpress.local/test/test-sub/test-sub-sub/', + type: 'page', + }, + ]; + const replaceBlock = jest.fn(); + const createBlock = jest.fn( + ( name, attributes, innerBlocks ) => ( { + name, + attributes, + innerBlocks, + } ) + ); + const convertLinks = convertSelectedBlockToNavigationLinks( { + pages, + clientId: 'testId', + replaceBlock, + createBlock, + } ); + convertLinks(); + expect( replaceBlock.mock.calls?.[ 0 ]?.[ 1 ] ).toEqual( [ + { + attributes: { + id: 2, + kind: 'post-type', + label: 'Sample Page', + type: 'page', + url: 'http://wordpress.local/sample-page/', + }, + innerBlocks: [], + name: 'core/navigation-link', + }, + { + attributes: { + id: 34, + kind: 'post-type', + label: 'About', + type: 'page', + url: 'http://wordpress.local/about/', + }, + innerBlocks: [ + { + attributes: { + id: 738, + kind: 'post-type', + label: 'About Sub 1', + type: 'page', + url: + 'http://wordpress.local/about/about-sub-1/', + }, + innerBlocks: [], + name: 'core/navigation-link', + }, + { + attributes: { + id: 740, + kind: 'post-type', + label: 'About Sub 2', + type: 'page', + url: + 'http://wordpress.local/about/about-sub-2/', + }, + innerBlocks: [], + name: 'core/navigation-link', + }, + ], + name: 'core/navigation-link', + }, + { + attributes: { + id: 37, + kind: 'post-type', + label: 'Contact Page', + type: 'page', + url: 'http://wordpress.local/contact-page/', + }, + innerBlocks: [], + name: 'core/navigation-link', + }, + { + attributes: { + id: 229, + kind: 'post-type', + label: 'Test', + type: 'page', + url: 'http://wordpress.local/test/', + }, + innerBlocks: [ + { + attributes: { + id: 742, + kind: 'post-type', + label: 'Test Sub', + type: 'page', + url: 'http://wordpress.local/test/test-sub/', + }, + innerBlocks: [ + { + attributes: { + id: 744, + kind: 'post-type', + label: 'Test Sub Sub', + type: 'page', + url: + 'http://wordpress.local/test/test-sub/test-sub-sub/', + }, + innerBlocks: [], + name: 'core/navigation-link', + }, + ], + name: 'core/navigation-link', + }, + ], + name: 'core/navigation-link', + }, + ] ); + } ); + it( 'Can create submenus, when children appear before parents', () => { + const pages = [ + { + title: { + raw: 'About Sub 1', + rendered: 'About Sub 1', + }, + id: 738, + parent: 34, + link: 'http://wordpress.local/about/about-sub-1/', + type: 'page', + }, + { + title: { + raw: 'About Sub 2', + rendered: 'About Sub 2', + }, + id: 740, + parent: 34, + link: 'http://wordpress.local/about/about-sub-2/', + type: 'page', + }, + { + title: { + raw: 'Test Sub Sub', + rendered: 'Test Sub Sub', + }, + id: 744, + parent: 742, + link: 'http://wordpress.local/test/test-sub/test-sub-sub/', + type: 'page', + }, + { + title: { + raw: 'Test Sub', + rendered: 'Test Sub', + }, + id: 742, + parent: 229, + link: 'http://wordpress.local/test/test-sub/', + type: 'page', + }, + { + title: { + raw: 'Sample Page', + rendered: 'Sample Page', + }, + id: 2, + parent: 0, + link: 'http://wordpress.local/sample-page/', + type: 'page', + }, + { + title: { + raw: 'About', + rendered: 'About', + }, + id: 34, + parent: 0, + link: 'http://wordpress.local/about/', + type: 'page', + }, + { + title: { + raw: 'Contact Page', + rendered: 'Contact Page', + }, + id: 37, + parent: 0, + link: 'http://wordpress.local/contact-page/', + type: 'page', + }, + { + title: { + raw: 'Test', + rendered: 'Test', + }, + id: 229, + parent: 0, + link: 'http://wordpress.local/test/', + type: 'page', + }, + ]; + const replaceBlock = jest.fn(); + const createBlock = jest.fn( + ( name, attributes, innerBlocks ) => ( { + name, + attributes, + innerBlocks, + } ) + ); + const convertLinks = convertSelectedBlockToNavigationLinks( { + pages, + clientId: 'testId', + replaceBlock, + createBlock, + } ); + convertLinks(); + expect( replaceBlock.mock.calls?.[ 0 ]?.[ 1 ] ).toEqual( [ + { + attributes: { + id: 2, + kind: 'post-type', + label: 'Sample Page', + type: 'page', + url: 'http://wordpress.local/sample-page/', + }, + innerBlocks: [], + name: 'core/navigation-link', + }, + { + attributes: { + id: 34, + kind: 'post-type', + label: 'About', + type: 'page', + url: 'http://wordpress.local/about/', + }, + innerBlocks: [ + { + attributes: { + id: 738, + kind: 'post-type', + label: 'About Sub 1', + type: 'page', + url: + 'http://wordpress.local/about/about-sub-1/', + }, + innerBlocks: [], + name: 'core/navigation-link', + }, + { + attributes: { + id: 740, + kind: 'post-type', + label: 'About Sub 2', + type: 'page', + url: + 'http://wordpress.local/about/about-sub-2/', + }, + innerBlocks: [], + name: 'core/navigation-link', + }, + ], + name: 'core/navigation-link', + }, + { + attributes: { + id: 37, + kind: 'post-type', + label: 'Contact Page', + type: 'page', + url: 'http://wordpress.local/contact-page/', + }, + innerBlocks: [], + name: 'core/navigation-link', + }, + { + attributes: { + id: 229, + kind: 'post-type', + label: 'Test', + type: 'page', + url: 'http://wordpress.local/test/', + }, + innerBlocks: [ + { + attributes: { + id: 742, + kind: 'post-type', + label: 'Test Sub', + type: 'page', + url: 'http://wordpress.local/test/test-sub/', + }, + innerBlocks: [ + { + attributes: { + id: 744, + kind: 'post-type', + label: 'Test Sub Sub', + type: 'page', + url: + 'http://wordpress.local/test/test-sub/test-sub-sub/', + }, + innerBlocks: [], + name: 'core/navigation-link', + }, + ], + name: 'core/navigation-link', + }, + ], + name: 'core/navigation-link', + }, + ] ); + } ); + } ); +} );