diff --git a/package-lock.json b/package-lock.json
index 04aab70e819de9..b29d5fd65d69f7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10788,6 +10788,7 @@
"@wordpress/blocks": "file:packages/blocks",
"@wordpress/components": "file:packages/components",
"@wordpress/compose": "file:packages/compose",
+ "@wordpress/core-data": "file:packages/core-data",
"@wordpress/data": "file:packages/data",
"@wordpress/data-controls": "file:packages/data-controls",
"@wordpress/dom-ready": "file:packages/dom-ready",
@@ -10801,7 +10802,8 @@
"@wordpress/url": "file:packages/url",
"classnames": "^2.2.5",
"lodash": "^4.17.15",
- "rememo": "^3.0.0"
+ "rememo": "^3.0.0",
+ "uuid": "^7.0.2"
}
},
"@wordpress/edit-post": {
diff --git a/packages/edit-navigation/package.json b/packages/edit-navigation/package.json
index 169d803b037373..2b8bba53657ef7 100644
--- a/packages/edit-navigation/package.json
+++ b/packages/edit-navigation/package.json
@@ -35,6 +35,7 @@
"@wordpress/blocks": "file:../blocks",
"@wordpress/components": "file:../components",
"@wordpress/compose": "file:../compose",
+ "@wordpress/core-data": "file:../core-data",
"@wordpress/data": "file:../data",
"@wordpress/data-controls": "file:../data-controls",
"@wordpress/dom-ready": "file:../dom-ready",
@@ -48,7 +49,8 @@
"@wordpress/url": "file:../url",
"classnames": "^2.2.5",
"lodash": "^4.17.15",
- "rememo": "^3.0.0"
+ "rememo": "^3.0.0",
+ "uuid": "^7.0.2"
},
"publishConfig": {
"access": "public"
diff --git a/packages/edit-navigation/src/components/menu-editor/index.js b/packages/edit-navigation/src/components/menu-editor/index.js
deleted file mode 100644
index ce40b46c4f6aea..00000000000000
--- a/packages/edit-navigation/src/components/menu-editor/index.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * WordPress dependencies
- */
-import {
- BlockEditorKeyboardShortcuts,
- BlockEditorProvider,
-} from '@wordpress/block-editor';
-import { useViewportMatch } from '@wordpress/compose';
-import { useMemo } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import useMenuItems from './use-menu-items';
-import useNavigationBlocks from './use-navigation-blocks';
-import MenuEditorShortcuts from './shortcuts';
-import BlockEditorArea from './block-editor-area';
-import NavigationStructureArea from './navigation-structure-area';
-
-export default function MenuEditor( {
- menuId,
- blockEditorSettings,
- onDeleteMenu,
-} ) {
- const isLargeViewport = useViewportMatch( 'medium' );
- const query = useMemo( () => ( { menus: menuId, per_page: -1 } ), [
- menuId,
- ] );
- const {
- menuItems,
- eventuallySaveMenuItems,
- createMissingMenuItems,
- } = useMenuItems( query );
- const { blocks, setBlocks, menuItemsRef } = useNavigationBlocks(
- menuItems
- );
- const saveMenuItems = () => eventuallySaveMenuItems( blocks, menuItemsRef );
-
- return (
-
-
-
-
- setBlocks( updatedBlocks ) }
- onChange={ ( updatedBlocks ) => {
- createMissingMenuItems( updatedBlocks, menuItemsRef );
- setBlocks( updatedBlocks );
- } }
- settings={ {
- ...blockEditorSettings,
- templateLock: 'all',
- hasFixedToolbar: true,
- } }
- >
-
-
-
-
-
-
- );
-}
diff --git a/packages/edit-navigation/src/components/menu-editor/promise-queue.js b/packages/edit-navigation/src/components/menu-editor/promise-queue.js
deleted file mode 100644
index d560164e91e76d..00000000000000
--- a/packages/edit-navigation/src/components/menu-editor/promise-queue.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * A concurrency primitive that runs at most `concurrency` async tasks at once.
- */
-export default class PromiseQueue {
- constructor( concurrency = 1 ) {
- this.concurrency = concurrency;
- this.queue = [];
- this.active = [];
- this.listeners = [];
- }
-
- enqueue( action ) {
- this.queue.push( action );
- this.run();
- }
-
- run() {
- while ( this.queue.length && this.active.length <= this.concurrency ) {
- const action = this.queue.shift();
- const promise = action().then( () => {
- this.active.splice( this.active.indexOf( promise ), 1 );
- this.run();
- this.notifyIfEmpty();
- } );
- this.active.push( promise );
- }
- }
-
- notifyIfEmpty() {
- if ( this.active.length === 0 && this.queue.length === 0 ) {
- for ( const l of this.listeners ) {
- l();
- }
- this.listeners = [];
- }
- }
-
- /**
- * Calls `callback` once all async actions in the queue are finished,
- * or immediately if no actions are running.
- *
- * @param {Function} callback Callback to call
- */
- then( callback ) {
- if ( this.active.length ) {
- this.listeners.push( callback );
- } else {
- callback();
- }
- }
-}
diff --git a/packages/edit-navigation/src/components/menu-editor/shortcuts.js b/packages/edit-navigation/src/components/menu-editor/shortcuts.js
deleted file mode 100644
index 96889b67a8daa9..00000000000000
--- a/packages/edit-navigation/src/components/menu-editor/shortcuts.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { useEffect, useCallback } from '@wordpress/element';
-import { useDispatch } from '@wordpress/data';
-import { useShortcut } from '@wordpress/keyboard-shortcuts';
-import { __ } from '@wordpress/i18n';
-
-function MenuEditorShortcuts( { saveBlocks } ) {
- useShortcut(
- 'core/edit-navigation/save-menu',
- useCallback( ( event ) => {
- event.preventDefault();
- saveBlocks();
- } ),
- {
- bindGlobal: true,
- }
- );
-
- return null;
-}
-
-function RegisterMenuEditorShortcuts() {
- const { registerShortcut } = useDispatch( 'core/keyboard-shortcuts' );
- useEffect( () => {
- registerShortcut( {
- name: 'core/edit-navigation/save-menu',
- category: 'global',
- description: __( 'Save the menu currently being edited.' ),
- keyCombination: {
- modifier: 'primary',
- character: 's',
- },
- } );
- }, [ registerShortcut ] );
-
- return null;
-}
-
-MenuEditorShortcuts.Register = RegisterMenuEditorShortcuts;
-
-export default MenuEditorShortcuts;
diff --git a/packages/edit-navigation/src/components/menu-editor/use-create-missing-menu-items.js b/packages/edit-navigation/src/components/menu-editor/use-create-missing-menu-items.js
deleted file mode 100644
index b21fa5416d1e62..00000000000000
--- a/packages/edit-navigation/src/components/menu-editor/use-create-missing-menu-items.js
+++ /dev/null
@@ -1,67 +0,0 @@
-/**
- * WordPress dependencies
- */
-import apiFetch from '@wordpress/api-fetch';
-import { useRef, useCallback } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import { flattenBlocks } from './helpers';
-import PromiseQueue from './promise-queue';
-
-/**
- * When a new Navigation child block is added, we create a draft menuItem for it because
- * the batch save endpoint expects all the menu items to have a valid id already.
- * PromiseQueue is used in order to
- * 1) limit the amount of requests processed at the same time
- * 2) save the menu only after all requests are finalized
- *
- * @return {function(*=): void} Function registering it's argument to be called once all menuItems are created.
- */
-export default function useCreateMissingMenuItems() {
- const promiseQueueRef = useRef( new PromiseQueue() );
- const enqueuedBlocksIds = useRef( [] );
- const createMissingMenuItems = ( blocks, menuItemsRef ) => {
- for ( const { clientId, name } of flattenBlocks( blocks ) ) {
- // No need to create menuItems for the wrapping navigation block
- if ( name === 'core/navigation' ) {
- continue;
- }
- // Menu item was already created
- if ( clientId in menuItemsRef.current ) {
- continue;
- }
- // Menu item already in the queue
- if ( enqueuedBlocksIds.current.includes( clientId ) ) {
- continue;
- }
- enqueuedBlocksIds.current.push( clientId );
- promiseQueueRef.current.enqueue( () =>
- createDraftMenuItem( clientId ).then( ( menuItem ) => {
- menuItemsRef.current[ clientId ] = menuItem;
- enqueuedBlocksIds.current.splice(
- enqueuedBlocksIds.current.indexOf( clientId )
- );
- } )
- );
- }
- };
- const onCreated = useCallback(
- ( callback ) => promiseQueueRef.current.then( callback ),
- [ promiseQueueRef.current ]
- );
- return { createMissingMenuItems, onCreated };
-}
-
-function createDraftMenuItem() {
- return apiFetch( {
- path: `/__experimental/menu-items`,
- method: 'POST',
- data: {
- title: 'Placeholder',
- url: 'Placeholder',
- menu_order: 0,
- },
- } );
-}
diff --git a/packages/edit-navigation/src/components/menu-editor/use-menu-items.js b/packages/edit-navigation/src/components/menu-editor/use-menu-items.js
deleted file mode 100644
index 250b9927153ab0..00000000000000
--- a/packages/edit-navigation/src/components/menu-editor/use-menu-items.js
+++ /dev/null
@@ -1,172 +0,0 @@
-/**
- * External dependencies
- */
-import { keyBy, omit } from 'lodash';
-
-/**
- * WordPress dependencies
- */
-import { useDispatch, useSelect } from '@wordpress/data';
-import { useEffect, useState } from '@wordpress/element';
-import { __ } from '@wordpress/i18n';
-import apiFetch from '@wordpress/api-fetch';
-
-/**
- * Internal dependencies
- */
-import useCreateMissingMenuItems from './use-create-missing-menu-items';
-
-export default function useMenuItems( query ) {
- const menuItems = useFetchMenuItems( query );
- const saveMenuItems = useSaveMenuItems( query );
- const { createMissingMenuItems, onCreated } = useCreateMissingMenuItems();
- const eventuallySaveMenuItems = ( blocks, menuItemsRef ) =>
- onCreated( () => saveMenuItems( blocks, menuItemsRef ) );
- return { menuItems, eventuallySaveMenuItems, createMissingMenuItems };
-}
-
-export function useFetchMenuItems( query ) {
- const { menuItems, isResolving } = useSelect( ( select ) => ( {
- menuItems: select( 'core' ).getMenuItems( query ),
- isResolving: select( 'core/data' ).isResolving(
- 'core',
- 'getMenuItems',
- [ query ]
- ),
- } ) );
-
- const [ resolvedMenuItems, setResolvedMenuItems ] = useState( null );
-
- useEffect( () => {
- if ( isResolving || menuItems === null ) {
- return;
- }
-
- setResolvedMenuItems( menuItems );
- }, [ isResolving, menuItems ] );
-
- return resolvedMenuItems;
-}
-
-export function useSaveMenuItems( query ) {
- const { receiveEntityRecords } = useDispatch( 'core' );
- const { createSuccessNotice, createErrorNotice } = useDispatch(
- 'core/notices'
- );
-
- const saveBlocks = async ( blocks, menuItemsRef ) => {
- const result = await batchSave(
- query.menus,
- menuItemsRef,
- blocks[ 0 ]
- );
-
- if ( result.success ) {
- receiveEntityRecords( 'root', 'menuItem', [], query, true );
- createSuccessNotice( __( 'Navigation saved.' ), {
- type: 'snackbar',
- } );
- } else {
- createErrorNotice( __( 'There was an error.' ), {
- type: 'snackbar',
- } );
- }
- };
-
- return saveBlocks;
-}
-
-async function batchSave( menuId, menuItemsRef, navigationBlock ) {
- const { nonce, stylesheet } = await apiFetch( {
- path: '/__experimental/customizer-nonces/get-save-nonce',
- } );
-
- // eslint-disable-next-line no-undef
- const body = new FormData();
- body.append( 'wp_customize', 'on' );
- body.append( 'customize_theme', stylesheet );
- body.append( 'nonce', nonce );
- body.append( 'customize_changeset_uuid', uuidv4() );
- body.append( 'customize_autosaved', 'on' );
- body.append( 'customize_changeset_status', 'publish' );
- body.append( 'action', 'customize_save' );
- body.append(
- 'customized',
- computeCustomizedAttribute(
- navigationBlock.innerBlocks,
- menuId,
- menuItemsRef
- )
- );
-
- return await apiFetch( {
- url: '/wp-admin/admin-ajax.php',
- method: 'POST',
- body,
- } );
-}
-
-function computeCustomizedAttribute( blocks, menuId, menuItemsRef ) {
- const blocksList = blocksTreeToFlatList( blocks );
- const dataList = blocksList.map( ( { block, parentId, position } ) =>
- linkBlockToRequestItem( block, parentId, position )
- );
-
- // Create an object like { "nav_menu_item[12]": {...}} }
- const computeKey = ( item ) => `nav_menu_item[${ item.id }]`;
- const dataObject = keyBy( dataList, computeKey );
-
- // Deleted menu items should be sent as false, e.g. { "nav_menu_item[13]": false }
- for ( const clientId in menuItemsRef.current ) {
- const key = computeKey( menuItemsRef.current[ clientId ] );
- if ( ! ( key in dataObject ) ) {
- dataObject[ key ] = false;
- }
- }
-
- return JSON.stringify( dataObject );
-
- function blocksTreeToFlatList( innerBlocks, parentId = 0 ) {
- return innerBlocks.flatMap( ( block, index ) =>
- [ { block, parentId, position: index + 1 } ].concat(
- blocksTreeToFlatList(
- block.innerBlocks,
- getMenuItemForBlock( block )?.id
- )
- )
- );
- }
-
- function linkBlockToRequestItem( block, parentId, position ) {
- const menuItem = omit( getMenuItemForBlock( block ), 'menus', 'meta' );
- return {
- ...menuItem,
- position,
- title: block.attributes?.label,
- url: block.attributes.url,
- original_title: '',
- classes: ( menuItem.classes || [] ).join( ' ' ),
- xfn: ( menuItem.xfn || [] ).join( ' ' ),
- nav_menu_term_id: menuId,
- menu_item_parent: parentId,
- status: 'publish',
- _invalid: false,
- };
- }
-
- function getMenuItemForBlock( block ) {
- return omit( menuItemsRef.current[ block.clientId ] || {}, '_links' );
- }
-}
-
-function uuidv4() {
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( /[xy]/g, ( c ) => {
- // eslint-disable-next-line no-restricted-syntax
- const a = Math.random() * 16;
- // eslint-disable-next-line no-bitwise
- const r = a | 0;
- // eslint-disable-next-line no-bitwise
- const v = c === 'x' ? r : ( r & 0x3 ) | 0x8;
- return v.toString( 16 );
- } );
-}
diff --git a/packages/edit-navigation/src/components/menu-editor/use-navigation-blocks.js b/packages/edit-navigation/src/components/menu-editor/use-navigation-blocks.js
deleted file mode 100644
index 1b7899737fd784..00000000000000
--- a/packages/edit-navigation/src/components/menu-editor/use-navigation-blocks.js
+++ /dev/null
@@ -1,114 +0,0 @@
-/**
- * External dependencies
- */
-import { keyBy, groupBy, sortBy } from 'lodash';
-
-/**
- * WordPress dependencies
- */
-import { createBlock } from '@wordpress/blocks';
-import { useState, useRef, useEffect } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import { flattenBlocks } from './helpers';
-
-export default function useNavigationBlocks( menuItems ) {
- const [ blocks, setBlocks ] = useState( [] );
- const menuItemsRef = useRef( {} );
-
- // Refresh our model whenever menuItems change
- useEffect( () => {
- const [ innerBlocks, clientIdToMenuItemMapping ] = menuItemsToBlocks(
- menuItems,
- blocks[ 0 ]?.innerBlocks,
- menuItemsRef.current
- );
-
- const navigationBlock = blocks[ 0 ]
- ? { ...blocks[ 0 ], innerBlocks }
- : createBlock( 'core/navigation', {}, innerBlocks );
-
- setBlocks( [ navigationBlock ] );
- menuItemsRef.current = clientIdToMenuItemMapping;
- }, [ menuItems ] );
-
- return {
- blocks,
- setBlocks,
- menuItemsRef,
- };
-}
-
-const menuItemsToBlocks = (
- menuItems,
- prevBlocks = [],
- prevClientIdToMenuItemMapping = {}
-) => {
- const blocksByMenuId = mapBlocksByMenuId(
- prevBlocks,
- prevClientIdToMenuItemMapping
- );
-
- const itemsByParentID = groupBy( menuItems, 'parent' );
- const clientIdToMenuItemMapping = {};
- const menuItemsToTreeOfBlocks = ( items ) => {
- const innerBlocks = [];
- if ( ! items ) {
- return;
- }
-
- const sortedItems = sortBy( items, 'menu_order' );
- for ( const item of sortedItems ) {
- let menuItemInnerBlocks = [];
- if ( itemsByParentID[ item.id ]?.length ) {
- menuItemInnerBlocks = menuItemsToTreeOfBlocks(
- itemsByParentID[ item.id ]
- );
- }
- const linkBlock = menuItemToLinkBlock(
- item,
- menuItemInnerBlocks,
- blocksByMenuId[ item.id ]
- );
- clientIdToMenuItemMapping[ linkBlock.clientId ] = item;
- innerBlocks.push( linkBlock );
- }
- return innerBlocks;
- };
-
- // menuItemsToTreeOfLinkBlocks takes an array of top-level menu items and recursively creates all their innerBlocks
- const blocks = menuItemsToTreeOfBlocks( itemsByParentID[ 0 ] || [] );
- return [ blocks, clientIdToMenuItemMapping ];
-};
-
-function menuItemToLinkBlock(
- menuItem,
- innerBlocks = [],
- existingBlock = null
-) {
- const attributes = {
- label: menuItem.title.rendered,
- url: menuItem.url,
- };
-
- if ( existingBlock ) {
- return {
- ...existingBlock,
- attributes,
- innerBlocks,
- };
- }
- return createBlock( 'core/navigation-link', attributes, innerBlocks );
-}
-
-const mapBlocksByMenuId = ( blocks, menuItemsByClientId ) => {
- const blocksByClientId = keyBy( flattenBlocks( blocks ), 'clientId' );
- const blocksByMenuId = {};
- for ( const clientId in menuItemsByClientId ) {
- const menuItem = menuItemsByClientId[ clientId ];
- blocksByMenuId[ menuItem.id ] = blocksByClientId[ clientId ];
- }
- return blocksByMenuId;
-};
diff --git a/packages/edit-navigation/src/components/menus-editor/index.js b/packages/edit-navigation/src/components/menus-editor/index.js
index 04507772174cbd..2f2b5c2407c803 100644
--- a/packages/edit-navigation/src/components/menus-editor/index.js
+++ b/packages/edit-navigation/src/components/menus-editor/index.js
@@ -16,7 +16,7 @@ import { __ } from '@wordpress/i18n';
* Internal dependencies
*/
import CreateMenuArea from './create-menu-area';
-import MenuEditor from '../menu-editor';
+import NavigationEditor from '../navigation-editor';
export default function MenusEditor( { blockEditorSettings } ) {
const { menus, hasLoadedMenus } = useSelect( ( select ) => {
@@ -80,7 +80,7 @@ export default function MenusEditor( { blockEditorSettings } ) {
label: menu.name,
} ) ) }
onChange={ ( selectedMenuId ) =>
- setMenuId( selectedMenuId )
+ setMenuId( Number( selectedMenuId ) )
}
value={ menuId }
/>
@@ -111,7 +111,7 @@ export default function MenusEditor( { blockEditorSettings } ) {
/>
) }
{ hasMenus && (
- {
diff --git a/packages/edit-navigation/src/components/menu-editor/block-editor-area.js b/packages/edit-navigation/src/components/navigation-editor/block-editor-area.js
similarity index 90%
rename from packages/edit-navigation/src/components/menu-editor/block-editor-area.js
rename to packages/edit-navigation/src/components/navigation-editor/block-editor-area.js
index 9c06ba2c8776e9..31651432ed1b9b 100644
--- a/packages/edit-navigation/src/components/menu-editor/block-editor-area.js
+++ b/packages/edit-navigation/src/components/navigation-editor/block-editor-area.js
@@ -66,9 +66,9 @@ export default function BlockEditorArea( {
}, [ rootBlockId ] );
return (
-
+
-
+
{ __( 'Navigation menu' ) }
@@ -79,7 +79,7 @@ export default function BlockEditorArea( {
( {
+ post: select( 'core/edit-navigation' ).getNavigationPostForMenu(
+ menuId
+ ),
+ hasResolved: select( 'core/edit-navigation' ).hasResolvedNavigationPost(
+ menuId
+ ),
+ } ) );
+ return (
+
+
+
+
+ { ! hasResolved ? (
+
+ ) : (
+
+ ) }
+
+ );
+}
+
+function NavigationPostEditor( { post, blockEditorSettings, onDeleteMenu } ) {
+ const isLargeViewport = useViewportMatch( 'medium' );
+ const [ blocks, onInput, onChange ] = useNavigationBlockEditor( post );
+ const { saveNavigationPost } = useDispatch( 'core/edit-navigation' );
+ const save = () => saveNavigationPost( post );
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/edit-navigation/src/components/menu-editor/navigation-structure-area.js b/packages/edit-navigation/src/components/navigation-editor/navigation-structure-area.js
similarity index 83%
rename from packages/edit-navigation/src/components/menu-editor/navigation-structure-area.js
rename to packages/edit-navigation/src/components/navigation-editor/navigation-structure-area.js
index f8e66704a85258..a3153a557427ec 100644
--- a/packages/edit-navigation/src/components/menu-editor/navigation-structure-area.js
+++ b/packages/edit-navigation/src/components/navigation-editor/navigation-structure-area.js
@@ -33,7 +33,7 @@ export default function NavigationStructureArea( { blocks, initialOpen } ) {
);
return isSmallScreen ? (
-
+
) : (
-
-
+
+
{ __( 'Navigation structure' ) }
{ content }
diff --git a/packages/edit-navigation/src/components/navigation-editor/shortcuts.js b/packages/edit-navigation/src/components/navigation-editor/shortcuts.js
new file mode 100644
index 00000000000000..d75d3f176e2e40
--- /dev/null
+++ b/packages/edit-navigation/src/components/navigation-editor/shortcuts.js
@@ -0,0 +1,80 @@
+/**
+ * WordPress dependencies
+ */
+import { useEffect, useCallback } from '@wordpress/element';
+import { useDispatch } from '@wordpress/data';
+import { useShortcut } from '@wordpress/keyboard-shortcuts';
+import { __ } from '@wordpress/i18n';
+
+function NavigationEditorShortcuts( { saveBlocks } ) {
+ useShortcut(
+ 'core/edit-navigation/save-menu',
+ useCallback( ( event ) => {
+ event.preventDefault();
+ saveBlocks();
+ } ),
+ {
+ bindGlobal: true,
+ }
+ );
+
+ const { redo, undo } = useDispatch( 'core' );
+ useShortcut(
+ 'core/edit-navigation/undo',
+ ( event ) => {
+ undo();
+ event.preventDefault();
+ },
+ { bindGlobal: true }
+ );
+
+ useShortcut(
+ 'core/edit-navigation/redo',
+ ( event ) => {
+ redo();
+ event.preventDefault();
+ },
+ { bindGlobal: true }
+ );
+
+ return null;
+}
+
+function RegisterNavigationEditorShortcuts() {
+ const { registerShortcut } = useDispatch( 'core/keyboard-shortcuts' );
+ useEffect( () => {
+ registerShortcut( {
+ name: 'core/edit-navigation/save-menu',
+ category: 'global',
+ description: __( 'Save the navigation currently being edited.' ),
+ keyCombination: {
+ modifier: 'primary',
+ character: 's',
+ },
+ } );
+ registerShortcut( {
+ name: 'core/edit-navigation/undo',
+ category: 'global',
+ description: __( 'Undo your last changes.' ),
+ keyCombination: {
+ modifier: 'primary',
+ character: 'z',
+ },
+ } );
+ registerShortcut( {
+ name: 'core/edit-navigation/redo',
+ category: 'global',
+ description: __( 'Redo your last undo.' ),
+ keyCombination: {
+ modifier: 'primaryShift',
+ character: 'z',
+ },
+ } );
+ }, [ registerShortcut ] );
+
+ return null;
+}
+
+NavigationEditorShortcuts.Register = RegisterNavigationEditorShortcuts;
+
+export default NavigationEditorShortcuts;
diff --git a/packages/edit-navigation/src/components/menu-editor/style.scss b/packages/edit-navigation/src/components/navigation-editor/style.scss
similarity index 87%
rename from packages/edit-navigation/src/components/menu-editor/style.scss
rename to packages/edit-navigation/src/components/navigation-editor/style.scss
index 59ce9536123520..2405ad20475f90 100644
--- a/packages/edit-navigation/src/components/menu-editor/style.scss
+++ b/packages/edit-navigation/src/components/navigation-editor/style.scss
@@ -1,4 +1,4 @@
-.edit-navigation-menu-editor {
+.edit-navigation-editor {
display: grid;
align-items: self-start;
grid-gap: 10px;
@@ -13,7 +13,7 @@
}
}
-.edit-navigation-menu-editor__block-editor-toolbar {
+.edit-navigation-editor__block-editor-toolbar {
height: 46px;
margin-bottom: 12px;
border: 1px solid #e2e4e7;
@@ -58,7 +58,7 @@
}
}
-.edit-navigation-menu-editor__navigation-structure-panel {
+.edit-navigation-editor__navigation-structure-panel {
// IE11 requires the column to be explicitly declared.
grid-column: 1;
@@ -73,11 +73,11 @@
}
}
-.edit-navigation-menu-editor__navigation-structure-header {
+.edit-navigation-editor__navigation-structure-header {
font-weight: bold;
}
-.edit-navigation-menu-editor__block-editor-area {
+.edit-navigation-editor__block-editor-area {
@include break-medium {
// IE11 requires the column to be explicitly declared.
// Only shift this into the second column on desktop.
@@ -91,7 +91,7 @@
justify-content: space-between;
}
- .edit-navigation-menu-editor__block-editor-area-header-text {
+ .edit-navigation-editor__block-editor-area-header-text {
flex-grow: 1;
font-weight: bold;
}
diff --git a/packages/edit-navigation/src/components/navigation-editor/use-navigation-block-editor.js b/packages/edit-navigation/src/components/navigation-editor/use-navigation-block-editor.js
new file mode 100644
index 00000000000000..7c97d50b8381ff
--- /dev/null
+++ b/packages/edit-navigation/src/components/navigation-editor/use-navigation-block-editor.js
@@ -0,0 +1,30 @@
+/**
+ * WordPress dependencies
+ */
+import { useDispatch } from '@wordpress/data';
+import { useCallback } from '@wordpress/element';
+import { useEntityBlockEditor } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import { KIND, POST_TYPE } from '../../store/utils';
+
+export default function useNavigationBlockEditor( post ) {
+ const { createMissingMenuItems } = useDispatch( 'core/edit-navigation' );
+
+ const [ blocks, onInput, _onChange ] = useEntityBlockEditor(
+ KIND,
+ POST_TYPE,
+ { id: post.id }
+ );
+ const onChange = useCallback(
+ async ( updatedBlocks ) => {
+ await _onChange( updatedBlocks );
+ createMissingMenuItems( post );
+ },
+ [ blocks, onChange ]
+ );
+
+ return [ blocks, onInput, onChange ];
+}
diff --git a/packages/edit-navigation/src/index.js b/packages/edit-navigation/src/index.js
index 558b9949a0b3e4..b3a9d6a9512421 100644
--- a/packages/edit-navigation/src/index.js
+++ b/packages/edit-navigation/src/index.js
@@ -21,6 +21,7 @@ import { decodeEntities } from '@wordpress/html-entities';
* Internal dependencies
*/
import Layout from './components/layout';
+import './store';
/**
* Fetches link suggestions from the API. This function is an exact copy of a function found at:
diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js
new file mode 100644
index 00000000000000..d17f03845b558f
--- /dev/null
+++ b/packages/edit-navigation/src/store/actions.js
@@ -0,0 +1,276 @@
+/**
+ * External dependencies
+ */
+import { invert, keyBy, omit } from 'lodash';
+import { v4 as uuid } from 'uuid';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import {
+ getNavigationPostForMenu,
+ getPendingActions,
+ isProcessingPost,
+ getMenuItemToClientIdMapping,
+ resolveMenuItems,
+ dispatch,
+ apiFetch,
+} from './controls';
+import { menuItemsQuery } from './utils';
+
+/**
+ * Creates a menu item for every block that doesn't have an associated menuItem.
+ * Requests POST /wp/v2/menu-items once for every menu item created.
+ *
+ * @param {Object} post A navigation post to process
+ * @return {Function} An action creator
+ */
+export const createMissingMenuItems = serializeProcessing( function* ( post ) {
+ const menuId = post.meta.menuId;
+
+ const mapping = yield {
+ type: 'GET_MENU_ITEM_TO_CLIENT_ID_MAPPING',
+ postId: post.id,
+ };
+ const clientIdToMenuId = invert( mapping );
+
+ const stack = [ post.blocks[ 0 ] ];
+ while ( stack.length ) {
+ const block = stack.pop();
+ if ( ! ( block.clientId in clientIdToMenuId ) ) {
+ const menuItem = yield apiFetch( {
+ path: `/__experimental/menu-items`,
+ method: 'POST',
+ data: {
+ title: 'Placeholder',
+ url: 'Placeholder',
+ menu_order: 0,
+ },
+ } );
+
+ mapping[ menuItem.id ] = block.clientId;
+ const menuItems = yield resolveMenuItems( menuId );
+ yield dispatch(
+ 'core',
+ 'receiveEntityRecords',
+ 'root',
+ 'menuItem',
+ [ ...menuItems, menuItem ],
+ menuItemsQuery( menuId ),
+ false
+ );
+ }
+ stack.push( ...block.innerBlocks );
+ }
+
+ yield {
+ type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING',
+ postId: post.id,
+ mapping,
+ };
+} );
+
+/**
+ * Converts all the blocks into menu items and submits a batch request to save everything at once.
+ *
+ * @param {Object} post A navigation post to process
+ * @return {Function} An action creator
+ */
+export const saveNavigationPost = serializeProcessing( function* ( post ) {
+ const menuId = post.meta.menuId;
+ const menuItemsByClientId = mapMenuItemsByClientId(
+ yield resolveMenuItems( menuId ),
+ yield getMenuItemToClientIdMapping( post.id )
+ );
+
+ try {
+ const response = yield* batchSave(
+ menuId,
+ menuItemsByClientId,
+ post.blocks[ 0 ]
+ );
+ if ( ! response.success ) {
+ throw new Error();
+ }
+ yield dispatch(
+ 'core/notices',
+ 'createSuccessNotice',
+ __( 'Navigation saved.' ),
+ {
+ type: 'snackbar',
+ }
+ );
+ } catch ( e ) {
+ yield dispatch(
+ 'core/notices',
+ 'createErrorNotice',
+ __( 'There was an error.' ),
+ {
+ type: 'snackbar',
+ }
+ );
+ }
+} );
+
+function mapMenuItemsByClientId( menuItems, clientIdsByMenuId ) {
+ const result = {};
+ if ( ! menuItems || ! clientIdsByMenuId ) {
+ return result;
+ }
+ for ( const menuItem of menuItems ) {
+ const clientId = clientIdsByMenuId[ menuItem.id ];
+ if ( clientId ) {
+ result[ clientId ] = menuItem;
+ }
+ }
+ return result;
+}
+
+function* batchSave( menuId, menuItemsByClientId, navigationBlock ) {
+ const { nonce, stylesheet } = yield apiFetch( {
+ path: '/__experimental/customizer-nonces/get-save-nonce',
+ } );
+ if ( ! nonce ) {
+ throw new Error();
+ }
+
+ // eslint-disable-next-line no-undef
+ const body = new FormData();
+ body.append( 'wp_customize', 'on' );
+ body.append( 'customize_theme', stylesheet );
+ body.append( 'nonce', nonce );
+ body.append( 'customize_changeset_uuid', uuid() );
+ body.append( 'customize_autosaved', 'on' );
+ body.append( 'customize_changeset_status', 'publish' );
+ body.append( 'action', 'customize_save' );
+ body.append(
+ 'customized',
+ computeCustomizedAttribute(
+ navigationBlock.innerBlocks,
+ menuId,
+ menuItemsByClientId
+ )
+ );
+
+ return yield apiFetch( {
+ url: '/wp-admin/admin-ajax.php',
+ method: 'POST',
+ body,
+ } );
+}
+
+function computeCustomizedAttribute( blocks, menuId, menuItemsByClientId ) {
+ const blocksList = blocksTreeToFlatList( blocks );
+ const dataList = blocksList.map( ( { block, parentId, position } ) =>
+ linkBlockToRequestItem( block, parentId, position )
+ );
+
+ // Create an object like { "nav_menu_item[12]": {...}} }
+ const computeKey = ( item ) => `nav_menu_item[${ item.id }]`;
+ const dataObject = keyBy( dataList, computeKey );
+
+ // Deleted menu items should be sent as false, e.g. { "nav_menu_item[13]": false }
+ for ( const clientId in menuItemsByClientId ) {
+ const key = computeKey( menuItemsByClientId[ clientId ] );
+ if ( ! ( key in dataObject ) ) {
+ dataObject[ key ] = false;
+ }
+ }
+
+ return JSON.stringify( dataObject );
+
+ function blocksTreeToFlatList( innerBlocks, parentId = 0 ) {
+ return innerBlocks.flatMap( ( block, index ) =>
+ [ { block, parentId, position: index + 1 } ].concat(
+ blocksTreeToFlatList(
+ block.innerBlocks,
+ getMenuItemForBlock( block )?.id
+ )
+ )
+ );
+ }
+
+ function linkBlockToRequestItem( block, parentId, position ) {
+ const menuItem = omit( getMenuItemForBlock( block ), 'menus', 'meta' );
+ return {
+ ...menuItem,
+ position,
+ title: block.attributes?.label,
+ url: block.attributes.url,
+ original_title: '',
+ classes: ( menuItem.classes || [] ).join( ' ' ),
+ xfn: ( menuItem.xfn || [] ).join( ' ' ),
+ nav_menu_term_id: menuId,
+ menu_item_parent: parentId,
+ status: 'publish',
+ _invalid: false,
+ };
+ }
+
+ function getMenuItemForBlock( block ) {
+ return omit( menuItemsByClientId[ block.clientId ] || {}, '_links' );
+ }
+}
+
+/**
+ * This wrapper guarantees serial execution of data processing actions.
+ *
+ * Examples:
+ * * saveNavigationPost() needs to wait for all the missing items to be created.
+ * * Concurrent createMissingMenuItems() could result in sending more requests than required.
+ *
+ * @param {Function} callback An action creator to wrap
+ * @return {Function} Original callback wrapped in a serial execution context
+ */
+function serializeProcessing( callback ) {
+ return function* ( post ) {
+ const postId = post.id;
+ const isProcessing = yield isProcessingPost( postId );
+
+ if ( isProcessing ) {
+ yield {
+ type: 'ENQUEUE_AFTER_PROCESSING',
+ postId,
+ action: callback,
+ };
+ return { status: 'pending' };
+ }
+ yield {
+ type: 'POP_PENDING_ACTION',
+ postId,
+ action: callback,
+ };
+
+ yield {
+ type: 'START_PROCESSING_POST',
+ postId,
+ };
+
+ try {
+ yield* callback( post );
+ } finally {
+ yield {
+ type: 'FINISH_PROCESSING_POST',
+ postId,
+ action: callback,
+ };
+
+ const pendingActions = yield getPendingActions( postId );
+ if ( pendingActions.length ) {
+ const serializedCallback = serializeProcessing(
+ pendingActions[ 0 ]
+ );
+
+ // re-fetch the post as running the callback() likely updated it
+ yield* serializedCallback(
+ yield getNavigationPostForMenu( post.meta.menuId )
+ );
+ }
+ }
+ };
+}
diff --git a/packages/edit-navigation/src/store/controls.js b/packages/edit-navigation/src/store/controls.js
new file mode 100644
index 00000000000000..8a534c9e35b79e
--- /dev/null
+++ b/packages/edit-navigation/src/store/controls.js
@@ -0,0 +1,178 @@
+/**
+ * WordPress dependencies
+ */
+import { default as triggerApiFetch } from '@wordpress/api-fetch';
+import { createRegistryControl } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { menuItemsQuery } from './utils';
+
+/**
+ * Trigger an API Fetch request.
+ *
+ * @param {Object} request API Fetch Request Object.
+ * @return {Object} control descriptor.
+ */
+export function apiFetch( request ) {
+ return {
+ type: 'API_FETCH',
+ request,
+ };
+}
+
+/**
+ * Returns a list of pending actions for given post id.
+ *
+ * @param {number} postId Post ID.
+ * @return {Array} List of pending actions.
+ */
+export function getPendingActions( postId ) {
+ return {
+ type: 'GET_PENDING_ACTIONS',
+ postId,
+ };
+}
+
+/**
+ * Returns boolean indicating whether or not an action processing specified
+ * post is currently running.
+ *
+ * @param {number} postId Post ID.
+ * @return {Object} Action.
+ */
+export function isProcessingPost( postId ) {
+ return {
+ type: 'IS_PROCESSING_POST',
+ postId,
+ };
+}
+
+/**
+ * Selects menuItemId -> clientId mapping (necessary for saving the navigation).
+ *
+ * @param {number} postId Navigation post ID.
+ * @return {Object} Action.
+ */
+export function getMenuItemToClientIdMapping( postId ) {
+ return {
+ type: 'GET_MENU_ITEM_TO_CLIENT_ID_MAPPING',
+ postId,
+ };
+}
+
+/**
+ * Resolves navigation post for given menuId.
+ *
+ * @see selectors.js
+ * @param {number} menuId Menu ID.
+ * @return {Object} Action.
+ */
+export function getNavigationPostForMenu( menuId ) {
+ return {
+ type: 'SELECT',
+ registryName: 'core/edit-navigation',
+ selectorName: 'getNavigationPostForMenu',
+ args: [ menuId ],
+ };
+}
+
+/**
+ * Resolves menu items for given menu id.
+ *
+ * @param {number} menuId Menu ID.
+ * @return {Object} Action.
+ */
+export function resolveMenuItems( menuId ) {
+ return {
+ type: 'RESOLVE_MENU_ITEMS',
+ query: menuItemsQuery( menuId ),
+ };
+}
+
+/**
+ * Calls a selector using chosen registry.
+ *
+ * @param {string} registryName Registry name.
+ * @param {string} selectorName Selector name.
+ * @param {Array} args Selector arguments.
+ * @return {Object} control descriptor.
+ */
+export function select( registryName, selectorName, ...args ) {
+ return {
+ type: 'SELECT',
+ registryName,
+ selectorName,
+ args,
+ };
+}
+
+/**
+ * Dispatches an action using chosen registry.
+ *
+ * @param {string} registryName Registry name.
+ * @param {string} actionName Action name.
+ * @param {Array} args Selector arguments.
+ * @return {Object} control descriptor.
+ */
+export function dispatch( registryName, actionName, ...args ) {
+ return {
+ type: 'DISPATCH',
+ registryName,
+ actionName,
+ args,
+ };
+}
+
+const controls = {
+ API_FETCH( { request } ) {
+ return triggerApiFetch( request );
+ },
+
+ SELECT: createRegistryControl(
+ ( registry ) => ( { registryName, selectorName, args } ) => {
+ return registry.select( registryName )[ selectorName ]( ...args );
+ }
+ ),
+
+ GET_PENDING_ACTIONS: createRegistryControl(
+ ( registry ) => ( { postId } ) => {
+ return (
+ getState( registry ).processingQueue[ postId ]
+ ?.pendingActions || []
+ );
+ }
+ ),
+
+ IS_PROCESSING_POST: createRegistryControl(
+ ( registry ) => ( { postId } ) => {
+ return getState( registry ).processingQueue[ postId ]?.inProgress;
+ }
+ ),
+
+ GET_MENU_ITEM_TO_CLIENT_ID_MAPPING: createRegistryControl(
+ ( registry ) => ( { postId } ) => {
+ return getState( registry ).mapping[ postId ] || {};
+ }
+ ),
+
+ DISPATCH: createRegistryControl(
+ ( registry ) => ( { registryName, actionName, args } ) => {
+ return registry.dispatch( registryName )[ actionName ]( ...args );
+ }
+ ),
+
+ RESOLVE_MENU_ITEMS: createRegistryControl(
+ ( registry ) => ( { query } ) => {
+ return registry
+ .__experimentalResolveSelect( 'core' )
+ .getMenuItems( query );
+ }
+ ),
+};
+
+const getState = ( registry ) =>
+ registry.stores[ 'core/edit-navigation' ].store.getState();
+
+export default controls;
diff --git a/packages/edit-navigation/src/store/index.js b/packages/edit-navigation/src/store/index.js
new file mode 100644
index 00000000000000..78da40045a09b5
--- /dev/null
+++ b/packages/edit-navigation/src/store/index.js
@@ -0,0 +1,37 @@
+/**
+ * WordPress dependencies
+ */
+import { registerStore } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import reducer from './reducer';
+import * as resolvers from './resolvers';
+import * as selectors from './selectors';
+import * as actions from './actions';
+import controls from './controls';
+
+/**
+ * Module Constants
+ */
+const MODULE_KEY = 'core/edit-navigation';
+
+/**
+ * Block editor data store configuration.
+ *
+ * @see https://github.com/WordPress/gutenberg/blob/master/packages/data/README.md#registerStore
+ *
+ * @type {Object}
+ */
+export const storeConfig = {
+ reducer,
+ controls,
+ selectors,
+ resolvers,
+ actions,
+};
+
+const store = registerStore( MODULE_KEY, storeConfig );
+
+export default store;
diff --git a/packages/edit-navigation/src/store/reducer.js b/packages/edit-navigation/src/store/reducer.js
new file mode 100644
index 00000000000000..544857b159af27
--- /dev/null
+++ b/packages/edit-navigation/src/store/reducer.js
@@ -0,0 +1,87 @@
+/**
+ * WordPress dependencies
+ */
+import { combineReducers } from '@wordpress/data';
+
+/**
+ * Internal to edit-navigation package.
+ *
+ * Stores menuItemId -> clientId mapping which is necessary for saving the navigation.
+ *
+ * @param {Object} state Redux state
+ * @param {Object} action Redux action
+ * @return {Object} Updated state
+ */
+export function mapping( state, action ) {
+ const { type, postId, ...rest } = action;
+ if ( type === 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING' ) {
+ return { ...state, [ postId ]: rest.mapping };
+ }
+
+ return state || {};
+}
+
+/**
+ * Internal to edit-navigation package.
+ *
+ * Enables serializeProcessing action wrapper by storing the underlying execution
+ * state and any pending actions.
+ *
+ * @param {Object} state Redux state
+ * @param {Object} action Redux action
+ * @return {Object} Updated state
+ */
+export function processingQueue( state, action ) {
+ const { type, postId, ...rest } = action;
+ switch ( type ) {
+ case 'START_PROCESSING_POST':
+ return {
+ ...state,
+ [ postId ]: {
+ ...state[ postId ],
+ inProgress: true,
+ },
+ };
+
+ case 'FINISH_PROCESSING_POST':
+ return {
+ ...state,
+ [ postId ]: {
+ ...state[ postId ],
+ inProgress: false,
+ },
+ };
+
+ case 'POP_PENDING_ACTION':
+ const postState = { ...state[ postId ] };
+ if ( 'pendingActions' in postState ) {
+ postState.pendingActions = postState.pendingActions?.filter(
+ ( item ) => item !== rest.action
+ );
+ }
+ return {
+ ...state,
+ [ postId ]: postState,
+ };
+
+ case 'ENQUEUE_AFTER_PROCESSING':
+ const pendingActions = state[ postId ]?.pendingActions || [];
+ if ( ! pendingActions.includes( rest.action ) ) {
+ return {
+ ...state,
+ [ postId ]: {
+ ...state[ postId ],
+ pendingActions: [ ...pendingActions, rest.action ],
+ },
+ };
+ }
+ break;
+ }
+
+ return state || {};
+}
+
+export default combineReducers( {
+ mapping,
+ processingQueue,
+} );
diff --git a/packages/edit-navigation/src/store/resolvers.js b/packages/edit-navigation/src/store/resolvers.js
new file mode 100644
index 00000000000000..bc828105a34d51
--- /dev/null
+++ b/packages/edit-navigation/src/store/resolvers.js
@@ -0,0 +1,125 @@
+/**
+ * External dependencies
+ */
+import { groupBy, sortBy } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { createBlock } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import { resolveMenuItems, dispatch } from './controls';
+import { KIND, POST_TYPE, buildNavigationPostId } from './utils';
+
+/**
+ * Creates a "stub" navigation post reflecting the contents of menu with id=menuId. The
+ * post is meant as a convenient to only exists in runtime and should never be saved. It
+ * enables a convenient way of editing the navigation by using a regular post editor.
+ *
+ * Fetches all menu items, converts them into blocks, and hydrates a new post with them.
+ *
+ * @param {number} menuId The id of menu to create a post from
+ * @return {void}
+ */
+export function* getNavigationPostForMenu( menuId ) {
+ const stubPost = createStubPost( menuId );
+ // Persist an empty post to warm up the state
+ yield persistPost( stubPost );
+
+ // Dispatch startResolution to skip the execution of the real getEntityRecord resolver - it would
+ // issue an http request and fail.
+ const args = [ KIND, POST_TYPE, stubPost.id ];
+ yield dispatch( 'core', 'startResolution', 'getEntityRecord', args );
+
+ // Now let's create a proper one hydrated using actual menu items
+ const menuItems = yield resolveMenuItems( menuId );
+ const [ navigationBlock, menuItemIdToClientId ] = createNavigationBlock(
+ menuItems
+ );
+ yield {
+ type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING',
+ postId: stubPost.id,
+ mapping: menuItemIdToClientId,
+ };
+ // Persist the actual post containing the navigation block
+ yield persistPost( createStubPost( menuId, navigationBlock ) );
+
+ // Dispatch finishResolution to conclude startResolution dispatched earlier
+ yield dispatch( 'core', 'finishResolution', 'getEntityRecord', args );
+}
+
+const createStubPost = ( menuId, navigationBlock ) => {
+ const id = buildNavigationPostId( menuId );
+ return {
+ id,
+ slug: id,
+ status: 'draft',
+ type: 'page',
+ blocks: [ navigationBlock ],
+ meta: {
+ menuId,
+ },
+ };
+};
+
+const persistPost = ( post ) =>
+ dispatch(
+ 'core',
+ 'receiveEntityRecords',
+ KIND,
+ POST_TYPE,
+ post,
+ { id: post.id },
+ false
+ );
+
+/**
+ * Converts an adjacency list of menuItems into a navigation block.
+ *
+ * @param {Array} menuItems a list of menu items
+ * @return {Object} Navigation block
+ */
+function createNavigationBlock( menuItems ) {
+ const itemsByParentID = groupBy( menuItems, 'parent' );
+ const menuItemIdToClientId = {};
+ const menuItemsToTreeOfBlocks = ( items ) => {
+ const innerBlocks = [];
+ if ( ! items ) {
+ return;
+ }
+
+ const sortedItems = sortBy( items, 'menu_order' );
+ for ( const item of sortedItems ) {
+ let menuItemInnerBlocks = [];
+ if ( itemsByParentID[ item.id ]?.length ) {
+ menuItemInnerBlocks = menuItemsToTreeOfBlocks(
+ itemsByParentID[ item.id ]
+ );
+ }
+ const linkBlock = convertMenuItemToLinkBlock(
+ item,
+ menuItemInnerBlocks
+ );
+ menuItemIdToClientId[ item.id ] = linkBlock.clientId;
+ innerBlocks.push( linkBlock );
+ }
+ return innerBlocks;
+ };
+
+ // menuItemsToTreeOfLinkBlocks takes an array of top-level menu items and recursively creates all their innerBlocks
+ const innerBlocks = menuItemsToTreeOfBlocks( itemsByParentID[ 0 ] || [] );
+ const navigationBlock = createBlock( 'core/navigation', {}, innerBlocks );
+ return [ navigationBlock, menuItemIdToClientId ];
+}
+
+function convertMenuItemToLinkBlock( menuItem, innerBlocks = [] ) {
+ const attributes = {
+ label: menuItem.title.rendered,
+ url: menuItem.url,
+ };
+
+ return createBlock( 'core/navigation-link', attributes, innerBlocks );
+}
diff --git a/packages/edit-navigation/src/store/selectors.js b/packages/edit-navigation/src/store/selectors.js
new file mode 100644
index 00000000000000..2891e1ac3046dd
--- /dev/null
+++ b/packages/edit-navigation/src/store/selectors.js
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import { invert } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { createRegistrySelector } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { KIND, POST_TYPE, buildNavigationPostId } from './utils';
+
+/**
+ * Returns a "stub" navigation post reflecting the contents of menu with id=menuId. The
+ * post is meant as a convenient to only exists in runtime and should never be saved. It
+ * enables a convenient way of editing the navigation by using a regular post editor.
+ *
+ * Related resolver fetches all menu items, converts them into blocks, and hydrates a new post with them.
+ *
+ * @param {number} menuId The id of menu to create a post from.
+ * @return {null|Object} Post once the resolver fetches it, otherwise null
+ */
+export const getNavigationPostForMenu = createRegistrySelector(
+ ( select ) => ( state, menuId ) => {
+ // When the record is unavailable, calling getEditedEntityRecord triggers a http
+ // request via it's related resolver. Let's return nothing until getNavigationPostForMenu
+ // resolver marks the record as resolved.
+ if ( ! hasResolvedNavigationPost( state, menuId ) ) {
+ return null;
+ }
+ return select( 'core' ).getEditedEntityRecord(
+ KIND,
+ POST_TYPE,
+ buildNavigationPostId( menuId )
+ );
+ }
+);
+
+/**
+ * Returns true if the navigation post related to menuId was already resolved.
+ *
+ * @param {number} menuId The id of menu.
+ * @return {boolean} True if the navigation post related to menuId was already resolved, false otherwise.
+ */
+export const hasResolvedNavigationPost = createRegistrySelector(
+ ( select ) => ( state, menuId ) => {
+ return select( 'core' ).hasFinishedResolution( 'getEntityRecord', [
+ KIND,
+ POST_TYPE,
+ buildNavigationPostId( menuId ),
+ ] );
+ }
+);
+
+/**
+ * Returns a menu item represented by the block with id clientId.
+ *
+ * @param {number} postId Navigation post id
+ * @param {number} clientId Block clientId
+ * @return {Object|null} Menu item entity
+ */
+export const getMenuItemForClientId = createRegistrySelector(
+ ( select ) => ( state, postId, clientId ) => {
+ const mapping = invert( state.mapping[ postId ] );
+ return select( 'core' ).getMenuItem( mapping[ clientId ] );
+ }
+);
diff --git a/packages/edit-navigation/src/store/test/reducer.js b/packages/edit-navigation/src/store/test/reducer.js
new file mode 100644
index 00000000000000..7eac08dae705c8
--- /dev/null
+++ b/packages/edit-navigation/src/store/test/reducer.js
@@ -0,0 +1,178 @@
+/**
+ * Internal dependencies
+ */
+import { mapping, processingQueue } from '../reducer';
+
+describe( 'mapping', () => {
+ it( 'should initialize empty mapping when there is no original state', () => {
+ expect( mapping( null, {} ) ).toEqual( {} );
+ } );
+
+ it( 'should add the mapping to state', () => {
+ const originalState = {};
+ const newState = mapping( originalState, {
+ type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING',
+ postId: 1,
+ mapping: { a: 'b' },
+ } );
+ expect( newState ).not.toBe( originalState );
+ expect( newState ).toEqual( {
+ 1: {
+ a: 'b',
+ },
+ } );
+ } );
+
+ it( 'should replace the mapping in state', () => {
+ const originalState = {
+ 1: {
+ c: 'd',
+ },
+ 2: {
+ e: 'f',
+ },
+ };
+ const newState = mapping( originalState, {
+ type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING',
+ postId: 1,
+ mapping: { g: 'h' },
+ } );
+ expect( newState ).toEqual( {
+ 1: {
+ g: 'h',
+ },
+ 2: {
+ e: 'f',
+ },
+ } );
+ } );
+} );
+
+describe( 'processingQueue', () => {
+ it( 'should initialize empty mapping when there is no original state', () => {
+ expect( processingQueue( null, {} ) ).toEqual( {} );
+ } );
+
+ it( 'ENQUEUE_AFTER_PROCESSING should add an action to pendingActions', () => {
+ const originalState = {};
+ const newState = processingQueue( originalState, {
+ type: 'ENQUEUE_AFTER_PROCESSING',
+ postId: 1,
+ action: 'some action',
+ } );
+ expect( newState ).toEqual( {
+ 1: {
+ pendingActions: [ 'some action' ],
+ },
+ } );
+ } );
+ it( 'ENQUEUE_AFTER_PROCESSING should not add the same action to pendingActions twice', () => {
+ const state1 = {};
+ const state2 = processingQueue( state1, {
+ type: 'ENQUEUE_AFTER_PROCESSING',
+ postId: 1,
+ action: 'some action',
+ } );
+ const state3 = processingQueue( state2, {
+ type: 'ENQUEUE_AFTER_PROCESSING',
+ postId: 1,
+ action: 'some action',
+ } );
+ expect( state3 ).toEqual( {
+ 1: {
+ pendingActions: [ 'some action' ],
+ },
+ } );
+ const state4 = processingQueue( state3, {
+ type: 'ENQUEUE_AFTER_PROCESSING',
+ postId: 1,
+ action: 'another action',
+ } );
+ expect( state4 ).toEqual( {
+ 1: {
+ pendingActions: [ 'some action', 'another action' ],
+ },
+ } );
+ } );
+
+ it( 'START_PROCESSING_POST should mark post as in progress', () => {
+ const originalState = {};
+ const newState = processingQueue( originalState, {
+ type: 'START_PROCESSING_POST',
+ postId: 1,
+ } );
+ expect( newState ).not.toBe( originalState );
+ expect( newState ).toEqual( {
+ 1: {
+ inProgress: true,
+ },
+ } );
+ } );
+
+ it( 'FINISH_PROCESSING_POST should mark post as not in progress', () => {
+ const originalState = {
+ 1: {
+ inProgress: true,
+ },
+ };
+ const newState = processingQueue( originalState, {
+ type: 'FINISH_PROCESSING_POST',
+ postId: 1,
+ } );
+ expect( newState ).not.toBe( originalState );
+ expect( newState ).toEqual( {
+ 1: {
+ inProgress: false,
+ },
+ } );
+ } );
+
+ it( 'FINISH_PROCESSING_POST should preserve other state data', () => {
+ const originalState = {
+ 1: {
+ inProgress: true,
+ a: 1,
+ },
+ 2: {
+ b: 2,
+ },
+ };
+ const newState = processingQueue( originalState, {
+ type: 'FINISH_PROCESSING_POST',
+ postId: 1,
+ } );
+ expect( newState ).not.toBe( originalState );
+ expect( newState ).toEqual( {
+ 1: {
+ inProgress: false,
+ a: 1,
+ },
+ 2: {
+ b: 2,
+ },
+ } );
+ } );
+
+ it( 'POP_PENDING_ACTION should remove the action from pendingActions', () => {
+ const originalState = {
+ 1: {
+ pendingActions: [
+ 'first action',
+ 'some action',
+ 'another action',
+ ],
+ },
+ };
+ const newState = processingQueue( originalState, {
+ type: 'POP_PENDING_ACTION',
+ postId: 1,
+ action: 'some action',
+ } );
+ expect( newState ).not.toBe( originalState );
+ expect( newState ).toEqual( {
+ 1: {
+ pendingActions: [ 'first action', 'another action' ],
+ },
+ } );
+ } );
+} );
diff --git a/packages/edit-navigation/src/store/utils.js b/packages/edit-navigation/src/store/utils.js
new file mode 100644
index 00000000000000..26fb6974903c40
--- /dev/null
+++ b/packages/edit-navigation/src/store/utils.js
@@ -0,0 +1,32 @@
+/**
+ * "Kind" of the navigation post.
+ *
+ * @type {string}
+ */
+export const KIND = 'root';
+
+/**
+ * "post type" of the navigation post.
+ *
+ * @type {string}
+ */
+export const POST_TYPE = 'postType';
+
+/**
+ * Builds an ID for a new navigation post.
+ *
+ * @param {number} menuId Menu id.
+ * @return {string} An ID.
+ */
+export const buildNavigationPostId = ( menuId ) =>
+ `navigation-post-${ menuId }`;
+
+/**
+ * Builds a query to resolve menu items.
+ *
+ * @param {number} menuId Menu id.
+ * @return {Object} Query.
+ */
+export function menuItemsQuery( menuId ) {
+ return { menus: menuId, per_page: -1 };
+}
diff --git a/packages/edit-navigation/src/style.scss b/packages/edit-navigation/src/style.scss
index 9146298802b266..82b1896c579564 100644
--- a/packages/edit-navigation/src/style.scss
+++ b/packages/edit-navigation/src/style.scss
@@ -5,7 +5,7 @@
}
@import "./components/layout/style.scss";
-@import "./components/menu-editor/style.scss";
+@import "./components/navigation-editor/style.scss";
@import "./components/menus-editor/style.scss";
@import "./components/delete-menu-button/style.scss";
@import "./components/notices/style.scss";