diff --git a/package-lock.json b/package-lock.json index 04aab70e819de..b29d5fd65d69f 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 169d803b03737..2b8bba53657ef 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 ce40b46c4f6ae..0000000000000 --- 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 d560164e91e76..0000000000000 --- 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 96889b67a8daa..0000000000000 --- 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 b21fa5416d1e6..0000000000000 --- 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 250b9927153ab..0000000000000 --- 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 1b7899737fd78..0000000000000 --- 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 04507772174cb..2f2b5c2407c80 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 9c06ba2c8776e..31651432ed1b9 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 f8e66704a8525..a3153a557427e 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 0000000000000..d75d3f176e2e4 --- /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 59ce953612352..2405ad20475f9 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 0000000000000..7c97d50b8381f --- /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 558b9949a0b3e..b3a9d6a951242 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 0000000000000..d17f03845b558 --- /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 0000000000000..8a534c9e35b79 --- /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 0000000000000..78da40045a09b --- /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 0000000000000..544857b159af2 --- /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 0000000000000..bc828105a34d5 --- /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 0000000000000..2891e1ac3046d --- /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 0000000000000..7eac08dae705c --- /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 0000000000000..26fb6974903c4 --- /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 9146298802b26..82b1896c57956 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";