diff --git a/lib/class-wp-rest-menu-items-batch-processor.php b/lib/class-wp-rest-menu-items-batch-processor.php new file mode 100644 index 00000000000000..51022742220116 --- /dev/null +++ b/lib/class-wp-rest-menu-items-batch-processor.php @@ -0,0 +1,214 @@ +navigation_id = $navigation_id; + $this->request = $request; + $this->controller = $controller; + $this->wpdb = $wpdb; + } + + /** + * Brings the stored menu items to the state described by $raw_input. Validates the entire + * input upfront and short-circuits if it's invalid. + * + * @param object $raw_input Raw input from the client - a tree of menu items that the user wants to persist. + * + * @return void|WP_Error Nothing on success, WP_Error carrying the data about specific input that caused the problem on failure + */ + public function process( $raw_input ) { + $batch = $this->compute_batch( $raw_input ); + if ( is_wp_error( $batch ) ) { + return $batch; + } + + $validated_batch = $this->validate_batch( $batch ); + if ( is_wp_error( $validated_batch ) ) { + return $validated_batch; + } + + do_action( 'menu_items_batch_processing_start', $this->navigation_id ); + + $result = $this->persist_batch( $validated_batch ); + + if ( is_wp_error( $result ) ) { + // We're in a broken state now, some operations succeeded and some other failed. + // This is okay for the experimental version 1. + // In the future let's wrap this in a transaction if WP tables are based on InnoDB + // and do something smart on rollback - e.g. try to restore the original state, or + // refresh all the caches that were affected in the process. + do_action( 'menu_items_batch_processing_failure', $this->navigation_id ); + + return $result; + } + + do_action( 'menu_items_batch_processing_success', $this->navigation_id ); + + return $result; + } + + /** + * Computes a list of updates and deletes necessary to reshape the current DB state into the one described by $input_tree. + * + * @param object $input_tree Raw input from the client - a tree of menu items that the user wants to persist. + * + * @return array|WP_Error List of operations on success, WP_Error carrying the data about specific input that caused the problem on failure. + */ + public function compute_batch( $input_tree ) { + $current_menu_items = $this->controller->get_menu_items( $this->navigation_id ); + $operations = array(); + + $stack = array( + array( null, $input_tree ), + ); + $updated_ids = array(); + + // Compute all necessary Updates. + while ( ! empty( $stack ) ) { + list( $parent_operation, $raw_menu_items ) = array_pop( $stack ); + foreach ( $raw_menu_items as $n => $raw_menu_item ) { + $children = ! empty( $raw_menu_item['children'] ) ? $raw_menu_item['children'] : array(); + unset( $raw_menu_item['children'] ); + // Let's infer the menu order and parent id from the input tree. + $raw_menu_item['menu_order'] = $n + 1; + $raw_menu_item['parent'] = $parent_operation ? $parent_operation[1]['id'] : 0; + + if ( ! empty( $raw_menu_item['id'] ) ) { + $updated_ids[] = $raw_menu_item['id']; + $operation = array( static::UPDATE, $raw_menu_item ); + $operations[] = $operation; + } else { + // Inserts are slow so we don't allow them here. Instead they are handled "on the fly" + // by use-navigation-blocks.js so that this code may deal exclusively with the updates. + return new WP_Error( 'insert_unsupported', __( 'Cannot insert new items using batch processing.', 'gutenberg' ), array( 'status' => 400 ) ); + } + + if ( $children ) { + array_push( $stack, array( $operation, $children ) ); + } + } + } + + // Delete any orphaned items. + foreach ( $current_menu_items as $item ) { + if ( ! in_array( $item->ID, $updated_ids, true ) ) { + $operations[] = array( + static::DELETE, + array( + 'menus' => $this->navigation_id, + 'force' => true, + 'id' => $item->ID, + ), + ); + } + } + + return $operations; + } + + /** + * Validates the list of operations from compute_batch. + * + * @param object $batch Output of compute_batch. + * + * @return array|WP_Error List of validated operations enriched with the database-ready arrays on success, WP_Error carrying the data about specific input that caused the problem on failure. + */ + public function validate_batch( $batch ) { + // We infer the menu order and parent id from the received input tree so there's no need + // to validate them in the controller. + foreach ( $batch as $k => list( $type, $input ) ) { + $request = new WP_REST_Request(); + $request->set_default_params( $input ); + $request->set_param( 'validate_order_and_hierarchy', false ); + if ( static::UPDATE === $type ) { + $result = $this->controller->update_item_validate( $request ); + } elseif ( static::DELETE === $type ) { + $result = $this->controller->delete_item_validate( $request ); + } + if ( is_wp_error( $result ) ) { + $result->add_data( $input, 'input' ); + return $result; + } + $batch[ $k ][] = $result; + } + + return $batch; + } + + /** + * Executes the operations prepared by compute_batch and validate_batch. + * + * @param object $validated_operations Output of batch_validate. + * + * @return void|WP_Error Nothing on success, WP_Error carrying the data about specific input that caused the problem on failure. + */ + public function persist_batch( $validated_operations ) { + foreach ( $validated_operations as $operation ) { + list( $type, $input, $prepared_nav_item ) = $operation; + $request = new WP_REST_Request(); + $request->set_default_params( $input ); + if ( static::UPDATE === $type ) { + $result = $this->controller->update_item_persist( $prepared_nav_item, $request ); + } elseif ( static::DELETE === $type ) { + $result = $this->controller->delete_item_persist( $request ); + } + + if ( is_wp_error( $result ) ) { + $result->add_data( $input, 'input' ); + return $result; + } + + if ( static::UPDATE === $type ) { + $this->controller->update_item_notify( $result, $request ); + } elseif ( static::DELETE === $type ) { + $this->controller->delete_item_notify( $result, new WP_REST_Response(), $request ); + } + } + } + +} diff --git a/lib/class-wp-rest-menu-items-controller.php b/lib/class-wp-rest-menu-items-controller.php index 1e1b84975abeb0..2774cfaaba002c 100644 --- a/lib/class-wp-rest-menu-items-controller.php +++ b/lib/class-wp-rest-menu-items-controller.php @@ -12,6 +12,14 @@ * @see WP_REST_Posts_Controller */ class WP_REST_Menu_Items_Controller extends WP_REST_Posts_Controller { + + /** + * List of cached menu items, used to avoid re-fetching during batch processing. + * + * @var array + */ + protected $cached_menu_items = array(); + /** * Constructor. * @@ -22,6 +30,28 @@ public function __construct( $post_type ) { $this->namespace = '__experimental'; } + /** + * Adds /batch endpoint + * + * @see WP_REST_Posts_Controller + */ + public function register_routes() { + parent::register_routes(); + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/batch', + array( + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'process_batch' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + /** * Get the post, if the ID is valid. * @@ -181,6 +211,26 @@ public function create_item( $request ) { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function update_item( $request ) { + $prepared_nav_item = $this->update_item_validate( $request ); + if ( is_wp_error( $prepared_nav_item ) ) { + return $prepared_nav_item; + } + $nav_menu_item = $this->update_item_persist( $prepared_nav_item, $request ); + $this->update_item_notify( $nav_menu_item, $request ); + + $response = $this->prepare_item_for_response( $nav_menu_item, $request ); + + return rest_ensure_response( $response ); + } + + /** + * Validates an update to a single nav menu item. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return object|WP_Error Result of prepare_item_for_database if validation succeeded, WP_Error otherwise. + */ + public function update_item_validate( $request ) { $valid_check = $this->get_nav_menu_item( $request['id'] ); if ( is_wp_error( $valid_check ) ) { return $valid_check; @@ -193,7 +243,18 @@ public function update_item( $request ) { } $prepared_nav_item = (array) $prepared_nav_item; + return $prepared_nav_item; + } + /** + * Updates an a single nav menu item once it's been validated. + * + * @param object $prepared_nav_item Validated navigation item to persist. + * @param WP_REST_Request $request Full details about the request. + * + * @return object|WP_Error Post object if update succeeded, WP_Error otherwise. + */ + public function update_item_persist( $prepared_nav_item, $request ) { $nav_menu_item_id = wp_update_nav_menu_item( $prepared_nav_item['menu-id'], $prepared_nav_item['menu-item-db-id'], $prepared_nav_item ); if ( is_wp_error( $nav_menu_item_id ) ) { @@ -233,14 +294,20 @@ public function update_item( $request ) { return $fields_update; } + return $nav_menu_item; + } + + /** + * Runs rest_after_insert_ action + * + * @param object $nav_menu_item Stored menu item. + * @param WP_REST_Request $request Full details about the request. + */ + public function update_item_notify( $nav_menu_item, $request ) { $request->set_param( 'context', 'edit' ); /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */ do_action( "rest_after_insert_{$this->post_type}", $nav_menu_item, $request, false ); - - $response = $this->prepare_item_for_response( $nav_menu_item, $request ); - - return rest_ensure_response( $response ); } /** @@ -250,7 +317,39 @@ public function update_item( $request ) { * @return true|WP_Error True on success, or WP_Error object on failure. */ public function delete_item( $request ) { - $menu_item = $this->get_nav_menu_item( $request['id'] ); + $menu_item = $this->delete_item_validate( $request ); + if ( is_wp_error( $menu_item ) ) { + return $menu_item; + } + $previous = $this->prepare_item_for_response( $menu_item, $request ); + + $result = $this->delete_item_persist( $request ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $response = new WP_REST_Response(); + $response->set_data( + array( + 'deleted' => true, + 'previous' => $previous->get_data(), + ) + ); + + $this->delete_item_notify( $menu_item, $response, $request ); + + return $response; + } + + /** + * Validates a delete of a single nav menu item. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return object|WP_Error Menu item to be deleted if validation succeeded, WP_Error otherwise. + */ + public function delete_item_validate( $request ) { + $menu_item = $this->get_nav_menu_item_cached( $request['id'], $request['menus'] ); if ( is_wp_error( $menu_item ) ) { return $menu_item; } @@ -263,22 +362,32 @@ public function delete_item( $request ) { return new WP_Error( 'rest_trash_not_supported', sprintf( __( "Menu items do not support trashing. Set '%s' to delete.", 'gutenberg' ), 'force=true' ), array( 'status' => 501 ) ); } - $previous = $this->prepare_item_for_response( $menu_item, $request ); + return $menu_item; + } + /** + * Delete a single nav menu item once it was validated. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return void|WP_Error Nothing on success, WP_Error on failure. + */ + public function delete_item_persist( $request ) { $result = wp_delete_post( $request['id'], true ); if ( ! $result ) { return new WP_Error( 'rest_cannot_delete', __( 'The post cannot be deleted.', 'gutenberg' ), array( 'status' => 500 ) ); } + } - $response = new WP_REST_Response(); - $response->set_data( - array( - 'deleted' => true, - 'previous' => $previous->get_data(), - ) - ); - + /** + * Runs rest_delete_ action after deleting a menu item + * + * @param Object $menu_item The deleted or trashed menu item. + * @param WP_REST_Response $response The response data. + * @param WP_REST_Request $request Full details about the request. + */ + public function delete_item_notify( $menu_item, $response, $request ) { /** * Fires immediately after a single menu item is deleted or trashed via the REST API. * @@ -289,8 +398,6 @@ public function delete_item( $request ) { * @param WP_REST_Request $request The request sent to the API. */ do_action( "rest_delete_{$this->post_type}", $menu_item, $response, $request ); - - return $response; } /** @@ -430,7 +537,7 @@ protected function prepare_item_for_database( $request ) { } // If menu id is set, valid the value of menu item position and parent id. - if ( ! empty( $prepared_nav_item['menu-id'] ) ) { + if ( $request->get_param( 'validate_order_and_hierarchy' ) !== false && ! empty( $prepared_nav_item['menu-id'] ) ) { // Check if nav menu is valid. if ( ! is_nav_menu( $prepared_nav_item['menu-id'] ) ) { return new WP_Error( 'invalid_menu_id', __( 'Invalid menu ID.', 'gutenberg' ), array( 'status' => 400 ) ); @@ -1060,4 +1167,75 @@ protected function get_menu_id( $menu_item_id ) { return $menu_id; } + + /** + * Processes a batch of update and delete operations. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True on success, or WP_Error object on failure. + */ + public function process_batch( $request ) { + $navigation_id = $request['menus']; + $processor = new WP_REST_Menu_Items_Batch_Processor( $navigation_id, $this, $request ); + + $result = $processor->process( $request['tree'] ); + if ( is_wp_error( $result ) ) { + // Provide information to the client about the specific input that caused the problem. + $current_data = $result->get_error_data() ? $result->get_error_data() : array(); + $current_data['input'] = $result->get_error_data( 'input' ); + $result->add_data( $current_data ); + + return $result; + } + + return $this->get_items( $request ); + } + + /** + * Returns a menu item without querying the database on subsequent calls. + * + * @param int $id ID of the menu item. + * @param int $menu_id ID of the menu the menu item belongs to. + * + * @return object + */ + protected function get_nav_menu_item_cached( $id, $menu_id ) { + $items = $this->get_menu_items( $menu_id ); + if ( ! empty( $items[ $id ] ) ) { + return $items[ $id ]; + } + + return $this->get_nav_menu_item( $id ); + } + + /** + * Each endpoint fetches all the menu items multiple times. Since the bulk processing + * endpoint reuses most of the regular endpoints logic, it would hit the database even more. + * This caching logic makes it possible to avoid most of those round trips. + * + * @param int $menu_id ID of the menu to retrieve menu items from. + * @param bool $refresh Should the cache be refreshed. + * + * @return object[] A list of menu items related to the specified menu + */ + public function get_menu_items( $menu_id, $refresh = false ) { + if ( empty( $this->cached_menu_items[ $menu_id ] ) || $refresh ) { + $items_by_id = array(); + $items = wp_get_nav_menu_items( $menu_id, array( 'post_status' => 'publish,draft' ) ); + if ( $items ) { + foreach ( $items as $item ) { + $items_by_id[ $item->ID ] = $item; + } + } + $this->cached_menu_items[ $menu_id ] = $items_by_id; + } + + if ( empty( $this->cached_menu_items[ $menu_id ] ) ) { + return null; + } + + return $this->cached_menu_items[ $menu_id ]; + } + } + diff --git a/lib/load.php b/lib/load.php index 71f5c0191068b7..430d4071f494a9 100644 --- a/lib/load.php +++ b/lib/load.php @@ -45,6 +45,9 @@ function gutenberg_is_experiment_enabled( $name ) { if ( ! class_exists( 'WP_REST_Menu_Items_Controller' ) ) { require_once dirname( __FILE__ ) . '/class-wp-rest-menu-items-controller.php'; } + if ( ! class_exists( 'WP_REST_Menu_Items_Batch_Processor' ) ) { + require_once dirname( __FILE__ ) . '/class-wp-rest-menu-items-batch-processor.php'; + } if ( ! class_exists( 'WP_REST_Menu_Locations_Controller' ) ) { require_once dirname( __FILE__ ) . '/class-wp-rest-menu-locations-controller.php'; } 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 b9f7dabe342252..0d818bbc6bf80a 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,15 +1,16 @@ /** * External dependencies */ -import { groupBy, isEqual, difference } from 'lodash'; +import { groupBy, omitBy, sortBy, isNil, keyBy, omit } from 'lodash'; /** * WordPress dependencies */ 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'; +import apiFetch from '@wordpress/api-fetch'; function createBlockFromMenuItem( menuItem, innerBlocks = [] ) { return createBlock( @@ -23,6 +24,10 @@ function createBlockFromMenuItem( menuItem, innerBlocks = [] ) { } function createMenuItemAttributesFromBlock( block ) { + const attributes = {}; + if ( block.attributes.label ) { + attributes.title = block.attributes.label; + } return { title: block.attributes.label, url: block.attributes.url, @@ -31,16 +36,29 @@ function createMenuItemAttributesFromBlock( block ) { 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' ); - const [ blocks, setBlocks ] = useState( [] ); + const [ blocks, setBlocks, menuItemsRef ] = useNavigationBlocksModel( + menuItems + ); + + const [ onFinished ] = useDynamicMenuItemPlaceholders( + menuId, + blocks, + menuItemsRef + ); + + const saveBlocks = useSaveBlocks( menuId, blocks, menuItemsRef, query ); + + return [ blocks, setBlocks, () => onFinished( saveBlocks ) ]; +} +const useNavigationBlocksModel = ( menuItems ) => { + const [ blocks, setBlocks ] = useState( [] ); const menuItemsRef = useRef( {} ); useEffect( () => { @@ -49,15 +67,15 @@ export default function useNavigationBlocks( menuId ) { } const itemsByParentID = groupBy( menuItems, 'parent' ); - menuItemsRef.current = {}; - const createMenuItemBlocks = ( items ) => { const innerBlocks = []; if ( ! items ) { return; } - for ( const item of items ) { + + const sortedItems = sortBy( items, 'menu_order' ); + for ( const item of sortedItems ) { let menuItemInnerBlocks = []; if ( itemsByParentID[ item.id ]?.length ) { menuItemInnerBlocks = createMenuItemBlocks( @@ -75,68 +93,225 @@ export default function useNavigationBlocks( menuId ) { }; // createMenuItemBlocks takes an array of top-level menu items and recursively creates all their innerBlocks - const innerBlocks = createMenuItemBlocks( itemsByParentID[ 0 ] ); + const innerBlocks = createMenuItemBlocks( itemsByParentID[ 0 ] || [] ); setBlocks( [ createBlock( 'core/navigation', {}, innerBlocks ) ] ); }, [ menuItems ] ); - 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; + return [ blocks, setBlocks, menuItemsRef ]; +}; + +// Creation and synchronization logic for creating menu items on the fly. +// This effects creates so many functions on each re-render - it would be great to +// refactor it it and minimize that. +const useDynamicMenuItemPlaceholders = ( + menuId, + currentBlocks, + menuItemsRef +) => { + const blocks = useDebouncedValue( currentBlocks, 800 ); + const blocksByIdRef = useRef( {} ); + // This is used to ensure we will only process one menu item at a time and won't save + // the entire menu before all menu items are created. We could increase the concurrency + // quite easily too. + const processingRef = useRef( { + queue: [], + running: false, + notify: [], + } ); + + useEffect( + function() { + const blocksById = mapBlocksByClientId( blocks ); + blocksByIdRef.current = blocksById; + + const clientIdsWithoutRelatedMenuItem = Object.keys( + blocksById + ).filter( ( clientId ) => ! menuItemsRef.current[ clientId ] ); + + for ( const clientId of clientIdsWithoutRelatedMenuItem ) { + schedulePlaceholderMenuItem( clientId ); + } + }, + [ blocks ] + ); + + function schedulePlaceholderMenuItem( clientId ) { + const queue = processingRef.current.queue; + if ( queue.includes( clientId ) ) { + return; + } + queue.push( clientId ); + processQueue(); + } + + async function processQueue() { + const processing = processingRef.current; + if ( processing.running ) { + return; + } + processing.running = true; + try { + while ( processing.queue.length ) { + const [ clientIdToProcess, idx ] = getNextProcessableClientId(); + if ( ! clientIdToProcess ) { + if ( processing.queue.length ) { + // Rudimentary assertion - suggestions welcome! + // eslint-disable-next-line no-console + console.error( + 'getNextProcessableClientId() did not return anything even though the processing queue is not empty' + ); + return; } + break; } + await createPlaceholderMenuItem( clientIdToProcess ); + processing.queue.splice( idx, 1 ); + } + } finally { + processing.running = false; + notify(); + } + } - 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, - } ); - } + function notify() { + const listeners = processingRef.current.notify; + for ( let i = listeners.length - 1; i >= 0; i-- ) { + listeners[ i ](); + listeners.splice( i, 1 ); + } + } - if ( block.innerBlocks.length ) { - saveNestedBlocks( block.innerBlocks, currentItemId ); - } + function getNextProcessableClientId() { + const queue = processingRef.current.queue; + // While loop makes it possible to safely mutate the list + for ( let i = queue.length - 1; i >= 0; i-- ) { + const clientId = queue[ i ]; + + // Blocks was removed before we got to process it + if ( ! ( clientId in blocksByIdRef.current ) ) { + queue.splice( i, 1 ); + continue; } - }; - saveNestedBlocks( innerBlocks, parentItemId ); + // Menu item was already created before we got here + if ( clientId in menuItemsRef.current ) { + queue.splice( i, 1 ); + continue; + } - const deletedClientIds = difference( - Object.keys( menuItemsRef.current ), - innerBlocks.map( ( block ) => block.clientId ) - ); + return [ clientId, i ]; + } + } + + async function createPlaceholderMenuItem( clientId ) { + const block = blocksByIdRef.current[ clientId ]; + const createdItem = await apiFetch( { + path: `/__experimental/menu-items`, + method: 'POST', + data: { + title: 'Placeholder', + url: 'Placeholder', + ...omitBy( createMenuItemAttributesFromBlock( block ), isNil ), + menu_order: 0, + }, + } ); + menuItemsRef.current[ clientId ] = createdItem; + return createdItem; + } - // 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 onFinished = function( callback ) { + if ( ! processingRef.current.running ) { + callback(); + return; } + processingRef.current.notify.push( callback ); + }; + + return [ onFinished ]; +}; + +const useSaveBlocks = ( menuId, blocks, menuItemsRef, query ) => { + const { receiveEntityRecords } = useDispatch( 'core' ); + + const { createSuccessNotice } = useDispatch( 'core/notices' ); + + const saveBlocks = async () => { + const requestData = prepareRequestData( blocks[ 0 ].innerBlocks ); + + const saved = await apiFetch( { + path: `/__experimental/menu-items/batch?menus=${ menuId }`, + method: 'PUT', + data: { tree: requestData }, + } ); createSuccessNotice( __( 'Navigation saved.' ), { type: 'snackbar', } ); + + const kind = 'root'; + const name = 'menuItem'; + // receiveEntityRecords( + // kind, + // name, + // saved, + // // { + // // ...item.data, + // // title: { rendered: 'experimental' }, + // // }, + // undefined, + // true + // ); + receiveEntityRecords( + 'root', + 'menuItem', + Object.values( saved ), + query, + false + ); }; - return [ blocks, setBlocks, saveBlocks ]; -} + const prepareRequestData = ( nestedBlocks ) => + nestedBlocks.map( ( block ) => ( { + ...prepareRequestItem( block ), + children: prepareRequestData( block.innerBlocks ), + } ) ); + + const prepareRequestItem = ( block ) => { + const menuItem = omit( + menuItemsRef.current[ block.clientId ] || {}, + '_links' + ); + + return { + ...menuItem, + ...createMenuItemAttributesFromBlock( block ), + clientId: block.clientId, + menus: menuId, + }; + }; + + return saveBlocks; +}; + +const useDebouncedValue = ( value, timeout ) => { + const [ state, setState ] = useState( value ); + + useEffect( () => { + const handler = setTimeout( () => setState( value ), timeout ); + + return () => clearTimeout( handler ); + }, [ value, timeout ] ); + + return state; +}; + +const mapBlocksByClientId = ( nextBlocks ) => + keyBy( + flatten( nextBlocks[ 0 ]?.innerBlocks || [], 'innerBlocks' ), + 'clientId' + ); + +const flatten = ( recursiveArray, childrenKey ) => + recursiveArray.flatMap( ( item ) => + [ item ].concat( flatten( item[ childrenKey ] || [], childrenKey ) ) + ); diff --git a/phpunit/class-rest-nav-menu-items-batch-processor-test.php b/phpunit/class-rest-nav-menu-items-batch-processor-test.php new file mode 100644 index 00000000000000..a9f875204d8747 --- /dev/null +++ b/phpunit/class-rest-nav-menu-items-batch-processor-test.php @@ -0,0 +1,276 @@ + REST_Nav_Menu_Items_Batch_Processor_Test class + * + * @package WordPress + * @subpackage REST_API + */ + +/** + * Tests for REST API batch processor for Menu items. + * + * @see WP_UnitTestCase + */ +class REST_Nav_Menu_Items_Batch_Processor_Test extends WP_UnitTestCase { + + /** + * @var int + */ + protected $menu_id; + + /** + * @var int + */ + protected $tag_id; + + /** + * @var int + */ + protected $menu_item_id; + + /** + * @var WP_REST_Menu_Items_Batch_Processor + */ + protected $batch_processor; + + /** + * @var int + */ + protected static $admin_id; + + /** + * @var int + */ + protected static $subscriber_id; + + /** + * + */ + const POST_TYPE = 'nav_menu_item'; + + /** + * Create fake data before our tests run. + * + * @param WP_UnitTest_Factory $factory Helper that lets us create fake data. + */ + public static function wpSetUpBeforeClass( $factory ) { + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$subscriber_id = $factory->user->create( + array( + 'role' => 'subscriber', + ) + ); + } + + /** + * + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$subscriber_id ); + } + + /** + * + */ + public function setUp() { + parent::setUp(); + + $this->tag_id = self::factory()->tag->create(); + + $this->menu_id = wp_create_nav_menu( rand_str() ); + + $request = new WP_REST_Request( 'POST', '/__experimental/menu-items/batch' ); + $controller = new WP_REST_Menu_Items_Controller( 'nav_menu_item' ); + $this->batch_processor = new WP_REST_Menu_Items_Batch_Processor( + $this->menu_id, + $controller, + $request + ); + } + + /** + * + */ + public function test_compute_batch_returns_delete_when_empty_tree_is_provided_single_item() { + $menu_item_id = $this->create_menu_item( 0 ); + $batch = $this->batch_processor->compute_batch( array() ); + + $this->assertEquals( + array( + $this->delete_operation( $menu_item_id ), + ), + $batch + ); + } + + /** + * + */ + public function test_compute_batch_returns_deletes_when_empty_tree_is_provided_flat_list() { + $menu_item_ids = array( + $this->create_menu_item( 0 ), + $this->create_menu_item( 0 ), + $this->create_menu_item( 0 ), + $this->create_menu_item( 0 ), + $this->create_menu_item( 0 ), + ); + + $batch = $this->batch_processor->compute_batch( array() ); + + $expected_operations = array(); + foreach ( $menu_item_ids as $id ) { + $expected_operations[] = $this->delete_operation( $id ); + } + + $this->assertEquals( + $expected_operations, + $batch + ); + } + + /** + * + */ + public function test_compute_batch_returns_deletes_when_empty_tree_is_provided_nested_list() { + $menu_item_ids = array( + $this->create_menu_item( 0 ), + $this->create_menu_item( 0 ), + $this->create_menu_item( 0 ), + $this->create_menu_item( 0 ), + $this->create_menu_item( 0 ), + ); + + $menu_item_ids[] = $this->create_menu_item( $menu_item_ids[0] ); + $menu_item_ids[] = $this->create_menu_item( $menu_item_ids[ count( $menu_item_ids ) - 1 ] ); + $menu_item_ids[] = $this->create_menu_item( $menu_item_ids[ count( $menu_item_ids ) - 2 ] ); + $menu_item_ids[] = $this->create_menu_item( $menu_item_ids[ count( $menu_item_ids ) - 1 ] ); + $menu_item_ids[] = $this->create_menu_item( $menu_item_ids[1] ); + $menu_item_ids[] = $this->create_menu_item( $menu_item_ids[ count( $menu_item_ids ) - 1 ] ); + + $batch = $this->batch_processor->compute_batch( array() ); + + $expected_operations = array(); + foreach ( $menu_item_ids as $id ) { + $expected_operations[] = $this->delete_operation( $id ); + } + + $this->assertEquals( + $expected_operations, + $batch + ); + } + + /** + * + */ + public function test_compute_batch_refuses_to_produce_an_insert() { + $tree = array( $this->create_tree_item( 0 ) ); + unset( $tree[0]['id'] ); + $batch = $this->batch_processor->compute_batch( $tree ); + + $this->assertTrue( is_wp_error( $batch ) ); + $this->assertEquals( 'insert_unsupported', $batch->get_error_code() ); + } + + /** + * + */ + public function test_compute_batch_returns_update_when_single_item_to_update_is_provided() { + $menu_item_id = $this->create_menu_item( 0 ); + $input_menu_item = $this->create_tree_item( $menu_item_id ); + $tree = array( $input_menu_item ); + + $batch = $this->batch_processor->compute_batch( $tree ); + + unset( $input_menu_item['children'] ); + $input_menu_item['parent'] = 0; + $input_menu_item['menu_order'] = 1; + + // @TODO: Delete menu item from the previous test case + $this->assertEquals( + array( + $this->update_operation( $input_menu_item ), + ), + $batch + ); + } + + /** + * + */ + protected function delete_operation( $menu_item_id ) { + return + array( + 'delete', + array( + 'menus' => $this->menu_id, + 'force' => true, + 'id' => $menu_item_id, + ), + ); + } + + /** + * + */ + protected function update_operation( $raw_menu_item ) { + return + array( + 'update', + $raw_menu_item, + ); + } + + /** + * + * @param int $parent_id Parent id. + */ + protected function create_menu_item( $parent_id ) { + return wp_update_nav_menu_item( + $this->menu_id, + 0, + array( + 'menu-item-type' => 'taxonomy', + 'menu-item-object' => 'post_tag', + 'menu-item-object-id' => $this->tag_id, + 'menu-item-status' => 'publish', + 'menu-item-parent-id' => $parent_id, + ) + ); + } + + /** + * + */ + protected function create_tree_item( $id ) { + return array( + 'id' => $id + 1, + 'title' => 'Be first', + 'status' => 'publish', + 'url' => 'http://localhost:8888/?page_id=5', + 'attr_title' => '', + 'description' => '', + 'type' => 'custom', + 'type_label' => 'Custom Link', + 'object' => 'custom', + 'menu_order' => 1, + 'target' => '', + 'classes' => array( + '', + ), + 'xfn' => array( + '', + ), + 'meta' => array(), + 'menus' => $this->menu_id, + 'children' => array(), + ); + } + + +} diff --git a/phpunit/class-rest-nav-menu-items-controller-test.php b/phpunit/class-rest-nav-menu-items-controller-test.php index a9c5ff444a7790..fb99c2973d5b6c 100644 --- a/phpunit/class-rest-nav-menu-items-controller-test.php +++ b/phpunit/class-rest-nav-menu-items-controller-test.php @@ -98,6 +98,8 @@ public function test_register_routes() { $this->assertCount( 2, $routes['/__experimental/menu-items'] ); $this->assertArrayHasKey( '/__experimental/menu-items/(?P[\d]+)', $routes ); $this->assertCount( 3, $routes['/__experimental/menu-items/(?P[\d]+)'] ); + $this->assertArrayHasKey( '/__experimental/menu-items/batch', $routes ); + $this->assertCount( 1, $routes['/__experimental/menu-items/batch'] ); } /**