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."
+ ) }
+
+
+
+ { __( 'Cancel' ) }
+
+
+ { __( 'Convert' ) }
+
+
+
+ );
+}
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',
+ },
+ ] );
+ } );
+ } );
+} );