diff --git a/lib/class-wp-rest-customizer-nonces.php b/lib/class-wp-rest-customizer-nonces.php new file mode 100644 index 0000000000000..1eab33cf2ef58 --- /dev/null +++ b/lib/class-wp-rest-customizer-nonces.php @@ -0,0 +1,73 @@ +namespace = '__experimental'; + $this->rest_base = 'customizer-nonces'; + } + + /** + * Registers the necessary REST API routes. + * + * @access public + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/get-save-nonce', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_save_nonce' ), + 'permission_callback' => array( $this, 'permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to read menu items if they have access to edit them. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function permissions_check( $request ) { + $post_type = get_post_type_object( 'nav_menu_item' ); + if ( ! current_user_can( $post_type->cap->edit_posts ) ) { + return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit posts in this post type.', 'gutenberg' ), array( 'status' => rest_authorization_required_code() ) ); + } + return true; + } + + /** + * Returns the nonce required to request the customizer API endpoint. + * + * @access public + */ + public function get_save_nonce() { + require_once ABSPATH . 'wp-includes/class-wp-customize-manager.php'; + $wp_customize = new WP_Customize_Manager(); + $nonce = wp_create_nonce( 'save-customize_' . $wp_customize->get_stylesheet() ); + return array( + 'success' => true, + 'nonce' => $nonce, + 'stylesheet' => $wp_customize->get_stylesheet(), + ); + } + +} diff --git a/lib/load.php b/lib/load.php index 5e159089df4ab..176cecd147c27 100644 --- a/lib/load.php +++ b/lib/load.php @@ -51,6 +51,9 @@ function gutenberg_is_experiment_enabled( $name ) { if ( ! class_exists( 'WP_REST_Menu_Locations_Controller' ) ) { require_once dirname( __FILE__ ) . '/class-wp-rest-menu-locations-controller.php'; } + if ( ! class_exists( 'WP_Rest_Customizer_Nonces' ) ) { + require_once dirname( __FILE__ ) . '/class-wp-rest-customizer-nonces.php'; + } /** * End: Include for phase 2 */ diff --git a/lib/rest-api.php b/lib/rest-api.php index d351bebc37c60..9f99995825638 100644 --- a/lib/rest-api.php +++ b/lib/rest-api.php @@ -144,6 +144,16 @@ function gutenberg_register_rest_menu_location() { $nav_menu_location->register_routes(); } add_action( 'rest_api_init', 'gutenberg_register_rest_menu_location' ); + +/** + * Registers the menu locations area REST API routes. + */ +function gutenberg_register_rest_customizer_nonces() { + $nav_menu_location = new WP_Rest_Customizer_Nonces(); + $nav_menu_location->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_rest_customizer_nonces' ); + /** * Hook in to the nav menu item post type and enable a post type rest endpoint. * diff --git a/packages/edit-navigation/src/components/menu-editor/batch-save.js b/packages/edit-navigation/src/components/menu-editor/batch-save.js new file mode 100644 index 0000000000000..d56c7702e8fbc --- /dev/null +++ b/packages/edit-navigation/src/components/menu-editor/batch-save.js @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { keyBy, omit } from 'lodash'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +export default 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/promise-queue.js b/packages/edit-navigation/src/components/menu-editor/promise-queue.js new file mode 100644 index 0000000000000..d560164e91e76 --- /dev/null +++ b/packages/edit-navigation/src/components/menu-editor/promise-queue.js @@ -0,0 +1,51 @@ +/** + * 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/use-debounced-value.js b/packages/edit-navigation/src/components/menu-editor/use-debounced-value.js new file mode 100644 index 0000000000000..a62135a775479 --- /dev/null +++ b/packages/edit-navigation/src/components/menu-editor/use-debounced-value.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; + +const useDebouncedValue = ( value, timeout ) => { + const [ state, setState ] = useState( value ); + + useEffect( () => { + const handler = setTimeout( () => setState( value ), timeout ); + + return () => clearTimeout( handler ); + }, [ value, timeout ] ); + + return state; +}; + +export default useDebouncedValue; 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 index b9f7dabe34225..43d319afa7b15 100644 --- a/packages/edit-navigation/src/components/menu-editor/use-navigation-blocks.js +++ b/packages/edit-navigation/src/components/menu-editor/use-navigation-blocks.js @@ -1,142 +1,164 @@ /** * External dependencies */ -import { groupBy, isEqual, difference } from 'lodash'; +import { groupBy, sortBy } from 'lodash'; /** * WordPress dependencies */ +import apiFetch from '@wordpress/api-fetch'; import { createBlock } from '@wordpress/blocks'; -import { useSelect, useDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; import { useState, useRef, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -function createBlockFromMenuItem( menuItem, innerBlocks = [] ) { - return createBlock( - 'core/navigation-link', - { - label: menuItem.title.rendered, - url: menuItem.url, - }, - innerBlocks - ); -} - -function createMenuItemAttributesFromBlock( block ) { - return { - title: block.attributes.label, - url: block.attributes.url, - }; -} +/** + * Internal dependencies + */ +import batchSave from './batch-save'; +import useDebouncedValue from './use-debounced-value'; +import PromiseQueue from './promise-queue'; export default function useNavigationBlocks( menuId ) { // menuItems is an array of menu item objects. + const query = { menus: menuId, per_page: -1 }; const menuItems = useSelect( - ( select ) => - select( 'core' ).getMenuItems( { menus: menuId, per_page: -1 } ), + ( select ) => select( 'core' ).getMenuItems( query ), [ menuId ] ); - const { saveMenuItem } = useDispatch( 'core' ); - const { createSuccessNotice } = useDispatch( 'core/notices' ); + // Data model const [ blocks, setBlocks ] = useState( [] ); - const menuItemsRef = useRef( {} ); + // Refresh our model whenever menuItems change useEffect( () => { - if ( ! menuItems ) { - return; + if ( menuItems ) { + const [ + navigationBlock, + clientIdToMenuItemMapping, + ] = menuItemsToNavigationBlock( menuItems ); + setBlocks( [ navigationBlock ] ); + menuItemsRef.current = clientIdToMenuItemMapping; } + }, [ menuItems ] ); - const itemsByParentID = groupBy( menuItems, 'parent' ); - - menuItemsRef.current = {}; + // When a new block is added, let's create a draft menuItem for it. + // 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 - const createMenuItemBlocks = ( items ) => { - const innerBlocks = []; - if ( ! items ) { - return; + // Let's debounce so that we don't call `getAllClientIds` on every keystroke + const debouncedBlocks = useDebouncedValue( blocks, 800 ); + const promiseQueueRef = useRef( new PromiseQueue() ); + const enqueuedBlocksIds = useRef( [] ); + useEffect( () => { + for ( const clientId of getAllClientIds( debouncedBlocks ) ) { + // Menu item was already created + if ( clientId in menuItemsRef.current ) { + continue; } - for ( const item of items ) { - let menuItemInnerBlocks = []; - if ( itemsByParentID[ item.id ]?.length ) { - menuItemInnerBlocks = createMenuItemBlocks( - itemsByParentID[ item.id ] - ); - } - const block = createBlockFromMenuItem( - item, - menuItemInnerBlocks - ); - menuItemsRef.current[ block.clientId ] = item; - innerBlocks.push( block ); + // Already in the queue + if ( enqueuedBlocksIds.current.includes( clientId ) ) { + continue; } - return innerBlocks; - }; + enqueuedBlocksIds.current.push( clientId ); + promiseQueueRef.current.enqueue( () => + createDraftMenuItem( clientId ).then( ( menuItem ) => { + menuItemsRef.current[ clientId ] = menuItem; + enqueuedBlocksIds.current.splice( + enqueuedBlocksIds.current.indexOf( clientId ) + ); + } ) + ); + } + }, [ debouncedBlocks ] ); - // createMenuItemBlocks takes an array of top-level menu items and recursively creates all their innerBlocks - const innerBlocks = createMenuItemBlocks( itemsByParentID[ 0 ] ); - setBlocks( [ createBlock( 'core/navigation', {}, innerBlocks ) ] ); - }, [ menuItems ] ); + // Save handler + const { receiveEntityRecords } = useDispatch( 'core' ); + const { createSuccessNotice, createErrorNotice } = useDispatch( + 'core/notices' + ); - const saveBlocks = () => { - const { clientId, innerBlocks } = blocks[ 0 ]; - const parentItemId = menuItemsRef.current[ clientId ]?.parent; - - const saveNestedBlocks = async ( nestedBlocks, parentId = 0 ) => { - for ( const block of nestedBlocks ) { - const menuItem = menuItemsRef.current[ block.clientId ]; - let currentItemId = menuItem?.id || 0; - - if ( ! menuItem ) { - const savedItem = await saveMenuItem( { - ...createMenuItemAttributesFromBlock( block ), - menus: menuId, - parent: parentId, - } ); - if ( block.innerBlocks.length ) { - currentItemId = savedItem.id; - } - } - - if ( - menuItem && - ! isEqual( - block.attributes, - createBlockFromMenuItem( menuItem ).attributes - ) - ) { - saveMenuItem( { - ...menuItem, - ...createMenuItemAttributesFromBlock( block ), - menus: menuId, // Gotta do this because REST API doesn't like receiving an array here. Maybe a bug in the REST API? - parent: parentId, - } ); - } - - if ( block.innerBlocks.length ) { - saveNestedBlocks( block.innerBlocks, currentItemId ); - } - } - }; + const saveBlocks = async () => { + const result = await batchSave( menuId, menuItemsRef, blocks[ 0 ] ); + + if ( result.success ) { + createSuccessNotice( __( 'Navigation saved.' ), { + type: 'snackbar', + } ); + receiveEntityRecords( 'root', 'menuItem', [], query, true ); + } else { + createErrorNotice( __( 'There was an error.' ), { + type: 'snackbar', + } ); + } + }; - saveNestedBlocks( innerBlocks, parentItemId ); + return [ + blocks, + setBlocks, + () => promiseQueueRef.current.then( saveBlocks ), + ]; +} - const deletedClientIds = difference( - Object.keys( menuItemsRef.current ), - innerBlocks.map( ( block ) => block.clientId ) - ); +async function createDraftMenuItem() { + return apiFetch( { + path: `/__experimental/menu-items`, + method: 'POST', + data: { + title: 'Placeholder', + url: 'Placeholder', + menu_order: 0, + }, + } ); +} - // Disable reason, this code will eventually be implemented. - // eslint-disable-next-line no-unused-vars - for ( const deletedClientId of deletedClientIds ) { - // TODO - delete menu items. +const menuItemsToNavigationBlock = ( menuItems ) => { + const itemsByParentID = groupBy( menuItems, 'parent' ); + const clientIdToMenuItemMapping = {}; + const menuItemsToTreeOfLinkBlocks = ( items ) => { + const innerBlocks = []; + if ( ! items ) { + return; } - createSuccessNotice( __( 'Navigation saved.' ), { - type: 'snackbar', - } ); + const sortedItems = sortBy( items, 'menu_order' ); + for ( const item of sortedItems ) { + let menuItemInnerBlocks = []; + if ( itemsByParentID[ item.id ]?.length ) { + menuItemInnerBlocks = menuItemsToTreeOfLinkBlocks( + itemsByParentID[ item.id ] + ); + } + const linkBlock = menuItemToLinkBlock( item, menuItemInnerBlocks ); + clientIdToMenuItemMapping[ linkBlock.clientId ] = item; + innerBlocks.push( linkBlock ); + } + return innerBlocks; }; - return [ blocks, setBlocks, saveBlocks ]; + // menuItemsToTreeOfLinkBlocks takes an array of top-level menu items and recursively creates all their innerBlocks + const innerBlocks = menuItemsToTreeOfLinkBlocks( + itemsByParentID[ 0 ] || [] + ); + const navigationBlock = createBlock( 'core/navigation', {}, innerBlocks ); + return [ navigationBlock, clientIdToMenuItemMapping ]; +}; + +function menuItemToLinkBlock( menuItem, innerBlocks = [] ) { + return createBlock( + 'core/navigation-link', + { + label: menuItem.title.rendered, + url: menuItem.url, + }, + innerBlocks + ); } + +const getAllClientIds = ( blocks ) => + blocks.flatMap( ( item ) => + [ item.clientId ].concat( getAllClientIds( item.innerBlocks || [] ) ) + );