From 4863f57668471779a430ed0fa47857328b52b788 Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Thu, 9 Sep 2021 09:09:26 +0100 Subject: [PATCH 01/57] Remove tests --- ...ss-rest-nav-menu-items-controller-test.php | 60 ------------------- 1 file changed, 60 deletions(-) diff --git a/phpunit/class-rest-nav-menu-items-controller-test.php b/phpunit/class-rest-nav-menu-items-controller-test.php index 77b629bbbc75d..0a7a4032b575f 100644 --- a/phpunit/class-rest-nav-menu-items-controller-test.php +++ b/phpunit/class-rest-nav-menu-items-controller-test.php @@ -271,66 +271,6 @@ public function test_create_item_invalid_term() { $this->assertErrorResponse( 'rest_term_invalid_id', $response, 400 ); } - /** - * - */ - public function test_create_item_change_position() { - wp_set_current_user( self::$admin_id ); - $new_menu_id = wp_create_nav_menu( rand_str() ); - $expected = array(); - $actual = array(); - for ( $i = 1; $i < 5; $i ++ ) { - $request = new WP_REST_Request( 'POST', '/__experimental/menu-items' ); - $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); - $params = $this->set_menu_item_data( - array( - 'menu_order' => $i, - 'menus' => $new_menu_id, - ) - ); - $request->set_body_params( $params ); - $response = rest_get_server()->dispatch( $request ); - $this->check_create_menu_item_response( $response ); - $data = $response->get_data(); - - $expected[] = $i; - $actual[] = $data['menu_order']; - } - $this->assertEquals( $actual, $expected ); - } - - /** - * - */ - public function test_menu_order_must_be_set() { - wp_set_current_user( self::$admin_id ); - $new_menu_id = wp_create_nav_menu( rand_str() ); - - $request = new WP_REST_Request( 'POST', '/__experimental/menu-items' ); - $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); - $params = $this->set_menu_item_data( - array( - 'menu_order' => 0, - 'menus' => $new_menu_id, - ) - ); - $request->set_body_params( $params ); - $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); - - $request = new WP_REST_Request( 'POST', '/__experimental/menu-items' ); - $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); - $params = $this->set_menu_item_data( - array( - 'menu_order' => 1, - 'menus' => $new_menu_id, - ) - ); - $request->set_body_params( $params ); - $response = rest_get_server()->dispatch( $request ); - $this->assertEquals( 201, $response->get_status() ); - } - /** * */ From 4310b3eff8ff0c58ed6106f96d43fcacd574a757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Sep 2021 14:16:44 +0200 Subject: [PATCH 02/57] Validate if menu_order is set and >= 1 --- lib/class-wp-rest-menu-items-controller.php | 17 ++++++ ...ss-rest-nav-menu-items-controller-test.php | 60 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/lib/class-wp-rest-menu-items-controller.php b/lib/class-wp-rest-menu-items-controller.php index e908bebe31577..30377cfae1a0d 100644 --- a/lib/class-wp-rest-menu-items-controller.php +++ b/lib/class-wp-rest-menu-items-controller.php @@ -524,6 +524,23 @@ protected function prepare_item_for_database( $request ) { } } + // If menu id is set, validate the value of menu item position. + if ( ! 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 ) ); + } + + // Check if menu item position is non-zero and positive. + if ( (int) $prepared_nav_item['menu-item-position'] < 1 ) { + return new WP_Error( 'invalid_menu_order', __( 'Invalid menu order.', 'gutenberg' ), array( 'status' => 400 ) ); + } + } + + if ( ! empty( $prepared_nav_item['menu-id'] ) && ! is_nav_menu( $prepared_nav_item['menu-id'] ) ) { + return new WP_Error( 'invalid_menu_id', __( 'Invalid menu ID.', 'gutenberg' ), array( 'status' => 400 ) ); + } + foreach ( array( 'menu-item-object-id', 'menu-item-parent-id' ) as $key ) { // Note we need to allow negative-integer IDs for previewed objects not inserted yet. $prepared_nav_item[ $key ] = (int) $prepared_nav_item[ $key ]; diff --git a/phpunit/class-rest-nav-menu-items-controller-test.php b/phpunit/class-rest-nav-menu-items-controller-test.php index 0a7a4032b575f..2388fa8c9f9b2 100644 --- a/phpunit/class-rest-nav-menu-items-controller-test.php +++ b/phpunit/class-rest-nav-menu-items-controller-test.php @@ -271,6 +271,66 @@ public function test_create_item_invalid_term() { $this->assertErrorResponse( 'rest_term_invalid_id', $response, 400 ); } + /** + * + */ + public function test_create_item_change_position() { + wp_set_current_user( self::$admin_id ); + $new_menu_id = wp_create_nav_menu( rand_str() ); + $expected = array(); + $actual = array(); + for ( $i = 1; $i < 5; $i ++ ) { + $request = new WP_REST_Request( 'POST', '/__experimental/menu-items' ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + $params = $this->set_menu_item_data( + array( + 'menu_order' => $i, + 'menus' => $new_menu_id, + ) + ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $this->check_create_menu_item_response( $response ); + $data = $response->get_data(); + + $expected[] = $i; + $actual[] = $data['menu_order']; + } + $this->assertEquals( $actual, $expected ); + } + + /** + * + */ + public function test_menu_order_must_be_set() { + wp_set_current_user( self::$admin_id ); + $new_menu_id = wp_create_nav_menu( rand_str() ); + + $request = new WP_REST_Request( 'POST', '/__experimental/menu-items' ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + $params = $this->set_menu_item_data( + array( + 'menu_order' => 0, + 'menus' => $new_menu_id, + ) + ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'invalid_menu_order', $response, 400 ); + + $request = new WP_REST_Request( 'POST', '/__experimental/menu-items' ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + $params = $this->set_menu_item_data( + array( + 'menu_order' => 1, + 'menus' => $new_menu_id, + ) + ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 201, $response->get_status() ); + } + /** * */ From 58598b1055e90d25c26563e2eeaf36fd95ca9f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 3 Sep 2021 18:25:39 +0200 Subject: [PATCH 03/57] First stab at saving menu items using the REST API --- .../data/src/redux-store/thunk-middleware.js | 3 +- packages/edit-navigation/src/store/actions.js | 153 +++++++++++++----- packages/edit-navigation/src/store/index.js | 1 + 3 files changed, 112 insertions(+), 45 deletions(-) diff --git a/packages/data/src/redux-store/thunk-middleware.js b/packages/data/src/redux-store/thunk-middleware.js index 903b7dfe2e515..ccb4b1a35e445 100644 --- a/packages/data/src/redux-store/thunk-middleware.js +++ b/packages/data/src/redux-store/thunk-middleware.js @@ -1,7 +1,8 @@ export default function createThunkMiddleware( args ) { return () => ( next ) => ( action ) => { if ( typeof action === 'function' ) { - return action( args ); + const retval = action( args ); + return retval; } return next( action ); diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 0eb641a12858f..d48c781274702 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -1,14 +1,14 @@ /** * External dependencies */ -import { invert } from 'lodash'; -import { v4 as uuid } from 'uuid'; +import { invert, omit } from 'lodash'; /** * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; +import { serialize } from '@wordpress/blocks'; /** * Internal dependencies @@ -16,19 +16,17 @@ import { store as noticesStore } from '@wordpress/notices'; import { getMenuItemToClientIdMapping, resolveMenuItems, - dispatch, - select, - apiFetch, + dispatch as registryDispatch, + select as registrySelect, + apiFetch as apiFetchControl, } from './controls'; import { NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE } from '../constants'; import { menuItemsQuery, serializeProcessing, - computeCustomizedAttribute, + blockAttributesToMenuItem, } from './utils'; -const { ajaxurl } = window; - /** * Returns an action object used to select menu. * @@ -59,7 +57,7 @@ export const createMissingMenuItems = serializeProcessing( function* ( post ) { while ( stack.length ) { const block = stack.pop(); if ( ! ( block.clientId in clientIdToMenuId ) ) { - const menuItem = yield apiFetch( { + const menuItem = yield apiFetchControl( { path: `/__experimental/menu-items`, method: 'POST', data: { @@ -71,7 +69,7 @@ export const createMissingMenuItems = serializeProcessing( function* ( post ) { mapping[ menuItem.id ] = block.clientId; const menuItems = yield resolveMenuItems( menuId ); - yield dispatch( + yield registryDispatch( 'core', 'receiveEntityRecords', 'root', @@ -106,7 +104,7 @@ export const saveNavigationPost = serializeProcessing( function* ( post ) { try { // Save edits to the menu, like the menu name. - yield dispatch( + yield registryDispatch( 'core', 'saveEditedEntityRecord', 'root', @@ -114,7 +112,7 @@ export const saveNavigationPost = serializeProcessing( function* ( post ) { menuId ); - const error = yield select( + const error = yield registrySelect( 'core', 'getLastEntitySaveError', 'root', @@ -126,6 +124,8 @@ export const saveNavigationPost = serializeProcessing( function* ( post ) { throw new Error( error.message ); } + // saveEntityRecord for each menu item with block-based data + // saveEntityRecord for each deleted menu item // Save blocks as menu items. const batchSaveResponse = yield* batchSave( menuId, @@ -138,7 +138,7 @@ export const saveNavigationPost = serializeProcessing( function* ( post ) { } // Clear "stub" navigation post edits to avoid a false "dirty" state. - yield dispatch( + yield registryDispatch( 'core', 'receiveEntityRecords', NAVIGATION_POST_KIND, @@ -147,7 +147,7 @@ export const saveNavigationPost = serializeProcessing( function* ( post ) { undefined ); - yield dispatch( + yield registryDispatch( noticesStore, 'createSuccessNotice', __( 'Navigation saved.' ), @@ -163,9 +163,14 @@ export const saveNavigationPost = serializeProcessing( function* ( post ) { saveError.message ) : __( 'Unable to save: An error ocurred.' ); - yield dispatch( noticesStore, 'createErrorNotice', errorMessage, { - type: 'snackbar', - } ); + yield registryDispatch( + noticesStore, + 'createErrorNotice', + errorMessage, + { + type: 'snackbar', + } + ); } } ); @@ -184,36 +189,96 @@ function mapMenuItemsByClientId( menuItems, clientIdsByMenuId ) { } function* batchSave( menuId, menuItemsByClientId, navigationBlock ) { - const { nonce, stylesheet } = yield apiFetch( { - path: '/__experimental/customizer-nonces/get-save-nonce', - } ); - if ( ! nonce ) { - throw new Error(); + const blocksList = blocksTreeToFlatList( navigationBlock.innerBlocks ); + + const batchTasks = []; + // Enqueue updates + for ( const { block, parentId, position } of blocksList ) { + const menuItem = getMenuItemForBlock( block ); + + // Update an existing navigation item. + yield registryDispatch( + 'core', + 'editEntityRecord', + 'root', + 'menuItem', + menuItem.id, + blockToEntityRecord( block, parentId, position ), + { undoIgnore: true } + ); + + const hasEdits = yield registrySelect( + 'core', + 'hasEditsForEntityRecord', + 'root', + 'menuItem', + menuItem.id + ); + + if ( ! hasEdits ) { + continue; + } + + batchTasks.push( ( { saveEditedEntityRecord } ) => + saveEditedEntityRecord( 'root', 'menuItem', menuItem.id ) + ); } + return yield registryDispatch( 'core', '__experimentalBatch', batchTasks ); - // 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 - ) - ); + // Enqueue deletes + // @TODO + + // 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; + // } + // } + + function blockToEntityRecord( block, parentId, position ) { + const menuItem = omit( getMenuItemForBlock( block ), 'menus', 'meta' ); + + let attributes; - return yield apiFetch( { - url: ajaxurl || '/wp-admin/admin-ajax.php', - method: 'POST', - body, - } ); + if ( block.name === 'core/navigation-link' ) { + attributes = blockAttributesToMenuItem( block.attributes ); + } else { + attributes = { + type: 'block', + content: serialize( block ), + }; + } + + return { + ...menuItem, + ...attributes, + position, + nav_menu_term_id: menuId, + menu_item_parent: parentId, + status: 'publish', + _invalid: false, + }; + } + + function blocksTreeToFlatList( innerBlocks, parentId = 0 ) { + return innerBlocks.flatMap( ( block, index ) => + [ { block, parentId, position: index + 1 } ].concat( + blocksTreeToFlatList( + block.innerBlocks, + getMenuItemForBlock( block )?.id + ) + ) + ); + } + + function getMenuItemForBlock( block ) { + return omit( menuItemsByClientId[ block.clientId ] || {}, '_links' ); + } } /** diff --git a/packages/edit-navigation/src/store/index.js b/packages/edit-navigation/src/store/index.js index 74c53af154d23..a64a8443609a7 100644 --- a/packages/edit-navigation/src/store/index.js +++ b/packages/edit-navigation/src/store/index.js @@ -27,6 +27,7 @@ const storeConfig = { resolvers, actions, persist: [ 'selectedMenuId' ], + __experimentalUseThunks: true, }; /** From 4ab63b86c54f298a293bb900235a2b1c0e5f15e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 7 Sep 2021 16:17:51 +0200 Subject: [PATCH 04/57] Migrate batch save to async thunks using REST API calls --- packages/edit-navigation/src/store/actions.js | 287 ++++++++------- .../edit-navigation/src/store/controls.js | 122 ------- .../edit-navigation/src/store/test/utils.js | 336 ------------------ packages/edit-navigation/src/store/utils.js | 141 -------- 4 files changed, 149 insertions(+), 737 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index d48c781274702..c840af59581ea 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -9,23 +9,14 @@ import { invert, omit } from 'lodash'; import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { serialize } from '@wordpress/blocks'; +import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies */ -import { - getMenuItemToClientIdMapping, - resolveMenuItems, - dispatch as registryDispatch, - select as registrySelect, - apiFetch as apiFetchControl, -} from './controls'; +import { STORE_NAME } from './constants'; import { NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE } from '../constants'; -import { - menuItemsQuery, - serializeProcessing, - blockAttributesToMenuItem, -} from './utils'; +import { menuItemsQuery, blockAttributesToMenuItem } from './utils'; /** * Returns an action object used to select menu. @@ -47,47 +38,62 @@ export function setSelectedMenuId( menuId ) { * @param {Object} post A navigation post to process * @return {Function} An action creator */ -export const createMissingMenuItems = serializeProcessing( function* ( post ) { +export const createMissingMenuItems = ( post ) => async ( { + dispatch, + registry, +} ) => { const menuId = post.meta.menuId; - - const mapping = yield getMenuItemToClientIdMapping( 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 apiFetchControl( { - path: `/__experimental/menu-items`, - method: 'POST', - data: { - title: 'Placeholder', - url: 'Placeholder', - menu_order: 1, - }, - } ); - - mapping[ menuItem.id ] = block.clientId; - const menuItems = yield resolveMenuItems( menuId ); - yield registryDispatch( - 'core', - 'receiveEntityRecords', - 'root', - 'menuItem', - [ ...menuItems, menuItem ], - menuItemsQuery( menuId ), - false - ); + // @TODO: extract locks to a separate package? + const lock = await registry + .dispatch( 'core' ) + .__unstableAcquireStoreLock( STORE_NAME, [ 'savingMenu' ], { + exclusive: false, + } ); + try { + const mapping = await getMenuItemToClientIdMapping( registry, 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 = await apiFetch( { + path: `/__experimental/menu-items`, + method: 'POST', + data: { + title: 'Placeholder', + url: 'Placeholder', + menu_order: 0, + }, + } ); + + mapping[ menuItem.id ] = block.clientId; + const menuItems = await registry + .resolveSelect( 'core' ) + .getMenuItems( { menus: menuId, per_page: -1 } ); + + await registry + .dispatch( 'core' ) + .receiveEntityRecords( + 'root', + 'menuItem', + [ ...menuItems, menuItem ], + menuItemsQuery( menuId ), + false + ); + } + stack.push( ...block.innerBlocks ); } - stack.push( ...block.innerBlocks ); - } - yield { - type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', - postId: post.id, - mapping, - }; -} ); + dispatch( { + type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', + postId: post.id, + mapping, + } ); + } finally { + await registry.dispatch( 'core' ).__unstableReleaseStoreLock( lock ); + } +}; /** * Converts all the blocks into menu items and submits a batch request to save everything at once. @@ -95,66 +101,58 @@ export const createMissingMenuItems = serializeProcessing( function* ( post ) { * @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 ) - ); - +export const saveNavigationPost = ( post ) => async ( { + registry, + dispatch, +} ) => { + const lock = await registry + .dispatch( 'core' ) + .__unstableAcquireStoreLock( STORE_NAME, [ 'savingMenu' ], { + exclusive: true, + } ); try { - // Save edits to the menu, like the menu name. - yield registryDispatch( - 'core', - 'saveEditedEntityRecord', - 'root', - 'menu', - menuId + const menuId = post.meta.menuId; + const menuItems = await registry + .resolveSelect( 'core' ) + .getMenuItems( { menus: menuId, per_page: -1 } ); + + const menuItemsByClientId = mapMenuItemsByClientId( + menuItems, + getMenuItemToClientIdMapping( registry, post.id ) ); - const error = yield registrySelect( - 'core', - 'getLastEntitySaveError', - 'root', - 'menu', - menuId - ); + await registry + .dispatch( 'core' ) + .saveEditedEntityRecord( 'root', 'menu', menuId ); + + const error = registry + .select( 'core' ) + .getLastEntitySaveError( 'root', 'menu', menuId ); if ( error ) { throw new Error( error.message ); } - // saveEntityRecord for each menu item with block-based data - // saveEntityRecord for each deleted menu item // Save blocks as menu items. - const batchSaveResponse = yield* batchSave( - menuId, - menuItemsByClientId, - post.blocks[ 0 ] + await dispatch( + batchSave( menuId, menuItemsByClientId, post.blocks[ 0 ] ) ); - if ( ! batchSaveResponse.success ) { - throw new Error( batchSaveResponse.data.message ); - } - // Clear "stub" navigation post edits to avoid a false "dirty" state. - yield registryDispatch( - 'core', - 'receiveEntityRecords', - NAVIGATION_POST_KIND, - NAVIGATION_POST_POST_TYPE, - [ post ], - undefined - ); + await registry + .dispatch( 'core' ) + .receiveEntityRecords( + NAVIGATION_POST_KIND, + NAVIGATION_POST_POST_TYPE, + [ post ], + undefined + ); - yield registryDispatch( - noticesStore, - 'createSuccessNotice', - __( 'Navigation saved.' ), - { + await registry + .dispatch( noticesStore ) + .createSuccessNotice( __( 'Navigation saved.' ), { type: 'snackbar', - } - ); + } ); } catch ( saveError ) { const errorMessage = saveError ? sprintf( @@ -162,17 +160,19 @@ export const saveNavigationPost = serializeProcessing( function* ( post ) { __( "Unable to save: '%s'" ), saveError.message ) - : __( 'Unable to save: An error ocurred.' ); - yield registryDispatch( - noticesStore, - 'createErrorNotice', - errorMessage, - { + : __( 'Unable to save: An error o1curred.' ); + await registry + .dispatch( noticesStore ) + .createErrorNotice( errorMessage, { type: 'snackbar', - } - ); + } ); + } finally { + await registry.dispatch( 'core' ).__unstableReleaseStoreLock( lock ); } -} ); +}; + +const getMenuItemToClientIdMapping = ( registry, postId ) => + registry.stores[ STORE_NAME ].store.getState().mapping[ postId ] || {}; function mapMenuItemsByClientId( menuItems, clientIdsByMenuId ) { const result = {}; @@ -188,57 +188,68 @@ function mapMenuItemsByClientId( menuItems, clientIdsByMenuId ) { return result; } -function* batchSave( menuId, menuItemsByClientId, navigationBlock ) { +// saveEntityRecord for each menu item with block-based data +// saveEntityRecord for each deleted menu item +const batchSave = ( menuId, menuItemsByClientId, navigationBlock ) => async ( { + registry, +} ) => { const blocksList = blocksTreeToFlatList( navigationBlock.innerBlocks ); const batchTasks = []; + + // Compute deletes + const clientIdToBlockId = Object.fromEntries( + blocksList.map( ( { block } ) => [ + block.clientId, + getMenuItemForBlock( block ).id, + ] ) + ); + const deletedMenuItems = []; + for ( const clientId in menuItemsByClientId ) { + if ( ! ( clientId in clientIdToBlockId ) ) { + deletedMenuItems.push( menuItemsByClientId[ clientId ].id ); + } + } + // Enqueue updates for ( const { block, parentId, position } of blocksList ) { const menuItem = getMenuItemForBlock( block ); + if ( deletedMenuItems.includes( menuItem.id ) ) { + continue; + } // Update an existing navigation item. - yield registryDispatch( - 'core', - 'editEntityRecord', - 'root', - 'menuItem', - menuItem.id, - blockToEntityRecord( block, parentId, position ), - { undoIgnore: true } - ); + await registry + .dispatch( 'core' ) + .editEntityRecord( + 'root', + 'menuItem', + menuItem.id, + blockToEntityRecord( block, parentId, position ), + { undoIgnore: true } + ); - const hasEdits = yield registrySelect( - 'core', - 'hasEditsForEntityRecord', - 'root', - 'menuItem', - menuItem.id - ); + const hasEdits = registry + .select( 'core' ) + .hasEditsForEntityRecord( 'root', 'menuItem', menuItem.id ); if ( ! hasEdits ) { continue; } - batchTasks.push( ( { saveEditedEntityRecord } ) => + batchTasks.unshift( ( { saveEditedEntityRecord } ) => saveEditedEntityRecord( 'root', 'menuItem', menuItem.id ) ); } - return yield registryDispatch( 'core', '__experimentalBatch', batchTasks ); // Enqueue deletes - // @TODO - - // 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; - // } - // } + for ( const menuItemId of deletedMenuItems ) { + batchTasks.unshift( ( { deleteEntityRecord } ) => + deleteEntityRecord( 'root', 'menuItem', menuItemId ) + ); + } + + return await registry.dispatch( 'core' ).__experimentalBatch( batchTasks ); function blockToEntityRecord( block, parentId, position ) { const menuItem = omit( getMenuItemForBlock( block ), 'menus', 'meta' ); @@ -279,7 +290,7 @@ function* batchSave( menuId, menuItemsByClientId, navigationBlock ) { function getMenuItemForBlock( block ) { return omit( menuItemsByClientId[ block.clientId ] || {}, '_links' ); } -} +}; /** * Returns an action object used to open/close the inserter. diff --git a/packages/edit-navigation/src/store/controls.js b/packages/edit-navigation/src/store/controls.js index 888efd8127a53..bdf39732a19cb 100644 --- a/packages/edit-navigation/src/store/controls.js +++ b/packages/edit-navigation/src/store/controls.js @@ -1,83 +1,12 @@ /** * WordPress dependencies */ -import { default as triggerApiFetch } from '@wordpress/api-fetch'; import { createRegistryControl } from '@wordpress/data'; /** * Internal dependencies */ import { menuItemsQuery } from './utils'; -import { STORE_NAME } from './constants'; - -/** - * 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: STORE_NAME, - selectorName: 'getNavigationPostForMenu', - args: [ menuId ], - }; -} /** * Resolves menu items for given menu id. @@ -92,23 +21,6 @@ export function resolveMenuItems( 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. * @@ -127,38 +39,6 @@ export function 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 ); @@ -172,6 +52,4 @@ const controls = { ), }; -const getState = ( registry ) => registry.stores[ STORE_NAME ].store.getState(); - export default controls; diff --git a/packages/edit-navigation/src/store/test/utils.js b/packages/edit-navigation/src/store/test/utils.js index f03f096a983e2..7a2f96cfdce84 100644 --- a/packages/edit-navigation/src/store/test/utils.js +++ b/packages/edit-navigation/src/store/test/utils.js @@ -4,16 +4,9 @@ import { buildNavigationPostId, menuItemsQuery, - serializeProcessing, - computeCustomizedAttribute, blockAttributesToMenuItem, menuItemToBlockAttributes, } from '../utils'; -import { - isProcessingPost, - getNavigationPostForMenu, - getPendingActions, -} from '../controls'; describe( 'buildNavigationPostId', () => { it( 'build navigation post id', () => { @@ -27,335 +20,6 @@ describe( 'menuItemsQuery', () => { } ); } ); -describe( 'serializeProcessing', () => { - it( 'calls the callback', () => { - const callback = jest.fn( function* () {} ); - - const post = { - id: 'navigation-post-123', - meta: { - menuId: 123, - }, - }; - - const generator = serializeProcessing( callback ); - - const iterator = generator( post ); - - expect( iterator.next().value ).toEqual( isProcessingPost( post.id ) ); - - expect( iterator.next( false ).value ).toEqual( { - type: 'POP_PENDING_ACTION', - postId: post.id, - action: callback, - } ); - - expect( iterator.next().value ).toEqual( { - type: 'START_PROCESSING_POST', - postId: post.id, - } ); - - expect( iterator.next().value ).toEqual( - getNavigationPostForMenu( post.meta.menuId ) - ); - - expect( iterator.next( post ).value ).toEqual( { - type: 'FINISH_PROCESSING_POST', - postId: post.id, - action: callback, - } ); - - expect( callback ).toHaveBeenCalledWith( post ); - - expect( iterator.next().value ).toEqual( getPendingActions( post.id ) ); - - expect( iterator.next( [] ).done ).toBe( true ); - } ); - - it( 'handles and pends the calls if there is pending call', () => { - const callback = jest.fn( function* () {} ); - - const post = { - id: 'navigation-post-123', - meta: { - menuId: 123, - }, - }; - - const generator = serializeProcessing( callback ); - - const iterator = generator( post ); - - expect( iterator.next().value ).toEqual( isProcessingPost( post.id ) ); - - expect( iterator.next( true ).value ).toEqual( { - type: 'ENQUEUE_AFTER_PROCESSING', - postId: post.id, - action: callback, - } ); - - expect( iterator.next().value ).toEqual( { status: 'pending' } ); - - expect( iterator.next().done ).toBe( true ); - } ); - - it( 'handles pending actions', () => { - const callback1 = jest.fn( function* () {} ); - const callback2 = jest.fn( function* () {} ); - const callback3 = jest.fn( function* () {} ); - - const post = { - id: 'navigation-post-123', - meta: { - menuId: 123, - }, - }; - - const generator = serializeProcessing( callback1 ); - - const iterator = generator( post ); - - function testIterator( pendingActions ) { - const [ callback ] = pendingActions; - - expect( iterator.next( pendingActions ).value ).toEqual( - isProcessingPost( post.id ) - ); - - expect( iterator.next( false ).value ).toEqual( { - type: 'POP_PENDING_ACTION', - postId: post.id, - action: callback, - } ); - - expect( iterator.next().value ).toEqual( { - type: 'START_PROCESSING_POST', - postId: post.id, - } ); - - expect( iterator.next().value ).toEqual( - getNavigationPostForMenu( post.meta.menuId ) - ); - - expect( iterator.next( post ).value ).toEqual( { - type: 'FINISH_PROCESSING_POST', - postId: post.id, - action: callback, - } ); - - expect( callback ).toHaveBeenCalledWith( post ); - - expect( iterator.next().value ).toEqual( - getPendingActions( post.id ) - ); - } - - testIterator( [ callback1 ] ); - testIterator( [ callback2, callback3 ] ); - testIterator( [ callback3 ] ); - - expect( iterator.next( [] ).done ).toBe( true ); - } ); - - it( 'handles errors in the callback', () => { - const callback = jest.fn( function* () { - throw new Error( 'error' ); - } ); - - const post = { - id: 'navigation-post-123', - meta: { - menuId: 123, - }, - }; - - const generator = serializeProcessing( callback ); - - expect( () => { - const iterator = generator( post ); - - expect( iterator.next().value ).toEqual( - isProcessingPost( post.id ) - ); - - expect( iterator.next( false ).value ).toEqual( { - type: 'POP_PENDING_ACTION', - postId: post.id, - action: callback, - } ); - - expect( iterator.next().value ).toEqual( { - type: 'START_PROCESSING_POST', - postId: post.id, - } ); - - expect( iterator.next().value ).toEqual( - getNavigationPostForMenu( post.meta.menuId ) - ); - - expect( iterator.next( post ).value ).toEqual( { - type: 'FINISH_PROCESSING_POST', - postId: post.id, - action: callback, - } ); - - expect( callback ).toHaveBeenCalledWith( post ); - - expect( iterator.next().value ).toEqual( - getPendingActions( post.id ) - ); - - expect( iterator.next( [] ).done ).toBe( true ); - } ).toThrow( new Error( 'error' ) ); - } ); -} ); - -describe( 'computeCustomizedAttribute', () => { - it( 'computes customized attributes', () => { - const blocks = [ - { - attributes: { - label: 'wp.org', - opensInNewTab: false, - url: 'http://wp.org', - className: 'block classnames', - rel: 'external', - type: 'custom', - kind: 'custom', - }, - clientId: 'navigation-link-block-client-id-1', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - { - attributes: { - label: 'wp.com', - opensInNewTab: true, - url: 'http://wp.com', - className: '', - rel: '', - type: 'custom', - kind: 'custom', - }, - clientId: 'navigation-link-block-client-id-2', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - { - attributes: { - id: 678, - label: 'Page Example', - opensInNewTab: false, - url: 'https://localhost:8889/page-example/', - className: '', - rel: '', - type: 'page', - kind: 'post-type', - }, - clientId: 'navigation-link-block-client-id-3', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - ]; - - const menuId = 123; - - const menuItemsByClientId = { - 'navigation-link-block-client-id-1': { - id: 100, - title: 'wp.com', - url: 'http://wp.com', - menu_order: 1, - menus: [ 1 ], - object: 'custom', - original_title: '', - }, - 'navigation-link-block-client-id-2': { - id: 101, - title: 'wp.org', - url: 'http://wp.org', - menu_order: 2, - menus: [ 1 ], - object: 'custom', - original_title: '', - }, - 'navigation-link-block-client-id-3': { - id: 102, - title: 'Page Example', - url: 'https://wordpress.org', - menu_order: 3, - menus: [ 1 ], - object: 'page', - object_id: 678, - type: 'post_type', - original_title: '', - }, - }; - - expect( - JSON.parse( - computeCustomizedAttribute( - blocks, - menuId, - menuItemsByClientId - ) - ) - ).toEqual( { - 'nav_menu_item[100]': { - _invalid: false, - classes: [ 'block', 'classnames' ], - id: 100, - menu_item_parent: 0, - menu_order: 1, - nav_menu_term_id: 123, - original_title: '', - object: 'custom', - position: 1, - status: 'publish', - title: 'wp.org', - url: 'http://wp.org', - xfn: [ 'external' ], - type: 'custom', - target: '', - }, - 'nav_menu_item[101]': { - _invalid: false, - id: 101, - menu_item_parent: 0, - menu_order: 2, - nav_menu_term_id: 123, - original_title: '', - position: 2, - status: 'publish', - title: 'wp.com', - object: 'custom', - url: 'http://wp.com', - target: '_blank', - type: 'custom', - }, - 'nav_menu_item[102]': { - _invalid: false, - id: 102, - menu_item_parent: 0, - menu_order: 3, - nav_menu_term_id: 123, - original_title: '', - position: 3, - status: 'publish', - title: 'Page Example', - object: 'page', // equivalent: block.attributes.type - object_id: 678, // equivalent: block.attributes.id - type: 'post_type', // // equivalent: block.attributes.kind - url: 'https://localhost:8889/page-example/', - target: '', - }, - } ); - } ); -} ); - describe( 'Mapping block attributes and menu item fields', () => { const blocksToMenuItems = [ { diff --git a/packages/edit-navigation/src/store/utils.js b/packages/edit-navigation/src/store/utils.js index b26b41dc4e830..8c73a9ea0e1c0 100644 --- a/packages/edit-navigation/src/store/utils.js +++ b/packages/edit-navigation/src/store/utils.js @@ -1,22 +1,6 @@ -/** - * External dependencies - */ -import { keyBy, omit } from 'lodash'; - -/** - * WordPress dependencies - */ -import { serialize } from '@wordpress/blocks'; - /** * Internal dependencies */ -import { - getNavigationPostForMenu, - getPendingActions, - isProcessingPost, -} from './controls'; - import { NEW_TAB_TARGET_ATTRIBUTE } from '../constants'; /** @@ -59,131 +43,6 @@ export function menuItemsQuery( menuId ) { return { menus: menuId, per_page: -1 }; } -/** - * 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 - */ -export 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( - // re-select the post as it could be outdated by now - yield getNavigationPostForMenu( post.meta.menuId ) - ); - } finally { - yield { - type: 'FINISH_PROCESSING_POST', - postId, - action: callback, - }; - - const pendingActions = yield getPendingActions( postId ); - if ( pendingActions.length ) { - const serializedCallback = serializeProcessing( - pendingActions[ 0 ] - ); - - yield* serializedCallback( post ); - } - } - }; -} - -export function computeCustomizedAttribute( - blocks, - menuId, - menuItemsByClientId -) { - const blocksList = blocksTreeToFlatList( blocks ); - const dataList = blocksList.map( ( { block, parentId, position } ) => - blockToRequestItem( 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 blockToRequestItem( block, parentId, position ) { - const menuItem = omit( getMenuItemForBlock( block ), 'menus', 'meta' ); - - let attributes; - - if ( - block.name === 'core/navigation-link' || - block.name === 'core/navigation-submenu' - ) { - attributes = blockAttributesToMenuItem( block.attributes ); - } else { - attributes = { - type: 'block', - content: serialize( block ), - }; - } - - return { - ...menuItem, - ...attributes, - position, - nav_menu_term_id: menuId, - menu_item_parent: parentId, - status: 'publish', - _invalid: false, - }; - } - - function getMenuItemForBlock( block ) { - return omit( menuItemsByClientId[ block.clientId ] || {}, '_links' ); - } -} - /** * Convert block attributes to menu item fields. * From 1e944293994a1bc2aaa5f84427704f50c4f993d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 14:59:53 +0200 Subject: [PATCH 05/57] simplify the logic --- packages/edit-navigation/src/store/actions.js | 168 +++++++++++------- 1 file changed, 102 insertions(+), 66 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index c840af59581ea..6f1618e2ac710 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -112,14 +112,6 @@ export const saveNavigationPost = ( post ) => async ( { } ); try { const menuId = post.meta.menuId; - const menuItems = await registry - .resolveSelect( 'core' ) - .getMenuItems( { menus: menuId, per_page: -1 } ); - - const menuItemsByClientId = mapMenuItemsByClientId( - menuItems, - getMenuItemToClientIdMapping( registry, post.id ) - ); await registry .dispatch( 'core' ) @@ -134,9 +126,10 @@ export const saveNavigationPost = ( post ) => async ( { } // Save blocks as menu items. - await dispatch( - batchSave( menuId, menuItemsByClientId, post.blocks[ 0 ] ) + const batchTasks = await dispatch( + createBatchSaveForEditedMenuItems( post ) ); + await registry.dispatch( 'core' ).__experimentalBatch( batchTasks ); // Clear "stub" navigation post edits to avoid a false "dirty" state. await registry @@ -190,31 +183,35 @@ function mapMenuItemsByClientId( menuItems, clientIdsByMenuId ) { // saveEntityRecord for each menu item with block-based data // saveEntityRecord for each deleted menu item -const batchSave = ( menuId, menuItemsByClientId, navigationBlock ) => async ( { +const createBatchSaveForEditedMenuItems = ( post ) => async ( { registry, } ) => { - const blocksList = blocksTreeToFlatList( navigationBlock.innerBlocks ); + const navigationBlock = post.blocks[ 0 ]; + const menuId = post.meta.menuId; + const menuItems = await registry + .resolveSelect( 'core' ) + .getMenuItems( { menus: menuId, per_page: -1 } ); - const batchTasks = []; + const apiMenuItemsByClientId = mapMenuItemsByClientId( + menuItems, + getMenuItemToClientIdMapping( registry, post.id ) + ); - // Compute deletes - const clientIdToBlockId = Object.fromEntries( - blocksList.map( ( { block } ) => [ - block.clientId, - getMenuItemForBlock( block ).id, - ] ) + const blocksList = blocksTreeToFlatList( + apiMenuItemsByClientId, + navigationBlock.innerBlocks + ); + + const deletedMenuItemsIds = computeDeletedMenuItemIds( + apiMenuItemsByClientId, + blocksList ); - const deletedMenuItems = []; - for ( const clientId in menuItemsByClientId ) { - if ( ! ( clientId in clientIdToBlockId ) ) { - deletedMenuItems.push( menuItemsByClientId[ clientId ].id ); - } - } + const batchTasks = []; // Enqueue updates for ( const { block, parentId, position } of blocksList ) { - const menuItem = getMenuItemForBlock( block ); - if ( deletedMenuItems.includes( menuItem.id ) ) { + const menuItemId = apiMenuItemsByClientId[ block.clientId ]?.id; + if ( ! menuItemId || deletedMenuItemsIds.includes( menuItemId ) ) { continue; } @@ -224,73 +221,112 @@ const batchSave = ( menuId, menuItemsByClientId, navigationBlock ) => async ( { .editEntityRecord( 'root', 'menuItem', - menuItem.id, - blockToEntityRecord( block, parentId, position ), + menuItemId, + blockToEntityRecord( + apiMenuItemsByClientId, + menuId, + block, + parentId, + position + ), { undoIgnore: true } ); const hasEdits = registry .select( 'core' ) - .hasEditsForEntityRecord( 'root', 'menuItem', menuItem.id ); + .hasEditsForEntityRecord( 'root', 'menuItem', menuItemId ); if ( ! hasEdits ) { continue; } batchTasks.unshift( ( { saveEditedEntityRecord } ) => - saveEditedEntityRecord( 'root', 'menuItem', menuItem.id ) + saveEditedEntityRecord( 'root', 'menuItem', menuItemId ) ); } // Enqueue deletes - for ( const menuItemId of deletedMenuItems ) { + for ( const menuItemId of deletedMenuItemsIds ) { batchTasks.unshift( ( { deleteEntityRecord } ) => - deleteEntityRecord( 'root', 'menuItem', menuItemId ) + deleteEntityRecord( 'root', 'menuItem', menuItemId, { + force: true, + } ) ); } - return await registry.dispatch( 'core' ).__experimentalBatch( batchTasks ); - - function blockToEntityRecord( block, parentId, position ) { - const menuItem = omit( getMenuItemForBlock( block ), 'menus', 'meta' ); + return batchTasks; +}; - let attributes; +function blockToEntityRecord( + apiMenuItemsByClientId, + menuId, + block, + parentId, + position +) { + const menuItem = omit( + apiMenuItemsByClientId[ block.clientId ], + 'menus', + 'meta', + '_links' + ); - if ( block.name === 'core/navigation-link' ) { - attributes = blockAttributesToMenuItem( block.attributes ); - } else { - attributes = { - type: 'block', - content: serialize( block ), - }; - } + let attributes; - return { - ...menuItem, - ...attributes, - position, - nav_menu_term_id: menuId, - menu_item_parent: parentId, - status: 'publish', - _invalid: false, + if ( block.name === 'core/navigation-link' ) { + attributes = blockAttributesToMenuItem( + apiMenuItemsByClientId, + block.attributes + ); + } else { + attributes = { + type: 'block', + content: serialize( block ), }; } - function blocksTreeToFlatList( innerBlocks, parentId = 0 ) { - return innerBlocks.flatMap( ( block, index ) => - [ { block, parentId, position: index + 1 } ].concat( - blocksTreeToFlatList( - block.innerBlocks, - getMenuItemForBlock( block )?.id - ) + return { + ...menuItem, + ...attributes, + position, + nav_menu_term_id: menuId, + menu_item_parent: parentId, + status: 'publish', + _invalid: false, + }; +} + +function blocksTreeToFlatList( + apiMenuItemsByClientId, + innerBlocks, + parentId = 0 +) { + return innerBlocks.flatMap( ( block, index ) => + [ { block, parentId, position: index + 1 } ].concat( + blocksTreeToFlatList( + apiMenuItemsByClientId, + block.innerBlocks, + apiMenuItemsByClientId[ block.clientId ]?.id ) - ); - } + ) + ); +} - function getMenuItemForBlock( block ) { - return omit( menuItemsByClientId[ block.clientId ] || {}, '_links' ); +function computeDeletedMenuItemIds( apiMenuItemsByClientId, blocksList ) { + const clientIdToBlockId = Object.fromEntries( + blocksList.map( ( { block } ) => [ + block.clientId, + apiMenuItemsByClientId[ block.clientId ]?.id, + ] ) + ); + const deletedMenuItems = []; + for ( const clientId in apiMenuItemsByClientId ) { + if ( ! ( clientId in clientIdToBlockId ) ) { + deletedMenuItems.push( apiMenuItemsByClientId[ clientId ]?.id ); + } } -}; + return deletedMenuItems; +} /** * Returns an action object used to open/close the inserter. From ba54594e20cef8098a89dd4824b1a8ab731fb051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 15:08:17 +0200 Subject: [PATCH 06/57] Simplify computeDeletedMenuItemIds --- packages/edit-navigation/src/store/actions.js | 40 +++++-------------- 1 file changed, 11 insertions(+), 29 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 6f1618e2ac710..dbc7b9c54402a 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -197,10 +197,7 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { getMenuItemToClientIdMapping( registry, post.id ) ); - const blocksList = blocksTreeToFlatList( - apiMenuItemsByClientId, - navigationBlock.innerBlocks - ); + const blocksList = blocksTreeToFlatList( navigationBlock.innerBlocks ); const deletedMenuItemsIds = computeDeletedMenuItemIds( apiMenuItemsByClientId, @@ -209,7 +206,7 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { const batchTasks = []; // Enqueue updates - for ( const { block, parentId, position } of blocksList ) { + for ( const { block, parentClientId, position } of blocksList ) { const menuItemId = apiMenuItemsByClientId[ block.clientId ]?.id; if ( ! menuItemId || deletedMenuItemsIds.includes( menuItemId ) ) { continue; @@ -226,7 +223,7 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { apiMenuItemsByClientId, menuId, block, - parentId, + apiMenuItemsByClientId[ parentClientId ]?.id, position ), { undoIgnore: true } @@ -296,36 +293,21 @@ function blockToEntityRecord( }; } -function blocksTreeToFlatList( - apiMenuItemsByClientId, - innerBlocks, - parentId = 0 -) { +function blocksTreeToFlatList( innerBlocks, parentClientId = null ) { return innerBlocks.flatMap( ( block, index ) => - [ { block, parentId, position: index + 1 } ].concat( - blocksTreeToFlatList( - apiMenuItemsByClientId, - block.innerBlocks, - apiMenuItemsByClientId[ block.clientId ]?.id - ) + [ { block, parentClientId, position: index + 1 } ].concat( + blocksTreeToFlatList( block.innerBlocks, block.clientId ) ) ); } function computeDeletedMenuItemIds( apiMenuItemsByClientId, blocksList ) { - const clientIdToBlockId = Object.fromEntries( - blocksList.map( ( { block } ) => [ - block.clientId, - apiMenuItemsByClientId[ block.clientId ]?.id, - ] ) + const editorBlocksIds = new Set( + blocksList.map( ( { block } ) => block.clientId ) ); - const deletedMenuItems = []; - for ( const clientId in apiMenuItemsByClientId ) { - if ( ! ( clientId in clientIdToBlockId ) ) { - deletedMenuItems.push( apiMenuItemsByClientId[ clientId ]?.id ); - } - } - return deletedMenuItems; + return Object.entries( apiMenuItemsByClientId ) + .filter( ( [ clientId, ] ) => ! editorBlocksIds.has( clientId ) ) + .map( ( [ , menuItem ] ) => menuItem.id ); } /** From 082bf8d279157afd7ee330b5a13add121fbff9aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 15:20:10 +0200 Subject: [PATCH 07/57] Use more generic variable names --- packages/edit-navigation/src/store/actions.js | 77 ++++++++++--------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index dbc7b9c54402a..7b67a40aede29 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -50,7 +50,10 @@ export const createMissingMenuItems = ( post ) => async ( { exclusive: false, } ); try { - const mapping = await getMenuItemToClientIdMapping( registry, post.id ); + const mapping = await getEntityRecordToBlockIdMapping( + registry, + post.id + ); const clientIdToMenuId = invert( mapping ); const stack = [ post.blocks[ 0 ] ]; @@ -164,21 +167,18 @@ export const saveNavigationPost = ( post ) => async ( { } }; -const getMenuItemToClientIdMapping = ( registry, postId ) => +const getEntityRecordToBlockIdMapping = ( registry, postId ) => registry.stores[ STORE_NAME ].store.getState().mapping[ postId ] || {}; -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 mapBlockIdToEntityRecord( entityIdToBlockId, entityRecords ) { + return Object.fromEntries( + entityRecords + .map( ( menuItem ) => [ + entityIdToBlockId[ menuItem.id ], + menuItem, + ] ) + .filter( ( [ blockId ] ) => blockId ) + ); } // saveEntityRecord for each menu item with block-based data @@ -192,23 +192,26 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { .resolveSelect( 'core' ) .getMenuItems( { menus: menuId, per_page: -1 } ); - const apiMenuItemsByClientId = mapMenuItemsByClientId( - menuItems, - getMenuItemToClientIdMapping( registry, post.id ) + const blockIdToAPIEntity = mapBlockIdToEntityRecord( + getEntityRecordToBlockIdMapping( registry, post.id ), + menuItems ); const blocksList = blocksTreeToFlatList( navigationBlock.innerBlocks ); - const deletedMenuItemsIds = computeDeletedMenuItemIds( - apiMenuItemsByClientId, + const deletedEntityRecordsIds = computeDeletedEntityRecordsIds( + blockIdToAPIEntity, blocksList ); const batchTasks = []; // Enqueue updates - for ( const { block, parentClientId, position } of blocksList ) { - const menuItemId = apiMenuItemsByClientId[ block.clientId ]?.id; - if ( ! menuItemId || deletedMenuItemsIds.includes( menuItemId ) ) { + for ( const { block, parentBlockId, position } of blocksList ) { + const entityRecordId = blockIdToAPIEntity[ block.clientId ]?.id; + if ( + ! entityRecordId || + deletedEntityRecordsIds.includes( entityRecordId ) + ) { continue; } @@ -218,12 +221,12 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { .editEntityRecord( 'root', 'menuItem', - menuItemId, + entityRecordId, blockToEntityRecord( - apiMenuItemsByClientId, + blockIdToAPIEntity, menuId, block, - apiMenuItemsByClientId[ parentClientId ]?.id, + blockIdToAPIEntity[ parentBlockId ]?.id, position ), { undoIgnore: true } @@ -231,21 +234,21 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { const hasEdits = registry .select( 'core' ) - .hasEditsForEntityRecord( 'root', 'menuItem', menuItemId ); + .hasEditsForEntityRecord( 'root', 'menuItem', entityRecordId ); if ( ! hasEdits ) { continue; } batchTasks.unshift( ( { saveEditedEntityRecord } ) => - saveEditedEntityRecord( 'root', 'menuItem', menuItemId ) + saveEditedEntityRecord( 'root', 'menuItem', entityRecordId ) ); } // Enqueue deletes - for ( const menuItemId of deletedMenuItemsIds ) { + for ( const entityRecordId of deletedEntityRecordsIds ) { batchTasks.unshift( ( { deleteEntityRecord } ) => - deleteEntityRecord( 'root', 'menuItem', menuItemId, { + deleteEntityRecord( 'root', 'menuItem', entityRecordId, { force: true, } ) ); @@ -255,14 +258,14 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { }; function blockToEntityRecord( - apiMenuItemsByClientId, + blockIdToAPIEntity, menuId, block, parentId, position ) { const menuItem = omit( - apiMenuItemsByClientId[ block.clientId ], + blockIdToAPIEntity[ block.clientId ], 'menus', 'meta', '_links' @@ -272,7 +275,7 @@ function blockToEntityRecord( if ( block.name === 'core/navigation-link' ) { attributes = blockAttributesToMenuItem( - apiMenuItemsByClientId, + blockIdToAPIEntity, block.attributes ); } else { @@ -293,20 +296,20 @@ function blockToEntityRecord( }; } -function blocksTreeToFlatList( innerBlocks, parentClientId = null ) { +function blocksTreeToFlatList( innerBlocks, parentBlockId = null ) { return innerBlocks.flatMap( ( block, index ) => - [ { block, parentClientId, position: index + 1 } ].concat( + [ { block, parentBlockId, position: index + 1 } ].concat( blocksTreeToFlatList( block.innerBlocks, block.clientId ) ) ); } -function computeDeletedMenuItemIds( apiMenuItemsByClientId, blocksList ) { +function computeDeletedEntityRecordsIds( blockIdToAPIEntity, blocksList ) { const editorBlocksIds = new Set( blocksList.map( ( { block } ) => block.clientId ) ); - return Object.entries( apiMenuItemsByClientId ) - .filter( ( [ clientId, ] ) => ! editorBlocksIds.has( clientId ) ) + return Object.entries( blockIdToAPIEntity ) + .filter( ( [ clientId ] ) => ! editorBlocksIds.has( clientId ) ) .map( ( [ , menuItem ] ) => menuItem.id ); } From d805067787614a796a7d1e13da7484d7dad895e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 15:22:05 +0200 Subject: [PATCH 08/57] Use more consistent variable names --- packages/edit-navigation/src/store/actions.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 7b67a40aede29..96b16dce81f22 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -173,9 +173,9 @@ const getEntityRecordToBlockIdMapping = ( registry, postId ) => function mapBlockIdToEntityRecord( entityIdToBlockId, entityRecords ) { return Object.fromEntries( entityRecords - .map( ( menuItem ) => [ - entityIdToBlockId[ menuItem.id ], - menuItem, + .map( ( entityRecord ) => [ + entityIdToBlockId[ entityRecord.id ], + entityRecord, ] ) .filter( ( [ blockId ] ) => blockId ) ); @@ -222,7 +222,7 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { 'root', 'menuItem', entityRecordId, - blockToEntityRecord( + blockToMenuItem( blockIdToAPIEntity, menuId, block, @@ -257,7 +257,7 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { return batchTasks; }; -function blockToEntityRecord( +function blockToMenuItem( blockIdToAPIEntity, menuId, block, @@ -310,7 +310,7 @@ function computeDeletedEntityRecordsIds( blockIdToAPIEntity, blocksList ) { ); return Object.entries( blockIdToAPIEntity ) .filter( ( [ clientId ] ) => ! editorBlocksIds.has( clientId ) ) - .map( ( [ , menuItem ] ) => menuItem.id ); + .map( ( [ , entityRecord ] ) => entityRecord.id ); } /** From 8a7cb65ab7c1f1fcc6b9fb84d3a512485ee48880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 17:00:03 +0200 Subject: [PATCH 09/57] Update the data flow to ensure API receives appropriate parameters names --- packages/core-data/src/entities.js | 1 + packages/edit-navigation/src/store/actions.js | 28 +++++------------ .../src/store/menu-items-to-blocks.js | 31 +++++++------------ packages/edit-navigation/src/store/utils.js | 31 +++++++------------ 4 files changed, 32 insertions(+), 59 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index e00e5f37319d8..03fafbe099bcc 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -116,6 +116,7 @@ export const defaultEntities = [ baseURLParams: { context: 'edit' }, plural: 'menuItems', label: __( 'Menu Item' ), + rawAttributes: [ 'title', 'content' ], }, { name: 'menuLocation', diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 96b16dce81f22..90ad72b9fffd3 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -223,7 +223,7 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { 'menuItem', entityRecordId, blockToMenuItem( - blockIdToAPIEntity, + blockIdToAPIEntity[ block.clientId ], menuId, block, blockIdToAPIEntity[ parentBlockId ]?.id, @@ -257,27 +257,13 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { return batchTasks; }; -function blockToMenuItem( - blockIdToAPIEntity, - menuId, - block, - parentId, - position -) { - const menuItem = omit( - blockIdToAPIEntity[ block.clientId ], - 'menus', - 'meta', - '_links' - ); +function blockToMenuItem( menuItem, menuId, block, parentId, position ) { + menuItem = omit( menuItem, 'menus', 'meta', '_links' ); let attributes; if ( block.name === 'core/navigation-link' ) { - attributes = blockAttributesToMenuItem( - blockIdToAPIEntity, - block.attributes - ); + attributes = blockAttributesToMenuItem( block.attributes ); } else { attributes = { type: 'block', @@ -288,9 +274,9 @@ function blockToMenuItem( return { ...menuItem, ...attributes, - position, - nav_menu_term_id: menuId, - menu_item_parent: parentId, + menu_order: position, + menu_id: menuId, + parent: parentId, status: 'publish', _invalid: false, }; diff --git a/packages/edit-navigation/src/store/menu-items-to-blocks.js b/packages/edit-navigation/src/store/menu-items-to-blocks.js index 88dd0d768f54a..5522f8f804e5d 100644 --- a/packages/edit-navigation/src/store/menu-items-to-blocks.js +++ b/packages/edit-navigation/src/store/menu-items-to-blocks.js @@ -118,7 +118,7 @@ function menuItemToBlockAttributes( { xfn, classes, // eslint-disable-next-line camelcase - attr_title, + attrTitle, object, // eslint-disable-next-line camelcase object_id, @@ -135,36 +135,29 @@ function menuItemToBlockAttributes( { object = 'tag'; } - return { + const attributes = { label: menuItemTitleField?.rendered || '', - ...( object?.length && { - type: object, - } ), + type: object, kind: menuItemTypeField?.replace( '_', '-' ) || 'custom', url: url || '', - ...( xfn?.length && - xfn.join( ' ' ).trim() && { - rel: xfn.join( ' ' ).trim(), - } ), - ...( classes?.length && - classes.join( ' ' ).trim() && { - className: classes.join( ' ' ).trim(), - } ), - ...( attr_title?.length && { - title: attr_title, - } ), + rel: xfn?.join( ' ' ).trim(), + className: classes?.join( ' ' ).trim(), + title: attrTitle?.raw || attrTitle || menuItemTitleField?.raw, // eslint-disable-next-line camelcase ...( object_id && 'custom' !== object && { id: object_id, } ), - ...( description?.length && { - description, - } ), + description, ...( target === '_blank' && { opensInNewTab: true, } ), }; + + // Filter out the empty values + return Object.fromEntries( + Object.entries( attributes ).filter( ( [ , v ] ) => v ) + ); } /** diff --git a/packages/edit-navigation/src/store/utils.js b/packages/edit-navigation/src/store/utils.js index 8c73a9ea0e1c0..26b16c24867e8 100644 --- a/packages/edit-navigation/src/store/utils.js +++ b/packages/edit-navigation/src/store/utils.js @@ -84,27 +84,15 @@ export const blockAttributesToMenuItem = ( { type = 'post_tag'; } - return { + const menuItem = { title: label, url, - ...( description?.length && { - description, - } ), - ...( rel?.length && { - xfn: rel?.trim().split( ' ' ), - } ), - ...( className?.length && { - classes: className?.trim().split( ' ' ), - } ), - ...( blockTitleAttr?.length && { - attr_title: blockTitleAttr, - } ), - ...( type?.length && { - object: type, - } ), - ...( kind?.length && { - type: kind?.replace( '-', '_' ), - } ), + description, + xfn: rel?.trim().split( ' ' ), + classes: className?.trim().split( ' ' ), + attr_title: blockTitleAttr, + object: type, + type: kind?.replace( '-', '_' ), // Only assign object_id if it's a entity type (ie: not "custom"). ...( id && 'custom' !== type && { @@ -112,6 +100,11 @@ export const blockAttributesToMenuItem = ( { } ), target: opensInNewTab ? NEW_TAB_TARGET_ATTRIBUTE : '', }; + + // Filter out the empty values + return Object.fromEntries( + Object.entries( menuItem ).filter( ( [ , v ] ) => v ) + ); }; /** From 56834335f98993f3caebca6304324f3abc0e512a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 17:04:43 +0200 Subject: [PATCH 10/57] Alter the order of arguments in blockToMenuItem --- packages/edit-navigation/src/store/actions.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 90ad72b9fffd3..ea48a7f3803d4 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -223,11 +223,11 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { 'menuItem', entityRecordId, blockToMenuItem( - blockIdToAPIEntity[ block.clientId ], - menuId, block, + blockIdToAPIEntity[ block.clientId ], blockIdToAPIEntity[ parentBlockId ]?.id, - position + position, + menuId ), { undoIgnore: true } ); @@ -257,7 +257,7 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { return batchTasks; }; -function blockToMenuItem( menuItem, menuId, block, parentId, position ) { +function blockToMenuItem( block, menuItem, parentId, position, menuId ) { menuItem = omit( menuItem, 'menus', 'meta', '_links' ); let attributes; From 7eaf186d07f84204e10ce9c2b92c3bfb254cb0da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 17:14:34 +0200 Subject: [PATCH 11/57] Move transformations to transform.js --- .../src/components/block-placeholder/index.js | 2 +- packages/edit-navigation/src/store/actions.js | 31 +- .../edit-navigation/src/store/resolvers.js | 3 +- .../edit-navigation/src/store/test/actions.js | 1300 ++++++++--------- .../{menu-items-to-blocks.js => transform.js} | 2 +- .../{menu-items-to-blocks.js => transform.js} | 104 +- packages/edit-navigation/src/store/utils.js | 129 -- 7 files changed, 753 insertions(+), 818 deletions(-) rename packages/edit-navigation/src/store/test/{menu-items-to-blocks.js => transform.js} (99%) rename packages/edit-navigation/src/store/{menu-items-to-blocks.js => transform.js} (63%) diff --git a/packages/edit-navigation/src/components/block-placeholder/index.js b/packages/edit-navigation/src/components/block-placeholder/index.js index f4e47fa6b83dc..48c2171dd4916 100644 --- a/packages/edit-navigation/src/components/block-placeholder/index.js +++ b/packages/edit-navigation/src/components/block-placeholder/index.js @@ -24,7 +24,7 @@ import { chevronDown } from '@wordpress/icons'; */ import { useMenuEntityProp, useSelectedMenuId } from '../../hooks'; import useNavigationEntities from './use-navigation-entities'; -import menuItemsToBlocks from './menu-items-to-blocks'; +import { menuItemsToBlocks } from '../../store/transform'; /** * Convert pages to blocks. diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index ea48a7f3803d4..8168798a09b81 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -1,14 +1,13 @@ /** * External dependencies */ -import { invert, omit } from 'lodash'; +import { invert } from 'lodash'; /** * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; -import { serialize } from '@wordpress/blocks'; import apiFetch from '@wordpress/api-fetch'; /** @@ -16,7 +15,8 @@ import apiFetch from '@wordpress/api-fetch'; */ import { STORE_NAME } from './constants'; import { NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE } from '../constants'; -import { menuItemsQuery, blockAttributesToMenuItem } from './utils'; +import { menuItemsQuery } from './utils'; +import { blockToMenuItem } from './transform'; /** * Returns an action object used to select menu. @@ -257,31 +257,6 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { return batchTasks; }; -function blockToMenuItem( block, menuItem, parentId, position, menuId ) { - menuItem = omit( menuItem, 'menus', 'meta', '_links' ); - - let attributes; - - if ( block.name === 'core/navigation-link' ) { - attributes = blockAttributesToMenuItem( block.attributes ); - } else { - attributes = { - type: 'block', - content: serialize( block ), - }; - } - - return { - ...menuItem, - ...attributes, - menu_order: position, - menu_id: menuId, - parent: parentId, - status: 'publish', - _invalid: false, - }; -} - function blocksTreeToFlatList( innerBlocks, parentBlockId = null ) { return innerBlocks.flatMap( ( block, index ) => [ { block, parentBlockId, position: index + 1 } ].concat( diff --git a/packages/edit-navigation/src/store/resolvers.js b/packages/edit-navigation/src/store/resolvers.js index 25648239434d5..66c79f9807a64 100644 --- a/packages/edit-navigation/src/store/resolvers.js +++ b/packages/edit-navigation/src/store/resolvers.js @@ -10,7 +10,8 @@ import { NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE } from '../constants'; import { resolveMenuItems, dispatch } from './controls'; import { buildNavigationPostId } from './utils'; -import menuItemsToBlocks from './menu-items-to-blocks'; +import { menuItemsToBlocks } from './transform'; + /** * 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 diff --git a/packages/edit-navigation/src/store/test/actions.js b/packages/edit-navigation/src/store/test/actions.js index fc5ca7f5c4a65..c9af2c0f19e1c 100644 --- a/packages/edit-navigation/src/store/test/actions.js +++ b/packages/edit-navigation/src/store/test/actions.js @@ -1,653 +1,647 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { store as noticesStore } from '@wordpress/notices'; - -/** - * Internal dependencies - */ -import { - createMissingMenuItems, - saveNavigationPost, - setSelectedMenuId, -} from '../actions'; -import { - resolveMenuItems, - getMenuItemToClientIdMapping, - dispatch, - select, - apiFetch, -} from '../controls'; -import { menuItemsQuery, computeCustomizedAttribute } from '../utils'; -import { - NAVIGATION_POST_KIND, - NAVIGATION_POST_POST_TYPE, -} from '../../constants'; - -jest.mock( '../utils', () => { - const utils = jest.requireActual( '../utils' ); - // Mock serializeProcessing to always return the callback for easier testing and less boilerplate. - utils.serializeProcessing = ( callback ) => callback; - return utils; -} ); - -describe( 'createMissingMenuItems', () => { - it( 'creates a missing menu for navigation block', () => { - const post = { - id: 'navigation-post-1', - slug: 'navigation-post-1', - type: 'page', - meta: { - menuId: 1, - }, - blocks: [ - { - attributes: { showSubmenuIcon: true }, - clientId: 'navigation-block-client-id', - innerBlocks: [], - isValid: true, - name: 'core/navigation', - }, - ], - }; - - const mapping = {}; - - const menuItemPlaceholder = { - id: 87, - title: { - raw: 'Placeholder', - rendered: 'Placeholder', - }, - }; - - const menuItems = []; - - const action = createMissingMenuItems( post ); - - expect( action.next( post ).value ).toEqual( - getMenuItemToClientIdMapping( post.id ) - ); - - expect( action.next( mapping ).value ).toEqual( - apiFetch( { - path: `/__experimental/menu-items`, - method: 'POST', - data: { - title: 'Placeholder', - url: 'Placeholder', - menu_order: 1, - }, - } ) - ); - - expect( action.next( menuItemPlaceholder ).value ).toEqual( - resolveMenuItems( post.meta.menuId ) - ); - - expect( action.next( menuItems ).value ).toEqual( - dispatch( - 'core', - 'receiveEntityRecords', - 'root', - 'menuItem', - [ ...menuItems, menuItemPlaceholder ], - menuItemsQuery( post.meta.menuId ), - false - ) - ); - - expect( action.next().value ).toEqual( { - type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', - postId: post.id, - mapping: { - 87: 'navigation-block-client-id', - }, - } ); - - expect( action.next( [] ).done ).toBe( true ); - } ); - - it( 'creates a missing menu for navigation link block', () => { - const post = { - id: 'navigation-post-1', - slug: 'navigation-post-1', - type: 'page', - meta: { - menuId: 1, - }, - blocks: [ - { - attributes: { showSubmenuIcon: true }, - clientId: 'navigation-block-client-id', - innerBlocks: [ - { - attributes: { - label: 'wp.org', - opensInNewTab: false, - url: 'http://wp.org', - }, - clientId: 'navigation-link-block-client-id-1', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - { - attributes: { - label: 'wp.com', - opensInNewTab: false, - url: 'http://wp.com', - }, - clientId: 'navigation-link-block-client-id-2', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - ], - isValid: true, - name: 'core/navigation', - }, - ], - }; - - const mapping = { - 87: 'navigation-block-client-id', - 100: 'navigation-link-block-client-id-1', - }; - - const menuItemPlaceholder = { - id: 101, - title: { - raw: 'Placeholder', - rendered: 'Placeholder', - }, - }; - - const menuItems = [ - { - id: 100, - title: { - raw: 'wp.com', - rendered: 'wp.com', - }, - url: 'http://wp.com', - menu_order: 1, - menus: [ 1 ], - }, - { - id: 101, - title: { - raw: 'wp.org', - rendered: 'wp.org', - }, - url: 'http://wp.org', - menu_order: 2, - menus: [ 1 ], - }, - ]; - - const action = createMissingMenuItems( post ); - - expect( action.next( post ).value ).toEqual( - getMenuItemToClientIdMapping( post.id ) - ); - - expect( action.next( mapping ).value ).toEqual( - apiFetch( { - path: `/__experimental/menu-items`, - method: 'POST', - data: { - title: 'Placeholder', - url: 'Placeholder', - menu_order: 1, - }, - } ) - ); - - expect( action.next( menuItemPlaceholder ).value ).toEqual( - resolveMenuItems( post.meta.menuId ) - ); - - expect( action.next( menuItems ).value ).toEqual( - dispatch( - 'core', - 'receiveEntityRecords', - 'root', - 'menuItem', - [ ...menuItems, menuItemPlaceholder ], - menuItemsQuery( post.meta.menuId ), - false - ) - ); - - expect( action.next().value ).toEqual( { - type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', - postId: post.id, - mapping: { - 87: 'navigation-block-client-id', - 100: 'navigation-link-block-client-id-1', - 101: 'navigation-link-block-client-id-2', - }, - } ); - - expect( action.next( [] ).done ).toBe( true ); - } ); -} ); - -describe( 'saveNavigationPost', () => { - it( 'converts all the blocks into menu items and batch save them at once', () => { - const post = { - id: 'navigation-post-1', - slug: 'navigation-post-1', - type: 'page', - meta: { - menuId: 1, - }, - blocks: [ - { - attributes: { showSubmenuIcon: true }, - clientId: 'navigation-block-client-id', - innerBlocks: [ - { - attributes: { - label: 'wp.org', - opensInNewTab: false, - url: 'http://wp.org', - className: '', - rel: '', - description: '', - title: '', - }, - clientId: 'navigation-link-block-client-id-1', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - { - attributes: { - label: 'wp.com', - opensInNewTab: false, - url: 'http://wp.com', - className: '', - rel: '', - description: '', - title: '', - }, - clientId: 'navigation-link-block-client-id-2', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - ], - isValid: true, - name: 'core/navigation', - }, - ], - }; - - const menuItems = [ - { - id: 100, - title: { - raw: 'wp.com', - rendered: 'wp.com', - }, - url: 'http://wp.com', - menu_order: 1, - menus: [ 1 ], - classes: [], - xfn: [], - description: '', - attr_title: '', - }, - { - id: 101, - title: { - raw: 'wp.org', - rendered: 'wp.org', - }, - url: 'http://wp.org', - menu_order: 2, - menus: [ 1 ], - classes: [], - xfn: [], - description: '', - attr_title: '', - }, - ]; - - const mapping = { - 100: 'navigation-link-block-client-id-1', - 101: 'navigation-link-block-client-id-2', - }; - - const action = saveNavigationPost( post ); - - expect( action.next().value ).toEqual( - resolveMenuItems( post.meta.menuId ) - ); - - expect( action.next( menuItems ).value ).toEqual( - getMenuItemToClientIdMapping( post.id ) - ); - - expect( action.next( mapping ).value ).toEqual( - dispatch( 'core', 'saveEditedEntityRecord', 'root', 'menu', 1 ) - ); - expect( action.next( { id: 1 } ).value ).toEqual( - select( 'core', 'getLastEntitySaveError', 'root', 'menu', 1 ) - ); - - expect( action.next().value ).toEqual( - apiFetch( { - path: '/__experimental/customizer-nonces/get-save-nonce', - } ) - ); - - const batchSaveApiFetch = action.next( { - nonce: 'nonce', - stylesheet: 'stylesheet', - } ).value; - - expect( batchSaveApiFetch.request.body.get( 'customized' ) ).toEqual( - computeCustomizedAttribute( - post.blocks[ 0 ].innerBlocks, - post.meta.menuId, - { - 'navigation-link-block-client-id-1': menuItems[ 0 ], - 'navigation-link-block-client-id-2': menuItems[ 1 ], - } - ) - ); - - expect( action.next( { success: true } ).value ).toEqual( - dispatch( - 'core', - 'receiveEntityRecords', - NAVIGATION_POST_KIND, - NAVIGATION_POST_POST_TYPE, - [ post ], - undefined - ) - ); - - expect( action.next().value ).toEqual( - dispatch( - noticesStore, - 'createSuccessNotice', - __( 'Navigation saved.' ), - { - type: 'snackbar', - } - ) - ); - } ); - - it( 'handles an error from the batch API and show error notifications', () => { - const post = { - id: 'navigation-post-1', - slug: 'navigation-post-1', - type: 'page', - meta: { - menuId: 1, - }, - blocks: [ - { - attributes: { showSubmenuIcon: true }, - clientId: 'navigation-block-client-id', - innerBlocks: [ - { - attributes: { - label: 'wp.org', - opensInNewTab: false, - url: 'http://wp.org', - className: '', - rel: '', - description: '', - title: '', - }, - clientId: 'navigation-link-block-client-id-1', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - { - attributes: { - label: 'wp.com', - opensInNewTab: false, - url: 'http://wp.com', - className: '', - rel: '', - description: '', - title: '', - }, - clientId: 'navigation-link-block-client-id-2', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - ], - isValid: true, - name: 'core/navigation', - }, - ], - }; - - const menuItems = [ - { - id: 100, - title: { - raw: 'wp.com', - rendered: 'wp.com', - }, - url: 'http://wp.com', - menu_order: 1, - menus: [ 1 ], - classes: [], - xfn: [], - description: '', - attr_title: '', - }, - { - id: 101, - title: { - raw: 'wp.org', - rendered: 'wp.org', - }, - url: 'http://wp.org', - menu_order: 2, - menus: [ 1 ], - classes: [], - xfn: [], - description: '', - attr_title: '', - }, - ]; - - const mapping = { - 100: 'navigation-link-block-client-id-1', - 101: 'navigation-link-block-client-id-2', - }; - - const action = saveNavigationPost( post ); - - expect( action.next().value ).toEqual( - resolveMenuItems( post.meta.menuId ) - ); - - expect( action.next( menuItems ).value ).toEqual( - getMenuItemToClientIdMapping( post.id ) - ); - - expect( action.next( mapping ).value ).toEqual( - dispatch( 'core', 'saveEditedEntityRecord', 'root', 'menu', 1 ) - ); - - expect( action.next( { id: 1 } ).value ).toEqual( - select( 'core', 'getLastEntitySaveError', 'root', 'menu', 1 ) - ); - - expect( action.next().value ).toEqual( - apiFetch( { - path: '/__experimental/customizer-nonces/get-save-nonce', - } ) - ); - - const batchSaveApiFetch = action.next( { - nonce: 'nonce', - stylesheet: 'stylesheet', - } ).value; - - expect( batchSaveApiFetch.request.body.get( 'customized' ) ).toEqual( - computeCustomizedAttribute( - post.blocks[ 0 ].innerBlocks, - post.meta.menuId, - { - 'navigation-link-block-client-id-1': menuItems[ 0 ], - 'navigation-link-block-client-id-2': menuItems[ 1 ], - } - ) - ); - - expect( - action.next( { success: false, data: { message: 'Test Message' } } ) - .value - ).toEqual( - dispatch( - noticesStore, - 'createErrorNotice', - __( "Unable to save: 'Test Message'" ), - { - type: 'snackbar', - } - ) - ); - } ); - - it( 'handles an error from the entity and show error notifications', () => { - const post = { - id: 'navigation-post-1', - slug: 'navigation-post-1', - type: 'page', - meta: { - menuId: 1, - }, - blocks: [ - { - attributes: { showSubmenuIcon: true }, - clientId: 'navigation-block-client-id', - innerBlocks: [ - { - attributes: { - label: 'wp.org', - opensInNewTab: false, - url: 'http://wp.org', - className: '', - rel: '', - description: '', - title: '', - }, - clientId: 'navigation-link-block-client-id-1', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - { - attributes: { - label: 'wp.com', - opensInNewTab: false, - url: 'http://wp.com', - className: '', - rel: '', - description: '', - title: '', - }, - clientId: 'navigation-link-block-client-id-2', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - ], - isValid: true, - name: 'core/navigation', - }, - ], - }; - - const menuItems = [ - { - id: 100, - title: { - raw: 'wp.com', - rendered: 'wp.com', - }, - url: 'http://wp.com', - menu_order: 1, - menus: [ 1 ], - classes: [], - xfn: [], - description: '', - attr_title: '', - }, - { - id: 101, - title: { - raw: 'wp.org', - rendered: 'wp.org', - }, - url: 'http://wp.org', - menu_order: 2, - menus: [ 1 ], - classes: [], - xfn: [], - description: '', - attr_title: '', - }, - ]; - - const mapping = { - 100: 'navigation-link-block-client-id-1', - 101: 'navigation-link-block-client-id-2', - }; - - const action = saveNavigationPost( post ); - - expect( action.next().value ).toEqual( - resolveMenuItems( post.meta.menuId ) - ); - - expect( action.next( menuItems ).value ).toEqual( - getMenuItemToClientIdMapping( post.id ) - ); - - expect( action.next( mapping ).value ).toEqual( - dispatch( 'core', 'saveEditedEntityRecord', 'root', 'menu', 1 ) - ); - - expect( action.next().value ).toEqual( - select( 'core', 'getLastEntitySaveError', 'root', 'menu', 1 ) - ); - - expect( action.next( { message: 'Test Message 2' } ).value ).toEqual( - dispatch( - noticesStore, - 'createErrorNotice', - __( "Unable to save: 'Test Message 2'" ), - { - type: 'snackbar', - } - ) - ); - } ); -} ); - -describe( 'setSelectedMenuId', () => { - it( 'should return the SET_SELECTED_MENU_ID action', () => { - const menuId = 1; - expect( setSelectedMenuId( menuId ) ).toEqual( { - type: 'SET_SELECTED_MENU_ID', - menuId, - } ); - } ); -} ); +// /** +// * WordPress dependencies +// */ +// import { __ } from '@wordpress/i18n'; +// import { store as noticesStore } from '@wordpress/notices'; +// +// /** +// * Internal dependencies +// */ +// import { +// createMissingMenuItems, +// saveNavigationPost, +// setSelectedMenuId, +// } from '../actions'; +// import { resolveMenuItems, dispatch, select, apiFetch } from '../controls'; +// import { menuItemsQuery, computeCustomizedAttribute } from '../utils'; +// import { +// NAVIGATION_POST_KIND, +// NAVIGATION_POST_POST_TYPE, +// } from '../../constants'; +// +// jest.mock( '../utils', () => { +// const utils = jest.requireActual( '../utils' ); +// // Mock serializeProcessing to always return the callback for easier testing and less boilerplate. +// utils.serializeProcessing = ( callback ) => callback; +// return utils; +// } ); +// +// describe( 'createMissingMenuItems', () => { +// it( 'creates a missing menu for navigation block', () => { +// const post = { +// id: 'navigation-post-1', +// slug: 'navigation-post-1', +// type: 'page', +// meta: { +// menuId: 1, +// }, +// blocks: [ +// { +// attributes: { showSubmenuIcon: true }, +// clientId: 'navigation-block-client-id', +// innerBlocks: [], +// isValid: true, +// name: 'core/navigation', +// }, +// ], +// }; +// +// const mapping = {}; +// +// const menuItemPlaceholder = { +// id: 87, +// title: { +// raw: 'Placeholder', +// rendered: 'Placeholder', +// }, +// }; +// +// const menuItems = []; +// +// const action = createMissingMenuItems( post ); +// +// expect( action.next( post ).value ).toEqual( +// getMenuItemToClientIdMapping( post.id ) +// ); +// +// expect( action.next( mapping ).value ).toEqual( +// apiFetch( { +// path: `/__experimental/menu-items`, +// method: 'POST', +// data: { +// title: 'Placeholder', +// url: 'Placeholder', +// menu_order: 0, +// }, +// } ) +// ); +// +// expect( action.next( menuItemPlaceholder ).value ).toEqual( +// resolveMenuItems( post.meta.menuId ) +// ); +// +// expect( action.next( menuItems ).value ).toEqual( +// dispatch( +// 'core', +// 'receiveEntityRecords', +// 'root', +// 'menuItem', +// [ ...menuItems, menuItemPlaceholder ], +// menuItemsQuery( post.meta.menuId ), +// false +// ) +// ); +// +// expect( action.next().value ).toEqual( { +// type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', +// postId: post.id, +// mapping: { +// 87: 'navigation-block-client-id', +// }, +// } ); +// +// expect( action.next( [] ).done ).toBe( true ); +// } ); +// +// it( 'creates a missing menu for navigation link block', () => { +// const post = { +// id: 'navigation-post-1', +// slug: 'navigation-post-1', +// type: 'page', +// meta: { +// menuId: 1, +// }, +// blocks: [ +// { +// attributes: { showSubmenuIcon: true }, +// clientId: 'navigation-block-client-id', +// innerBlocks: [ +// { +// attributes: { +// label: 'wp.org', +// opensInNewTab: false, +// url: 'http://wp.org', +// }, +// clientId: 'navigation-link-block-client-id-1', +// innerBlocks: [], +// isValid: true, +// name: 'core/navigation-link', +// }, +// { +// attributes: { +// label: 'wp.com', +// opensInNewTab: false, +// url: 'http://wp.com', +// }, +// clientId: 'navigation-link-block-client-id-2', +// innerBlocks: [], +// isValid: true, +// name: 'core/navigation-link', +// }, +// ], +// isValid: true, +// name: 'core/navigation', +// }, +// ], +// }; +// +// const mapping = { +// 87: 'navigation-block-client-id', +// 100: 'navigation-link-block-client-id-1', +// }; +// +// const menuItemPlaceholder = { +// id: 101, +// title: { +// raw: 'Placeholder', +// rendered: 'Placeholder', +// }, +// }; +// +// const menuItems = [ +// { +// id: 100, +// title: { +// raw: 'wp.com', +// rendered: 'wp.com', +// }, +// url: 'http://wp.com', +// menu_order: 1, +// menus: [ 1 ], +// }, +// { +// id: 101, +// title: { +// raw: 'wp.org', +// rendered: 'wp.org', +// }, +// url: 'http://wp.org', +// menu_order: 2, +// menus: [ 1 ], +// }, +// ]; +// +// const action = createMissingMenuItems( post ); +// +// expect( action.next( post ).value ).toEqual( +// getMenuItemToClientIdMapping( post.id ) +// ); +// +// expect( action.next( mapping ).value ).toEqual( +// apiFetch( { +// path: `/__experimental/menu-items`, +// method: 'POST', +// data: { +// title: 'Placeholder', +// url: 'Placeholder', +// menu_order: 0, +// }, +// } ) +// ); +// +// expect( action.next( menuItemPlaceholder ).value ).toEqual( +// resolveMenuItems( post.meta.menuId ) +// ); +// +// expect( action.next( menuItems ).value ).toEqual( +// dispatch( +// 'core', +// 'receiveEntityRecords', +// 'root', +// 'menuItem', +// [ ...menuItems, menuItemPlaceholder ], +// menuItemsQuery( post.meta.menuId ), +// false +// ) +// ); +// +// expect( action.next().value ).toEqual( { +// type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', +// postId: post.id, +// mapping: { +// 87: 'navigation-block-client-id', +// 100: 'navigation-link-block-client-id-1', +// 101: 'navigation-link-block-client-id-2', +// }, +// } ); +// +// expect( action.next( [] ).done ).toBe( true ); +// } ); +// } ); +// +// describe( 'saveNavigationPost', () => { +// it( 'converts all the blocks into menu items and batch save them at once', () => { +// const post = { +// id: 'navigation-post-1', +// slug: 'navigation-post-1', +// type: 'page', +// meta: { +// menuId: 1, +// }, +// blocks: [ +// { +// attributes: { showSubmenuIcon: true }, +// clientId: 'navigation-block-client-id', +// innerBlocks: [ +// { +// attributes: { +// label: 'wp.org', +// opensInNewTab: false, +// url: 'http://wp.org', +// className: '', +// rel: '', +// description: '', +// title: '', +// }, +// clientId: 'navigation-link-block-client-id-1', +// innerBlocks: [], +// isValid: true, +// name: 'core/navigation-link', +// }, +// { +// attributes: { +// label: 'wp.com', +// opensInNewTab: false, +// url: 'http://wp.com', +// className: '', +// rel: '', +// description: '', +// title: '', +// }, +// clientId: 'navigation-link-block-client-id-2', +// innerBlocks: [], +// isValid: true, +// name: 'core/navigation-link', +// }, +// ], +// isValid: true, +// name: 'core/navigation', +// }, +// ], +// }; +// +// const menuItems = [ +// { +// id: 100, +// title: { +// raw: 'wp.com', +// rendered: 'wp.com', +// }, +// url: 'http://wp.com', +// menu_order: 1, +// menus: [ 1 ], +// classes: [], +// xfn: [], +// description: '', +// attr_title: '', +// }, +// { +// id: 101, +// title: { +// raw: 'wp.org', +// rendered: 'wp.org', +// }, +// url: 'http://wp.org', +// menu_order: 2, +// menus: [ 1 ], +// classes: [], +// xfn: [], +// description: '', +// attr_title: '', +// }, +// ]; +// +// const mapping = { +// 100: 'navigation-link-block-client-id-1', +// 101: 'navigation-link-block-client-id-2', +// }; +// +// const action = saveNavigationPost( post ); +// +// expect( action.next().value ).toEqual( +// resolveMenuItems( post.meta.menuId ) +// ); +// +// expect( action.next( menuItems ).value ).toEqual( +// getMenuItemToClientIdMapping( post.id ) +// ); +// +// expect( action.next( mapping ).value ).toEqual( +// dispatch( 'core', 'saveEditedEntityRecord', 'root', 'menu', 1 ) +// ); +// expect( action.next( { id: 1 } ).value ).toEqual( +// select( 'core', 'getLastEntitySaveError', 'root', 'menu', 1 ) +// ); +// +// expect( action.next().value ).toEqual( +// apiFetch( { +// path: '/__experimental/customizer-nonces/get-save-nonce', +// } ) +// ); +// +// const batchSaveApiFetch = action.next( { +// nonce: 'nonce', +// stylesheet: 'stylesheet', +// } ).value; +// +// expect( batchSaveApiFetch.request.body.get( 'customized' ) ).toEqual( +// computeCustomizedAttribute( +// post.blocks[ 0 ].innerBlocks, +// post.meta.menuId, +// { +// 'navigation-link-block-client-id-1': menuItems[ 0 ], +// 'navigation-link-block-client-id-2': menuItems[ 1 ], +// } +// ) +// ); +// +// expect( action.next( { success: true } ).value ).toEqual( +// dispatch( +// 'core', +// 'receiveEntityRecords', +// NAVIGATION_POST_KIND, +// NAVIGATION_POST_POST_TYPE, +// [ post ], +// undefined +// ) +// ); +// +// expect( action.next().value ).toEqual( +// dispatch( +// noticesStore, +// 'createSuccessNotice', +// __( 'Navigation saved.' ), +// { +// type: 'snackbar', +// } +// ) +// ); +// } ); +// +// it( 'handles an error from the batch API and show error notifications', () => { +// const post = { +// id: 'navigation-post-1', +// slug: 'navigation-post-1', +// type: 'page', +// meta: { +// menuId: 1, +// }, +// blocks: [ +// { +// attributes: { showSubmenuIcon: true }, +// clientId: 'navigation-block-client-id', +// innerBlocks: [ +// { +// attributes: { +// label: 'wp.org', +// opensInNewTab: false, +// url: 'http://wp.org', +// className: '', +// rel: '', +// description: '', +// title: '', +// }, +// clientId: 'navigation-link-block-client-id-1', +// innerBlocks: [], +// isValid: true, +// name: 'core/navigation-link', +// }, +// { +// attributes: { +// label: 'wp.com', +// opensInNewTab: false, +// url: 'http://wp.com', +// className: '', +// rel: '', +// description: '', +// title: '', +// }, +// clientId: 'navigation-link-block-client-id-2', +// innerBlocks: [], +// isValid: true, +// name: 'core/navigation-link', +// }, +// ], +// isValid: true, +// name: 'core/navigation', +// }, +// ], +// }; +// +// const menuItems = [ +// { +// id: 100, +// title: { +// raw: 'wp.com', +// rendered: 'wp.com', +// }, +// url: 'http://wp.com', +// menu_order: 1, +// menus: [ 1 ], +// classes: [], +// xfn: [], +// description: '', +// attr_title: '', +// }, +// { +// id: 101, +// title: { +// raw: 'wp.org', +// rendered: 'wp.org', +// }, +// url: 'http://wp.org', +// menu_order: 2, +// menus: [ 1 ], +// classes: [], +// xfn: [], +// description: '', +// attr_title: '', +// }, +// ]; +// +// const mapping = { +// 100: 'navigation-link-block-client-id-1', +// 101: 'navigation-link-block-client-id-2', +// }; +// +// const action = saveNavigationPost( post ); +// +// expect( action.next().value ).toEqual( +// resolveMenuItems( post.meta.menuId ) +// ); +// +// expect( action.next( menuItems ).value ).toEqual( +// getMenuItemToClientIdMapping( post.id ) +// ); +// +// expect( action.next( mapping ).value ).toEqual( +// dispatch( 'core', 'saveEditedEntityRecord', 'root', 'menu', 1 ) +// ); +// +// expect( action.next( { id: 1 } ).value ).toEqual( +// select( 'core', 'getLastEntitySaveError', 'root', 'menu', 1 ) +// ); +// +// expect( action.next().value ).toEqual( +// apiFetch( { +// path: '/__experimental/customizer-nonces/get-save-nonce', +// } ) +// ); +// +// const batchSaveApiFetch = action.next( { +// nonce: 'nonce', +// stylesheet: 'stylesheet', +// } ).value; +// +// expect( batchSaveApiFetch.request.body.get( 'customized' ) ).toEqual( +// computeCustomizedAttribute( +// post.blocks[ 0 ].innerBlocks, +// post.meta.menuId, +// { +// 'navigation-link-block-client-id-1': menuItems[ 0 ], +// 'navigation-link-block-client-id-2': menuItems[ 1 ], +// } +// ) +// ); +// +// expect( +// action.next( { success: false, data: { message: 'Test Message' } } ) +// .value +// ).toEqual( +// dispatch( +// noticesStore, +// 'createErrorNotice', +// __( "Unable to save: 'Test Message'" ), +// { +// type: 'snackbar', +// } +// ) +// ); +// } ); +// +// it( 'handles an error from the entity and show error notifications', () => { +// const post = { +// id: 'navigation-post-1', +// slug: 'navigation-post-1', +// type: 'page', +// meta: { +// menuId: 1, +// }, +// blocks: [ +// { +// attributes: { showSubmenuIcon: true }, +// clientId: 'navigation-block-client-id', +// innerBlocks: [ +// { +// attributes: { +// label: 'wp.org', +// opensInNewTab: false, +// url: 'http://wp.org', +// className: '', +// rel: '', +// description: '', +// title: '', +// }, +// clientId: 'navigation-link-block-client-id-1', +// innerBlocks: [], +// isValid: true, +// name: 'core/navigation-link', +// }, +// { +// attributes: { +// label: 'wp.com', +// opensInNewTab: false, +// url: 'http://wp.com', +// className: '', +// rel: '', +// description: '', +// title: '', +// }, +// clientId: 'navigation-link-block-client-id-2', +// innerBlocks: [], +// isValid: true, +// name: 'core/navigation-link', +// }, +// ], +// isValid: true, +// name: 'core/navigation', +// }, +// ], +// }; +// +// const menuItems = [ +// { +// id: 100, +// title: { +// raw: 'wp.com', +// rendered: 'wp.com', +// }, +// url: 'http://wp.com', +// menu_order: 1, +// menus: [ 1 ], +// classes: [], +// xfn: [], +// description: '', +// attr_title: '', +// }, +// { +// id: 101, +// title: { +// raw: 'wp.org', +// rendered: 'wp.org', +// }, +// url: 'http://wp.org', +// menu_order: 2, +// menus: [ 1 ], +// classes: [], +// xfn: [], +// description: '', +// attr_title: '', +// }, +// ]; +// +// const mapping = { +// 100: 'navigation-link-block-client-id-1', +// 101: 'navigation-link-block-client-id-2', +// }; +// +// const action = saveNavigationPost( post ); +// +// expect( action.next().value ).toEqual( +// resolveMenuItems( post.meta.menuId ) +// ); +// +// expect( action.next( menuItems ).value ).toEqual( +// getMenuItemToClientIdMapping( post.id ) +// ); +// +// expect( action.next( mapping ).value ).toEqual( +// dispatch( 'core', 'saveEditedEntityRecord', 'root', 'menu', 1 ) +// ); +// +// expect( action.next().value ).toEqual( +// select( 'core', 'getLastEntitySaveError', 'root', 'menu', 1 ) +// ); +// +// expect( action.next( { message: 'Test Message 2' } ).value ).toEqual( +// dispatch( +// noticesStore, +// 'createErrorNotice', +// __( "Unable to save: 'Test Message 2'" ), +// { +// type: 'snackbar', +// } +// ) +// ); +// } ); +// } ); +// +// describe( 'setSelectedMenuId', () => { +// it( 'should return the SET_SELECTED_MENU_ID action', () => { +// const menuId = 1; +// expect( setSelectedMenuId( menuId ) ).toEqual( { +// type: 'SET_SELECTED_MENU_ID', +// menuId, +// } ); +// } ); +// } ); diff --git a/packages/edit-navigation/src/store/test/menu-items-to-blocks.js b/packages/edit-navigation/src/store/test/transform.js similarity index 99% rename from packages/edit-navigation/src/store/test/menu-items-to-blocks.js rename to packages/edit-navigation/src/store/test/transform.js index f4a40f0dae27e..39083ac07e901 100644 --- a/packages/edit-navigation/src/store/test/menu-items-to-blocks.js +++ b/packages/edit-navigation/src/store/test/transform.js @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import menuItemsToBlocks from '../menu-items-to-blocks'; +import { menuItemsToBlocks } from '../transform'; // Mock createBlock to avoid creating the blocks in test environment. jest.mock( '@wordpress/blocks', () => { diff --git a/packages/edit-navigation/src/store/menu-items-to-blocks.js b/packages/edit-navigation/src/store/transform.js similarity index 63% rename from packages/edit-navigation/src/store/menu-items-to-blocks.js rename to packages/edit-navigation/src/store/transform.js index 5522f8f804e5d..324df1b65f98e 100644 --- a/packages/edit-navigation/src/store/menu-items-to-blocks.js +++ b/packages/edit-navigation/src/store/transform.js @@ -1,12 +1,42 @@ /** * External dependencies */ -import { sortBy } from 'lodash'; +import { omit, sortBy } from 'lodash'; /** * WordPress dependencies */ -import { createBlock, parse } from '@wordpress/blocks'; +import { serialize, createBlock, parse } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { NEW_TAB_TARGET_ATTRIBUTE } from '../constants'; + +export function blockToMenuItem( block, menuItem, parentId, position, menuId ) { + menuItem = omit( menuItem, 'menus', 'meta', '_links' ); + + let attributes; + + if ( block.name === 'core/navigation-link' ) { + attributes = blockAttributesToMenuItem( block.attributes ); + } else { + attributes = { + type: 'block', + content: serialize( block ), + }; + } + + return { + ...menuItem, + ...attributes, + menu_order: position, + menu_id: menuId, + parent: parentId, + status: 'publish', + _invalid: false, + }; +} /** * Convert a flat menu item structure to a nested blocks structure. @@ -15,7 +45,7 @@ import { createBlock, parse } from '@wordpress/blocks'; * * @return {WPBlock[]} An array of blocks. */ -export default function menuItemsToBlocks( menuItems ) { +export function menuItemsToBlocks( menuItems ) { if ( ! menuItems ) { return null; } @@ -86,6 +116,70 @@ function mapMenuItemsToBlocks( menuItems ) { }; } +/** + * Convert block attributes to menu item fields. + * + * Note that nav_menu_item has defaults provided in Core so in the case of undefined Block attributes + * we need only include a subset of values in the knowledge that the defaults will be provided in Core. + * + * See: https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L438. + * + * @param {Object} blockAttributes the block attributes of the block to be converted into menu item fields. + * @param {string} blockAttributes.label the visual name of the block shown in the UI. + * @param {string} blockAttributes.url the URL for the link. + * @param {string} blockAttributes.description a link description. + * @param {string} blockAttributes.rel the XFN relationship expressed in the link of this menu item. + * @param {string} blockAttributes.className the custom CSS classname attributes for this block. + * @param {string} blockAttributes.title the HTML title attribute for the block's link. + * @param {string} blockAttributes.type the type of variation of the block used (eg: 'Post', 'Custom', 'Category'...etc). + * @param {number} blockAttributes.id the ID of the entity optionally associated with the block's link (eg: the Post ID). + * @param {string} blockAttributes.kind the family of objects originally represented, such as 'post-type' or 'taxonomy'. + * @param {boolean} blockAttributes.opensInNewTab whether or not the block's link should open in a new tab. + * @return {Object} the menu item (converted from block attributes). + */ +export const blockAttributesToMenuItem = ( { + label = '', + url = '', + description, + rel, + className, + title: blockTitleAttr, + type, + id, + kind, + opensInNewTab, +} ) => { + // For historical reasons, the `core/navigation-link` variation type is `tag` + // whereas WP Core expects `post_tag` as the `object` type. + // To avoid writing a block migration we perform a conversion here. + // See also inverse equivalent in `menuItemToBlockAttributes`. + if ( type && type === 'tag' ) { + type = 'post_tag'; + } + + const menuItem = { + title: label, + url, + description, + xfn: rel?.trim().split( ' ' ), + classes: className?.trim().split( ' ' ), + attr_title: blockTitleAttr, + object: type, + type: kind?.replace( '-', '_' ), + // Only assign object_id if it's a entity type (ie: not "custom"). + ...( id && + 'custom' !== type && { + object_id: id, + } ), + target: opensInNewTab ? NEW_TAB_TARGET_ATTRIBUTE : '', + }; + + // Filter out the empty values + return Object.fromEntries( + Object.entries( menuItem ).filter( ( [ , v ] ) => v ) + ); +}; + /** * A WP nav_menu_item object. * For more documentation on the individual fields present on a menu item please see: @@ -113,7 +207,7 @@ function mapMenuItemsToBlocks( menuItems ) { * @param {WPNavMenuItem} menuItem the menu item to be converted to block attributes. * @return {Object} the block attributes converted from the WPNavMenuItem item. */ -function menuItemToBlockAttributes( { +export function menuItemToBlockAttributes( { title: menuItemTitleField, xfn, classes, @@ -149,7 +243,7 @@ function menuItemToBlockAttributes( { id: object_id, } ), description, - ...( target === '_blank' && { + ...( target === NEW_TAB_TARGET_ATTRIBUTE && { opensInNewTab: true, } ), }; diff --git a/packages/edit-navigation/src/store/utils.js b/packages/edit-navigation/src/store/utils.js index 26b16c24867e8..5193e202c051b 100644 --- a/packages/edit-navigation/src/store/utils.js +++ b/packages/edit-navigation/src/store/utils.js @@ -1,8 +1,3 @@ -/** - * Internal dependencies - */ -import { NEW_TAB_TARGET_ATTRIBUTE } from '../constants'; - /** * A WP nav_menu_item object. * For more documentation on the individual fields present on a menu item please see: @@ -42,127 +37,3 @@ export const buildNavigationPostId = ( menuId ) => export function menuItemsQuery( menuId ) { return { menus: menuId, per_page: -1 }; } - -/** - * Convert block attributes to menu item fields. - * - * Note that nav_menu_item has defaults provided in Core so in the case of undefined Block attributes - * we need only include a subset of values in the knowledge that the defaults will be provided in Core. - * - * See: https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L438. - * - * @param {Object} blockAttributes the block attributes of the block to be converted into menu item fields. - * @param {string} blockAttributes.label the visual name of the block shown in the UI. - * @param {string} blockAttributes.url the URL for the link. - * @param {string} blockAttributes.description a link description. - * @param {string} blockAttributes.rel the XFN relationship expressed in the link of this menu item. - * @param {string} blockAttributes.className the custom CSS classname attributes for this block. - * @param {string} blockAttributes.title the HTML title attribute for the block's link. - * @param {string} blockAttributes.type the type of variation of the block used (eg: 'Post', 'Custom', 'Category'...etc). - * @param {number} blockAttributes.id the ID of the entity optionally associated with the block's link (eg: the Post ID). - * @param {string} blockAttributes.kind the family of objects originally represented, such as 'post-type' or 'taxonomy'. - * @param {boolean} blockAttributes.opensInNewTab whether or not the block's link should open in a new tab. - * @return {Object} the menu item (converted from block attributes). - */ -export const blockAttributesToMenuItem = ( { - label = '', - url = '', - description, - rel, - className, - title: blockTitleAttr, - type, - id, - kind, - opensInNewTab, -} ) => { - // For historical reasons, the `core/navigation-link` variation type is `tag` - // whereas WP Core expects `post_tag` as the `object` type. - // To avoid writing a block migration we perform a conversion here. - // See also inverse equivalent in `menuItemToBlockAttributes`. - if ( type && type === 'tag' ) { - type = 'post_tag'; - } - - const menuItem = { - title: label, - url, - description, - xfn: rel?.trim().split( ' ' ), - classes: className?.trim().split( ' ' ), - attr_title: blockTitleAttr, - object: type, - type: kind?.replace( '-', '_' ), - // Only assign object_id if it's a entity type (ie: not "custom"). - ...( id && - 'custom' !== type && { - object_id: id, - } ), - target: opensInNewTab ? NEW_TAB_TARGET_ATTRIBUTE : '', - }; - - // Filter out the empty values - return Object.fromEntries( - Object.entries( menuItem ).filter( ( [ , v ] ) => v ) - ); -}; - -/** - * Convert block attributes to menu item. - * - * @param {WPNavMenuItem} menuItem the menu item to be converted to block attributes. - * @return {Object} the block attributes converted from the menu item. - */ -export const menuItemToBlockAttributes = ( { - title: menuItemTitleField, - xfn, - classes, - // eslint-disable-next-line camelcase - attr_title, - object, - // eslint-disable-next-line camelcase - object_id, - description, - url, - type: menuItemTypeField, - target, -} ) => { - // For historical reasons, the `core/navigation-link` variation type is `tag` - // whereas WP Core expects `post_tag` as the `object` type. - // To avoid writing a block migration we perform a conversion here. - // See also inverse equivalent in `blockAttributesToMenuItem`. - if ( object && object === 'post_tag' ) { - object = 'tag'; - } - - return { - label: menuItemTitleField?.rendered || '', - ...( object?.length && { - type: object, - } ), - kind: menuItemTypeField?.replace( '_', '-' ) || 'custom', - url: url || '', - ...( xfn?.length && - xfn.join( ' ' ).trim() && { - rel: xfn.join( ' ' ).trim(), - } ), - ...( classes?.length && - classes.join( ' ' ).trim() && { - className: classes.join( ' ' ).trim(), - } ), - ...( attr_title?.length && { - title: attr_title, - } ), - // eslint-disable-next-line camelcase - ...( object_id && - 'custom' !== object && { - id: object_id, - } ), - ...( description?.length && { - description, - } ), - ...( target === NEW_TAB_TARGET_ATTRIBUTE && { - opensInNewTab: true, - } ), - }; -}; From 9357af3005fb1410d70c7e0d40b262713a44ade8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 17:21:49 +0200 Subject: [PATCH 12/57] Remove dev comment --- packages/edit-navigation/src/store/actions.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 8168798a09b81..70d87e38efa10 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -181,8 +181,6 @@ function mapBlockIdToEntityRecord( entityIdToBlockId, entityRecords ) { ); } -// saveEntityRecord for each menu item with block-based data -// saveEntityRecord for each deleted menu item const createBatchSaveForEditedMenuItems = ( post ) => async ( { registry, } ) => { From 1c5e66becf4a7c7d6a4aa48c1cfe52709d4957b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 18:07:10 +0200 Subject: [PATCH 13/57] Operate on lists and sets, not on trees --- packages/edit-navigation/src/store/actions.js | 186 +++++++++--------- 1 file changed, 97 insertions(+), 89 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 70d87e38efa10..2a6b7bd329289 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { invert } from 'lodash'; - /** * WordPress dependencies */ @@ -50,54 +45,62 @@ export const createMissingMenuItems = ( post ) => async ( { exclusive: false, } ); try { - const mapping = await getEntityRecordToBlockIdMapping( + const menuItemIdToBlockId = await getEntityRecordIdToBlockIdMapping( registry, 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 = await apiFetch( { - path: `/__experimental/menu-items`, - method: 'POST', - data: { - title: 'Placeholder', - url: 'Placeholder', - menu_order: 0, - }, - } ); - - mapping[ menuItem.id ] = block.clientId; - const menuItems = await registry - .resolveSelect( 'core' ) - .getMenuItems( { menus: menuId, per_page: -1 } ); - - await registry - .dispatch( 'core' ) - .receiveEntityRecords( - 'root', - 'menuItem', - [ ...menuItems, menuItem ], - menuItemsQuery( menuId ), - false - ); + const knownBlockIds = new Set( Object.values( menuItemIdToBlockId ) ); + + const blocks = blocksTreeToFlatList( post.blocks[ 0 ].innerBlocks ); + for ( const { block } of blocks ) { + if ( ! knownBlockIds.has( block.clientId ) ) { + const menuItem = await dispatch( + createPlaceholderMenuItem( block, menuId ) + ); + menuItemIdToBlockId[ menuItem.id ] = block.clientId; } - stack.push( ...block.innerBlocks ); } dispatch( { type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', postId: post.id, - mapping, + mapping: menuItemIdToBlockId, } ); } finally { await registry.dispatch( 'core' ).__unstableReleaseStoreLock( lock ); } }; +const createPlaceholderMenuItem = ( block, menuId ) => async ( { + registry, +} ) => { + const menuItem = await apiFetch( { + path: `/__experimental/menu-items`, + method: 'POST', + data: { + title: 'Placeholder', + url: 'Placeholder', + menu_order: 0, + }, + } ); + + const menuItems = await registry + .resolveSelect( 'core' ) + .getMenuItems( { menus: menuId, per_page: -1 } ); + + await registry + .dispatch( 'core' ) + .receiveEntityRecords( + 'root', + 'menuItem', + [ ...menuItems, menuItem ], + menuItemsQuery( menuId ), + false + ); + + return menuItem; +}; + /** * Converts all the blocks into menu items and submits a batch request to save everything at once. * @@ -129,8 +132,12 @@ export const saveNavigationPost = ( post ) => async ( { } // Save blocks as menu items. + const oldMenuItems = await registry + .resolveSelect( 'core' ) + .getMenuItems( { menus: post.meta.menuId, per_page: -1 } ); + const newMenuItems = await dispatch( computeNewMenuItems( post ) ); const batchTasks = await dispatch( - createBatchSaveForEditedMenuItems( post ) + createBatchSave( 'root', 'menuItem', oldMenuItems, newMenuItems ) ); await registry.dispatch( 'core' ).__experimentalBatch( batchTasks ); @@ -167,9 +174,33 @@ export const saveNavigationPost = ( post ) => async ( { } }; -const getEntityRecordToBlockIdMapping = ( registry, postId ) => +const getEntityRecordIdToBlockIdMapping = ( registry, postId ) => registry.stores[ STORE_NAME ].store.getState().mapping[ postId ] || {}; +const computeNewMenuItems = ( post ) => async ( { registry } ) => { + const navigationBlock = post.blocks[ 0 ]; + const menuId = post.meta.menuId; + const oldEntityRecords = await registry + .resolveSelect( 'core' ) + .getMenuItems( { menus: menuId, per_page: -1 } ); + + const blockIdToOldEntityRecord = mapBlockIdToEntityRecord( + getEntityRecordIdToBlockIdMapping( registry, post.id ), + oldEntityRecords + ); + + const blocksList = blocksTreeToFlatList( navigationBlock.innerBlocks ); + return blocksList.map( ( { block, parentBlockId, position } ) => + blockToMenuItem( + block, + blockIdToOldEntityRecord[ block.clientId ], + blockIdToOldEntityRecord[ parentBlockId ]?.id, + position, + menuId + ) + ); +}; + function mapBlockIdToEntityRecord( entityIdToBlockId, entityRecords ) { return Object.fromEntries( entityRecords @@ -181,72 +212,53 @@ function mapBlockIdToEntityRecord( entityIdToBlockId, entityRecords ) { ); } -const createBatchSaveForEditedMenuItems = ( post ) => async ( { - registry, -} ) => { - const navigationBlock = post.blocks[ 0 ]; - const menuId = post.meta.menuId; - const menuItems = await registry - .resolveSelect( 'core' ) - .getMenuItems( { menus: menuId, per_page: -1 } ); - - const blockIdToAPIEntity = mapBlockIdToEntityRecord( - getEntityRecordToBlockIdMapping( registry, post.id ), - menuItems - ); - - const blocksList = blocksTreeToFlatList( navigationBlock.innerBlocks ); - - const deletedEntityRecordsIds = computeDeletedEntityRecordsIds( - blockIdToAPIEntity, - blocksList +const createBatchSave = ( + kind, + type, + oldEntityRecords, + newEntityRecords +) => async ( { registry } ) => { + const deletedEntityRecordsIds = new Set( + diff( + oldEntityRecords.map( ( { id } ) => id ), + newEntityRecords.map( ( { id } ) => id ) + ) ); const batchTasks = []; // Enqueue updates - for ( const { block, parentBlockId, position } of blocksList ) { - const entityRecordId = blockIdToAPIEntity[ block.clientId ]?.id; + for ( const entityRecord of newEntityRecords ) { if ( - ! entityRecordId || - deletedEntityRecordsIds.includes( entityRecordId ) + ! entityRecord?.id || + deletedEntityRecordsIds.has( entityRecord?.id ) ) { continue; } - // Update an existing navigation item. + // Update an existing entity record. await registry .dispatch( 'core' ) - .editEntityRecord( - 'root', - 'menuItem', - entityRecordId, - blockToMenuItem( - block, - blockIdToAPIEntity[ block.clientId ], - blockIdToAPIEntity[ parentBlockId ]?.id, - position, - menuId - ), - { undoIgnore: true } - ); + .editEntityRecord( kind, type, entityRecord.id, entityRecord, { + undoIgnore: true, + } ); const hasEdits = registry .select( 'core' ) - .hasEditsForEntityRecord( 'root', 'menuItem', entityRecordId ); + .hasEditsForEntityRecord( kind, type, entityRecord.id ); if ( ! hasEdits ) { continue; } batchTasks.unshift( ( { saveEditedEntityRecord } ) => - saveEditedEntityRecord( 'root', 'menuItem', entityRecordId ) + saveEditedEntityRecord( kind, type, entityRecord.id ) ); } // Enqueue deletes for ( const entityRecordId of deletedEntityRecordsIds ) { batchTasks.unshift( ( { deleteEntityRecord } ) => - deleteEntityRecord( 'root', 'menuItem', entityRecordId, { + deleteEntityRecord( kind, type, entityRecordId, { force: true, } ) ); @@ -255,6 +267,11 @@ const createBatchSaveForEditedMenuItems = ( post ) => async ( { return batchTasks; }; +function diff( listA, listB ) { + const setB = new Set( listB ); + return listA.filter( ( x ) => ! setB.has( x ) ); +} + function blocksTreeToFlatList( innerBlocks, parentBlockId = null ) { return innerBlocks.flatMap( ( block, index ) => [ { block, parentBlockId, position: index + 1 } ].concat( @@ -263,15 +280,6 @@ function blocksTreeToFlatList( innerBlocks, parentBlockId = null ) { ); } -function computeDeletedEntityRecordsIds( blockIdToAPIEntity, blocksList ) { - const editorBlocksIds = new Set( - blocksList.map( ( { block } ) => block.clientId ) - ); - return Object.entries( blockIdToAPIEntity ) - .filter( ( [ clientId ] ) => ! editorBlocksIds.has( clientId ) ) - .map( ( [ , entityRecord ] ) => entityRecord.id ); -} - /** * Returns an action object used to open/close the inserter. * From 8c90ae80b7311e7638ba99088ecd414f37f48dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 18:39:31 +0200 Subject: [PATCH 14/57] Extract batchSaveMenuItems --- packages/edit-navigation/src/store/actions.js | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 2a6b7bd329289..704f8eb0611d3 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -45,9 +45,8 @@ export const createMissingMenuItems = ( post ) => async ( { exclusive: false, } ); try { - const menuItemIdToBlockId = await getEntityRecordIdToBlockIdMapping( - registry, - post.id + const menuItemIdToBlockId = await dispatch( + getEntityRecordIdToBlockIdMapping( post.id ) ); const knownBlockIds = new Set( Object.values( menuItemIdToBlockId ) ); @@ -73,6 +72,7 @@ export const createMissingMenuItems = ( post ) => async ( { const createPlaceholderMenuItem = ( block, menuId ) => async ( { registry, + dispatch, } ) => { const menuItem = await apiFetch( { path: `/__experimental/menu-items`, @@ -84,9 +84,7 @@ const createPlaceholderMenuItem = ( block, menuId ) => async ( { }, } ); - const menuItems = await registry - .resolveSelect( 'core' ) - .getMenuItems( { menus: menuId, per_page: -1 } ); + const menuItems = await dispatch( resolveSelectMenuItems( menuId ) ); await registry .dispatch( 'core' ) @@ -131,15 +129,7 @@ export const saveNavigationPost = ( post ) => async ( { throw new Error( error.message ); } - // Save blocks as menu items. - const oldMenuItems = await registry - .resolveSelect( 'core' ) - .getMenuItems( { menus: post.meta.menuId, per_page: -1 } ); - const newMenuItems = await dispatch( computeNewMenuItems( post ) ); - const batchTasks = await dispatch( - createBatchSave( 'root', 'menuItem', oldMenuItems, newMenuItems ) - ); - await registry.dispatch( 'core' ).__experimentalBatch( batchTasks ); + await dispatch( batchSaveMenuItems( post ) ); // Clear "stub" navigation post edits to avoid a false "dirty" state. await registry @@ -174,33 +164,51 @@ export const saveNavigationPost = ( post ) => async ( { } }; -const getEntityRecordIdToBlockIdMapping = ( registry, postId ) => - registry.stores[ STORE_NAME ].store.getState().mapping[ postId ] || {}; - -const computeNewMenuItems = ( post ) => async ( { registry } ) => { - const navigationBlock = post.blocks[ 0 ]; - const menuId = post.meta.menuId; - const oldEntityRecords = await registry - .resolveSelect( 'core' ) - .getMenuItems( { menus: menuId, per_page: -1 } ); +const batchSaveMenuItems = ( post ) => async ( { dispatch, registry } ) => { + const oldMenuItems = await dispatch( + resolveSelectMenuItems( post.meta.menuId ) + ); + const newMenuItems = await dispatch( + computeNewMenuItems( post, oldMenuItems ) + ); + const batchTasks = await dispatch( + createBatchSave( 'root', 'menuItem', oldMenuItems, newMenuItems ) + ); + return await registry.dispatch( 'core' ).__experimentalBatch( batchTasks ); +}; +const computeNewMenuItems = ( post, oldMenuItems ) => async ( { + dispatch, +} ) => { + const mapping = await dispatch( + getEntityRecordIdToBlockIdMapping( post.id ) + ); const blockIdToOldEntityRecord = mapBlockIdToEntityRecord( - getEntityRecordIdToBlockIdMapping( registry, post.id ), - oldEntityRecords + mapping, + oldMenuItems ); - const blocksList = blocksTreeToFlatList( navigationBlock.innerBlocks ); + const blocksList = blocksTreeToFlatList( post.blocks[ 0 ].innerBlocks ); return blocksList.map( ( { block, parentBlockId, position } ) => blockToMenuItem( block, blockIdToOldEntityRecord[ block.clientId ], blockIdToOldEntityRecord[ parentBlockId ]?.id, position, - menuId + post.meta.menuId ) ); }; +const getEntityRecordIdToBlockIdMapping = ( postId ) => async ( { + registry, +} ) => registry.stores[ STORE_NAME ].store.getState().mapping[ postId ] || {}; + +const resolveSelectMenuItems = ( menuId ) => async ( { registry } ) => + await registry + .resolveSelect( 'core' ) + .getMenuItems( { menus: menuId, per_page: -1 } ); + function mapBlockIdToEntityRecord( entityIdToBlockId, entityRecords ) { return Object.fromEntries( entityRecords From 0dbc490cf7cbccc68958d390d481286dee399a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 8 Sep 2021 18:41:31 +0200 Subject: [PATCH 15/57] Inline blockIdToOldEntityRecord --- packages/edit-navigation/src/store/actions.js | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 704f8eb0611d3..ca1c29fb6171b 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -180,12 +180,16 @@ const batchSaveMenuItems = ( post ) => async ( { dispatch, registry } ) => { const computeNewMenuItems = ( post, oldMenuItems ) => async ( { dispatch, } ) => { - const mapping = await dispatch( + const entityIdToBlockId = await dispatch( getEntityRecordIdToBlockIdMapping( post.id ) ); - const blockIdToOldEntityRecord = mapBlockIdToEntityRecord( - mapping, + const blockIdToOldEntityRecord = Object.fromEntries( oldMenuItems + .map( ( entityRecord ) => [ + entityIdToBlockId[ entityRecord.id ], + entityRecord, + ] ) + .filter( ( [ blockId ] ) => blockId ) ); const blocksList = blocksTreeToFlatList( post.blocks[ 0 ].innerBlocks ); @@ -209,17 +213,6 @@ const resolveSelectMenuItems = ( menuId ) => async ( { registry } ) => .resolveSelect( 'core' ) .getMenuItems( { menus: menuId, per_page: -1 } ); -function mapBlockIdToEntityRecord( entityIdToBlockId, entityRecords ) { - return Object.fromEntries( - entityRecords - .map( ( entityRecord ) => [ - entityIdToBlockId[ entityRecord.id ], - entityRecord, - ] ) - .filter( ( [ blockId ] ) => blockId ) - ); -} - const createBatchSave = ( kind, type, From 9532bd4b61e14ebf80f69add20f402a5161f55da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 9 Sep 2021 14:23:37 +0200 Subject: [PATCH 16/57] Ensure consistency between stored menu items and the ones recovered from the block attributes --- .../edit-navigation/src/store/transform.js | 188 +++++++++--------- 1 file changed, 94 insertions(+), 94 deletions(-) diff --git a/packages/edit-navigation/src/store/transform.js b/packages/edit-navigation/src/store/transform.js index 324df1b65f98e..3fe5874538f52 100644 --- a/packages/edit-navigation/src/store/transform.js +++ b/packages/edit-navigation/src/store/transform.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { omit, sortBy } from 'lodash'; +import { get, omit, sortBy } from 'lodash'; /** * WordPress dependencies @@ -13,8 +13,30 @@ import { serialize, createBlock, parse } from '@wordpress/blocks'; */ import { NEW_TAB_TARGET_ATTRIBUTE } from '../constants'; +/** + * A WP nav_menu_item object. + * For more documentation on the individual fields present on a menu item please see: + * https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L789 + * + * Changes made here should also be mirrored in packages/edit-navigation/src/store/utils.js. + * + * @typedef WPNavMenuItem + * + * @property {Object} title stores the raw and rendered versions of the title/label for this menu item. + * @property {Array} xfn the XFN relationships expressed in the link of this menu item. + * @property {Array} classes the HTML class attributes for this menu item. + * @property {string} attr_title the HTML title attribute for this menu item. + * @property {string} object The type of object originally represented, such as 'category', 'post', or 'attachment'. + * @property {string} object_id The DB ID of the original object this menu item represents, e.g. ID for posts and term_id for categories. + * @property {string} description The description of this menu item. + * @property {string} url The URL to which this menu item points. + * @property {string} type The family of objects originally represented, such as 'post_type' or 'taxonomy'. + * @property {string} target The target attribute of the link element for this menu item. + */ + export function blockToMenuItem( block, menuItem, parentId, position, menuId ) { menuItem = omit( menuItem, 'menus', 'meta', '_links' ); + menuItem.content = get( menuItem.content, 'raw', menuItem.content ); let attributes; @@ -31,13 +53,76 @@ export function blockToMenuItem( block, menuItem, parentId, position, menuId ) { ...menuItem, ...attributes, menu_order: position, - menu_id: menuId, - parent: parentId, + menus: [ menuId ], + parent: ! parentId ? 0 : parentId, status: 'publish', - _invalid: false, }; } +/** + * Convert block attributes to menu item fields. + * + * Note that nav_menu_item has defaults provided in Core so in the case of undefined Block attributes + * we need only include a subset of values in the knowledge that the defaults will be provided in Core. + * + * See: https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L438. + * + * @param {Object} blockAttributes the block attributes of the block to be converted into menu item fields. + * @param {string} blockAttributes.label the visual name of the block shown in the UI. + * @param {string} blockAttributes.url the URL for the link. + * @param {string} blockAttributes.description a link description. + * @param {string} blockAttributes.rel the XFN relationship expressed in the link of this menu item. + * @param {string} blockAttributes.className the custom CSS classname attributes for this block. + * @param {string} blockAttributes.title the HTML title attribute for the block's link. + * @param {string} blockAttributes.type the type of variation of the block used (eg: 'Post', 'Custom', 'Category'...etc). + * @param {number} blockAttributes.id the ID of the entity optionally associated with the block's link (eg: the Post ID). + * @param {string} blockAttributes.kind the family of objects originally represented, such as 'post-type' or 'taxonomy'. + * @param {boolean} blockAttributes.opensInNewTab whether or not the block's link should open in a new tab. + * @return {WPNavMenuItem} the menu item (converted from block attributes). + */ +export const blockAttributesToMenuItem = ( { + label = '', + url = '', + description, + rel, + className, + title: blockTitleAttr, + type, + id, + kind, + opensInNewTab, +} ) => { + // For historical reasons, the `core/navigation-link` variation type is `tag` + // whereas WP Core expects `post_tag` as the `object` type. + // To avoid writing a block migration we perform a conversion here. + // See also inverse equivalent in `menuItemToBlockAttributes`. + if ( type && type === 'tag' ) { + type = 'post_tag'; + } + + const menuItem = { + title: label, + url, + description, + xfn: rel?.trim().split( ' ' ), + classes: className?.trim().split( ' ' ), + attr_title: blockTitleAttr, + object: type, + type: kind === 'custom' ? '' : kind?.replace( '-', '_' ), + // Only assign object_id if it's a entity type (ie: not "custom"). + ...( id && + 'custom' !== type && { + object_id: id, + } ), + target: opensInNewTab ? NEW_TAB_TARGET_ATTRIBUTE : '', + }; + + // Filter out the empty values + return Object.fromEntries( + Object.entries( menuItem ).filter( ( [ , v ] ) => v ) + ); +}; + /** * Convert a flat menu item structure to a nested blocks structure. * @@ -116,91 +201,8 @@ function mapMenuItemsToBlocks( menuItems ) { }; } -/** - * Convert block attributes to menu item fields. - * - * Note that nav_menu_item has defaults provided in Core so in the case of undefined Block attributes - * we need only include a subset of values in the knowledge that the defaults will be provided in Core. - * - * See: https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L438. - * - * @param {Object} blockAttributes the block attributes of the block to be converted into menu item fields. - * @param {string} blockAttributes.label the visual name of the block shown in the UI. - * @param {string} blockAttributes.url the URL for the link. - * @param {string} blockAttributes.description a link description. - * @param {string} blockAttributes.rel the XFN relationship expressed in the link of this menu item. - * @param {string} blockAttributes.className the custom CSS classname attributes for this block. - * @param {string} blockAttributes.title the HTML title attribute for the block's link. - * @param {string} blockAttributes.type the type of variation of the block used (eg: 'Post', 'Custom', 'Category'...etc). - * @param {number} blockAttributes.id the ID of the entity optionally associated with the block's link (eg: the Post ID). - * @param {string} blockAttributes.kind the family of objects originally represented, such as 'post-type' or 'taxonomy'. - * @param {boolean} blockAttributes.opensInNewTab whether or not the block's link should open in a new tab. - * @return {Object} the menu item (converted from block attributes). - */ -export const blockAttributesToMenuItem = ( { - label = '', - url = '', - description, - rel, - className, - title: blockTitleAttr, - type, - id, - kind, - opensInNewTab, -} ) => { - // For historical reasons, the `core/navigation-link` variation type is `tag` - // whereas WP Core expects `post_tag` as the `object` type. - // To avoid writing a block migration we perform a conversion here. - // See also inverse equivalent in `menuItemToBlockAttributes`. - if ( type && type === 'tag' ) { - type = 'post_tag'; - } - - const menuItem = { - title: label, - url, - description, - xfn: rel?.trim().split( ' ' ), - classes: className?.trim().split( ' ' ), - attr_title: blockTitleAttr, - object: type, - type: kind?.replace( '-', '_' ), - // Only assign object_id if it's a entity type (ie: not "custom"). - ...( id && - 'custom' !== type && { - object_id: id, - } ), - target: opensInNewTab ? NEW_TAB_TARGET_ATTRIBUTE : '', - }; - - // Filter out the empty values - return Object.fromEntries( - Object.entries( menuItem ).filter( ( [ , v ] ) => v ) - ); -}; - -/** - * A WP nav_menu_item object. - * For more documentation on the individual fields present on a menu item please see: - * https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L789 - * - * Changes made here should also be mirrored in packages/edit-navigation/src/store/utils.js. - * - * @typedef WPNavMenuItem - * - * @property {Object} title stores the raw and rendered versions of the title/label for this menu item. - * @property {Array} xfn the XFN relationships expressed in the link of this menu item. - * @property {Array} classes the HTML class attributes for this menu item. - * @property {string} attr_title the HTML title attribute for this menu item. - * @property {string} object The type of object originally represented, such as 'category', 'post', or 'attachment'. - * @property {string} object_id The DB ID of the original object this menu item represents, e.g. ID for posts and term_id for categories. - * @property {string} description The description of this menu item. - * @property {string} url The URL to which this menu item points. - * @property {string} type The family of objects originally represented, such as 'post_type' or 'taxonomy'. - * @property {string} target The target attribute of the link element for this menu item. - */ - +// A few parameters are using snake case, let's embrace that for convenience: +/* eslint-disable camelcase */ /** * Convert block attributes to menu item. * @@ -211,10 +213,8 @@ export function menuItemToBlockAttributes( { title: menuItemTitleField, xfn, classes, - // eslint-disable-next-line camelcase - attrTitle, + attr_title, object, - // eslint-disable-next-line camelcase object_id, description, url, @@ -236,8 +236,7 @@ export function menuItemToBlockAttributes( { url: url || '', rel: xfn?.join( ' ' ).trim(), className: classes?.join( ' ' ).trim(), - title: attrTitle?.raw || attrTitle || menuItemTitleField?.raw, - // eslint-disable-next-line camelcase + title: attr_title?.raw || attr_title || menuItemTitleField?.raw, ...( object_id && 'custom' !== object && { id: object_id, @@ -253,6 +252,7 @@ export function menuItemToBlockAttributes( { Object.entries( attributes ).filter( ( [ , v ] ) => v ) ); } +/* eslint-enable camelcase */ /** * Creates a nested, hierarchical tree representation from unstructured data that From a0eab6ebdc2eaab8bd105365d579fb3bdc06ae99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 9 Sep 2021 15:10:33 +0200 Subject: [PATCH 17/57] Add error checking --- packages/edit-navigation/src/store/actions.js | 68 +++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index ca1c29fb6171b..2e8fd84084ae6 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { zip } from 'lodash'; + /** * WordPress dependencies */ @@ -171,10 +176,43 @@ const batchSaveMenuItems = ( post ) => async ( { dispatch, registry } ) => { const newMenuItems = await dispatch( computeNewMenuItems( post, oldMenuItems ) ); - const batchTasks = await dispatch( - createBatchSave( 'root', 'menuItem', oldMenuItems, newMenuItems ) + const annotatedBatchTasks = await dispatch( + createBatchTasks( 'root', 'menuItem', oldMenuItems, newMenuItems ) ); - return await registry.dispatch( 'core' ).__experimentalBatch( batchTasks ); + const batchTasks = annotatedBatchTasks.map( ( { task } ) => task ); + const results = await registry + .dispatch( 'core' ) + .__experimentalBatch( batchTasks ); + + const failedDeletes = zip( annotatedBatchTasks, results ) + .filter( ( [ { type } ] ) => type === 'delete' ) + .filter( ( [ , result ] ) => ! result?.hasOwnProperty( 'deleted' ) ) + .map( ( [ { id } ] ) => id ); + + const failedUpdates = annotatedBatchTasks + .filter( ( { type } ) => type === 'update' ) + .filter( + ( { id } ) => + id && + registry + .select( 'core' ) + .getLastEntitySaveError( 'root', 'menuItem', id ) + ) + .map( ( { id } ) => id ); + + const failedEntityRecordIds = [ ...failedDeletes, ...failedUpdates ]; + + if ( failedEntityRecordIds.length ) { + throw new Error( + sprintf( + /* translators: %s: List of menu items ids */ + __( 'Could not save the following menu items: %s.' ), + failedEntityRecordIds.join( ', ' ) + ) + ); + } + + return results; }; const computeNewMenuItems = ( post, oldMenuItems ) => async ( { @@ -213,7 +251,7 @@ const resolveSelectMenuItems = ( menuId ) => async ( { registry } ) => .resolveSelect( 'core' ) .getMenuItems( { menus: menuId, per_page: -1 } ); -const createBatchSave = ( +const createBatchTasks = ( kind, type, oldEntityRecords, @@ -251,18 +289,24 @@ const createBatchSave = ( continue; } - batchTasks.unshift( ( { saveEditedEntityRecord } ) => - saveEditedEntityRecord( kind, type, entityRecord.id ) - ); + batchTasks.unshift( { + type: 'update', + id: entityRecord.id, + task: ( { saveEditedEntityRecord } ) => + saveEditedEntityRecord( kind, type, entityRecord.id ), + } ); } // Enqueue deletes for ( const entityRecordId of deletedEntityRecordsIds ) { - batchTasks.unshift( ( { deleteEntityRecord } ) => - deleteEntityRecord( kind, type, entityRecordId, { - force: true, - } ) - ); + batchTasks.unshift( { + type: 'delete', + id: entityRecordId, + task: ( { deleteEntityRecord } ) => + deleteEntityRecord( kind, type, entityRecordId, { + force: true, + } ), + } ); } return batchTasks; From 11f3d4b759ea610e89117d370d1b04f147e83d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 9 Sep 2021 15:21:59 +0200 Subject: [PATCH 18/57] Add generic batchSaveDiff function --- packages/edit-navigation/src/store/actions.js | 112 ++++++++++-------- 1 file changed, 65 insertions(+), 47 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 2e8fd84084ae6..df4ab6aa32fa3 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -134,7 +134,16 @@ export const saveNavigationPost = ( post ) => async ( { throw new Error( error.message ); } - await dispatch( batchSaveMenuItems( post ) ); + // Batch save menu items + const oldMenuItems = await dispatch( + resolveSelectMenuItems( post.meta.menuId ) + ); + const newMenuItems = await dispatch( + computeNewMenuItems( post, oldMenuItems ) + ); + await dispatch( + batchSaveDiff( 'root', 'menuItem', oldMenuItems, newMenuItems ) + ); // Clear "stub" navigation post edits to avoid a false "dirty" state. await registry @@ -169,52 +178,6 @@ export const saveNavigationPost = ( post ) => async ( { } }; -const batchSaveMenuItems = ( post ) => async ( { dispatch, registry } ) => { - const oldMenuItems = await dispatch( - resolveSelectMenuItems( post.meta.menuId ) - ); - const newMenuItems = await dispatch( - computeNewMenuItems( post, oldMenuItems ) - ); - const annotatedBatchTasks = await dispatch( - createBatchTasks( 'root', 'menuItem', oldMenuItems, newMenuItems ) - ); - const batchTasks = annotatedBatchTasks.map( ( { task } ) => task ); - const results = await registry - .dispatch( 'core' ) - .__experimentalBatch( batchTasks ); - - const failedDeletes = zip( annotatedBatchTasks, results ) - .filter( ( [ { type } ] ) => type === 'delete' ) - .filter( ( [ , result ] ) => ! result?.hasOwnProperty( 'deleted' ) ) - .map( ( [ { id } ] ) => id ); - - const failedUpdates = annotatedBatchTasks - .filter( ( { type } ) => type === 'update' ) - .filter( - ( { id } ) => - id && - registry - .select( 'core' ) - .getLastEntitySaveError( 'root', 'menuItem', id ) - ) - .map( ( { id } ) => id ); - - const failedEntityRecordIds = [ ...failedDeletes, ...failedUpdates ]; - - if ( failedEntityRecordIds.length ) { - throw new Error( - sprintf( - /* translators: %s: List of menu items ids */ - __( 'Could not save the following menu items: %s.' ), - failedEntityRecordIds.join( ', ' ) - ) - ); - } - - return results; -}; - const computeNewMenuItems = ( post, oldMenuItems ) => async ( { dispatch, } ) => { @@ -251,6 +214,61 @@ const resolveSelectMenuItems = ( menuId ) => async ( { registry } ) => .resolveSelect( 'core' ) .getMenuItems( { menus: menuId, per_page: -1 } ); +const batchSaveDiff = ( + kind, + type, + oldEntityRecords, + newEntityRecords +) => async ( { dispatch, registry } ) => { + const annotatedBatchTasks = await dispatch( + createBatchTasks( kind, type, oldEntityRecords, newEntityRecords ) + ); + + const results = await registry + .dispatch( 'core' ) + .__experimentalBatch( annotatedBatchTasks.map( ( { task } ) => task ) ); + + const failures = await dispatch( + getFailedBatchTasks( kind, type, annotatedBatchTasks, results ) + ); + + if ( failures.length ) { + throw new Error( + sprintf( + /* translators: %s: List of numeric ids */ + __( 'Could not save the following records: %s.' ), + failures.map( ( { id } ) => id ).join( ', ' ) + ) + ); + } + + return results; +}; + +const getFailedBatchTasks = ( + kind, + entityType, + annotatedBatchTasks, + results +) => async ( { registry } ) => { + const failedDeletes = zip( annotatedBatchTasks, results ) + .filter( ( [ { type } ] ) => type === 'delete' ) + .filter( ( [ , result ] ) => ! result?.hasOwnProperty( 'deleted' ) ) + .map( ( [ task ] ) => task ); + + const failedUpdates = annotatedBatchTasks + .filter( ( { type } ) => type === 'update' ) + .filter( + ( { id } ) => + id && + registry + .select( 'core' ) + .getLastEntitySaveError( kind, entityType, id ) + ); + + return [ ...failedDeletes, ...failedUpdates ]; +}; + const createBatchTasks = ( kind, type, From 6667fcc64f06e519786996fa0d81085b9cf38831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 9 Sep 2021 16:12:42 +0200 Subject: [PATCH 19/57] Preprocess menu data before sending it over to the API --- packages/core-data/src/actions.js | 3 +++ packages/core-data/src/entities.js | 22 +++++++++++++++++++ packages/edit-navigation/src/store/actions.js | 6 ++--- .../edit-navigation/src/store/transform.js | 10 +++++++-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 8dfbaa6571ef3..4ab8e002b5c4f 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -264,6 +264,9 @@ export const editEntityRecord = ( ? { ...editedRecordValue, ...edits[ key ] } : edits[ key ]; acc[ key ] = isEqual( recordValue, value ) ? undefined : value; + if ( ! isEqual( recordValue, value ) ) { + // console.log(kind, name, key, {recordValue, value}) + } return acc; }, {} ), transientEdits, diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 03fafbe099bcc..171da54df70d4 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -117,6 +117,7 @@ export const defaultEntities = [ plural: 'menuItems', label: __( 'Menu Item' ), rawAttributes: [ 'title', 'content' ], + __unstablePrePersist: prePersistMenuItem, }, { name: 'menuLocation', @@ -134,6 +135,27 @@ export const kinds = [ { name: 'taxonomy', loadEntities: loadTaxonomyEntities }, ]; +/** + * Returns a function to be used to retrieve extra edits to apply before persisting a menu item. + * + * @param {Object} persistedRecord Already persisted Menu item + * @param {Object} edits Edits. + * @return {Object} Updated edits. + */ +function prePersistMenuItem( persistedRecord, edits ) { + const newEdits = { + ...persistedRecord, + ...edits, + }; + if ( Array.isArray( newEdits.menus ) ) { + newEdits.menus = newEdits.menus[ 0 ]; + } + if ( newEdits.type === '' ) { + newEdits.type = 'custom'; + } + return newEdits; +} + /** * Returns a function to be used to retrieve extra edits to apply before persisting a post type. * diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index df4ab6aa32fa3..9839ee8606f8c 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -194,12 +194,12 @@ const computeNewMenuItems = ( post, oldMenuItems ) => async ( { ); const blocksList = blocksTreeToFlatList( post.blocks[ 0 ].innerBlocks ); - return blocksList.map( ( { block, parentBlockId, position } ) => + return blocksList.map( ( { block, parentBlockId }, idx ) => blockToMenuItem( block, blockIdToOldEntityRecord[ block.clientId ], blockIdToOldEntityRecord[ parentBlockId ]?.id, - position, + idx, post.meta.menuId ) ); @@ -337,7 +337,7 @@ function diff( listA, listB ) { function blocksTreeToFlatList( innerBlocks, parentBlockId = null ) { return innerBlocks.flatMap( ( block, index ) => - [ { block, parentBlockId, position: index + 1 } ].concat( + [ { block, parentBlockId, childIndex: index } ].concat( blocksTreeToFlatList( block.innerBlocks, block.clientId ) ) ); diff --git a/packages/edit-navigation/src/store/transform.js b/packages/edit-navigation/src/store/transform.js index 3fe5874538f52..15b695ab35952 100644 --- a/packages/edit-navigation/src/store/transform.js +++ b/packages/edit-navigation/src/store/transform.js @@ -34,7 +34,13 @@ import { NEW_TAB_TARGET_ATTRIBUTE } from '../constants'; * @property {string} target The target attribute of the link element for this menu item. */ -export function blockToMenuItem( block, menuItem, parentId, position, menuId ) { +export function blockToMenuItem( + block, + menuItem, + parentId, + blockPosition, + menuId +) { menuItem = omit( menuItem, 'menus', 'meta', '_links' ); menuItem.content = get( menuItem.content, 'raw', menuItem.content ); @@ -52,7 +58,7 @@ export function blockToMenuItem( block, menuItem, parentId, position, menuId ) { return { ...menuItem, ...attributes, - menu_order: position, + menu_order: blockPosition + 1, menus: [ menuId ], parent: ! parentId ? 0 : parentId, status: 'publish', From 44fc290b12ba8b3d5c89dcba51bbb65125a216be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 9 Sep 2021 17:15:25 +0200 Subject: [PATCH 20/57] Adjust unit tests --- .../block-placeholder/menu-items-to-blocks.js | 2 +- packages/edit-navigation/src/store/actions.js | 2 +- .../edit-navigation/src/store/test/actions.js | 818 ++++-------------- .../src/store/test/controls.js | 288 ------ .../src/store/test/transform.js | 522 ++++++++++- .../edit-navigation/src/store/test/utils.js | 523 +---------- .../edit-navigation/src/store/transform.js | 60 +- 7 files changed, 732 insertions(+), 1483 deletions(-) delete mode 100644 packages/edit-navigation/src/store/test/controls.js diff --git a/packages/edit-navigation/src/components/block-placeholder/menu-items-to-blocks.js b/packages/edit-navigation/src/components/block-placeholder/menu-items-to-blocks.js index db29190438447..4c69808e69983 100644 --- a/packages/edit-navigation/src/components/block-placeholder/menu-items-to-blocks.js +++ b/packages/edit-navigation/src/components/block-placeholder/menu-items-to-blocks.js @@ -11,7 +11,7 @@ import { createBlock } from '@wordpress/blocks'; /** * Internal dependencies */ -import { menuItemToBlockAttributes } from '../../store/utils'; +import { menuItemToBlockAttributes } from '../../store/transform'; /** * Convert a flat menu item structure to a nested blocks structure. diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 9839ee8606f8c..e4cf05bb80300 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -75,7 +75,7 @@ export const createMissingMenuItems = ( post ) => async ( { } }; -const createPlaceholderMenuItem = ( block, menuId ) => async ( { +export const createPlaceholderMenuItem = ( block, menuId ) => async ( { registry, dispatch, } ) => { diff --git a/packages/edit-navigation/src/store/test/actions.js b/packages/edit-navigation/src/store/test/actions.js index c9af2c0f19e1c..1dc381531996b 100644 --- a/packages/edit-navigation/src/store/test/actions.js +++ b/packages/edit-navigation/src/store/test/actions.js @@ -1,647 +1,171 @@ -// /** -// * WordPress dependencies -// */ -// import { __ } from '@wordpress/i18n'; -// import { store as noticesStore } from '@wordpress/notices'; -// -// /** -// * Internal dependencies -// */ -// import { -// createMissingMenuItems, -// saveNavigationPost, -// setSelectedMenuId, -// } from '../actions'; -// import { resolveMenuItems, dispatch, select, apiFetch } from '../controls'; -// import { menuItemsQuery, computeCustomizedAttribute } from '../utils'; -// import { -// NAVIGATION_POST_KIND, -// NAVIGATION_POST_POST_TYPE, -// } from '../../constants'; -// -// jest.mock( '../utils', () => { -// const utils = jest.requireActual( '../utils' ); -// // Mock serializeProcessing to always return the callback for easier testing and less boilerplate. -// utils.serializeProcessing = ( callback ) => callback; -// return utils; -// } ); -// -// describe( 'createMissingMenuItems', () => { -// it( 'creates a missing menu for navigation block', () => { -// const post = { -// id: 'navigation-post-1', -// slug: 'navigation-post-1', -// type: 'page', -// meta: { -// menuId: 1, -// }, -// blocks: [ -// { -// attributes: { showSubmenuIcon: true }, -// clientId: 'navigation-block-client-id', -// innerBlocks: [], -// isValid: true, -// name: 'core/navigation', -// }, -// ], -// }; -// -// const mapping = {}; -// -// const menuItemPlaceholder = { -// id: 87, -// title: { -// raw: 'Placeholder', -// rendered: 'Placeholder', -// }, -// }; -// -// const menuItems = []; -// -// const action = createMissingMenuItems( post ); -// -// expect( action.next( post ).value ).toEqual( -// getMenuItemToClientIdMapping( post.id ) -// ); -// -// expect( action.next( mapping ).value ).toEqual( -// apiFetch( { -// path: `/__experimental/menu-items`, -// method: 'POST', -// data: { -// title: 'Placeholder', -// url: 'Placeholder', -// menu_order: 0, -// }, -// } ) -// ); -// -// expect( action.next( menuItemPlaceholder ).value ).toEqual( -// resolveMenuItems( post.meta.menuId ) -// ); -// -// expect( action.next( menuItems ).value ).toEqual( -// dispatch( -// 'core', -// 'receiveEntityRecords', -// 'root', -// 'menuItem', -// [ ...menuItems, menuItemPlaceholder ], -// menuItemsQuery( post.meta.menuId ), -// false -// ) -// ); -// -// expect( action.next().value ).toEqual( { -// type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', -// postId: post.id, -// mapping: { -// 87: 'navigation-block-client-id', -// }, -// } ); -// -// expect( action.next( [] ).done ).toBe( true ); -// } ); -// -// it( 'creates a missing menu for navigation link block', () => { -// const post = { -// id: 'navigation-post-1', -// slug: 'navigation-post-1', -// type: 'page', -// meta: { -// menuId: 1, -// }, -// blocks: [ -// { -// attributes: { showSubmenuIcon: true }, -// clientId: 'navigation-block-client-id', -// innerBlocks: [ -// { -// attributes: { -// label: 'wp.org', -// opensInNewTab: false, -// url: 'http://wp.org', -// }, -// clientId: 'navigation-link-block-client-id-1', -// innerBlocks: [], -// isValid: true, -// name: 'core/navigation-link', -// }, -// { -// attributes: { -// label: 'wp.com', -// opensInNewTab: false, -// url: 'http://wp.com', -// }, -// clientId: 'navigation-link-block-client-id-2', -// innerBlocks: [], -// isValid: true, -// name: 'core/navigation-link', -// }, -// ], -// isValid: true, -// name: 'core/navigation', -// }, -// ], -// }; -// -// const mapping = { -// 87: 'navigation-block-client-id', -// 100: 'navigation-link-block-client-id-1', -// }; -// -// const menuItemPlaceholder = { -// id: 101, -// title: { -// raw: 'Placeholder', -// rendered: 'Placeholder', -// }, -// }; -// -// const menuItems = [ -// { -// id: 100, -// title: { -// raw: 'wp.com', -// rendered: 'wp.com', -// }, -// url: 'http://wp.com', -// menu_order: 1, -// menus: [ 1 ], -// }, -// { -// id: 101, -// title: { -// raw: 'wp.org', -// rendered: 'wp.org', -// }, -// url: 'http://wp.org', -// menu_order: 2, -// menus: [ 1 ], -// }, -// ]; -// -// const action = createMissingMenuItems( post ); -// -// expect( action.next( post ).value ).toEqual( -// getMenuItemToClientIdMapping( post.id ) -// ); -// -// expect( action.next( mapping ).value ).toEqual( -// apiFetch( { -// path: `/__experimental/menu-items`, -// method: 'POST', -// data: { -// title: 'Placeholder', -// url: 'Placeholder', -// menu_order: 0, -// }, -// } ) -// ); -// -// expect( action.next( menuItemPlaceholder ).value ).toEqual( -// resolveMenuItems( post.meta.menuId ) -// ); -// -// expect( action.next( menuItems ).value ).toEqual( -// dispatch( -// 'core', -// 'receiveEntityRecords', -// 'root', -// 'menuItem', -// [ ...menuItems, menuItemPlaceholder ], -// menuItemsQuery( post.meta.menuId ), -// false -// ) -// ); -// -// expect( action.next().value ).toEqual( { -// type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', -// postId: post.id, -// mapping: { -// 87: 'navigation-block-client-id', -// 100: 'navigation-link-block-client-id-1', -// 101: 'navigation-link-block-client-id-2', -// }, -// } ); -// -// expect( action.next( [] ).done ).toBe( true ); -// } ); -// } ); -// -// describe( 'saveNavigationPost', () => { -// it( 'converts all the blocks into menu items and batch save them at once', () => { -// const post = { -// id: 'navigation-post-1', -// slug: 'navigation-post-1', -// type: 'page', -// meta: { -// menuId: 1, -// }, -// blocks: [ -// { -// attributes: { showSubmenuIcon: true }, -// clientId: 'navigation-block-client-id', -// innerBlocks: [ -// { -// attributes: { -// label: 'wp.org', -// opensInNewTab: false, -// url: 'http://wp.org', -// className: '', -// rel: '', -// description: '', -// title: '', -// }, -// clientId: 'navigation-link-block-client-id-1', -// innerBlocks: [], -// isValid: true, -// name: 'core/navigation-link', -// }, -// { -// attributes: { -// label: 'wp.com', -// opensInNewTab: false, -// url: 'http://wp.com', -// className: '', -// rel: '', -// description: '', -// title: '', -// }, -// clientId: 'navigation-link-block-client-id-2', -// innerBlocks: [], -// isValid: true, -// name: 'core/navigation-link', -// }, -// ], -// isValid: true, -// name: 'core/navigation', -// }, -// ], -// }; -// -// const menuItems = [ -// { -// id: 100, -// title: { -// raw: 'wp.com', -// rendered: 'wp.com', -// }, -// url: 'http://wp.com', -// menu_order: 1, -// menus: [ 1 ], -// classes: [], -// xfn: [], -// description: '', -// attr_title: '', -// }, -// { -// id: 101, -// title: { -// raw: 'wp.org', -// rendered: 'wp.org', -// }, -// url: 'http://wp.org', -// menu_order: 2, -// menus: [ 1 ], -// classes: [], -// xfn: [], -// description: '', -// attr_title: '', -// }, -// ]; -// -// const mapping = { -// 100: 'navigation-link-block-client-id-1', -// 101: 'navigation-link-block-client-id-2', -// }; -// -// const action = saveNavigationPost( post ); -// -// expect( action.next().value ).toEqual( -// resolveMenuItems( post.meta.menuId ) -// ); -// -// expect( action.next( menuItems ).value ).toEqual( -// getMenuItemToClientIdMapping( post.id ) -// ); -// -// expect( action.next( mapping ).value ).toEqual( -// dispatch( 'core', 'saveEditedEntityRecord', 'root', 'menu', 1 ) -// ); -// expect( action.next( { id: 1 } ).value ).toEqual( -// select( 'core', 'getLastEntitySaveError', 'root', 'menu', 1 ) -// ); -// -// expect( action.next().value ).toEqual( -// apiFetch( { -// path: '/__experimental/customizer-nonces/get-save-nonce', -// } ) -// ); -// -// const batchSaveApiFetch = action.next( { -// nonce: 'nonce', -// stylesheet: 'stylesheet', -// } ).value; -// -// expect( batchSaveApiFetch.request.body.get( 'customized' ) ).toEqual( -// computeCustomizedAttribute( -// post.blocks[ 0 ].innerBlocks, -// post.meta.menuId, -// { -// 'navigation-link-block-client-id-1': menuItems[ 0 ], -// 'navigation-link-block-client-id-2': menuItems[ 1 ], -// } -// ) -// ); -// -// expect( action.next( { success: true } ).value ).toEqual( -// dispatch( -// 'core', -// 'receiveEntityRecords', -// NAVIGATION_POST_KIND, -// NAVIGATION_POST_POST_TYPE, -// [ post ], -// undefined -// ) -// ); -// -// expect( action.next().value ).toEqual( -// dispatch( -// noticesStore, -// 'createSuccessNotice', -// __( 'Navigation saved.' ), -// { -// type: 'snackbar', -// } -// ) -// ); -// } ); -// -// it( 'handles an error from the batch API and show error notifications', () => { -// const post = { -// id: 'navigation-post-1', -// slug: 'navigation-post-1', -// type: 'page', -// meta: { -// menuId: 1, -// }, -// blocks: [ -// { -// attributes: { showSubmenuIcon: true }, -// clientId: 'navigation-block-client-id', -// innerBlocks: [ -// { -// attributes: { -// label: 'wp.org', -// opensInNewTab: false, -// url: 'http://wp.org', -// className: '', -// rel: '', -// description: '', -// title: '', -// }, -// clientId: 'navigation-link-block-client-id-1', -// innerBlocks: [], -// isValid: true, -// name: 'core/navigation-link', -// }, -// { -// attributes: { -// label: 'wp.com', -// opensInNewTab: false, -// url: 'http://wp.com', -// className: '', -// rel: '', -// description: '', -// title: '', -// }, -// clientId: 'navigation-link-block-client-id-2', -// innerBlocks: [], -// isValid: true, -// name: 'core/navigation-link', -// }, -// ], -// isValid: true, -// name: 'core/navigation', -// }, -// ], -// }; -// -// const menuItems = [ -// { -// id: 100, -// title: { -// raw: 'wp.com', -// rendered: 'wp.com', -// }, -// url: 'http://wp.com', -// menu_order: 1, -// menus: [ 1 ], -// classes: [], -// xfn: [], -// description: '', -// attr_title: '', -// }, -// { -// id: 101, -// title: { -// raw: 'wp.org', -// rendered: 'wp.org', -// }, -// url: 'http://wp.org', -// menu_order: 2, -// menus: [ 1 ], -// classes: [], -// xfn: [], -// description: '', -// attr_title: '', -// }, -// ]; -// -// const mapping = { -// 100: 'navigation-link-block-client-id-1', -// 101: 'navigation-link-block-client-id-2', -// }; -// -// const action = saveNavigationPost( post ); -// -// expect( action.next().value ).toEqual( -// resolveMenuItems( post.meta.menuId ) -// ); -// -// expect( action.next( menuItems ).value ).toEqual( -// getMenuItemToClientIdMapping( post.id ) -// ); -// -// expect( action.next( mapping ).value ).toEqual( -// dispatch( 'core', 'saveEditedEntityRecord', 'root', 'menu', 1 ) -// ); -// -// expect( action.next( { id: 1 } ).value ).toEqual( -// select( 'core', 'getLastEntitySaveError', 'root', 'menu', 1 ) -// ); -// -// expect( action.next().value ).toEqual( -// apiFetch( { -// path: '/__experimental/customizer-nonces/get-save-nonce', -// } ) -// ); -// -// const batchSaveApiFetch = action.next( { -// nonce: 'nonce', -// stylesheet: 'stylesheet', -// } ).value; -// -// expect( batchSaveApiFetch.request.body.get( 'customized' ) ).toEqual( -// computeCustomizedAttribute( -// post.blocks[ 0 ].innerBlocks, -// post.meta.menuId, -// { -// 'navigation-link-block-client-id-1': menuItems[ 0 ], -// 'navigation-link-block-client-id-2': menuItems[ 1 ], -// } -// ) -// ); -// -// expect( -// action.next( { success: false, data: { message: 'Test Message' } } ) -// .value -// ).toEqual( -// dispatch( -// noticesStore, -// 'createErrorNotice', -// __( "Unable to save: 'Test Message'" ), -// { -// type: 'snackbar', -// } -// ) -// ); -// } ); -// -// it( 'handles an error from the entity and show error notifications', () => { -// const post = { -// id: 'navigation-post-1', -// slug: 'navigation-post-1', -// type: 'page', -// meta: { -// menuId: 1, -// }, -// blocks: [ -// { -// attributes: { showSubmenuIcon: true }, -// clientId: 'navigation-block-client-id', -// innerBlocks: [ -// { -// attributes: { -// label: 'wp.org', -// opensInNewTab: false, -// url: 'http://wp.org', -// className: '', -// rel: '', -// description: '', -// title: '', -// }, -// clientId: 'navigation-link-block-client-id-1', -// innerBlocks: [], -// isValid: true, -// name: 'core/navigation-link', -// }, -// { -// attributes: { -// label: 'wp.com', -// opensInNewTab: false, -// url: 'http://wp.com', -// className: '', -// rel: '', -// description: '', -// title: '', -// }, -// clientId: 'navigation-link-block-client-id-2', -// innerBlocks: [], -// isValid: true, -// name: 'core/navigation-link', -// }, -// ], -// isValid: true, -// name: 'core/navigation', -// }, -// ], -// }; -// -// const menuItems = [ -// { -// id: 100, -// title: { -// raw: 'wp.com', -// rendered: 'wp.com', -// }, -// url: 'http://wp.com', -// menu_order: 1, -// menus: [ 1 ], -// classes: [], -// xfn: [], -// description: '', -// attr_title: '', -// }, -// { -// id: 101, -// title: { -// raw: 'wp.org', -// rendered: 'wp.org', -// }, -// url: 'http://wp.org', -// menu_order: 2, -// menus: [ 1 ], -// classes: [], -// xfn: [], -// description: '', -// attr_title: '', -// }, -// ]; -// -// const mapping = { -// 100: 'navigation-link-block-client-id-1', -// 101: 'navigation-link-block-client-id-2', -// }; -// -// const action = saveNavigationPost( post ); -// -// expect( action.next().value ).toEqual( -// resolveMenuItems( post.meta.menuId ) -// ); -// -// expect( action.next( menuItems ).value ).toEqual( -// getMenuItemToClientIdMapping( post.id ) -// ); -// -// expect( action.next( mapping ).value ).toEqual( -// dispatch( 'core', 'saveEditedEntityRecord', 'root', 'menu', 1 ) -// ); -// -// expect( action.next().value ).toEqual( -// select( 'core', 'getLastEntitySaveError', 'root', 'menu', 1 ) -// ); -// -// expect( action.next( { message: 'Test Message 2' } ).value ).toEqual( -// dispatch( -// noticesStore, -// 'createErrorNotice', -// __( "Unable to save: 'Test Message 2'" ), -// { -// type: 'snackbar', -// } -// ) -// ); -// } ); -// } ); -// -// describe( 'setSelectedMenuId', () => { -// it( 'should return the SET_SELECTED_MENU_ID action', () => { -// const menuId = 1; -// expect( setSelectedMenuId( menuId ) ).toEqual( { -// type: 'SET_SELECTED_MENU_ID', -// menuId, -// } ); -// } ); -// } ); +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +jest.mock( '@wordpress/api-fetch' ); + +/** + * Internal dependencies + */ +import { + createMissingMenuItems, + createPlaceholderMenuItem, + setSelectedMenuId, +} from '../actions'; +import { menuItemsQuery } from '../utils'; + +jest.mock( '../utils', () => { + const utils = jest.requireActual( '../utils' ); + // Mock serializeProcessing to always return the callback for easier testing and less boilerplate. + utils.serializeProcessing = ( callback ) => callback; + return utils; +} ); + +describe( 'createMissingMenuItems', () => { + it( 'creates a missing menu for navigation block', async () => { + const post = { + id: 'navigation-post-1', + slug: 'navigation-post-1', + type: 'page', + meta: { + menuId: 1, + }, + blocks: [ + { + attributes: { showSubmenuIcon: true }, + clientId: 'navigation-block-client-id', + innerBlocks: [ + { + attributes: {}, + clientId: 'navigation-block-client-id', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + { + attributes: {}, + clientId: 'navigation-block-client-id2', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + ], + isValid: true, + name: 'core/navigation', + }, + ], + }; + + const menuItemPlaceholder = { + id: 87, + title: { + raw: 'Placeholder', + rendered: 'Placeholder', + }, + }; + + const dispatch = jest + .fn() + .mockReturnValueOnce( {} ) + .mockReturnValueOnce( menuItemPlaceholder ) + .mockReturnValueOnce( { ...menuItemPlaceholder, id: 88 } ); + const registryDispatch = { + __unstableAcquireStoreLock: jest.fn(), + __unstableReleaseStoreLock: jest.fn(), + }; + const registry = { + dispatch: jest.fn( () => registryDispatch ), + }; + + await createMissingMenuItems( post )( { registry, dispatch } ); + + expect( dispatch ).toHaveBeenCalledWith( { + type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', + postId: post.id, + mapping: { + 87: 'navigation-block-client-id', + 88: 'navigation-block-client-id2', + }, + } ); + } ); +} ); + +describe( 'createPlaceholderMenuItem', () => { + beforeEach( async () => { + apiFetch.mockReset(); + } ); + + it( 'creates a missing menu for navigation link block', async () => { + const menuItemPlaceholder = { + id: 102, + title: { + raw: 'Placeholder', + rendered: 'Placeholder', + }, + }; + const menuItems = [ + { + id: 100, + title: { + raw: 'wp.com', + rendered: 'wp.com', + }, + url: 'http://wp.com', + menu_order: 1, + menus: [ 1 ], + }, + { + id: 101, + title: { + raw: 'wp.org', + rendered: 'wp.org', + }, + url: 'http://wp.org', + menu_order: 2, + menus: [ 1 ], + }, + ]; + + // Provide response + apiFetch.mockImplementation( () => menuItemPlaceholder ); + + const registryDispatch = { + receiveEntityRecords: jest.fn(), + }; + const registry = { dispatch: jest.fn( () => registryDispatch ) }; + const dispatch = jest.fn( () => menuItems ); + + await createPlaceholderMenuItem( + { + id: 101, + title: { + raw: 'wp.org', + rendered: 'wp.org', + }, + url: 'http://wp.org', + menu_order: 2, + menus: [ 1 ], + }, + 199 + )( { registry, dispatch } ); + + expect( registryDispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'root', + 'menuItem', + [ ...menuItems, menuItemPlaceholder ], + menuItemsQuery( 199 ), + false + ); + } ); +} ); + +describe( 'setSelectedMenuId', () => { + it( 'should return the SET_SELECTED_MENU_ID action', () => { + const menuId = 1; + expect( setSelectedMenuId( menuId ) ).toEqual( { + type: 'SET_SELECTED_MENU_ID', + menuId, + } ); + } ); +} ); diff --git a/packages/edit-navigation/src/store/test/controls.js b/packages/edit-navigation/src/store/test/controls.js deleted file mode 100644 index 07c1b9179ff99..0000000000000 --- a/packages/edit-navigation/src/store/test/controls.js +++ /dev/null @@ -1,288 +0,0 @@ -/** - * WordPress dependencies - */ -import triggerApiFetch from '@wordpress/api-fetch'; - -/** - * Internal dependencies - */ -import controls, { - apiFetch, - getPendingActions, - isProcessingPost, - getMenuItemToClientIdMapping, - getNavigationPostForMenu, - resolveMenuItems, - select, - dispatch, -} from '../controls'; -import { menuItemsQuery } from '../utils'; -import { STORE_NAME } from '../constants'; - -// Mock it to prevent calling window.fetch in test environment -jest.mock( '@wordpress/api-fetch', () => jest.fn( ( request ) => request ) ); - -describe( 'apiFetch', () => { - it( 'has the correct type and payload', () => { - expect( apiFetch( { foo: 'bar' } ) ).toEqual( { - type: 'API_FETCH', - request: { - foo: 'bar', - }, - } ); - } ); -} ); - -describe( 'getPendingActions', () => { - it( 'has the correct type and payload', () => { - expect( getPendingActions( 123 ) ).toEqual( { - type: 'GET_PENDING_ACTIONS', - postId: 123, - } ); - } ); -} ); - -describe( 'isProcessingPost', () => { - it( 'has the correct type and payload', () => { - expect( isProcessingPost( 123 ) ).toEqual( { - type: 'IS_PROCESSING_POST', - postId: 123, - } ); - } ); -} ); - -describe( 'getMenuItemToClientIdMapping', () => { - it( 'has the correct type and payload', () => { - expect( getMenuItemToClientIdMapping( 123 ) ).toEqual( { - type: 'GET_MENU_ITEM_TO_CLIENT_ID_MAPPING', - postId: 123, - } ); - } ); -} ); - -describe( 'getNavigationPostForMenu', () => { - it( 'has the correct type and payload', () => { - expect( getNavigationPostForMenu( 123 ) ).toEqual( { - type: 'SELECT', - registryName: STORE_NAME, - selectorName: 'getNavigationPostForMenu', - args: [ 123 ], - } ); - } ); -} ); - -describe( 'resolveMenuItems', () => { - it( 'has the correct type and payload', () => { - expect( resolveMenuItems( 123 ) ).toEqual( { - type: 'RESOLVE_MENU_ITEMS', - query: menuItemsQuery( 123 ), - } ); - } ); -} ); - -describe( 'select', () => { - it( 'has the correct type and payload', () => { - expect( - select( 'registryName', 'selectorName', 'arg1', 'arg2' ) - ).toEqual( { - type: 'SELECT', - registryName: 'registryName', - selectorName: 'selectorName', - args: [ 'arg1', 'arg2' ], - } ); - } ); -} ); - -describe( 'dispatch', () => { - it( 'has the correct type and payload', () => { - expect( - dispatch( 'registryName', 'actionName', 'arg1', 'arg2' ) - ).toEqual( { - type: 'DISPATCH', - registryName: 'registryName', - actionName: 'actionName', - args: [ 'arg1', 'arg2' ], - } ); - } ); -} ); - -describe( 'controls', () => { - it( 'triggers API_FETCH', () => { - expect( controls.API_FETCH( { request: { foo: 'bar' } } ) ).toEqual( { - foo: 'bar', - } ); - - expect( triggerApiFetch ).toHaveBeenCalledWith( { foo: 'bar' } ); - } ); - - it( 'triggers SELECT', () => { - const selector = jest.fn( () => 'value' ); - const registry = { - select: jest.fn( () => ( { - selectorName: selector, - } ) ), - }; - - expect( - controls.SELECT( registry )( { - registryName: 'registryName', - selectorName: 'selectorName', - args: [ 'arg1', 'arg2' ], - } ) - ).toBe( 'value' ); - - expect( registry.select ).toHaveBeenCalledWith( 'registryName' ); - expect( selector ).toHaveBeenCalledWith( 'arg1', 'arg2' ); - } ); - - it( 'triggers GET_PENDING_ACTIONS', () => { - const state = { - processingQueue: { - postId: { - pendingActions: [ 'action1', 'action2' ], - }, - }, - }; - const registry = { - stores: { - [ STORE_NAME ]: { - store: { - getState: jest.fn( () => state ), - }, - }, - }, - }; - - expect( - controls.GET_PENDING_ACTIONS( registry )( { postId: 'postId' } ) - ).toEqual( [ 'action1', 'action2' ] ); - - expect( - registry.stores[ STORE_NAME ].store.getState - ).toHaveBeenCalledTimes( 1 ); - - expect( - controls.GET_PENDING_ACTIONS( registry )( { - postId: 'non-exist-post-id', - } ) - ).toEqual( [] ); - - expect( - registry.stores[ STORE_NAME ].store.getState - ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'triggers IS_PROCESSING_POST', () => { - const state = { - processingQueue: { - postId: { - inProgress: true, - }, - }, - }; - const registry = { - stores: { - [ STORE_NAME ]: { - store: { - getState: jest.fn( () => state ), - }, - }, - }, - }; - - expect( - controls.IS_PROCESSING_POST( registry )( { postId: 'postId' } ) - ).toBe( true ); - - expect( - registry.stores[ STORE_NAME ].store.getState - ).toHaveBeenCalledTimes( 1 ); - - expect( - controls.IS_PROCESSING_POST( registry )( { - postId: 'non-exist-post-id', - } ) - ).toBe( false ); - - expect( - registry.stores[ STORE_NAME ].store.getState - ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'triggers GET_MENU_ITEM_TO_CLIENT_ID_MAPPING', () => { - const state = { - mapping: { - postId: { - 123: 'client-id', - }, - }, - }; - const registry = { - stores: { - [ STORE_NAME ]: { - store: { - getState: jest.fn( () => state ), - }, - }, - }, - }; - - expect( - controls.GET_MENU_ITEM_TO_CLIENT_ID_MAPPING( registry )( { - postId: 'postId', - } ) - ).toEqual( { - 123: 'client-id', - } ); - - expect( - registry.stores[ STORE_NAME ].store.getState - ).toHaveBeenCalledTimes( 1 ); - - expect( - controls.GET_MENU_ITEM_TO_CLIENT_ID_MAPPING( registry )( { - postId: 'non-exist-post-id', - } ) - ).toEqual( {} ); - - expect( - registry.stores[ STORE_NAME ].store.getState - ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'triggers DISPATCH', () => { - const dispatcher = jest.fn(); - const registry = { - dispatch: jest.fn( () => ( { - actionName: dispatcher, - } ) ), - }; - - controls.DISPATCH( registry )( { - registryName: 'registryName', - actionName: 'actionName', - args: [ 'arg1', 'arg2' ], - } ); - - expect( registry.dispatch ).toHaveBeenCalledWith( 'registryName' ); - expect( dispatcher ).toHaveBeenCalledWith( 'arg1', 'arg2' ); - } ); - - it( 'triggers RESOLVE_MENU_ITEMS', () => { - const getMenuItems = jest.fn( () => [ 'menu-1', 'menu-2' ] ); - const registry = { - resolveSelect: jest.fn( () => ( { - getMenuItems, - } ) ), - }; - - expect( - controls.RESOLVE_MENU_ITEMS( registry )( { - query: 'query', - } ) - ).toEqual( [ 'menu-1', 'menu-2' ] ); - - expect( registry.resolveSelect ).toHaveBeenCalledWith( 'core' ); - expect( getMenuItems ).toHaveBeenCalledTimes( 1 ); - } ); -} ); diff --git a/packages/edit-navigation/src/store/test/transform.js b/packages/edit-navigation/src/store/test/transform.js index 39083ac07e901..816eeaffe1820 100644 --- a/packages/edit-navigation/src/store/test/transform.js +++ b/packages/edit-navigation/src/store/test/transform.js @@ -1,7 +1,11 @@ /** * Internal dependencies */ -import { menuItemsToBlocks } from '../transform'; +import { + menuItemsToBlocks, + blockAttributesToMenuItem, + menuItemToBlockAttributes, +} from '../transform'; // Mock createBlock to avoid creating the blocks in test environment. jest.mock( '@wordpress/blocks', () => { @@ -366,3 +370,519 @@ describe( 'converting menu items to blocks', () => { expect( actual ).toEqual( [] ); } ); } ); + +describe( 'Mapping block attributes and menu item fields', () => { + const blocksToMenuItems = [ + { + block: { + attributes: { + label: 'Example Page', + url: '/example-page/', + description: 'Lorem ipsum dolor sit amet.', + rel: 'friend met', + className: 'my-custom-class-one my-custom-class-two', + title: 'Example page link title attribute', + id: 100, + type: 'page', + kind: 'post-type', + opensInNewTab: true, + }, + clientId: 'navigation-link-block-client-id-1', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + menuItem: { + title: 'Example Page', + url: '/example-page/', + description: 'Lorem ipsum dolor sit amet.', + xfn: [ 'friend', 'met' ], + classes: [ 'my-custom-class-one', 'my-custom-class-two' ], + attr_title: 'Example page link title attribute', + object_id: 100, + object: 'page', + type: 'post_type', + target: '_blank', + }, + }, + { + block: { + attributes: { + label: 'Example Post', + url: '/example-post/', + description: 'Consectetur adipiscing elit.', + rel: 'friend', + className: 'my-custom-class-one', + title: 'Example post link title attribute', + id: 101, + type: 'post', + kind: 'post-type', + opensInNewTab: false, + }, + clientId: 'navigation-link-block-client-id-2', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + menuItem: { + title: 'Example Post', + url: '/example-post/', + description: 'Consectetur adipiscing elit.', + xfn: [ 'friend' ], + classes: [ 'my-custom-class-one' ], + attr_title: 'Example post link title attribute', + object_id: 101, + object: 'post', + type: 'post_type', + target: '', + }, + }, + { + block: { + attributes: { + label: 'Example Category', + url: '/example-category/', + description: + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + rel: '', + className: '', + title: '', + id: 102, + type: 'category', + kind: 'taxonomy', + opensInNewTab: true, + }, + clientId: 'navigation-link-block-client-id-3', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + menuItem: { + title: 'Example Category', + url: '/example-category/', + description: + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + object_id: 102, + object: 'category', + type: 'taxonomy', + target: '_blank', + }, + }, + { + block: { + attributes: { + label: 'Example Tag', + url: '/example-tag/', + description: '', + rel: '', + className: '', + title: '', + id: 103, + type: 'tag', + kind: 'taxonomy', + opensInNewTab: false, + }, + clientId: 'navigation-link-block-client-id-4', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + menuItem: { + title: 'Example Tag', + url: '/example-tag/', + object_id: 103, + object: 'post_tag', + type: 'taxonomy', + target: '', + }, + }, + { + block: { + attributes: { + label: 'Example Custom Link', + url: 'https://wordpress.org', + description: '', + rel: '', + className: '', + title: '', + type: 'custom', + kind: 'custom', + opensInNewTab: true, + }, + clientId: 'navigation-link-block-client-id-5', + innerBlocks: [], + isValid: true, + name: 'core/navigation-link', + }, + menuItem: { + title: 'Example Custom Link', + url: 'https://wordpress.org', + object: 'custom', + type: 'custom', + target: '_blank', + }, + }, + ]; + + const menuItemsToBlockAttrs = [ + { + menuItem: { + title: { + raw: 'Example Page', + rendered: 'Example Page', + }, + url: '/example-page/', + description: 'Lorem ipsum dolor sit amet.', + xfn: [ 'friend', 'met' ], + classes: [ 'my-custom-class-one', 'my-custom-class-two' ], + attr_title: 'Example page link title attribute', + object_id: 100, + object: 'page', + type: 'post_type', + target: '_blank', + }, + blockAttrs: { + label: 'Example Page', + url: '/example-page/', + description: 'Lorem ipsum dolor sit amet.', + rel: 'friend met', + className: 'my-custom-class-one my-custom-class-two', + title: 'Example page link title attribute', + id: 100, + type: 'page', + kind: 'post-type', + opensInNewTab: true, + }, + }, + { + menuItem: { + title: { + raw: 'Example Post', + rendered: 'Example Post', + }, + url: '/example-post/', + description: 'Consectetur adipiscing elit.', + xfn: [ 'friend' ], + classes: [ 'my-custom-class-one' ], + attr_title: 'Example post link title attribute', + object_id: 101, + object: 'post', + type: 'post_type', + target: '', + }, + blockAttrs: { + label: 'Example Post', + url: '/example-post/', + description: 'Consectetur adipiscing elit.', + rel: 'friend', + className: 'my-custom-class-one', + title: 'Example post link title attribute', + id: 101, + type: 'post', + kind: 'post-type', + }, + }, + { + menuItem: { + title: { + raw: 'Example Category', + rendered: 'Example Category', + }, + url: '/example-category/', + description: + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + xfn: [ ' ', ' ' ], + classes: [ ' ', ' ' ], + attr_title: '', + object_id: 102, + object: 'category', + type: 'taxonomy', + target: '_blank', + }, + blockAttrs: { + label: 'Example Category', + url: '/example-category/', + description: + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + id: 102, + type: 'category', + kind: 'taxonomy', + opensInNewTab: true, + }, + }, + { + menuItem: { + title: { + raw: 'Example Tag', + rendered: 'Example Tag', + }, + url: '/example-tag/', + description: '', + xfn: [ '' ], + classes: [ '' ], + attr_title: '', + object_id: 103, + object: 'tag', + type: 'taxonomy', + target: '', + }, + blockAttrs: { + label: 'Example Tag', + url: '/example-tag/', + id: 103, + type: 'tag', + kind: 'taxonomy', + }, + }, + { + menuItem: { + title: { + raw: 'Example Custom Link', + rendered: 'Example Custom Link', + }, + url: 'https://wordpress.org', + description: '', + xfn: [ '' ], + classes: [ '' ], + attr_title: '', + object: 'custom', + type: 'custom', + target: '_blank', + }, + blockAttrs: { + label: 'Example Custom Link', + url: 'https://wordpress.org', + type: 'custom', + kind: 'custom', + opensInNewTab: true, + }, + }, + ]; + + describe( 'mapping block attributes to menu item fields', () => { + it( 'maps block attributes to equivalent menu item fields', () => { + const [ actual, expected ] = blocksToMenuItems.reduce( + ( acc, item ) => { + acc[ 0 ].push( + blockAttributesToMenuItem( item.block.attributes ) + ); + acc[ 1 ].push( item.menuItem ); + return acc; + }, + [ [], [] ] + ); + + expect( actual ).toEqual( expected ); + } ); + + it( 'does not map block attribute "id" to menu item "object_id" field for custom (non-entity) links', () => { + const customLinkBlockAttributes = { + id: 12345, // added for test purposes only - should't exist. + type: 'custom', // custom type indicates we shouldn't need an `id` field. + kind: 'custom', // custom type indicates we shouldn't need an `id` field. + label: 'Example Custom Link', + url: 'https://wordpress.org', + description: '', + rel: '', + className: '', + title: '', + opensInNewTab: true, + }; + + const actual = blockAttributesToMenuItem( + customLinkBlockAttributes + ); + + // Check the basic conversion to menuItem happened successfully. + expect( actual ).toEqual( { + title: 'Example Custom Link', + url: 'https://wordpress.org', + object: 'custom', + type: 'custom', + target: '_blank', + } ); + + // Assert `id` attr has NOT been converted to a `object_id` field for a "custom" type even if present. + expect( actual.object_id ).toBeUndefined(); + } ); + + it( 'correctly maps "tag" block type variation to "post_tag" value as expected in "object" type field', () => { + const tagLinkBlockVariation = { + id: 12345, // added for test purposes only - should't exist. + type: 'tag', // custom type indicates we shouldn't need an `id` field. + kind: 'taxonomy', // custom type indicates we shouldn't need an `id` field. + label: 'Example Tag', + url: '/example-tag/', + }; + + const actual = blockAttributesToMenuItem( tagLinkBlockVariation ); + + expect( actual.object ).toBe( 'post_tag' ); + } ); + + it( 'gracefully handles undefined values by falling back to menu item defaults', () => { + const blockAttrsWithUndefinedValues = { + id: undefined, + type: undefined, + kind: undefined, + label: undefined, + url: undefined, + description: undefined, + rel: undefined, + className: undefined, + title: undefined, + opensInNewTab: undefined, + }; + + const actual = blockAttributesToMenuItem( + blockAttrsWithUndefinedValues + ); + + // Defaults are taken from https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L438. + expect( actual ).toEqual( + expect.objectContaining( { + title: '', + url: '', + } ) + ); + + // Remaining values should not be present. + expect( Object.keys( actual ) ).not.toEqual( + expect.arrayContaining( [ + 'description', + 'xfn', + 'classes', + 'attr_title', + 'object', + 'type', + 'object_id', + 'target', + ] ) + ); + + expect( Object.values( actual ) ).not.toContain( undefined ); + } ); + + it( 'allows for setting and unsetting of target property based on opensInNewTab arttribute boolean', () => { + const shared = { + id: 12345, // added for test purposes only - should't exist. + type: 'custom', // custom type indicates we shouldn't need an `id` field. + kind: 'custom', // custom type indicates we shouldn't need an `id` field. + label: 'Example', + url: '/example/', + }; + + const openInNewTabBlock = { + ...shared, + opensInNewTab: true, + }; + + const doNotOpenInNewTabBlock = { + ...shared, + opensInNewTab: false, + }; + + const shouldOpenInNewTab = blockAttributesToMenuItem( + openInNewTabBlock + ); + + const shouldNotOpenInNewTab = blockAttributesToMenuItem( + doNotOpenInNewTabBlock + ); + + expect( shouldOpenInNewTab.target ).toBe( '_blank' ); + + // Should also allow unsetting of an existing value. + expect( shouldNotOpenInNewTab.target ).toBe( '' ); + } ); + } ); + + describe( 'mapping menu item fields to block attributes', () => { + it( 'maps menu item fields to equivalent block attributes', () => { + const [ actual, expected ] = menuItemsToBlockAttrs.reduce( + ( acc, item ) => { + acc[ 0 ].push( menuItemToBlockAttributes( item.menuItem ) ); + acc[ 1 ].push( item.blockAttrs ); + return acc; + }, + [ [], [] ] + ); + + expect( actual ).toEqual( expected ); + } ); + + it( 'does not map menu item "object_id" field to block attribute "id" for custom (non-entity) links', () => { + const customLinkMenuItem = { + title: 'Example Custom Link', + url: 'https://wordpress.org', + description: '', + xfn: [ '' ], + classes: [ '' ], + attr_title: '', + object_id: 123456, // added for test purposes. + object: 'custom', + type: 'custom', + target: '_blank', + }; + const actual = menuItemToBlockAttributes( customLinkMenuItem ); + + expect( actual.id ).toBeUndefined(); + } ); + + it( 'correctly maps "post_tag" menu item object type to "tag" block type variation', () => { + const tagMenuItem = { + title: 'Example Tag', + url: '/example-tag/', + object_id: 123456, + object: 'post_tag', + type: 'taxonomy', + }; + + const actual = menuItemToBlockAttributes( tagMenuItem ); + + expect( actual.type ).toBe( 'tag' ); + } ); + + it( 'gracefully handles undefined values by falling back to block attribute defaults', () => { + // Note that whilst Core provides default values for nav_menu_item's it is possible that these + // values could be manipulated via Plugins. As such we must account for unexpected values. + const menuItemsWithUndefinedValues = { + title: undefined, + url: undefined, + description: undefined, + xfn: undefined, + classes: undefined, + attr_title: undefined, + object_id: undefined, + object: undefined, + type: undefined, + target: undefined, + }; + + const actual = menuItemToBlockAttributes( + menuItemsWithUndefinedValues + ); + + expect( actual ).toEqual( + expect.objectContaining( { + label: '', + url: '', + kind: 'custom', + } ) + ); + + expect( Object.keys( actual ) ).not.toEqual( + expect.arrayContaining( [ + 'rel', + 'className', + 'title', + 'id', + 'description', + 'opensInNewTab', + ] ) + ); + + expect( Object.values( actual ) ).not.toContain( undefined ); + } ); + } ); +} ); diff --git a/packages/edit-navigation/src/store/test/utils.js b/packages/edit-navigation/src/store/test/utils.js index 7a2f96cfdce84..d839116ab92e3 100644 --- a/packages/edit-navigation/src/store/test/utils.js +++ b/packages/edit-navigation/src/store/test/utils.js @@ -1,12 +1,7 @@ /** * Internal dependencies */ -import { - buildNavigationPostId, - menuItemsQuery, - blockAttributesToMenuItem, - menuItemToBlockAttributes, -} from '../utils'; +import { buildNavigationPostId, menuItemsQuery } from '../utils'; describe( 'buildNavigationPostId', () => { it( 'build navigation post id', () => { @@ -19,519 +14,3 @@ describe( 'menuItemsQuery', () => { expect( menuItemsQuery( 123 ) ).toEqual( { menus: 123, per_page: -1 } ); } ); } ); - -describe( 'Mapping block attributes and menu item fields', () => { - const blocksToMenuItems = [ - { - block: { - attributes: { - label: 'Example Page', - url: '/example-page/', - description: 'Lorem ipsum dolor sit amet.', - rel: 'friend met', - className: 'my-custom-class-one my-custom-class-two', - title: 'Example page link title attribute', - id: 100, - type: 'page', - kind: 'post-type', - opensInNewTab: true, - }, - clientId: 'navigation-link-block-client-id-1', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - menuItem: { - title: 'Example Page', - url: '/example-page/', - description: 'Lorem ipsum dolor sit amet.', - xfn: [ 'friend', 'met' ], - classes: [ 'my-custom-class-one', 'my-custom-class-two' ], - attr_title: 'Example page link title attribute', - object_id: 100, - object: 'page', - type: 'post_type', - target: '_blank', - }, - }, - { - block: { - attributes: { - label: 'Example Post', - url: '/example-post/', - description: 'Consectetur adipiscing elit.', - rel: 'friend', - className: 'my-custom-class-one', - title: 'Example post link title attribute', - id: 101, - type: 'post', - kind: 'post-type', - opensInNewTab: false, - }, - clientId: 'navigation-link-block-client-id-2', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - menuItem: { - title: 'Example Post', - url: '/example-post/', - description: 'Consectetur adipiscing elit.', - xfn: [ 'friend' ], - classes: [ 'my-custom-class-one' ], - attr_title: 'Example post link title attribute', - object_id: 101, - object: 'post', - type: 'post_type', - target: '', - }, - }, - { - block: { - attributes: { - label: 'Example Category', - url: '/example-category/', - description: - 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - rel: '', - className: '', - title: '', - id: 102, - type: 'category', - kind: 'taxonomy', - opensInNewTab: true, - }, - clientId: 'navigation-link-block-client-id-3', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - menuItem: { - title: 'Example Category', - url: '/example-category/', - description: - 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - object_id: 102, - object: 'category', - type: 'taxonomy', - target: '_blank', - }, - }, - { - block: { - attributes: { - label: 'Example Tag', - url: '/example-tag/', - description: '', - rel: '', - className: '', - title: '', - id: 103, - type: 'tag', - kind: 'taxonomy', - opensInNewTab: false, - }, - clientId: 'navigation-link-block-client-id-4', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - menuItem: { - title: 'Example Tag', - url: '/example-tag/', - object_id: 103, - object: 'post_tag', - type: 'taxonomy', - target: '', - }, - }, - { - block: { - attributes: { - label: 'Example Custom Link', - url: 'https://wordpress.org', - description: '', - rel: '', - className: '', - title: '', - type: 'custom', - kind: 'custom', - opensInNewTab: true, - }, - clientId: 'navigation-link-block-client-id-5', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - menuItem: { - title: 'Example Custom Link', - url: 'https://wordpress.org', - object: 'custom', - type: 'custom', - target: '_blank', - }, - }, - ]; - - const menuItemsToBlockAttrs = [ - { - menuItem: { - title: { - raw: 'Example Page', - rendered: 'Example Page', - }, - url: '/example-page/', - description: 'Lorem ipsum dolor sit amet.', - xfn: [ 'friend', 'met' ], - classes: [ 'my-custom-class-one', 'my-custom-class-two' ], - attr_title: 'Example page link title attribute', - object_id: 100, - object: 'page', - type: 'post_type', - target: '_blank', - }, - blockAttrs: { - label: 'Example Page', - url: '/example-page/', - description: 'Lorem ipsum dolor sit amet.', - rel: 'friend met', - className: 'my-custom-class-one my-custom-class-two', - title: 'Example page link title attribute', - id: 100, - type: 'page', - kind: 'post-type', - opensInNewTab: true, - }, - }, - { - menuItem: { - title: { - raw: 'Example Post', - rendered: 'Example Post', - }, - url: '/example-post/', - description: 'Consectetur adipiscing elit.', - xfn: [ 'friend' ], - classes: [ 'my-custom-class-one' ], - attr_title: 'Example post link title attribute', - object_id: 101, - object: 'post', - type: 'post_type', - target: '', - }, - blockAttrs: { - label: 'Example Post', - url: '/example-post/', - description: 'Consectetur adipiscing elit.', - rel: 'friend', - className: 'my-custom-class-one', - title: 'Example post link title attribute', - id: 101, - type: 'post', - kind: 'post-type', - }, - }, - { - menuItem: { - title: { - raw: 'Example Category', - rendered: 'Example Category', - }, - url: '/example-category/', - description: - 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - xfn: [ ' ', ' ' ], - classes: [ ' ', ' ' ], - attr_title: '', - object_id: 102, - object: 'category', - type: 'taxonomy', - target: '_blank', - }, - blockAttrs: { - label: 'Example Category', - url: '/example-category/', - description: - 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - id: 102, - type: 'category', - kind: 'taxonomy', - opensInNewTab: true, - }, - }, - { - menuItem: { - title: { - raw: 'Example Tag', - rendered: 'Example Tag', - }, - url: '/example-tag/', - description: '', - xfn: [ '' ], - classes: [ '' ], - attr_title: '', - object_id: 103, - object: 'tag', - type: 'taxonomy', - target: '', - }, - blockAttrs: { - label: 'Example Tag', - url: '/example-tag/', - id: 103, - type: 'tag', - kind: 'taxonomy', - }, - }, - { - menuItem: { - title: { - raw: 'Example Custom Link', - rendered: 'Example Custom Link', - }, - url: 'https://wordpress.org', - description: '', - xfn: [ '' ], - classes: [ '' ], - attr_title: '', - object: 'custom', - type: 'custom', - target: '_blank', - }, - blockAttrs: { - label: 'Example Custom Link', - url: 'https://wordpress.org', - type: 'custom', - kind: 'custom', - opensInNewTab: true, - }, - }, - ]; - - describe( 'mapping block attributes to menu item fields', () => { - it( 'maps block attributes to equivalent menu item fields', () => { - const [ actual, expected ] = blocksToMenuItems.reduce( - ( acc, item ) => { - acc[ 0 ].push( - blockAttributesToMenuItem( item.block.attributes ) - ); - acc[ 1 ].push( item.menuItem ); - return acc; - }, - [ [], [] ] - ); - - expect( actual ).toEqual( expected ); - } ); - - it( 'does not map block attribute "id" to menu item "object_id" field for custom (non-entity) links', () => { - const customLinkBlockAttributes = { - id: 12345, // added for test purposes only - should't exist. - type: 'custom', // custom type indicates we shouldn't need an `id` field. - kind: 'custom', // custom type indicates we shouldn't need an `id` field. - label: 'Example Custom Link', - url: 'https://wordpress.org', - description: '', - rel: '', - className: '', - title: '', - opensInNewTab: true, - }; - - const actual = blockAttributesToMenuItem( - customLinkBlockAttributes - ); - - // Check the basic conversion to menuItem happened successfully. - expect( actual ).toEqual( { - title: 'Example Custom Link', - url: 'https://wordpress.org', - object: 'custom', - type: 'custom', - target: '_blank', - } ); - - // Assert `id` attr has NOT been converted to a `object_id` field for a "custom" type even if present. - expect( actual.object_id ).toBeUndefined(); - } ); - - it( 'correctly maps "tag" block type variation to "post_tag" value as expected in "object" type field', () => { - const tagLinkBlockVariation = { - id: 12345, // added for test purposes only - should't exist. - type: 'tag', // custom type indicates we shouldn't need an `id` field. - kind: 'taxonomy', // custom type indicates we shouldn't need an `id` field. - label: 'Example Tag', - url: '/example-tag/', - }; - - const actual = blockAttributesToMenuItem( tagLinkBlockVariation ); - - expect( actual.object ).toBe( 'post_tag' ); - } ); - - it( 'gracefully handles undefined values by falling back to menu item defaults', () => { - const blockAttrsWithUndefinedValues = { - id: undefined, - type: undefined, - kind: undefined, - label: undefined, - url: undefined, - description: undefined, - rel: undefined, - className: undefined, - title: undefined, - opensInNewTab: undefined, - }; - - const actual = blockAttributesToMenuItem( - blockAttrsWithUndefinedValues - ); - - // Defaults are taken from https://core.trac.wordpress.org/browser/tags/5.7.1/src/wp-includes/nav-menu.php#L438. - expect( actual ).toEqual( - expect.objectContaining( { - title: '', - url: '', - } ) - ); - - // Remaining values should not be present. - expect( Object.keys( actual ) ).not.toEqual( - expect.arrayContaining( [ - 'description', - 'xfn', - 'classes', - 'attr_title', - 'object', - 'type', - 'object_id', - 'target', - ] ) - ); - - expect( Object.values( actual ) ).not.toContain( undefined ); - } ); - - it( 'allows for setting and unsetting of target property based on opensInNewTab arttribute boolean', () => { - const shared = { - id: 12345, // added for test purposes only - should't exist. - type: 'custom', // custom type indicates we shouldn't need an `id` field. - kind: 'custom', // custom type indicates we shouldn't need an `id` field. - label: 'Example', - url: '/example/', - }; - - const openInNewTabBlock = { - ...shared, - opensInNewTab: true, - }; - - const doNotOpenInNewTabBlock = { - ...shared, - opensInNewTab: false, - }; - - const shouldOpenInNewTab = blockAttributesToMenuItem( - openInNewTabBlock - ); - - const shouldNotOpenInNewTab = blockAttributesToMenuItem( - doNotOpenInNewTabBlock - ); - - expect( shouldOpenInNewTab.target ).toBe( '_blank' ); - - // Should also allow unsetting of an existing value. - expect( shouldNotOpenInNewTab.target ).toBe( '' ); - } ); - } ); - - describe( 'mapping menu item fields to block attributes', () => { - it( 'maps menu item fields to equivalent block attributes', () => { - const [ actual, expected ] = menuItemsToBlockAttrs.reduce( - ( acc, item ) => { - acc[ 0 ].push( menuItemToBlockAttributes( item.menuItem ) ); - acc[ 1 ].push( item.blockAttrs ); - return acc; - }, - [ [], [] ] - ); - - expect( actual ).toEqual( expected ); - } ); - - it( 'does not map menu item "object_id" field to block attribute "id" for custom (non-entity) links', () => { - const customLinkMenuItem = { - title: 'Example Custom Link', - url: 'https://wordpress.org', - description: '', - xfn: [ '' ], - classes: [ '' ], - attr_title: '', - object_id: 123456, // added for test purposes. - object: 'custom', - type: 'custom', - target: '_blank', - }; - const actual = menuItemToBlockAttributes( customLinkMenuItem ); - - expect( actual.id ).toBeUndefined(); - } ); - - it( 'correctly maps "post_tag" menu item object type to "tag" block type variation', () => { - const tagMenuItem = { - title: 'Example Tag', - url: '/example-tag/', - object_id: 123456, - object: 'post_tag', - type: 'taxonomy', - }; - - const actual = menuItemToBlockAttributes( tagMenuItem ); - - expect( actual.type ).toBe( 'tag' ); - } ); - - it( 'gracefully handles undefined values by falling back to block attribute defaults', () => { - // Note that whilst Core provides default values for nav_menu_item's it is possible that these - // values could be manipulated via Plugins. As such we must account for unexpected values. - const menuItemsWithUndefinedValues = { - title: undefined, - url: undefined, - description: undefined, - xfn: undefined, - classes: undefined, - attr_title: undefined, - object_id: undefined, - object: undefined, - type: undefined, - target: undefined, - }; - - const actual = menuItemToBlockAttributes( - menuItemsWithUndefinedValues - ); - - expect( actual ).toEqual( - expect.objectContaining( { - label: '', - url: '', - kind: 'custom', - } ) - ); - - expect( Object.keys( actual ) ).not.toEqual( - expect.arrayContaining( [ - 'rel', - 'className', - 'title', - 'id', - 'description', - 'opensInNewTab', - ] ) - ); - - expect( Object.values( actual ) ).not.toContain( undefined ); - } ); - } ); -} ); diff --git a/packages/edit-navigation/src/store/transform.js b/packages/edit-navigation/src/store/transform.js index 15b695ab35952..2dbd167a59366 100644 --- a/packages/edit-navigation/src/store/transform.js +++ b/packages/edit-navigation/src/store/transform.js @@ -106,15 +106,27 @@ export const blockAttributesToMenuItem = ( { type = 'post_tag'; } - const menuItem = { + return { title: label, url, - description, - xfn: rel?.trim().split( ' ' ), - classes: className?.trim().split( ' ' ), - attr_title: blockTitleAttr, - object: type, - type: kind === 'custom' ? '' : kind?.replace( '-', '_' ), + ...( description?.length && { + description, + } ), + ...( rel?.length && { + xfn: rel?.trim().split( ' ' ), + } ), + ...( className?.length && { + classes: className?.trim().split( ' ' ), + } ), + ...( blockTitleAttr?.length && { + attr_title: blockTitleAttr, + } ), + ...( type?.length && { + object: type, + } ), + ...( kind?.length && { + type: kind?.replace( '-', '_' ), + } ), // Only assign object_id if it's a entity type (ie: not "custom"). ...( id && 'custom' !== type && { @@ -122,11 +134,6 @@ export const blockAttributesToMenuItem = ( { } ), target: opensInNewTab ? NEW_TAB_TARGET_ATTRIBUTE : '', }; - - // Filter out the empty values - return Object.fromEntries( - Object.entries( menuItem ).filter( ( [ , v ] ) => v ) - ); }; /** @@ -235,28 +242,35 @@ export function menuItemToBlockAttributes( { object = 'tag'; } - const attributes = { + return { label: menuItemTitleField?.rendered || '', - type: object, + ...( object?.length && { + type: object, + } ), kind: menuItemTypeField?.replace( '_', '-' ) || 'custom', url: url || '', - rel: xfn?.join( ' ' ).trim(), - className: classes?.join( ' ' ).trim(), - title: attr_title?.raw || attr_title || menuItemTitleField?.raw, + ...( xfn?.length && + xfn.join( ' ' ).trim() && { + rel: xfn.join( ' ' ).trim(), + } ), + ...( classes?.length && + classes.join( ' ' ).trim() && { + className: classes.join( ' ' ).trim(), + } ), + ...( attr_title?.length && { + title: attr_title, + } ), ...( object_id && 'custom' !== object && { id: object_id, } ), - description, + ...( description?.length && { + description, + } ), ...( target === NEW_TAB_TARGET_ATTRIBUTE && { opensInNewTab: true, } ), }; - - // Filter out the empty values - return Object.fromEntries( - Object.entries( attributes ).filter( ( [ , v ] ) => v ) - ); } /* eslint-enable camelcase */ From 2be5615173b1a539b9eb499f5650f2a9085c5369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 9 Sep 2021 17:16:43 +0200 Subject: [PATCH 21/57] Remove dev artifacts --- packages/core-data/src/actions.js | 3 --- packages/data/src/redux-store/thunk-middleware.js | 3 +-- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 4ab8e002b5c4f..8dfbaa6571ef3 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -264,9 +264,6 @@ export const editEntityRecord = ( ? { ...editedRecordValue, ...edits[ key ] } : edits[ key ]; acc[ key ] = isEqual( recordValue, value ) ? undefined : value; - if ( ! isEqual( recordValue, value ) ) { - // console.log(kind, name, key, {recordValue, value}) - } return acc; }, {} ), transientEdits, diff --git a/packages/data/src/redux-store/thunk-middleware.js b/packages/data/src/redux-store/thunk-middleware.js index ccb4b1a35e445..903b7dfe2e515 100644 --- a/packages/data/src/redux-store/thunk-middleware.js +++ b/packages/data/src/redux-store/thunk-middleware.js @@ -1,8 +1,7 @@ export default function createThunkMiddleware( args ) { return () => ( next ) => ( action ) => { if ( typeof action === 'function' ) { - const retval = action( args ); - return retval; + return action( args ); } return next( action ); From 2fcc115d2220c005204254d623b04f5cd40a5cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Sep 2021 14:23:50 +0200 Subject: [PATCH 22/57] Replace map/filter with a for loop for readability --- packages/edit-navigation/src/store/actions.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index e4cf05bb80300..7339b3006819d 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -184,14 +184,13 @@ const computeNewMenuItems = ( post, oldMenuItems ) => async ( { const entityIdToBlockId = await dispatch( getEntityRecordIdToBlockIdMapping( post.id ) ); - const blockIdToOldEntityRecord = Object.fromEntries( - oldMenuItems - .map( ( entityRecord ) => [ - entityIdToBlockId[ entityRecord.id ], - entityRecord, - ] ) - .filter( ( [ blockId ] ) => blockId ) - ); + const blockIdToOldEntityRecord = {}; + for ( const oldMenuItem of oldMenuItems ) { + const blockId = entityIdToBlockId[ oldMenuItem.id ]; + if ( blockId ) { + blockIdToOldEntityRecord[ blockId ] = oldMenuItem; + } + } const blocksList = blocksTreeToFlatList( post.blocks[ 0 ].innerBlocks ); return blocksList.map( ( { block, parentBlockId }, idx ) => From 2ecdeb3448848228e2835e6ea65624e202d7d46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Sep 2021 14:53:59 +0200 Subject: [PATCH 23/57] Rename "new" to Desired" to avoid abiguity --- packages/edit-navigation/src/store/actions.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 7339b3006819d..5c4b06747468d 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -138,11 +138,11 @@ export const saveNavigationPost = ( post ) => async ( { const oldMenuItems = await dispatch( resolveSelectMenuItems( post.meta.menuId ) ); - const newMenuItems = await dispatch( - computeNewMenuItems( post, oldMenuItems ) + const desiredMenuItems = await dispatch( + getDesiredMenuItems( post, oldMenuItems ) ); await dispatch( - batchSaveDiff( 'root', 'menuItem', oldMenuItems, newMenuItems ) + batchSaveDiff( 'root', 'menuItem', oldMenuItems, desiredMenuItems ) ); // Clear "stub" navigation post edits to avoid a false "dirty" state. @@ -178,12 +178,13 @@ export const saveNavigationPost = ( post ) => async ( { } }; -const computeNewMenuItems = ( post, oldMenuItems ) => async ( { +const getDesiredMenuItems = ( post, oldMenuItems ) => async ( { dispatch, } ) => { const entityIdToBlockId = await dispatch( getEntityRecordIdToBlockIdMapping( post.id ) ); + const blockIdToOldEntityRecord = {}; for ( const oldMenuItem of oldMenuItems ) { const blockId = entityIdToBlockId[ oldMenuItem.id ]; @@ -217,10 +218,10 @@ const batchSaveDiff = ( kind, type, oldEntityRecords, - newEntityRecords + desiredEntityRecords ) => async ( { dispatch, registry } ) => { const annotatedBatchTasks = await dispatch( - createBatchTasks( kind, type, oldEntityRecords, newEntityRecords ) + createBatchTasks( kind, type, oldEntityRecords, desiredEntityRecords ) ); const results = await registry @@ -272,18 +273,18 @@ const createBatchTasks = ( kind, type, oldEntityRecords, - newEntityRecords + desiredEntityRecords ) => async ( { registry } ) => { const deletedEntityRecordsIds = new Set( diff( oldEntityRecords.map( ( { id } ) => id ), - newEntityRecords.map( ( { id } ) => id ) + desiredEntityRecords.map( ( { id } ) => id ) ) ); const batchTasks = []; // Enqueue updates - for ( const entityRecord of newEntityRecords ) { + for ( const entityRecord of desiredEntityRecords ) { if ( ! entityRecord?.id || deletedEntityRecordsIds.has( entityRecord?.id ) From 68e0ea3d9cedccdda469afd968cf5adfd72d3ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Sep 2021 15:05:50 +0200 Subject: [PATCH 24/57] Rename tasks and diffs to changesets --- packages/edit-navigation/src/store/actions.js | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 5c4b06747468d..3f4e4237ebbaa 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -142,7 +142,12 @@ export const saveNavigationPost = ( post ) => async ( { getDesiredMenuItems( post, oldMenuItems ) ); await dispatch( - batchSaveDiff( 'root', 'menuItem', oldMenuItems, desiredMenuItems ) + batchSaveChanges( + 'root', + 'menuItem', + oldMenuItems, + desiredMenuItems + ) ); // Clear "stub" navigation post edits to avoid a false "dirty" state. @@ -214,22 +219,22 @@ const resolveSelectMenuItems = ( menuId ) => async ( { registry } ) => .resolveSelect( 'core' ) .getMenuItems( { menus: menuId, per_page: -1 } ); -const batchSaveDiff = ( +const batchSaveChanges = ( kind, type, oldEntityRecords, desiredEntityRecords ) => async ( { dispatch, registry } ) => { - const annotatedBatchTasks = await dispatch( - createBatchTasks( kind, type, oldEntityRecords, desiredEntityRecords ) + const changeset = await dispatch( + computeChangeset( kind, type, oldEntityRecords, desiredEntityRecords ) ); const results = await registry .dispatch( 'core' ) - .__experimentalBatch( annotatedBatchTasks.map( ( { task } ) => task ) ); + .__experimentalBatch( changeset.map( ( { batchTask } ) => batchTask ) ); const failures = await dispatch( - getFailedBatchTasks( kind, type, annotatedBatchTasks, results ) + getFailedChanges( kind, type, changeset, results ) ); if ( failures.length ) { @@ -245,31 +250,30 @@ const batchSaveDiff = ( return results; }; -const getFailedBatchTasks = ( - kind, - entityType, - annotatedBatchTasks, - results -) => async ( { registry } ) => { - const failedDeletes = zip( annotatedBatchTasks, results ) - .filter( ( [ { type } ] ) => type === 'delete' ) - .filter( ( [ , result ] ) => ! result?.hasOwnProperty( 'deleted' ) ) - .map( ( [ task ] ) => task ); - - const failedUpdates = annotatedBatchTasks - .filter( ( { type } ) => type === 'update' ) +const getFailedChanges = ( kind, entityType, changeset, results ) => async ( { + registry, +} ) => { + const failedDeletes = zip( changeset, results ) .filter( - ( { id } ) => - id && - registry - .select( 'core' ) - .getLastEntitySaveError( kind, entityType, id ) - ); + ( [ change, result ] ) => + change.type === 'delete' && + ! result?.hasOwnProperty( 'deleted' ) + ) + .map( ( [ change ] ) => change ); + + const failedUpdates = changeset.filter( + ( change ) => + change.type === 'update' && + change.id && + registry + .select( 'core' ) + .getLastEntitySaveError( kind, entityType, change.id ) + ); return [ ...failedDeletes, ...failedUpdates ]; }; -const createBatchTasks = ( +const computeChangeset = ( kind, type, oldEntityRecords, @@ -282,7 +286,7 @@ const createBatchTasks = ( ) ); - const batchTasks = []; + const changes = []; // Enqueue updates for ( const entityRecord of desiredEntityRecords ) { if ( @@ -307,27 +311,27 @@ const createBatchTasks = ( continue; } - batchTasks.unshift( { + changes.unshift( { type: 'update', id: entityRecord.id, - task: ( { saveEditedEntityRecord } ) => + batchTask: ( { saveEditedEntityRecord } ) => saveEditedEntityRecord( kind, type, entityRecord.id ), } ); } // Enqueue deletes for ( const entityRecordId of deletedEntityRecordsIds ) { - batchTasks.unshift( { + changes.unshift( { type: 'delete', id: entityRecordId, - task: ( { deleteEntityRecord } ) => + batchTask: ( { deleteEntityRecord } ) => deleteEntityRecord( kind, type, entityRecordId, { force: true, } ), } ); } - return batchTasks; + return changes; }; function diff( listA, listB ) { From 5dcdb56b709fd17d04bc269b63b27e50b3981a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 14:59:28 +0200 Subject: [PATCH 25/57] Remove prePersistMenuItem --- lib/class-wp-rest-menu-items-controller.php | 1 + packages/core-data/src/entities.js | 22 ------------------- .../edit-navigation/src/store/test/actions.js | 6 ++--- .../src/store/test/resolvers.js | 12 +++++----- .../edit-navigation/src/store/transform.js | 2 +- 5 files changed, 11 insertions(+), 32 deletions(-) diff --git a/lib/class-wp-rest-menu-items-controller.php b/lib/class-wp-rest-menu-items-controller.php index 30377cfae1a0d..c0e2af72876b8 100644 --- a/lib/class-wp-rest-menu-items-controller.php +++ b/lib/class-wp-rest-menu-items-controller.php @@ -391,6 +391,7 @@ protected function prepare_item_for_database( $request ) { 'menu-item-object' => $menu_item_obj->object, 'menu-item-parent-id' => $menu_item_obj->menu_item_parent, 'menu-item-position' => $position, + 'menu-item-type' => $menu_item_obj->type, 'menu-item-title' => $menu_item_obj->title, 'menu-item-url' => $menu_item_obj->url, 'menu-item-description' => $menu_item_obj->description, diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 171da54df70d4..03fafbe099bcc 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -117,7 +117,6 @@ export const defaultEntities = [ plural: 'menuItems', label: __( 'Menu Item' ), rawAttributes: [ 'title', 'content' ], - __unstablePrePersist: prePersistMenuItem, }, { name: 'menuLocation', @@ -135,27 +134,6 @@ export const kinds = [ { name: 'taxonomy', loadEntities: loadTaxonomyEntities }, ]; -/** - * Returns a function to be used to retrieve extra edits to apply before persisting a menu item. - * - * @param {Object} persistedRecord Already persisted Menu item - * @param {Object} edits Edits. - * @return {Object} Updated edits. - */ -function prePersistMenuItem( persistedRecord, edits ) { - const newEdits = { - ...persistedRecord, - ...edits, - }; - if ( Array.isArray( newEdits.menus ) ) { - newEdits.menus = newEdits.menus[ 0 ]; - } - if ( newEdits.type === '' ) { - newEdits.type = 'custom'; - } - return newEdits; -} - /** * Returns a function to be used to retrieve extra edits to apply before persisting a post type. * diff --git a/packages/edit-navigation/src/store/test/actions.js b/packages/edit-navigation/src/store/test/actions.js index 1dc381531996b..9563a678e9826 100644 --- a/packages/edit-navigation/src/store/test/actions.js +++ b/packages/edit-navigation/src/store/test/actions.js @@ -113,7 +113,7 @@ describe( 'createPlaceholderMenuItem', () => { }, url: 'http://wp.com', menu_order: 1, - menus: [ 1 ], + menus: 1, }, { id: 101, @@ -123,7 +123,7 @@ describe( 'createPlaceholderMenuItem', () => { }, url: 'http://wp.org', menu_order: 2, - menus: [ 1 ], + menus: 1, }, ]; @@ -145,7 +145,7 @@ describe( 'createPlaceholderMenuItem', () => { }, url: 'http://wp.org', menu_order: 2, - menus: [ 1 ], + menus: 1, }, 199 )( { registry, dispatch } ); diff --git a/packages/edit-navigation/src/store/test/resolvers.js b/packages/edit-navigation/src/store/test/resolvers.js index eced25a0396f9..4684ef76416ea 100644 --- a/packages/edit-navigation/src/store/test/resolvers.js +++ b/packages/edit-navigation/src/store/test/resolvers.js @@ -85,7 +85,7 @@ describe( 'getNavigationPostForMenu', () => { }, url: 'http://wp.com', menu_order: 1, - menus: [ 1 ], + menus: 1, parent: 0, classes: [ 'menu', 'classes' ], xfn: [ 'nofollow' ], @@ -100,7 +100,7 @@ describe( 'getNavigationPostForMenu', () => { }, url: 'http://wp.org', menu_order: 2, - menus: [ 1 ], + menus: 1, parent: 0, classes: [], xfn: [], @@ -119,7 +119,7 @@ describe( 'getNavigationPostForMenu', () => { object_id: 56789, type: 'post-type', menu_order: 3, - menus: [ 1 ], + menus: 1, parent: 0, classes: [], xfn: [], @@ -240,7 +240,7 @@ describe( 'getNavigationPostForMenu', () => { }, url: 'http://wp.com', menu_order: 1, - menus: [ 1 ], + menus: 1, parent: 0, classes: [ 'menu', 'classes' ], xfn: [ 'nofollow' ], @@ -257,7 +257,7 @@ describe( 'getNavigationPostForMenu', () => { }, url: 'http://wp.org', menu_order: 2, - menus: [ 1 ], + menus: 1, parent: 0, classes: [], xfn: [], @@ -274,7 +274,7 @@ describe( 'getNavigationPostForMenu', () => { }, url: 'https://wordpress.org', menu_order: 3, - menus: [ 1 ], + menus: 1, parent: 0, classes: [], xfn: [], diff --git a/packages/edit-navigation/src/store/transform.js b/packages/edit-navigation/src/store/transform.js index 2dbd167a59366..789a3d68a9d95 100644 --- a/packages/edit-navigation/src/store/transform.js +++ b/packages/edit-navigation/src/store/transform.js @@ -59,7 +59,7 @@ export function blockToMenuItem( ...menuItem, ...attributes, menu_order: blockPosition + 1, - menus: [ menuId ], + menus: menuId, parent: ! parentId ? 0 : parentId, status: 'publish', }; From 61d44b7747bac31f95468931d2b1464fd47149aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:00:55 +0200 Subject: [PATCH 26/57] Don't await releaseStoreLock --- packages/edit-navigation/src/store/actions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 3f4e4237ebbaa..11110299c8ddf 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -71,7 +71,7 @@ export const createMissingMenuItems = ( post ) => async ( { mapping: menuItemIdToBlockId, } ); } finally { - await registry.dispatch( 'core' ).__unstableReleaseStoreLock( lock ); + registry.dispatch( 'core' ).__unstableReleaseStoreLock( lock ); } }; @@ -179,7 +179,7 @@ export const saveNavigationPost = ( post ) => async ( { type: 'snackbar', } ); } finally { - await registry.dispatch( 'core' ).__unstableReleaseStoreLock( lock ); + registry.dispatch( 'core' ).__unstableReleaseStoreLock( lock ); } }; From 520dde465b960799fa77b2893e62cdee570c659e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:02:23 +0200 Subject: [PATCH 27/57] Inline resolveSelectMenuItems --- packages/edit-navigation/src/store/actions.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 11110299c8ddf..1422733fe255d 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -77,7 +77,6 @@ export const createMissingMenuItems = ( post ) => async ( { export const createPlaceholderMenuItem = ( block, menuId ) => async ( { registry, - dispatch, } ) => { const menuItem = await apiFetch( { path: `/__experimental/menu-items`, @@ -89,7 +88,9 @@ export const createPlaceholderMenuItem = ( block, menuId ) => async ( { }, } ); - const menuItems = await dispatch( resolveSelectMenuItems( menuId ) ); + const menuItems = await registry + .resolveSelect( 'core' ) + .getMenuItems( { menus: menuId, per_page: -1 } ); await registry .dispatch( 'core' ) @@ -135,9 +136,10 @@ export const saveNavigationPost = ( post ) => async ( { } // Batch save menu items - const oldMenuItems = await dispatch( - resolveSelectMenuItems( post.meta.menuId ) - ); + const oldMenuItems = await registry + .resolveSelect( 'core' ) + .getMenuItems( { menus: post.meta.menuId, per_page: -1 } ); + const desiredMenuItems = await dispatch( getDesiredMenuItems( post, oldMenuItems ) ); @@ -214,11 +216,6 @@ const getEntityRecordIdToBlockIdMapping = ( postId ) => async ( { registry, } ) => registry.stores[ STORE_NAME ].store.getState().mapping[ postId ] || {}; -const resolveSelectMenuItems = ( menuId ) => async ( { registry } ) => - await registry - .resolveSelect( 'core' ) - .getMenuItems( { menus: menuId, per_page: -1 } ); - const batchSaveChanges = ( kind, type, From d9f5e9aa7ffce4bb17f8a37be8b11f7d93a6302a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:06:35 +0200 Subject: [PATCH 28/57] Resolve a list of all menu items prior to creating placeholder menu items. --- packages/edit-navigation/src/store/actions.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 1422733fe255d..4432e6d5e1641 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -50,6 +50,11 @@ export const createMissingMenuItems = ( post ) => async ( { exclusive: false, } ); try { + // Ensure all the menu items are available before we start creating placeholders. + await registry + .resolveSelect( 'core' ) + .getMenuItems( { menus: menuId, per_page: -1 } ); + const menuItemIdToBlockId = await dispatch( getEntityRecordIdToBlockIdMapping( post.id ) ); @@ -89,7 +94,7 @@ export const createPlaceholderMenuItem = ( block, menuId ) => async ( { } ); const menuItems = await registry - .resolveSelect( 'core' ) + .select( 'core' ) .getMenuItems( { menus: menuId, per_page: -1 } ); await registry From 91baba8c8f102b89f5a9ccb8d812932a08c4bee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:08:21 +0200 Subject: [PATCH 29/57] Remove async code from the synchronous getDesiredMenuItems selector --- packages/edit-navigation/src/store/actions.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 4432e6d5e1641..7d2dec15b7b1b 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -145,7 +145,7 @@ export const saveNavigationPost = ( post ) => async ( { .resolveSelect( 'core' ) .getMenuItems( { menus: post.meta.menuId, per_page: -1 } ); - const desiredMenuItems = await dispatch( + const desiredMenuItems = dispatch( getDesiredMenuItems( post, oldMenuItems ) ); await dispatch( @@ -190,10 +190,8 @@ export const saveNavigationPost = ( post ) => async ( { } }; -const getDesiredMenuItems = ( post, oldMenuItems ) => async ( { - dispatch, -} ) => { - const entityIdToBlockId = await dispatch( +const getDesiredMenuItems = ( post, oldMenuItems ) => ( { dispatch } ) => { + const entityIdToBlockId = dispatch( getEntityRecordIdToBlockIdMapping( post.id ) ); @@ -217,9 +215,8 @@ const getDesiredMenuItems = ( post, oldMenuItems ) => async ( { ); }; -const getEntityRecordIdToBlockIdMapping = ( postId ) => async ( { - registry, -} ) => registry.stores[ STORE_NAME ].store.getState().mapping[ postId ] || {}; +const getEntityRecordIdToBlockIdMapping = ( postId ) => ( { registry } ) => + registry.stores[ STORE_NAME ].store.getState().mapping[ postId ] || {}; const batchSaveChanges = ( kind, From 259a09acc0f0440a185860769663d72f5902c629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:08:58 +0200 Subject: [PATCH 30/57] Don't await createErrorNotice and createSuccessNotice --- packages/edit-navigation/src/store/actions.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 7d2dec15b7b1b..895408db93ad6 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -167,7 +167,7 @@ export const saveNavigationPost = ( post ) => async ( { undefined ); - await registry + registry .dispatch( noticesStore ) .createSuccessNotice( __( 'Navigation saved.' ), { type: 'snackbar', @@ -180,11 +180,9 @@ export const saveNavigationPost = ( post ) => async ( { saveError.message ) : __( 'Unable to save: An error o1curred.' ); - await registry - .dispatch( noticesStore ) - .createErrorNotice( errorMessage, { - type: 'snackbar', - } ); + registry.dispatch( noticesStore ).createErrorNotice( errorMessage, { + type: 'snackbar', + } ); } finally { registry.dispatch( 'core' ).__unstableReleaseStoreLock( lock ); } From 414687832c7fcd87c31d488f3887b3036810a798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:10:37 +0200 Subject: [PATCH 31/57] Rename computeChangeset to prepareChangeset and remove awaits from it and getFailedChanges --- packages/edit-navigation/src/store/actions.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 895408db93ad6..ea95f5a3a4f53 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -222,8 +222,8 @@ const batchSaveChanges = ( oldEntityRecords, desiredEntityRecords ) => async ( { dispatch, registry } ) => { - const changeset = await dispatch( - computeChangeset( kind, type, oldEntityRecords, desiredEntityRecords ) + const changeset = dispatch( + prepareChangeset( kind, type, oldEntityRecords, desiredEntityRecords ) ); const results = await registry @@ -270,7 +270,7 @@ const getFailedChanges = ( kind, entityType, changeset, results ) => async ( { return [ ...failedDeletes, ...failedUpdates ]; }; -const computeChangeset = ( +const prepareChangeset = ( kind, type, oldEntityRecords, From cfa7bbf5815bc9b77446c995f38d0e4d62e49abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:12:48 +0200 Subject: [PATCH 32/57] Use store object to refer to core-data store, not a string identifier --- packages/edit-navigation/src/store/actions.js | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index ea95f5a3a4f53..bfd193d8fdc07 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -8,6 +8,7 @@ import { zip } from 'lodash'; */ import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; +import { store as coreDataStore } from '@wordpress/core-data'; import apiFetch from '@wordpress/api-fetch'; /** @@ -45,14 +46,14 @@ export const createMissingMenuItems = ( post ) => async ( { const menuId = post.meta.menuId; // @TODO: extract locks to a separate package? const lock = await registry - .dispatch( 'core' ) + .dispatch( coreDataStore ) .__unstableAcquireStoreLock( STORE_NAME, [ 'savingMenu' ], { exclusive: false, } ); try { // Ensure all the menu items are available before we start creating placeholders. await registry - .resolveSelect( 'core' ) + .resolveSelect( coreDataStore ) .getMenuItems( { menus: menuId, per_page: -1 } ); const menuItemIdToBlockId = await dispatch( @@ -76,7 +77,7 @@ export const createMissingMenuItems = ( post ) => async ( { mapping: menuItemIdToBlockId, } ); } finally { - registry.dispatch( 'core' ).__unstableReleaseStoreLock( lock ); + registry.dispatch( coreDataStore ).__unstableReleaseStoreLock( lock ); } }; @@ -94,11 +95,11 @@ export const createPlaceholderMenuItem = ( block, menuId ) => async ( { } ); const menuItems = await registry - .select( 'core' ) + .select( coreDataStore ) .getMenuItems( { menus: menuId, per_page: -1 } ); await registry - .dispatch( 'core' ) + .dispatch( coreDataStore ) .receiveEntityRecords( 'root', 'menuItem', @@ -121,7 +122,7 @@ export const saveNavigationPost = ( post ) => async ( { dispatch, } ) => { const lock = await registry - .dispatch( 'core' ) + .dispatch( coreDataStore ) .__unstableAcquireStoreLock( STORE_NAME, [ 'savingMenu' ], { exclusive: true, } ); @@ -129,11 +130,11 @@ export const saveNavigationPost = ( post ) => async ( { const menuId = post.meta.menuId; await registry - .dispatch( 'core' ) + .dispatch( coreDataStore ) .saveEditedEntityRecord( 'root', 'menu', menuId ); const error = registry - .select( 'core' ) + .select( coreDataStore ) .getLastEntitySaveError( 'root', 'menu', menuId ); if ( error ) { @@ -142,7 +143,7 @@ export const saveNavigationPost = ( post ) => async ( { // Batch save menu items const oldMenuItems = await registry - .resolveSelect( 'core' ) + .resolveSelect( coreDataStore ) .getMenuItems( { menus: post.meta.menuId, per_page: -1 } ); const desiredMenuItems = dispatch( @@ -159,7 +160,7 @@ export const saveNavigationPost = ( post ) => async ( { // Clear "stub" navigation post edits to avoid a false "dirty" state. await registry - .dispatch( 'core' ) + .dispatch( coreDataStore ) .receiveEntityRecords( NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE, @@ -184,7 +185,7 @@ export const saveNavigationPost = ( post ) => async ( { type: 'snackbar', } ); } finally { - registry.dispatch( 'core' ).__unstableReleaseStoreLock( lock ); + registry.dispatch( coreDataStore ).__unstableReleaseStoreLock( lock ); } }; @@ -227,10 +228,10 @@ const batchSaveChanges = ( ); const results = await registry - .dispatch( 'core' ) + .dispatch( coreDataStore ) .__experimentalBatch( changeset.map( ( { batchTask } ) => batchTask ) ); - const failures = await dispatch( + const failures = dispatch( getFailedChanges( kind, type, changeset, results ) ); @@ -247,7 +248,7 @@ const batchSaveChanges = ( return results; }; -const getFailedChanges = ( kind, entityType, changeset, results ) => async ( { +const getFailedChanges = ( kind, entityType, changeset, results ) => ( { registry, } ) => { const failedDeletes = zip( changeset, results ) @@ -263,7 +264,7 @@ const getFailedChanges = ( kind, entityType, changeset, results ) => async ( { change.type === 'update' && change.id && registry - .select( 'core' ) + .select( coreDataStore ) .getLastEntitySaveError( kind, entityType, change.id ) ); @@ -275,7 +276,7 @@ const prepareChangeset = ( type, oldEntityRecords, desiredEntityRecords -) => async ( { registry } ) => { +) => ( { registry } ) => { const deletedEntityRecordsIds = new Set( diff( oldEntityRecords.map( ( { id } ) => id ), @@ -294,14 +295,14 @@ const prepareChangeset = ( } // Update an existing entity record. - await registry - .dispatch( 'core' ) + registry + .dispatch( coreDataStore ) .editEntityRecord( kind, type, entityRecord.id, entityRecord, { undoIgnore: true, } ); const hasEdits = registry - .select( 'core' ) + .select( coreDataStore ) .hasEditsForEntityRecord( kind, type, entityRecord.id ); if ( ! hasEdits ) { From 315b9ccafa0637e79e0eeab9c4e60eeeea834512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:29:54 +0200 Subject: [PATCH 33/57] Set placeholder menu order to 1 --- packages/edit-navigation/src/store/actions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index bfd193d8fdc07..9dea5d1f4a1bc 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -90,7 +90,7 @@ export const createPlaceholderMenuItem = ( block, menuId ) => async ( { data: { title: 'Placeholder', url: 'Placeholder', - menu_order: 0, + menu_order: 1, }, } ); From a54f430c7221c725b90fbacb4266a31c884049c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:37:46 +0200 Subject: [PATCH 34/57] Don't create placeholder menu items for non-link blocks --- packages/edit-navigation/src/store/actions.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 9dea5d1f4a1bc..5b01f964cea57 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -63,6 +63,9 @@ export const createMissingMenuItems = ( post ) => async ( { const blocks = blocksTreeToFlatList( post.blocks[ 0 ].innerBlocks ); for ( const { block } of blocks ) { + if ( block.name !== 'core/navigation-link' ) { + continue; + } if ( ! knownBlockIds.has( block.clientId ) ) { const menuItem = await dispatch( createPlaceholderMenuItem( block, menuId ) @@ -84,7 +87,11 @@ export const createMissingMenuItems = ( post ) => async ( { export const createPlaceholderMenuItem = ( block, menuId ) => async ( { registry, } ) => { - const menuItem = await apiFetch( { + const existingMenuItems = await registry + .select( coreDataStore ) + .getMenuItems( { menus: menuId, per_page: -1 } ); + + const createdMenuItem = await apiFetch( { path: `/__experimental/menu-items`, method: 'POST', data: { @@ -94,21 +101,17 @@ export const createPlaceholderMenuItem = ( block, menuId ) => async ( { }, } ); - const menuItems = await registry - .select( coreDataStore ) - .getMenuItems( { menus: menuId, per_page: -1 } ); - await registry .dispatch( coreDataStore ) .receiveEntityRecords( 'root', 'menuItem', - [ ...menuItems, menuItem ], + [ ...existingMenuItems, createdMenuItem ], menuItemsQuery( menuId ), false ); - return menuItem; + return createdMenuItem; }; /** From 2d5c1f266c062ee41dfd23663943591d9fd98cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:39:10 +0200 Subject: [PATCH 35/57] Remove menu order validation --- lib/class-wp-rest-menu-items-controller.php | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/lib/class-wp-rest-menu-items-controller.php b/lib/class-wp-rest-menu-items-controller.php index c0e2af72876b8..fd2c9b6c54760 100644 --- a/lib/class-wp-rest-menu-items-controller.php +++ b/lib/class-wp-rest-menu-items-controller.php @@ -525,19 +525,6 @@ protected function prepare_item_for_database( $request ) { } } - // If menu id is set, validate the value of menu item position. - if ( ! 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 ) ); - } - - // Check if menu item position is non-zero and positive. - if ( (int) $prepared_nav_item['menu-item-position'] < 1 ) { - return new WP_Error( 'invalid_menu_order', __( 'Invalid menu order.', 'gutenberg' ), array( 'status' => 400 ) ); - } - } - if ( ! empty( $prepared_nav_item['menu-id'] ) && ! is_nav_menu( $prepared_nav_item['menu-id'] ) ) { return new WP_Error( 'invalid_menu_id', __( 'Invalid menu ID.', 'gutenberg' ), array( 'status' => 400 ) ); } From fb989c64e61edb551a8c851993454ebb021088cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:39:59 +0200 Subject: [PATCH 36/57] Remove menu id validation --- lib/class-wp-rest-menu-items-controller.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/class-wp-rest-menu-items-controller.php b/lib/class-wp-rest-menu-items-controller.php index fd2c9b6c54760..84f0c48d42d49 100644 --- a/lib/class-wp-rest-menu-items-controller.php +++ b/lib/class-wp-rest-menu-items-controller.php @@ -525,10 +525,6 @@ protected function prepare_item_for_database( $request ) { } } - if ( ! empty( $prepared_nav_item['menu-id'] ) && ! is_nav_menu( $prepared_nav_item['menu-id'] ) ) { - return new WP_Error( 'invalid_menu_id', __( 'Invalid menu ID.', 'gutenberg' ), array( 'status' => 400 ) ); - } - foreach ( array( 'menu-item-object-id', 'menu-item-parent-id' ) as $key ) { // Note we need to allow negative-integer IDs for previewed objects not inserted yet. $prepared_nav_item[ $key ] = (int) $prepared_nav_item[ $key ]; From ee5e70331a742f3acf300e57446175ed7ae3f343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 15:41:12 +0200 Subject: [PATCH 37/57] Restore rest_invalid_param validation error code --- phpunit/class-rest-nav-menu-items-controller-test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit/class-rest-nav-menu-items-controller-test.php b/phpunit/class-rest-nav-menu-items-controller-test.php index 2388fa8c9f9b2..77b629bbbc75d 100644 --- a/phpunit/class-rest-nav-menu-items-controller-test.php +++ b/phpunit/class-rest-nav-menu-items-controller-test.php @@ -316,7 +316,7 @@ public function test_menu_order_must_be_set() { ); $request->set_body_params( $params ); $response = rest_get_server()->dispatch( $request ); - $this->assertErrorResponse( 'invalid_menu_order', $response, 400 ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); $request = new WP_REST_Request( 'POST', '/__experimental/menu-items' ); $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); From 5a6f86bb331034ebe2dee6107f8d8b089749e100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 17:13:17 +0200 Subject: [PATCH 38/57] Fix unit tests --- packages/edit-navigation/src/store/test/actions.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/edit-navigation/src/store/test/actions.js b/packages/edit-navigation/src/store/test/actions.js index 9563a678e9826..ee5af6d0c13e8 100644 --- a/packages/edit-navigation/src/store/test/actions.js +++ b/packages/edit-navigation/src/store/test/actions.js @@ -76,6 +76,8 @@ describe( 'createMissingMenuItems', () => { }; const registry = { dispatch: jest.fn( () => registryDispatch ), + select: jest.fn( () => ( {} ) ), + resolveSelect: jest.fn( () => ( { getMenuItems: () => [] } ) ), }; await createMissingMenuItems( post )( { registry, dispatch } ); @@ -133,7 +135,13 @@ describe( 'createPlaceholderMenuItem', () => { const registryDispatch = { receiveEntityRecords: jest.fn(), }; - const registry = { dispatch: jest.fn( () => registryDispatch ) }; + const registry = { + dispatch: jest.fn( () => registryDispatch ), + select: jest.fn( () => ( { getMenuItems: () => menuItems } ) ), + resolveSelect: jest.fn( () => ( { + getMenuItems: () => menuItems, + } ) ), + }; const dispatch = jest.fn( () => menuItems ); await createPlaceholderMenuItem( From 7b46df1ec0d00bcfb99d9feeceb1a6e2815b5477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Sep 2021 19:00:55 +0200 Subject: [PATCH 39/57] Add comments blocks --- packages/edit-navigation/src/store/actions.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 5b01f964cea57..b1f4e146c4359 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -84,6 +84,14 @@ export const createMissingMenuItems = ( post ) => async ( { } }; +/** + * Creates a single placeholder menu item a specified block without an associated menuItem. + * Requests POST /wp/v2/menu-items once for every menu item created. + * + * @param {Object} block The block to create a menu item based on. + * @param {number} menuId Menu id to embed the placeholder in. + * @return {Function} An action creator + */ export const createPlaceholderMenuItem = ( block, menuId ) => async ( { registry, } ) => { @@ -192,6 +200,14 @@ export const saveNavigationPost = ( post ) => async ( { } }; +/** + * Converts a post into a flat list of menu item entity records, + * representing the desired state after the save is finished. + * + * @param {Object} post The post. + * @param {Object[]} oldMenuItems The currently stored list of menu items. + * @return {Function} An action creator + */ const getDesiredMenuItems = ( post, oldMenuItems ) => ( { dispatch } ) => { const entityIdToBlockId = dispatch( getEntityRecordIdToBlockIdMapping( post.id ) @@ -217,9 +233,26 @@ const getDesiredMenuItems = ( post, oldMenuItems ) => ( { dispatch } ) => { ); }; +/** + * A selector in disguise. It returns mapping between menu item ID and it's related blocks client id. + * + * @param {number} postId The id of the stub post to get the mapping for. + * @return {Function} An action creator + */ const getEntityRecordIdToBlockIdMapping = ( postId ) => ( { registry } ) => registry.stores[ STORE_NAME ].store.getState().mapping[ postId ] || {}; +/** + * Persists the desiredEntityRecords while preserving IDs from oldEntityRecords. + * The batch request contains the minimal number of requests necessary to go from + * desiredEntityRecords to oldEntityRecords. + * + * @param {string} kind Entity kind. + * @param {string} type Entity type. + * @param {Object[]} oldEntityRecords The entity records that are currently persisted. + * @param {Object[]} desiredEntityRecords The entity records are to be persisted. + * @return {Function} An action creator + */ const batchSaveChanges = ( kind, type, @@ -251,6 +284,15 @@ const batchSaveChanges = ( return results; }; +/** + * Filters the changeset for failed operations. + * + * @param {string} kind Entity kind. + * @param {string} entityType Entity type. + * @param {Object[]} changeset The changeset. + * @param {Object[]} results The results of persisting the changeset. + * @return {Object[]} A list of failed changeset entries. + */ const getFailedChanges = ( kind, entityType, changeset, results ) => ( { registry, } ) => { @@ -274,6 +316,16 @@ const getFailedChanges = ( kind, entityType, changeset, results ) => ( { return [ ...failedDeletes, ...failedUpdates ]; }; +/** + * Diffs oldEntityRecords and desiredEntityRecords, returning a list of + * create, delete, and update tasks necessary to go from the former to the latter. + * + * @param {string} kind Entity kind. + * @param {string} type Entity type. + * @param {Object[]} oldEntityRecords The entity records that are currently persisted. + * @param {Object[]} desiredEntityRecords The entity records are to be persisted. + * @return {Function} An action creator + */ const prepareChangeset = ( kind, type, @@ -335,11 +387,26 @@ const prepareChangeset = ( return changes; }; +/** + * Returns elements of list A that are not in list B. + * + * @param {Array} listA List A. + * @param {Array} listB List B. + * @return {Array} elements of list A that are not in list B. + */ function diff( listA, listB ) { const setB = new Set( listB ); return listA.filter( ( x ) => ! setB.has( x ) ); } +/** + * Turns a recursive list of blocks into a flat list of blocks. + * + * @param {Object[]} innerBlocks A list of blocks containing zero or more inner blocks. + * @param {number|null} parentBlockId The id of the currently processed parent block. + * @return {Object} A flat list of blocks, annotated by their index and parent ID, consisting + * of all the input blocks and all the inner blocks in the tree. + */ function blocksTreeToFlatList( innerBlocks, parentBlockId = null ) { return innerBlocks.flatMap( ( block, index ) => [ { block, parentBlockId, childIndex: index } ].concat( From 7f7862799cc7a644e27b81f2a9ef68a17d49d4ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 15 Sep 2021 15:49:40 +0200 Subject: [PATCH 40/57] Remove "@TODO: extract locks to a separate package?" --- packages/edit-navigation/src/store/actions.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index b1f4e146c4359..a6decd472cb0e 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -44,7 +44,6 @@ export const createMissingMenuItems = ( post ) => async ( { registry, } ) => { const menuId = post.meta.menuId; - // @TODO: extract locks to a separate package? const lock = await registry .dispatch( coreDataStore ) .__unstableAcquireStoreLock( STORE_NAME, [ 'savingMenu' ], { From 09d7e184172b2b096338daa7e012ae5e8c473bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 15 Sep 2021 15:57:55 +0200 Subject: [PATCH 41/57] Remove unused block argument from createPlaceholderMenuItem --- packages/edit-navigation/src/store/actions.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index a6decd472cb0e..e96ac88ef58bd 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -84,14 +84,13 @@ export const createMissingMenuItems = ( post ) => async ( { }; /** - * Creates a single placeholder menu item a specified block without an associated menuItem. + * Creates a single placeholder menu item. * Requests POST /wp/v2/menu-items once for every menu item created. * - * @param {Object} block The block to create a menu item based on. * @param {number} menuId Menu id to embed the placeholder in. * @return {Function} An action creator */ -export const createPlaceholderMenuItem = ( block, menuId ) => async ( { +export const createPlaceholderMenuItem = ( menuId ) => async ( { registry, } ) => { const existingMenuItems = await registry From fc9dca3d7b2ab7564ff6ab0bfc19e7f6bf057d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 15 Sep 2021 17:06:35 +0200 Subject: [PATCH 42/57] Remove obsolete "block" argument --- packages/edit-navigation/src/store/actions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index e96ac88ef58bd..5986afc4c1d63 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -67,7 +67,7 @@ export const createMissingMenuItems = ( post ) => async ( { } if ( ! knownBlockIds.has( block.clientId ) ) { const menuItem = await dispatch( - createPlaceholderMenuItem( block, menuId ) + createPlaceholderMenuItem( menuId ) ); menuItemIdToBlockId[ menuItem.id ] = block.clientId; } From 6248810264368712e5ad89bf933de858ada1a785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 15 Sep 2021 17:08:31 +0200 Subject: [PATCH 43/57] Replace diff() with _.difference() --- packages/edit-navigation/src/store/actions.js | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 5986afc4c1d63..2f1b10b98bc66 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { zip } from 'lodash'; +import { zip, difference } from 'lodash'; /** * WordPress dependencies @@ -331,7 +331,7 @@ const prepareChangeset = ( desiredEntityRecords ) => ( { registry } ) => { const deletedEntityRecordsIds = new Set( - diff( + difference( oldEntityRecords.map( ( { id } ) => id ), desiredEntityRecords.map( ( { id } ) => id ) ) @@ -385,18 +385,6 @@ const prepareChangeset = ( return changes; }; -/** - * Returns elements of list A that are not in list B. - * - * @param {Array} listA List A. - * @param {Array} listB List B. - * @return {Array} elements of list A that are not in list B. - */ -function diff( listA, listB ) { - const setB = new Set( listB ); - return listA.filter( ( x ) => ! setB.has( x ) ); -} - /** * Turns a recursive list of blocks into a flat list of blocks. * From cf69c6e0f8ee62950e17f604802cae0d57aa05c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 15 Sep 2021 17:37:30 +0200 Subject: [PATCH 44/57] Replace clientId mapping with internal attribute denoting entity record ID --- packages/edit-navigation/src/store/actions.js | 91 ++++----- packages/edit-navigation/src/store/reducer.js | 80 -------- .../edit-navigation/src/store/resolvers.js | 15 +- .../edit-navigation/src/store/selectors.js | 16 +- .../edit-navigation/src/store/test/actions.js | 9 - .../edit-navigation/src/store/test/reducer.js | 176 +----------------- .../src/store/test/resolvers.js | 10 - .../src/store/test/selectors.js | 33 ---- .../edit-navigation/src/store/transform.js | 7 +- packages/edit-navigation/src/store/utils.js | 32 ++++ 10 files changed, 77 insertions(+), 392 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 2f1b10b98bc66..3fdfadf54b4e4 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -16,7 +16,11 @@ import apiFetch from '@wordpress/api-fetch'; */ import { STORE_NAME } from './constants'; import { NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE } from '../constants'; -import { menuItemsQuery } from './utils'; +import { + addRecordIdToBlock, + getRecordIdFromBlock, + menuItemsQuery, +} from './utils'; import { blockToMenuItem } from './transform'; /** @@ -55,29 +59,25 @@ export const createMissingMenuItems = ( post ) => async ( { .resolveSelect( coreDataStore ) .getMenuItems( { menus: menuId, per_page: -1 } ); - const menuItemIdToBlockId = await dispatch( - getEntityRecordIdToBlockIdMapping( post.id ) - ); - const knownBlockIds = new Set( Object.values( menuItemIdToBlockId ) ); - - const blocks = blocksTreeToFlatList( post.blocks[ 0 ].innerBlocks ); + const blocks = blocksTreeToFlatList( post.blocks[ 0 ] ); for ( const { block } of blocks ) { if ( block.name !== 'core/navigation-link' ) { continue; } - if ( ! knownBlockIds.has( block.clientId ) ) { + if ( ! getRecordIdFromBlock( block ) ) { const menuItem = await dispatch( createPlaceholderMenuItem( menuId ) ); - menuItemIdToBlockId[ menuItem.id ] = block.clientId; + block.attributes = addRecordIdToBlock( + block, + menuItem.id + ).attributes; } } - dispatch( { - type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', - postId: post.id, - mapping: menuItemIdToBlockId, - } ); + registry + .dispatch( coreDataStore ) + .receiveEntityRecords( 'root', 'postType', post, undefined ); } finally { registry.dispatch( coreDataStore ).__unstableReleaseStoreLock( lock ); } @@ -155,27 +155,19 @@ export const saveNavigationPost = ( post ) => async ( { .resolveSelect( coreDataStore ) .getMenuItems( { menus: post.meta.menuId, per_page: -1 } ); - const desiredMenuItems = dispatch( - getDesiredMenuItems( post, oldMenuItems ) - ); await dispatch( batchSaveChanges( 'root', 'menuItem', oldMenuItems, - desiredMenuItems + getDesiredMenuItems( post, oldMenuItems ) ) ); // Clear "stub" navigation post edits to avoid a false "dirty" state. - await registry + registry .dispatch( coreDataStore ) - .receiveEntityRecords( - NAVIGATION_POST_KIND, - NAVIGATION_POST_POST_TYPE, - [ post ], - undefined - ); + .receiveEntityRecords( 'root', 'postType', post, undefined ); registry .dispatch( noticesStore ) @@ -206,39 +198,25 @@ export const saveNavigationPost = ( post ) => async ( { * @param {Object[]} oldMenuItems The currently stored list of menu items. * @return {Function} An action creator */ -const getDesiredMenuItems = ( post, oldMenuItems ) => ( { dispatch } ) => { - const entityIdToBlockId = dispatch( - getEntityRecordIdToBlockIdMapping( post.id ) - ); - - const blockIdToOldEntityRecord = {}; - for ( const oldMenuItem of oldMenuItems ) { - const blockId = entityIdToBlockId[ oldMenuItem.id ]; - if ( blockId ) { - blockIdToOldEntityRecord[ blockId ] = oldMenuItem; - } - } - - const blocksList = blocksTreeToFlatList( post.blocks[ 0 ].innerBlocks ); - return blocksList.map( ( { block, parentBlockId }, idx ) => +const getDesiredMenuItems = ( post, oldMenuItems ) => { + const blocksList = blocksTreeToFlatList( post.blocks[ 0 ] ); + const items = blocksList.map( ( { block, parentBlock }, idx ) => blockToMenuItem( block, - blockIdToOldEntityRecord[ block.clientId ], - blockIdToOldEntityRecord[ parentBlockId ]?.id, + oldMenuItems.find( + ( record ) => record.id === getRecordIdFromBlock( block ) + ), + getRecordIdFromBlock( parentBlock ), idx, post.meta.menuId ) ); -}; -/** - * A selector in disguise. It returns mapping between menu item ID and it's related blocks client id. - * - * @param {number} postId The id of the stub post to get the mapping for. - * @return {Function} An action creator - */ -const getEntityRecordIdToBlockIdMapping = ( postId ) => ( { registry } ) => - registry.stores[ STORE_NAME ].store.getState().mapping[ postId ] || {}; + console.log( 'items', items ); + console.log( { oldMenuItems, blocksList } ); + + return items; +}; /** * Persists the desiredEntityRecords while preserving IDs from oldEntityRecords. @@ -388,15 +366,14 @@ const prepareChangeset = ( /** * Turns a recursive list of blocks into a flat list of blocks. * - * @param {Object[]} innerBlocks A list of blocks containing zero or more inner blocks. - * @param {number|null} parentBlockId The id of the currently processed parent block. + * @param {Object} parentBlock A parent block to flatten * @return {Object} A flat list of blocks, annotated by their index and parent ID, consisting * of all the input blocks and all the inner blocks in the tree. */ -function blocksTreeToFlatList( innerBlocks, parentBlockId = null ) { - return innerBlocks.flatMap( ( block, index ) => - [ { block, parentBlockId, childIndex: index } ].concat( - blocksTreeToFlatList( block.innerBlocks, block.clientId ) +function blocksTreeToFlatList( parentBlock ) { + return ( parentBlock.innerBlocks || [] ).flatMap( ( innerBlock, index ) => + [ { block: innerBlock, parentBlock, childIndex: index } ].concat( + blocksTreeToFlatList( innerBlock ) ) ); } diff --git a/packages/edit-navigation/src/store/reducer.js b/packages/edit-navigation/src/store/reducer.js index acafb5b3dd86b..5cf78bf05780e 100644 --- a/packages/edit-navigation/src/store/reducer.js +++ b/packages/edit-navigation/src/store/reducer.js @@ -3,84 +3,6 @@ */ 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 || {}; -} - /** * Reducer keeping track of selected menu ID. * @@ -114,8 +36,6 @@ function blockInserterPanel( state = false, action ) { } export default combineReducers( { - mapping, - processingQueue, selectedMenuId, blockInserterPanel, } ); diff --git a/packages/edit-navigation/src/store/resolvers.js b/packages/edit-navigation/src/store/resolvers.js index 66c79f9807a64..b485db6a63edc 100644 --- a/packages/edit-navigation/src/store/resolvers.js +++ b/packages/edit-navigation/src/store/resolvers.js @@ -42,14 +42,7 @@ export function* getNavigationPostForMenu( menuId ) { // 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, - }; + const navigationBlock = createNavigationBlock( menuItems ); // Persist the actual post containing the navigation block yield persistPost( createStubPost( menuId, navigationBlock ) ); @@ -89,9 +82,7 @@ const persistPost = ( post ) => * @return {Object} Navigation block */ function createNavigationBlock( menuItems ) { - const { innerBlocks, mapping: menuItemIdToClientId } = menuItemsToBlocks( - menuItems - ); + const { innerBlocks } = menuItemsToBlocks( menuItems ); const navigationBlock = createBlock( 'core/navigation', @@ -100,5 +91,5 @@ function createNavigationBlock( menuItems ) { }, innerBlocks ); - return [ navigationBlock, menuItemIdToClientId ]; + return navigationBlock; } diff --git a/packages/edit-navigation/src/store/selectors.js b/packages/edit-navigation/src/store/selectors.js index f170fb7fec114..2db76196aabc7 100644 --- a/packages/edit-navigation/src/store/selectors.js +++ b/packages/edit-navigation/src/store/selectors.js @@ -43,7 +43,7 @@ export const getNavigationPostForMenu = createRegistrySelector( if ( ! hasResolvedNavigationPost( state, menuId ) ) { return null; } - return select( coreStore ).getEditedEntityRecord( + return select( coreStore ).getEntityRecord( NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE, buildNavigationPostId( menuId ) @@ -67,20 +67,6 @@ export const hasResolvedNavigationPost = createRegistrySelector( } ); -/** - * 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( coreStore ).getMenuItem( mapping[ clientId ] ); - } -); - /** * Returns true if the inserter is opened. * diff --git a/packages/edit-navigation/src/store/test/actions.js b/packages/edit-navigation/src/store/test/actions.js index ee5af6d0c13e8..32f97e5ce4613 100644 --- a/packages/edit-navigation/src/store/test/actions.js +++ b/packages/edit-navigation/src/store/test/actions.js @@ -81,15 +81,6 @@ describe( 'createMissingMenuItems', () => { }; await createMissingMenuItems( post )( { registry, dispatch } ); - - expect( dispatch ).toHaveBeenCalledWith( { - type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', - postId: post.id, - mapping: { - 87: 'navigation-block-client-id', - 88: 'navigation-block-client-id2', - }, - } ); } ); } ); diff --git a/packages/edit-navigation/src/store/test/reducer.js b/packages/edit-navigation/src/store/test/reducer.js index b4758643cca55..f2fafad0a4a76 100644 --- a/packages/edit-navigation/src/store/test/reducer.js +++ b/packages/edit-navigation/src/store/test/reducer.js @@ -1,181 +1,7 @@ /** * Internal dependencies */ -import { mapping, processingQueue, selectedMenuId } 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' ], - }, - } ); - } ); -} ); +import { selectedMenuId } from '../reducer'; describe( 'selectedMenuId', () => { it( 'should apply default state', () => { diff --git a/packages/edit-navigation/src/store/test/resolvers.js b/packages/edit-navigation/src/store/test/resolvers.js index 4684ef76416ea..d7a042694ed7d 100644 --- a/packages/edit-navigation/src/store/test/resolvers.js +++ b/packages/edit-navigation/src/store/test/resolvers.js @@ -129,16 +129,6 @@ describe( 'getNavigationPostForMenu', () => { }, ]; - expect( generator.next( menuItems ).value ).toEqual( { - type: 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', - postId: stubPost.id, - mapping: { - 100: expect.stringMatching( /client-id-\d+/ ), - 101: expect.stringMatching( /client-id-\d+/ ), - 102: expect.stringMatching( /client-id-\d+/ ), - }, - } ); - const navigationBlockStubPost = { id, slug: id, diff --git a/packages/edit-navigation/src/store/test/selectors.js b/packages/edit-navigation/src/store/test/selectors.js index 235745ad42969..13f9ba5641667 100644 --- a/packages/edit-navigation/src/store/test/selectors.js +++ b/packages/edit-navigation/src/store/test/selectors.js @@ -9,7 +9,6 @@ import { store as coreDataStore } from '@wordpress/core-data'; import { getNavigationPostForMenu, hasResolvedNavigationPost, - getMenuItemForClientId, getSelectedMenuId, } from '../selectors'; import { @@ -105,38 +104,6 @@ describe( 'hasResolvedNavigationPost', () => { } ); } ); -describe( 'getMenuItemForClientId', () => { - it( 'gets menu item for client id', () => { - const getMenuItem = jest.fn( () => 'menuItem' ); - - const registry = { - select: jest.fn( () => ( { - getMenuItem, - } ) ), - }; - - const state = { - mapping: { - postId: { - 123: 'clientId', - }, - }, - }; - - const defaultRegistry = getMenuItemForClientId.registry; - getMenuItemForClientId.registry = registry; - - expect( getMenuItemForClientId( state, 'postId', 'clientId' ) ).toBe( - 'menuItem' - ); - - expect( registry.select ).toHaveBeenCalledWith( coreDataStore ); - expect( getMenuItem ).toHaveBeenCalledWith( '123' ); - - getMenuItemForClientId.registry = defaultRegistry; - } ); -} ); - describe( 'getSelectedMenuId', () => { it( 'returns default selected menu ID (zero)', () => { const state = {}; diff --git a/packages/edit-navigation/src/store/transform.js b/packages/edit-navigation/src/store/transform.js index 789a3d68a9d95..d299b95d8844b 100644 --- a/packages/edit-navigation/src/store/transform.js +++ b/packages/edit-navigation/src/store/transform.js @@ -12,6 +12,7 @@ import { serialize, createBlock, parse } from '@wordpress/blocks'; * Internal dependencies */ import { NEW_TAB_TARGET_ATTRIBUTE } from '../constants'; +import { addRecordIdToBlock, getRecordIdFromBlock } from './utils'; /** * A WP nav_menu_item object. @@ -58,6 +59,7 @@ export function blockToMenuItem( return { ...menuItem, ...attributes, + id: getRecordIdFromBlock( block ), menu_order: blockPosition + 1, menus: menuId, parent: ! parentId ? 0 : parentId, @@ -200,7 +202,10 @@ function mapMenuItemsToBlocks( menuItems ) { : 'core/navigation-link'; // Create block with nested "innerBlocks". - const block = createBlock( itemBlockName, attributes, nestedBlocks ); + const block = addRecordIdToBlock( + createBlock( itemBlockName, attributes, nestedBlocks ), + menuItem + ); // Create mapping for menuItem -> block mapping[ menuItem.id ] = block.clientId; diff --git a/packages/edit-navigation/src/store/utils.js b/packages/edit-navigation/src/store/utils.js index 5193e202c051b..f5d25c65c88fb 100644 --- a/packages/edit-navigation/src/store/utils.js +++ b/packages/edit-navigation/src/store/utils.js @@ -37,3 +37,35 @@ export const buildNavigationPostId = ( menuId ) => export function menuItemsQuery( menuId ) { return { menus: menuId, per_page: -1 }; } + +/** + * Get the internal record id from block. + * + * @typedef {Object} Attributes + * @property {string} __internalRecordId The internal record id. + * @typedef {Object} Block + * @property {Attributes} attributes The attributes of the block. + * + * @param {Block} block The block. + * @return {string} The internal record id. + */ +export function getRecordIdFromBlock( block ) { + return block.attributes.__internalRecordId; +} + +/** + * Add internal record id to block's attributes. + * + * @param {Block} block The block. + * @param {string} recordId The record id. + * @return {Block} The updated block. + */ +export function addRecordIdToBlock( block, recordId ) { + return { + ...block, + attributes: { + ...( block.attributes || {} ), + __internalRecordId: recordId, + }, + }; +} From b3a6b083fc9174a7363f166ecb724f78e470fe34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 17 Sep 2021 16:43:19 +0200 Subject: [PATCH 45/57] Radically simplify batch saving --- lib/class-wp-rest-menu-items-controller.php | 1 - .../src/components/layout/index.js | 10 +- .../src/hooks/use-navigation-block-editor.js | 34 -- packages/edit-navigation/src/store/actions.js | 351 +++++------------- .../edit-navigation/src/store/resolvers.js | 5 +- .../edit-navigation/src/store/selectors.js | 7 +- .../edit-navigation/src/store/transform.js | 33 +- 7 files changed, 116 insertions(+), 325 deletions(-) delete mode 100644 packages/edit-navigation/src/hooks/use-navigation-block-editor.js diff --git a/lib/class-wp-rest-menu-items-controller.php b/lib/class-wp-rest-menu-items-controller.php index 84f0c48d42d49..f8b17f55eca15 100644 --- a/lib/class-wp-rest-menu-items-controller.php +++ b/lib/class-wp-rest-menu-items-controller.php @@ -442,7 +442,6 @@ protected function prepare_item_for_database( $request ) { ); $schema = $this->get_item_schema(); - foreach ( $mapping as $original => $api_request ) { if ( ! empty( $schema['properties'][ $api_request ] ) && isset( $request[ $api_request ] ) ) { $check = rest_validate_value_from_schema( $request[ $api_request ], $schema['properties'][ $api_request ] ); diff --git a/packages/edit-navigation/src/components/layout/index.js b/packages/edit-navigation/src/components/layout/index.js index 006f764ef148f..c6ca87187aebd 100644 --- a/packages/edit-navigation/src/components/layout/index.js +++ b/packages/edit-navigation/src/components/layout/index.js @@ -7,6 +7,7 @@ import { BlockTools, __unstableUseBlockSelectionClearer as useBlockSelectionClearer, } from '@wordpress/block-editor'; +import { useEntityBlockEditor } from '@wordpress/core-data'; import { Popover, SlotFillProvider, Spinner } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; import { useEffect, useMemo, useState } from '@wordpress/element'; @@ -25,7 +26,6 @@ import UnselectedMenuState from './unselected-menu-state'; import { IsMenuNameControlFocusedContext, useNavigationEditor, - useNavigationBlockEditor, useMenuNotifications, } from '../../hooks'; import ErrorBoundary from '../error-boundary'; @@ -68,8 +68,12 @@ export default function Layout( { blockEditorSettings } ) { isMenuSelected, } = useNavigationEditor(); - const [ blocks, onInput, onChange ] = useNavigationBlockEditor( - navigationPost + const [ blocks, onInput, onChange ] = useEntityBlockEditor( + 'root', + 'postType', + { + id: navigationPost?.id, + } ); const { hasSidebarEnabled, isInserterOpened } = useSelect( diff --git a/packages/edit-navigation/src/hooks/use-navigation-block-editor.js b/packages/edit-navigation/src/hooks/use-navigation-block-editor.js deleted file mode 100644 index 24f179d04bd75..0000000000000 --- a/packages/edit-navigation/src/hooks/use-navigation-block-editor.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * WordPress dependencies - */ -import { useDispatch } from '@wordpress/data'; -import { useCallback } from '@wordpress/element'; -import { useEntityBlockEditor } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import { NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE } from '../constants'; -import { store as editNavigationStore } from '../store'; - -export default function useNavigationBlockEditor( post ) { - const { createMissingMenuItems } = useDispatch( editNavigationStore ); - - const [ blocks, onInput, onEntityChange ] = useEntityBlockEditor( - NAVIGATION_POST_KIND, - NAVIGATION_POST_POST_TYPE, - { - id: post?.id, - } - ); - - const onChange = useCallback( - async ( ...args ) => { - await onEntityChange( ...args ); - createMissingMenuItems( post ); - }, - [ onEntityChange, post ] - ); - - return [ blocks, onInput, onChange ]; -} diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 3fdfadf54b4e4..c5bd9544bfea4 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -9,19 +9,13 @@ import { zip, difference } from 'lodash'; import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { store as coreDataStore } from '@wordpress/core-data'; -import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies */ import { STORE_NAME } from './constants'; -import { NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE } from '../constants'; -import { - addRecordIdToBlock, - getRecordIdFromBlock, - menuItemsQuery, -} from './utils'; -import { blockToMenuItem } from './transform'; +import { getRecordIdFromBlock } from './utils'; +import { blockToMenuItem, menuItemToBlockAttributes } from './transform'; /** * Returns an action object used to select menu. @@ -36,90 +30,6 @@ export function setSelectedMenuId( menuId ) { }; } -/** - * 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 = ( post ) => async ( { - dispatch, - registry, -} ) => { - const menuId = post.meta.menuId; - const lock = await registry - .dispatch( coreDataStore ) - .__unstableAcquireStoreLock( STORE_NAME, [ 'savingMenu' ], { - exclusive: false, - } ); - try { - // Ensure all the menu items are available before we start creating placeholders. - await registry - .resolveSelect( coreDataStore ) - .getMenuItems( { menus: menuId, per_page: -1 } ); - - const blocks = blocksTreeToFlatList( post.blocks[ 0 ] ); - for ( const { block } of blocks ) { - if ( block.name !== 'core/navigation-link' ) { - continue; - } - if ( ! getRecordIdFromBlock( block ) ) { - const menuItem = await dispatch( - createPlaceholderMenuItem( menuId ) - ); - block.attributes = addRecordIdToBlock( - block, - menuItem.id - ).attributes; - } - } - - registry - .dispatch( coreDataStore ) - .receiveEntityRecords( 'root', 'postType', post, undefined ); - } finally { - registry.dispatch( coreDataStore ).__unstableReleaseStoreLock( lock ); - } -}; - -/** - * Creates a single placeholder menu item. - * Requests POST /wp/v2/menu-items once for every menu item created. - * - * @param {number} menuId Menu id to embed the placeholder in. - * @return {Function} An action creator - */ -export const createPlaceholderMenuItem = ( menuId ) => async ( { - registry, -} ) => { - const existingMenuItems = await registry - .select( coreDataStore ) - .getMenuItems( { menus: menuId, per_page: -1 } ); - - const createdMenuItem = await apiFetch( { - path: `/__experimental/menu-items`, - method: 'POST', - data: { - title: 'Placeholder', - url: 'Placeholder', - menu_order: 1, - }, - } ); - - await registry - .dispatch( coreDataStore ) - .receiveEntityRecords( - 'root', - 'menuItem', - [ ...existingMenuItems, createdMenuItem ], - menuItemsQuery( menuId ), - false - ); - - return createdMenuItem; -}; - /** * Converts all the blocks into menu items and submits a batch request to save everything at once. * @@ -150,19 +60,26 @@ export const saveNavigationPost = ( post ) => async ( { throw new Error( error.message ); } - // Batch save menu items + // Make sure all the existing menu items are available before proceeding const oldMenuItems = await registry .resolveSelect( coreDataStore ) .getMenuItems( { menus: post.meta.menuId, per_page: -1 } ); - await dispatch( - batchSaveChanges( - 'root', - 'menuItem', - oldMenuItems, - getDesiredMenuItems( post, oldMenuItems ) + const annotatedBlocks = blocksTreeToFlatList( + post.blocks[ 0 ] + ).filter( ( { block: { name } } ) => isSupportedBlock( name ) ); + + await dispatch( batchInsertPlaceholderMenuItems( annotatedBlocks ) ); + await dispatch( batchUpdateMenuItems( annotatedBlocks, menuId ) ); + + // Delete menu items + const deletedIds = difference( + oldMenuItems.map( ( { id } ) => id ), + annotatedBlocks.map( ( { block } ) => + getRecordIdFromBlock( block ) ) ); + await dispatch( batchDeleteMenuItems( deletedIds ) ); // Clear "stub" navigation post edits to avoid a false "dirty" state. registry @@ -181,7 +98,7 @@ export const saveNavigationPost = ( post ) => async ( { __( "Unable to save: '%s'" ), saveError.message ) - : __( 'Unable to save: An error o1curred.' ); + : __( 'Unable to save: An error ocurred.' ); registry.dispatch( noticesStore ).createErrorNotice( errorMessage, { type: 'snackbar', } ); @@ -191,176 +108,106 @@ export const saveNavigationPost = ( post ) => async ( { }; /** - * Converts a post into a flat list of menu item entity records, - * representing the desired state after the save is finished. - * - * @param {Object} post The post. - * @param {Object[]} oldMenuItems The currently stored list of menu items. - * @return {Function} An action creator - */ -const getDesiredMenuItems = ( post, oldMenuItems ) => { - const blocksList = blocksTreeToFlatList( post.blocks[ 0 ] ); - const items = blocksList.map( ( { block, parentBlock }, idx ) => - blockToMenuItem( - block, - oldMenuItems.find( - ( record ) => record.id === getRecordIdFromBlock( block ) - ), - getRecordIdFromBlock( parentBlock ), - idx, - post.meta.menuId - ) - ); - - console.log( 'items', items ); - console.log( { oldMenuItems, blocksList } ); - - return items; -}; - -/** - * Persists the desiredEntityRecords while preserving IDs from oldEntityRecords. - * The batch request contains the minimal number of requests necessary to go from - * desiredEntityRecords to oldEntityRecords. + * 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 {string} kind Entity kind. - * @param {string} type Entity type. - * @param {Object[]} oldEntityRecords The entity records that are currently persisted. - * @param {Object[]} desiredEntityRecords The entity records are to be persisted. + * @param {Object[]} annotatedBlocks Blocks to create menu items for. * @return {Function} An action creator */ -const batchSaveChanges = ( - kind, - type, - oldEntityRecords, - desiredEntityRecords -) => async ( { dispatch, registry } ) => { - const changeset = dispatch( - prepareChangeset( kind, type, oldEntityRecords, desiredEntityRecords ) - ); +const batchInsertPlaceholderMenuItems = ( annotatedBlocks ) => async ( { + registry, +} ) => { + const tasks = annotatedBlocks + .filter( ( { block } ) => ! getRecordIdFromBlock( block ) ) + .map( ( { block } ) => async ( { saveEntityRecord } ) => { + const record = await saveEntityRecord( 'root', 'menuItem', { + title: 'Menu item', + url: '#placeholder', + menu_order: 1, + } ); + block.attributes.__internalRecordId = record.id; + return record; + } ); - const results = await registry + return await registry .dispatch( coreDataStore ) - .__experimentalBatch( changeset.map( ( { batchTask } ) => batchTask ) ); - - const failures = dispatch( - getFailedChanges( kind, type, changeset, results ) - ); - - if ( failures.length ) { - throw new Error( - sprintf( - /* translators: %s: List of numeric ids */ - __( 'Could not save the following records: %s.' ), - failures.map( ( { id } ) => id ).join( ', ' ) - ) - ); - } - - return results; + .__experimentalBatch( tasks ); }; -/** - * Filters the changeset for failed operations. - * - * @param {string} kind Entity kind. - * @param {string} entityType Entity type. - * @param {Object[]} changeset The changeset. - * @param {Object[]} results The results of persisting the changeset. - * @return {Object[]} A list of failed changeset entries. - */ -const getFailedChanges = ( kind, entityType, changeset, results ) => ( { +const batchUpdateMenuItems = ( annotatedBlocks, menuId ) => async ( { registry, + dispatch, } ) => { - const failedDeletes = zip( changeset, results ) - .filter( - ( [ change, result ] ) => - change.type === 'delete' && - ! result?.hasOwnProperty( 'deleted' ) - ) - .map( ( [ change ] ) => change ); - - const failedUpdates = changeset.filter( - ( change ) => - change.type === 'update' && - change.id && - registry - .select( coreDataStore ) - .getLastEntitySaveError( kind, entityType, change.id ) + const desiredMenuItems = annotatedBlocks.map( + ( { block, parentBlock }, idx ) => + blockToMenuItem( + block, + registry + .select( coreDataStore ) + .getMenuItem( getRecordIdFromBlock( block ) ), + getRecordIdFromBlock( parentBlock ), + idx, + menuId + ) ); - return [ ...failedDeletes, ...failedUpdates ]; -}; - -/** - * Diffs oldEntityRecords and desiredEntityRecords, returning a list of - * create, delete, and update tasks necessary to go from the former to the latter. - * - * @param {string} kind Entity kind. - * @param {string} type Entity type. - * @param {Object[]} oldEntityRecords The entity records that are currently persisted. - * @param {Object[]} desiredEntityRecords The entity records are to be persisted. - * @return {Function} An action creator - */ -const prepareChangeset = ( - kind, - type, - oldEntityRecords, - desiredEntityRecords -) => ( { registry } ) => { - const deletedEntityRecordsIds = new Set( - difference( - oldEntityRecords.map( ( { id } ) => id ), - desiredEntityRecords.map( ( { id } ) => id ) + const updateBatch = zip( desiredMenuItems, annotatedBlocks ) + .filter( ( [ menuItem ] ) => + dispatch( applyEdits( menuItem.id, menuItem ) ) ) - ); + .map( + ( [ menuItem, block ] ) => async ( { saveEditedEntityRecord } ) => { + await saveEditedEntityRecord( 'root', 'menuItem', menuItem.id ); + // @TODO failures should be thrown in core-data + const failure = registry + .select( coreDataStore ) + .getLastEntitySaveError( 'root', 'menuItem', menuItem.id ); + if ( failure ) { + throw new Error( failure ); + } + block.attributes = menuItemToBlockAttributes( + registry.select( coreDataStore ).getMenuItem( menuItem.id ) + ); + } + ); + return await registry + .dispatch( coreDataStore ) + .__experimentalBatch( updateBatch ); +}; - const changes = []; - // Enqueue updates - for ( const entityRecord of desiredEntityRecords ) { - if ( - ! entityRecord?.id || - deletedEntityRecordsIds.has( entityRecord?.id ) - ) { - continue; - } +const isSupportedBlock = ( name ) => + [ 'core/navigation-link', 'core/navigation-submenu' ].includes( name ); - // Update an existing entity record. - registry - .dispatch( coreDataStore ) - .editEntityRecord( kind, type, entityRecord.id, entityRecord, { - undoIgnore: true, - } ); +const applyEdits = ( id, edits ) => ( { registry } ) => { + // Update an existing entity record. + registry + .dispatch( coreDataStore ) + .editEntityRecord( 'root', 'menuItem', id, edits, { + undoIgnore: true, + } ); - const hasEdits = registry - .select( coreDataStore ) - .hasEditsForEntityRecord( kind, type, entityRecord.id ); + return registry + .select( coreDataStore ) + .hasEditsForEntityRecord( 'root', 'menuItem', id ); +}; - if ( ! hasEdits ) { - continue; +const batchDeleteMenuItems = ( deletedIds ) => async ( { registry } ) => { + const deleteBatch = deletedIds.map( + ( id ) => async ( { deleteEntityRecord } ) => { + const success = await deleteEntityRecord( 'root', 'menuItem', id, { + force: true, + } ); + // @TODO failures should be thrown in core-data + if ( ! success ) { + throw new Error( id ); + } + return success; } + ); - changes.unshift( { - type: 'update', - id: entityRecord.id, - batchTask: ( { saveEditedEntityRecord } ) => - saveEditedEntityRecord( kind, type, entityRecord.id ), - } ); - } - - // Enqueue deletes - for ( const entityRecordId of deletedEntityRecordsIds ) { - changes.unshift( { - type: 'delete', - id: entityRecordId, - batchTask: ( { deleteEntityRecord } ) => - deleteEntityRecord( kind, type, entityRecordId, { - force: true, - } ), - } ); - } - - return changes; + return await registry + .dispatch( coreDataStore ) + .__experimentalBatch( deleteBatch ); }; /** diff --git a/packages/edit-navigation/src/store/resolvers.js b/packages/edit-navigation/src/store/resolvers.js index b485db6a63edc..635d2eef958c2 100644 --- a/packages/edit-navigation/src/store/resolvers.js +++ b/packages/edit-navigation/src/store/resolvers.js @@ -82,14 +82,13 @@ const persistPost = ( post ) => * @return {Object} Navigation block */ function createNavigationBlock( menuItems ) { - const { innerBlocks } = menuItemsToBlocks( menuItems ); + const innerBlocks = menuItemsToBlocks( menuItems ); - const navigationBlock = createBlock( + return createBlock( 'core/navigation', { orientation: 'vertical', }, innerBlocks ); - return navigationBlock; } diff --git a/packages/edit-navigation/src/store/selectors.js b/packages/edit-navigation/src/store/selectors.js index 2db76196aabc7..2c18cc18fe8cc 100644 --- a/packages/edit-navigation/src/store/selectors.js +++ b/packages/edit-navigation/src/store/selectors.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { invert } from 'lodash'; - /** * WordPress dependencies */ @@ -43,7 +38,7 @@ export const getNavigationPostForMenu = createRegistrySelector( if ( ! hasResolvedNavigationPost( state, menuId ) ) { return null; } - return select( coreStore ).getEntityRecord( + return select( coreStore ).getEditedEntityRecord( NAVIGATION_POST_KIND, NAVIGATION_POST_POST_TYPE, buildNavigationPostId( menuId ) diff --git a/packages/edit-navigation/src/store/transform.js b/packages/edit-navigation/src/store/transform.js index d299b95d8844b..af91abec618a9 100644 --- a/packages/edit-navigation/src/store/transform.js +++ b/packages/edit-navigation/src/store/transform.js @@ -59,6 +59,7 @@ export function blockToMenuItem( return { ...menuItem, ...attributes, + content: attributes.content || '', id: getRecordIdFromBlock( block ), menu_order: blockPosition + 1, menus: menuId, @@ -151,6 +152,7 @@ export function menuItemsToBlocks( menuItems ) { } const menuTree = createDataTree( menuItems ); + return mapMenuItemsToBlocks( menuTree ); } @@ -161,12 +163,10 @@ export function menuItemsToBlocks( menuItems ) { * @return {Object} Object containing innerBlocks and mapping. */ function mapMenuItemsToBlocks( menuItems ) { - let mapping = {}; - // The menuItem should be in menu_order sort order. const sortedItems = sortBy( menuItems, 'menu_order' ); - const innerBlocks = sortedItems.map( ( menuItem ) => { + return sortedItems.map( ( menuItem ) => { if ( menuItem.type === 'block' ) { const [ block ] = parse( menuItem.content.raw ); @@ -182,18 +182,9 @@ function mapMenuItemsToBlocks( menuItems ) { const attributes = menuItemToBlockAttributes( menuItem ); // If there are children recurse to build those nested blocks. - const { - innerBlocks: nestedBlocks = [], // alias to avoid shadowing - mapping: nestedMapping = {}, // alias to avoid shadowing - } = menuItem.children?.length + const nestedBlocks = menuItem.children?.length ? mapMenuItemsToBlocks( menuItem.children ) - : {}; - - // Update parent mapping with nested mapping. - mapping = { - ...mapping, - ...nestedMapping, - }; + : []; // Create a submenu block when there are inner blocks, or just a link // for a standalone item. @@ -202,21 +193,11 @@ function mapMenuItemsToBlocks( menuItems ) { : 'core/navigation-link'; // Create block with nested "innerBlocks". - const block = addRecordIdToBlock( + return addRecordIdToBlock( createBlock( itemBlockName, attributes, nestedBlocks ), - menuItem + menuItem.id ); - - // Create mapping for menuItem -> block - mapping[ menuItem.id ] = block.clientId; - - return block; } ); - - return { - innerBlocks, - mapping, - }; } // A few parameters are using snake case, let's embrace that for convenience: From fa3768cd9c1bd98c056355c17ef465097ba1c8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 17 Sep 2021 16:53:55 +0200 Subject: [PATCH 46/57] Fix unit tests --- .../edit-navigation/src/store/test/actions.js | 151 +----------------- .../src/store/test/resolvers.js | 12 +- .../src/store/test/transform.js | 8 +- 3 files changed, 10 insertions(+), 161 deletions(-) diff --git a/packages/edit-navigation/src/store/test/actions.js b/packages/edit-navigation/src/store/test/actions.js index 32f97e5ce4613..03a556bd57e05 100644 --- a/packages/edit-navigation/src/store/test/actions.js +++ b/packages/edit-navigation/src/store/test/actions.js @@ -1,19 +1,7 @@ -/** - * WordPress dependencies - */ -import apiFetch from '@wordpress/api-fetch'; - -jest.mock( '@wordpress/api-fetch' ); - /** * Internal dependencies */ -import { - createMissingMenuItems, - createPlaceholderMenuItem, - setSelectedMenuId, -} from '../actions'; -import { menuItemsQuery } from '../utils'; +import { setSelectedMenuId } from '../actions'; jest.mock( '../utils', () => { const utils = jest.requireActual( '../utils' ); @@ -22,143 +10,6 @@ jest.mock( '../utils', () => { return utils; } ); -describe( 'createMissingMenuItems', () => { - it( 'creates a missing menu for navigation block', async () => { - const post = { - id: 'navigation-post-1', - slug: 'navigation-post-1', - type: 'page', - meta: { - menuId: 1, - }, - blocks: [ - { - attributes: { showSubmenuIcon: true }, - clientId: 'navigation-block-client-id', - innerBlocks: [ - { - attributes: {}, - clientId: 'navigation-block-client-id', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - { - attributes: {}, - clientId: 'navigation-block-client-id2', - innerBlocks: [], - isValid: true, - name: 'core/navigation-link', - }, - ], - isValid: true, - name: 'core/navigation', - }, - ], - }; - - const menuItemPlaceholder = { - id: 87, - title: { - raw: 'Placeholder', - rendered: 'Placeholder', - }, - }; - - const dispatch = jest - .fn() - .mockReturnValueOnce( {} ) - .mockReturnValueOnce( menuItemPlaceholder ) - .mockReturnValueOnce( { ...menuItemPlaceholder, id: 88 } ); - const registryDispatch = { - __unstableAcquireStoreLock: jest.fn(), - __unstableReleaseStoreLock: jest.fn(), - }; - const registry = { - dispatch: jest.fn( () => registryDispatch ), - select: jest.fn( () => ( {} ) ), - resolveSelect: jest.fn( () => ( { getMenuItems: () => [] } ) ), - }; - - await createMissingMenuItems( post )( { registry, dispatch } ); - } ); -} ); - -describe( 'createPlaceholderMenuItem', () => { - beforeEach( async () => { - apiFetch.mockReset(); - } ); - - it( 'creates a missing menu for navigation link block', async () => { - const menuItemPlaceholder = { - id: 102, - title: { - raw: 'Placeholder', - rendered: 'Placeholder', - }, - }; - const menuItems = [ - { - id: 100, - title: { - raw: 'wp.com', - rendered: 'wp.com', - }, - url: 'http://wp.com', - menu_order: 1, - menus: 1, - }, - { - id: 101, - title: { - raw: 'wp.org', - rendered: 'wp.org', - }, - url: 'http://wp.org', - menu_order: 2, - menus: 1, - }, - ]; - - // Provide response - apiFetch.mockImplementation( () => menuItemPlaceholder ); - - const registryDispatch = { - receiveEntityRecords: jest.fn(), - }; - const registry = { - dispatch: jest.fn( () => registryDispatch ), - select: jest.fn( () => ( { getMenuItems: () => menuItems } ) ), - resolveSelect: jest.fn( () => ( { - getMenuItems: () => menuItems, - } ) ), - }; - const dispatch = jest.fn( () => menuItems ); - - await createPlaceholderMenuItem( - { - id: 101, - title: { - raw: 'wp.org', - rendered: 'wp.org', - }, - url: 'http://wp.org', - menu_order: 2, - menus: 1, - }, - 199 - )( { registry, dispatch } ); - - expect( registryDispatch.receiveEntityRecords ).toHaveBeenCalledWith( - 'root', - 'menuItem', - [ ...menuItems, menuItemPlaceholder ], - menuItemsQuery( 199 ), - false - ); - } ); -} ); - describe( 'setSelectedMenuId', () => { it( 'should return the SET_SELECTED_MENU_ID action', () => { const menuId = 1; diff --git a/packages/edit-navigation/src/store/test/resolvers.js b/packages/edit-navigation/src/store/test/resolvers.js index d7a042694ed7d..f59072273defc 100644 --- a/packages/edit-navigation/src/store/test/resolvers.js +++ b/packages/edit-navigation/src/store/test/resolvers.js @@ -143,6 +143,7 @@ describe( 'getNavigationPostForMenu', () => { innerBlocks: [ { attributes: { + __internalRecordId: 100, label: 'wp.com', url: 'http://wp.com', className: 'menu classes', @@ -157,6 +158,7 @@ describe( 'getNavigationPostForMenu', () => { }, { attributes: { + __internalRecordId: 101, label: 'wp.org', url: 'http://wp.org', opensInNewTab: true, @@ -168,6 +170,7 @@ describe( 'getNavigationPostForMenu', () => { }, { attributes: { + __internalRecordId: 102, label: 'My Example Page', url: '/my-example-page/', opensInNewTab: true, @@ -188,7 +191,7 @@ describe( 'getNavigationPostForMenu', () => { }, }; - expect( generator.next().value ).toEqual( + expect( generator.next( menuItems ).value ).toEqual( dispatch( 'core', 'receiveEntityRecords', @@ -274,13 +277,8 @@ describe( 'getNavigationPostForMenu', () => { }, ]; - // // Gen step: yield 'SET_MENU_ITEM_TO_CLIENT_ID_MAPPING', - // By feeding `menuItems` to the generator this will overload the **result** of - // the call to yield resolveMenuItems( menuId ); - generator.next( menuItems ); - // Gen step: yield persistPost - const persistPostAction = generator.next().value; + const persistPostAction = generator.next( menuItems ).value; // Get the core/navigation-link blocks from the generated core/navigation block innerBlocks. const blockAttrs = persistPostAction.args[ 2 ].blocks[ 0 ].innerBlocks.map( diff --git a/packages/edit-navigation/src/store/test/transform.js b/packages/edit-navigation/src/store/test/transform.js index 816eeaffe1820..af80d95ca84ee 100644 --- a/packages/edit-navigation/src/store/test/transform.js +++ b/packages/edit-navigation/src/store/test/transform.js @@ -25,7 +25,7 @@ jest.mock( '@wordpress/blocks', () => { describe( 'converting menu items to blocks', () => { it( 'converts an flat structure of menu item objects to blocks', () => { - const { innerBlocks: actual } = menuItemsToBlocks( [ + const actual = menuItemsToBlocks( [ { id: 1, title: { @@ -77,7 +77,7 @@ describe( 'converting menu items to blocks', () => { } ); it( 'converts an nested structure of menu item objects to nested blocks', () => { - const { innerBlocks: actual } = menuItemsToBlocks( [ + const actual = menuItemsToBlocks( [ { id: 1, title: { @@ -238,7 +238,7 @@ describe( 'converting menu items to blocks', () => { } ); it( 'respects menu order when converting to blocks', () => { - const { innerBlocks: actual } = menuItemsToBlocks( [ + const actual = menuItemsToBlocks( [ { id: 1, title: { @@ -366,7 +366,7 @@ describe( 'converting menu items to blocks', () => { } ); it( 'returns an empty array when menu items argument is an empty array', () => { - const { innerBlocks: actual } = menuItemsToBlocks( [] ); + const actual = menuItemsToBlocks( [] ); expect( actual ).toEqual( [] ); } ); } ); From 33d45b04fc84daa3dc91e7807c6c117bf64ac64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 17 Sep 2021 16:55:53 +0200 Subject: [PATCH 47/57] Translate Menu item title --- packages/edit-navigation/src/store/actions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index c5bd9544bfea4..ad97f54b83006 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -121,7 +121,7 @@ const batchInsertPlaceholderMenuItems = ( annotatedBlocks ) => async ( { .filter( ( { block } ) => ! getRecordIdFromBlock( block ) ) .map( ( { block } ) => async ( { saveEntityRecord } ) => { const record = await saveEntityRecord( 'root', 'menuItem', { - title: 'Menu item', + title: __( 'Menu item' ), url: '#placeholder', menu_order: 1, } ); From 77f33163ba35e110c0809d2c025913e78910f6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 17 Sep 2021 17:01:39 +0200 Subject: [PATCH 48/57] Remove obsolete imports --- packages/edit-navigation/src/hooks/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/edit-navigation/src/hooks/index.js b/packages/edit-navigation/src/hooks/index.js index 2e5b4c072dd17..45b10d609a457 100644 --- a/packages/edit-navigation/src/hooks/index.js +++ b/packages/edit-navigation/src/hooks/index.js @@ -10,7 +10,6 @@ export const IsMenuNameControlFocusedContext = createContext(); export { default as useMenuEntity } from './use-menu-entity'; export { default as useMenuEntityProp } from './use-menu-entity-prop'; export { default as useNavigationEditor } from './use-navigation-editor'; -export { default as useNavigationBlockEditor } from './use-navigation-block-editor'; export { default as useMenuNotifications } from './use-menu-notifications'; export { default as useSelectedMenuId } from './use-selected-menu-id'; export { default as useMenuLocations } from './use-menu-locations'; From 89182df8b8a14d565612244f9591e7cc7fe47599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 20 Sep 2021 15:51:12 +0200 Subject: [PATCH 49/57] Switch to using immutable data structures --- packages/edit-navigation/src/store/actions.js | 220 ++++++++++++------ .../edit-navigation/src/store/transform.js | 14 +- 2 files changed, 152 insertions(+), 82 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index ad97f54b83006..062fc72b06412 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -14,7 +14,7 @@ import { store as coreDataStore } from '@wordpress/core-data'; * Internal dependencies */ import { STORE_NAME } from './constants'; -import { getRecordIdFromBlock } from './utils'; +import { addRecordIdToBlock, getRecordIdFromBlock } from './utils'; import { blockToMenuItem, menuItemToBlockAttributes } from './transform'; /** @@ -47,45 +47,24 @@ export const saveNavigationPost = ( post ) => async ( { } ); try { const menuId = post.meta.menuId; - - await registry - .dispatch( coreDataStore ) - .saveEditedEntityRecord( 'root', 'menu', menuId ); - - const error = registry - .select( coreDataStore ) - .getLastEntitySaveError( 'root', 'menu', menuId ); - - if ( error ) { - throw new Error( error.message ); - } - - // Make sure all the existing menu items are available before proceeding - const oldMenuItems = await registry - .resolveSelect( coreDataStore ) - .getMenuItems( { menus: post.meta.menuId, per_page: -1 } ); - - const annotatedBlocks = blocksTreeToFlatList( - post.blocks[ 0 ] - ).filter( ( { block: { name } } ) => isSupportedBlock( name ) ); - - await dispatch( batchInsertPlaceholderMenuItems( annotatedBlocks ) ); - await dispatch( batchUpdateMenuItems( annotatedBlocks, menuId ) ); - - // Delete menu items - const deletedIds = difference( - oldMenuItems.map( ( { id } ) => id ), - annotatedBlocks.map( ( { block } ) => - getRecordIdFromBlock( block ) - ) + await dispatch( saveEditedMenu( menuId ) ); + const updatedBlocks = await dispatch( + batchSaveMenuItems( post.blocks[ 0 ], menuId ) ); - await dispatch( batchDeleteMenuItems( deletedIds ) ); // Clear "stub" navigation post edits to avoid a false "dirty" state. registry .dispatch( coreDataStore ) .receiveEntityRecords( 'root', 'postType', post, undefined ); + const updatedPost = { + ...post, + blocks: [ updatedBlocks ], + }; + registry + .dispatch( coreDataStore ) + .receiveEntityRecords( 'root', 'postType', updatedPost, undefined ); + registry .dispatch( noticesStore ) .createSuccessNotice( __( 'Navigation saved.' ), { @@ -107,75 +86,151 @@ export const saveNavigationPost = ( post ) => async ( { } }; +const saveEditedMenu = ( menuId ) => async ( { registry } ) => { + await registry + .dispatch( coreDataStore ) + .saveEditedEntityRecord( 'root', 'menu', menuId ); + + const error = registry + .select( coreDataStore ) + .getLastEntitySaveError( 'root', 'menu', menuId ); + + if ( error ) { + throw new Error( error.message ); + } +}; + +const batchSaveMenuItems = ( navigationBlock, menuId ) => async ( { + dispatch, + registry, +} ) => { + // Make sure all the existing menu items are available before proceeding + const oldMenuItems = await registry + .resolveSelect( coreDataStore ) + .getMenuItems( { menus: menuId, per_page: -1 } ); + + // Insert placeholders for new menu items to have an ID to work with. + // We need that in case these new items have any children. If so, + // we need to provide a parent id that we don't have yet. + const navBlockWithRecordIds = await dispatch( + batchInsertPlaceholderMenuItems( navigationBlock ) + ); + + // Update menu items. This is separate from deleting, because there + // are no consistency guarantees and we don't want to delete something + // that was a parent node before another node takes it place. + const navBlockAfterUpdates = await dispatch( + batchUpdateMenuItems( navBlockWithRecordIds, menuId ) + ); + + // Delete menu items + const deletedIds = difference( + oldMenuItems.map( ( { id } ) => id ), + blocksTreeToList( navBlockAfterUpdates ).map( getRecordIdFromBlock ) + ); + await dispatch( batchDeleteMenuItems( deletedIds ) ); + + return navBlockAfterUpdates; +}; + /** * 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[]} annotatedBlocks Blocks to create menu items for. + * @param {Object} navigationBlock Blocks to create menu items for. * @return {Function} An action creator */ -const batchInsertPlaceholderMenuItems = ( annotatedBlocks ) => async ( { +const batchInsertPlaceholderMenuItems = ( navigationBlock ) => async ( { registry, } ) => { - const tasks = annotatedBlocks - .filter( ( { block } ) => ! getRecordIdFromBlock( block ) ) - .map( ( { block } ) => async ( { saveEntityRecord } ) => { - const record = await saveEntityRecord( 'root', 'menuItem', { - title: __( 'Menu item' ), - url: '#placeholder', - menu_order: 1, - } ); - block.attributes.__internalRecordId = record.id; - return record; - } ); + const blocksWithoutRecordId = blocksTreeToList( navigationBlock ) + .filter( isSupportedBlock ) + .filter( ( block ) => ! getRecordIdFromBlock( block ) ); - return await registry + const tasks = blocksWithoutRecordId.map( () => ( { saveEntityRecord } ) => + saveEntityRecord( 'root', 'menuItem', { + title: __( 'Menu item' ), + url: '#placeholder', + menu_order: 1, + } ) + ); + + const results = await registry .dispatch( coreDataStore ) .__experimentalBatch( tasks ); + + // Return an updated navigation block with all the IDs in + const blockToResult = new Map( zip( blocksWithoutRecordId, results ) ); + return mapBlocksTree( navigationBlock, ( block ) => { + if ( ! blockToResult.has( block ) ) { + return block; + } + return addRecordIdToBlock( block, blockToResult.get( block ).id ); + } ); }; -const batchUpdateMenuItems = ( annotatedBlocks, menuId ) => async ( { +const batchUpdateMenuItems = ( navigationBlock, menuId ) => async ( { registry, dispatch, } ) => { - const desiredMenuItems = annotatedBlocks.map( - ( { block, parentBlock }, idx ) => + const updatedMenuItems = blocksTreeToAnnotatedList( navigationBlock ) + // Filter out unsupported blocks + .filter( ( { block } ) => isSupportedBlock( block ) ) + // Transform the blocks into menu items + .map( ( { block, parentBlock, childIndex } ) => blockToMenuItem( block, registry .select( coreDataStore ) .getMenuItem( getRecordIdFromBlock( block ) ), getRecordIdFromBlock( parentBlock ), - idx, + childIndex, menuId ) + ) + // Filter out menu items without any edits + .filter( ( menuItem ) => + dispatch( applyEdits( menuItem.id, menuItem ) ) + ); + + // Map the edited menu items to batch tasks + const tasks = updatedMenuItems.map( + ( menuItem ) => ( { saveEditedEntityRecord } ) => + saveEditedEntityRecord( 'root', 'menuItem', menuItem.id ) ); - const updateBatch = zip( desiredMenuItems, annotatedBlocks ) - .filter( ( [ menuItem ] ) => - dispatch( applyEdits( menuItem.id, menuItem ) ) - ) - .map( - ( [ menuItem, block ] ) => async ( { saveEditedEntityRecord } ) => { - await saveEditedEntityRecord( 'root', 'menuItem', menuItem.id ); - // @TODO failures should be thrown in core-data - const failure = registry - .select( coreDataStore ) - .getLastEntitySaveError( 'root', 'menuItem', menuItem.id ); - if ( failure ) { - throw new Error( failure ); - } - block.attributes = menuItemToBlockAttributes( - registry.select( coreDataStore ).getMenuItem( menuItem.id ) - ); - } + await registry.dispatch( coreDataStore ).__experimentalBatch( tasks ); + + // Throw on failure. @TODO failures should be thrown in core-data + updatedMenuItems.forEach( ( menuItem ) => { + const failure = registry + .select( coreDataStore ) + .getLastEntitySaveError( 'root', 'menuItem', menuItem.id ); + if ( failure ) { + throw new Error( failure.message ); + } + } ); + + // Return an updated navigation block reflecting the changes persisted in the batch update. + return mapBlocksTree( navigationBlock, ( block ) => { + if ( ! isSupportedBlock( block ) ) { + return block; + } + const updatedMenuItem = registry + .select( coreDataStore ) + .getMenuItem( getRecordIdFromBlock( block ) ); + + return addRecordIdToBlock( + { + ...block, + attributes: menuItemToBlockAttributes( updatedMenuItem ), + }, + updatedMenuItem.id ); - return await registry - .dispatch( coreDataStore ) - .__experimentalBatch( updateBatch ); + } ); }; -const isSupportedBlock = ( name ) => +const isSupportedBlock = ( { name } ) => [ 'core/navigation-link', 'core/navigation-submenu' ].includes( name ); const applyEdits = ( id, edits ) => ( { registry } ) => { @@ -217,14 +272,29 @@ const batchDeleteMenuItems = ( deletedIds ) => async ( { registry } ) => { * @return {Object} A flat list of blocks, annotated by their index and parent ID, consisting * of all the input blocks and all the inner blocks in the tree. */ -function blocksTreeToFlatList( parentBlock ) { +function blocksTreeToAnnotatedList( parentBlock ) { return ( parentBlock.innerBlocks || [] ).flatMap( ( innerBlock, index ) => [ { block: innerBlock, parentBlock, childIndex: index } ].concat( - blocksTreeToFlatList( innerBlock ) + blocksTreeToAnnotatedList( innerBlock ) ) ); } +function blocksTreeToList( parentBlock ) { + return blocksTreeToAnnotatedList( parentBlock ).map( + ( { block } ) => block + ); +} + +function mapBlocksTree( block, callback, parentBlock = null, idx = 0 ) { + return { + ...callback( block, parentBlock, idx ), + innerBlocks: ( block.innerBlocks || [] ).map( ( innerBlock, index ) => + mapBlocksTree( innerBlock, callback, block, index ) + ), + }; +} + /** * Returns an action object used to open/close the inserter. * diff --git a/packages/edit-navigation/src/store/transform.js b/packages/edit-navigation/src/store/transform.js index af91abec618a9..b569b2654f15a 100644 --- a/packages/edit-navigation/src/store/transform.js +++ b/packages/edit-navigation/src/store/transform.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { get, omit, sortBy } from 'lodash'; +import { get, omit, sortBy, zip } from 'lodash'; /** * WordPress dependencies @@ -166,7 +166,7 @@ function mapMenuItemsToBlocks( menuItems ) { // The menuItem should be in menu_order sort order. const sortedItems = sortBy( menuItems, 'menu_order' ); - return sortedItems.map( ( menuItem ) => { + const blocks = sortedItems.map( ( menuItem ) => { if ( menuItem.type === 'block' ) { const [ block ] = parse( menuItem.content.raw ); @@ -178,7 +178,6 @@ function mapMenuItemsToBlocks( menuItems ) { return block; } - const attributes = menuItemToBlockAttributes( menuItem ); // If there are children recurse to build those nested blocks. @@ -193,11 +192,12 @@ function mapMenuItemsToBlocks( menuItems ) { : 'core/navigation-link'; // Create block with nested "innerBlocks". - return addRecordIdToBlock( - createBlock( itemBlockName, attributes, nestedBlocks ), - menuItem.id - ); + return createBlock( itemBlockName, attributes, nestedBlocks ); } ); + + return zip( blocks, sortedItems ).map( ( [ block, menuItem ] ) => + addRecordIdToBlock( block, menuItem.id ) + ); } // A few parameters are using snake case, let's embrace that for convenience: From fb90bd6e95736dd5d9bd9bb355eeb7e642f1c3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 20 Sep 2021 16:07:34 +0200 Subject: [PATCH 50/57] Add doc blocks --- packages/edit-navigation/src/store/actions.js | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 062fc72b06412..ef6beff2cf697 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -47,7 +47,21 @@ export const saveNavigationPost = ( post ) => async ( { } ); try { const menuId = post.meta.menuId; - await dispatch( saveEditedMenu( menuId ) ); + + // Save menu + await registry + .dispatch( coreDataStore ) + .saveEditedEntityRecord( 'root', 'menu', menuId ); + + const error = registry + .select( coreDataStore ) + .getLastEntitySaveError( 'root', 'menu', menuId ); + + if ( error ) { + throw new Error( error.message ); + } + + // Save menu items const updatedBlocks = await dispatch( batchSaveMenuItems( post.blocks[ 0 ], menuId ) ); @@ -86,20 +100,14 @@ export const saveNavigationPost = ( post ) => async ( { } }; -const saveEditedMenu = ( menuId ) => async ( { registry } ) => { - await registry - .dispatch( coreDataStore ) - .saveEditedEntityRecord( 'root', 'menu', menuId ); - - const error = registry - .select( coreDataStore ) - .getLastEntitySaveError( 'root', 'menu', menuId ); - - if ( error ) { - throw new Error( error.message ); - } -}; - +/** + * Executes appropriate insert, update, and delete operations to turn the current + * menu (with id=menuId) into one represented by the passed navigation block. + * + * @param {Object} navigationBlock The navigation block representing the desired state of the menu. + * @param {number} menuId Menu Id to process. + * @return {Function} An action creator + */ const batchSaveMenuItems = ( navigationBlock, menuId ) => async ( { dispatch, registry, @@ -135,9 +143,9 @@ const batchSaveMenuItems = ( navigationBlock, menuId ) => async ( { /** * 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. + * Sends a batch request with one POST /wp/v2/menu-items for every created menu item. * - * @param {Object} navigationBlock Blocks to create menu items for. + * @param {Object} navigationBlock A navigation block to find created menu items in. * @return {Function} An action creator */ const batchInsertPlaceholderMenuItems = ( navigationBlock ) => async ( { @@ -169,6 +177,14 @@ const batchInsertPlaceholderMenuItems = ( navigationBlock ) => async ( { } ); }; +/** + * Updates every menu item where a related block has changed. + * Sends a batch request with one PUT /wp/v2/menu-items for every updated menu item. + * + * @param {Object} navigationBlock A navigation block to find updated menu items in. + * @param {number} menuId Menu ID. + * @return {Function} An action creator + */ const batchUpdateMenuItems = ( navigationBlock, menuId ) => async ( { registry, dispatch, @@ -230,8 +246,16 @@ const batchUpdateMenuItems = ( navigationBlock, menuId ) => async ( { } ); }; -const isSupportedBlock = ( { name } ) => - [ 'core/navigation-link', 'core/navigation-submenu' ].includes( name ); +/** + * Checks if a given block should be persisted as a menu item. + * + * @param {Object} block Block to check. + * @return {boolean} True if a given block should be persisted as a menu item, false otherwise. + */ +const isSupportedBlock = ( block ) => + [ 'core/navigation-link', 'core/navigation-submenu' ].includes( + block.name + ); const applyEdits = ( id, edits ) => ( { registry } ) => { // Update an existing entity record. @@ -246,6 +270,13 @@ const applyEdits = ( id, edits ) => ( { registry } ) => { .hasEditsForEntityRecord( 'root', 'menuItem', id ); }; +/** + * Deletes multiple menu items. + * Sends a batch request with one DELETE /wp/v2/menu-items for every deleted menu item. + * + * @param {Object} deletedIds A list of menu item ids to delete + * @return {Function} An action creator + */ const batchDeleteMenuItems = ( deletedIds ) => async ( { registry } ) => { const deleteBatch = deletedIds.map( ( id ) => async ( { deleteEntityRecord } ) => { @@ -266,7 +297,8 @@ const batchDeleteMenuItems = ( deletedIds ) => async ( { registry } ) => { }; /** - * Turns a recursive list of blocks into a flat list of blocks. + * Turns a recursive list of blocks into a flat list of blocks annotated with + * their child index and parent block. * * @param {Object} parentBlock A parent block to flatten * @return {Object} A flat list of blocks, annotated by their index and parent ID, consisting @@ -286,6 +318,15 @@ function blocksTreeToList( parentBlock ) { ); } +/** + * Maps one tree of blocks into another tree by invoking a callback on every node. + * + * @param {Object} block The root of the mapped tree. + * @param {Function} callback The callback to invoke. + * @param {Object} parentBlock Internal. The current parent block. + * @param {number} idx Internal. The current child index. + * @return {Object} A mapped tree. + */ function mapBlocksTree( block, callback, parentBlock = null, idx = 0 ) { return { ...callback( block, parentBlock, idx ), From fa30c0fd8853a90939592b934cce080b58585413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 20 Sep 2021 18:23:00 +0200 Subject: [PATCH 51/57] Add a test for default 'menu-item-type' --- ...ss-rest-nav-menu-items-controller-test.php | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/phpunit/class-rest-nav-menu-items-controller-test.php b/phpunit/class-rest-nav-menu-items-controller-test.php index 77b629bbbc75d..82fbf3e2826ff 100644 --- a/phpunit/class-rest-nav-menu-items-controller-test.php +++ b/phpunit/class-rest-nav-menu-items-controller-test.php @@ -544,6 +544,42 @@ public function test_update_item() { $this->assertEquals( $params['xfn'], explode( ' ', $menu_item->xfn ) ); } + /** + * + */ + public function test_update_item_preserves_type() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/__experimental/menu-items/%d', $this->menu_item_id ) ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + $params = array( + 'status' => 'draft', + 'type' => 'block', + 'title' => 'TEST', + 'content' => '

Block content

', + ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $this->check_update_menu_item_response( $response ); + $new_data = $response->get_data(); + $this->assertEquals( 'block', $new_data['type'] ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/__experimental/menu-items/%d', $this->menu_item_id ) ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + $params = array( + 'status' => 'draft', + 'title' => 'TEST2', + 'content' => '

Block content

', + ); + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $this->check_update_menu_item_response( $response ); + $new_data = $response->get_data(); + + // The type shouldn't change just because it was missing from request args + $this->assertEquals( 'block', $new_data['type'] ); + } + /** * */ From 9efdb5762c6200b737c0a68d690b8b31cafcdd4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 20 Sep 2021 18:23:26 +0200 Subject: [PATCH 52/57] Restore newline --- lib/class-wp-rest-menu-items-controller.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/class-wp-rest-menu-items-controller.php b/lib/class-wp-rest-menu-items-controller.php index f8b17f55eca15..84f0c48d42d49 100644 --- a/lib/class-wp-rest-menu-items-controller.php +++ b/lib/class-wp-rest-menu-items-controller.php @@ -442,6 +442,7 @@ protected function prepare_item_for_database( $request ) { ); $schema = $this->get_item_schema(); + foreach ( $mapping as $original => $api_request ) { if ( ! empty( $schema['properties'][ $api_request ] ) && isset( $request[ $api_request ] ) ) { $check = rest_validate_value_from_schema( $request[ $api_request ], $schema['properties'][ $api_request ] ); From de2a3d4a2ed9797f553ef47fd146a2bdfe92103d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 20 Sep 2021 19:20:30 +0200 Subject: [PATCH 53/57] Lint --- phpunit/class-rest-nav-menu-items-controller-test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit/class-rest-nav-menu-items-controller-test.php b/phpunit/class-rest-nav-menu-items-controller-test.php index 82fbf3e2826ff..b840aedb5c4e6 100644 --- a/phpunit/class-rest-nav-menu-items-controller-test.php +++ b/phpunit/class-rest-nav-menu-items-controller-test.php @@ -576,7 +576,7 @@ public function test_update_item_preserves_type() { $this->check_update_menu_item_response( $response ); $new_data = $response->get_data(); - // The type shouldn't change just because it was missing from request args + // The type shouldn't change just because it was missing from request args. $this->assertEquals( 'block', $new_data['type'] ); } From 56b5579217fb29e6775688c1556b7a944c699e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 21 Sep 2021 11:12:12 +0200 Subject: [PATCH 54/57] Use isBlockSupportedInNav in blockToMenuItem --- packages/edit-navigation/src/store/actions.js | 21 +++++-------------- .../edit-navigation/src/store/transform.js | 8 +++++-- packages/edit-navigation/src/store/utils.js | 11 ++++++++++ 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index ef6beff2cf697..a19650e3f8329 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { zip, difference } from 'lodash'; +import { difference, zip } from 'lodash'; /** * WordPress dependencies @@ -14,7 +14,7 @@ import { store as coreDataStore } from '@wordpress/core-data'; * Internal dependencies */ import { STORE_NAME } from './constants'; -import { addRecordIdToBlock, getRecordIdFromBlock } from './utils'; +import { addRecordIdToBlock, getRecordIdFromBlock, isBlockSupportedInNav } from './utils'; import { blockToMenuItem, menuItemToBlockAttributes } from './transform'; /** @@ -152,7 +152,7 @@ const batchInsertPlaceholderMenuItems = ( navigationBlock ) => async ( { registry, } ) => { const blocksWithoutRecordId = blocksTreeToList( navigationBlock ) - .filter( isSupportedBlock ) + .filter( isBlockSupportedInNav ) .filter( ( block ) => ! getRecordIdFromBlock( block ) ); const tasks = blocksWithoutRecordId.map( () => ( { saveEntityRecord } ) => @@ -191,7 +191,7 @@ const batchUpdateMenuItems = ( navigationBlock, menuId ) => async ( { } ) => { const updatedMenuItems = blocksTreeToAnnotatedList( navigationBlock ) // Filter out unsupported blocks - .filter( ( { block } ) => isSupportedBlock( block ) ) + .filter( ( { block } ) => isBlockSupportedInNav( block ) ) // Transform the blocks into menu items .map( ( { block, parentBlock, childIndex } ) => blockToMenuItem( @@ -229,7 +229,7 @@ const batchUpdateMenuItems = ( navigationBlock, menuId ) => async ( { // Return an updated navigation block reflecting the changes persisted in the batch update. return mapBlocksTree( navigationBlock, ( block ) => { - if ( ! isSupportedBlock( block ) ) { + if ( ! isBlockSupportedInNav( block ) ) { return block; } const updatedMenuItem = registry @@ -246,17 +246,6 @@ const batchUpdateMenuItems = ( navigationBlock, menuId ) => async ( { } ); }; -/** - * Checks if a given block should be persisted as a menu item. - * - * @param {Object} block Block to check. - * @return {boolean} True if a given block should be persisted as a menu item, false otherwise. - */ -const isSupportedBlock = ( block ) => - [ 'core/navigation-link', 'core/navigation-submenu' ].includes( - block.name - ); - const applyEdits = ( id, edits ) => ( { registry } ) => { // Update an existing entity record. registry diff --git a/packages/edit-navigation/src/store/transform.js b/packages/edit-navigation/src/store/transform.js index b569b2654f15a..5d07601b9412d 100644 --- a/packages/edit-navigation/src/store/transform.js +++ b/packages/edit-navigation/src/store/transform.js @@ -12,7 +12,11 @@ import { serialize, createBlock, parse } from '@wordpress/blocks'; * Internal dependencies */ import { NEW_TAB_TARGET_ATTRIBUTE } from '../constants'; -import { addRecordIdToBlock, getRecordIdFromBlock } from './utils'; +import { + addRecordIdToBlock, + getRecordIdFromBlock, + isBlockSupportedInNav, +} from './utils'; /** * A WP nav_menu_item object. @@ -47,7 +51,7 @@ export function blockToMenuItem( let attributes; - if ( block.name === 'core/navigation-link' ) { + if ( isBlockSupportedInNav( block ) ) { attributes = blockAttributesToMenuItem( block.attributes ); } else { attributes = { diff --git a/packages/edit-navigation/src/store/utils.js b/packages/edit-navigation/src/store/utils.js index f5d25c65c88fb..37a63b717d1f6 100644 --- a/packages/edit-navigation/src/store/utils.js +++ b/packages/edit-navigation/src/store/utils.js @@ -69,3 +69,14 @@ export function addRecordIdToBlock( block, recordId ) { }, }; } + +/** + * Checks if a given block should be persisted as a menu item. + * + * @param {Object} block Block to check. + * @return {boolean} True if a given block should be persisted as a menu item, false otherwise. + */ +export const isBlockSupportedInNav = ( block ) => + [ 'core/navigation-link', 'core/navigation-submenu' ].includes( + block.name + ); From d62f3bf3fbd7493073d07a8a2962601b8f98bf13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 21 Sep 2021 11:12:28 +0200 Subject: [PATCH 55/57] Lint --- packages/edit-navigation/src/store/actions.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index a19650e3f8329..66797fede837b 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -14,7 +14,11 @@ import { store as coreDataStore } from '@wordpress/core-data'; * Internal dependencies */ import { STORE_NAME } from './constants'; -import { addRecordIdToBlock, getRecordIdFromBlock, isBlockSupportedInNav } from './utils'; +import { + addRecordIdToBlock, + getRecordIdFromBlock, + isBlockSupportedInNav, +} from './utils'; import { blockToMenuItem, menuItemToBlockAttributes } from './transform'; /** From 005fbfdff7f618891d8b7a7f0c749d10a7327eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 21 Sep 2021 11:13:28 +0200 Subject: [PATCH 56/57] Code style: Collapse two filters into one --- packages/edit-navigation/src/store/actions.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 66797fede837b..8c391f96c1252 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -155,9 +155,10 @@ const batchSaveMenuItems = ( navigationBlock, menuId ) => async ( { const batchInsertPlaceholderMenuItems = ( navigationBlock ) => async ( { registry, } ) => { - const blocksWithoutRecordId = blocksTreeToList( navigationBlock ) - .filter( isBlockSupportedInNav ) - .filter( ( block ) => ! getRecordIdFromBlock( block ) ); + const blocksWithoutRecordId = blocksTreeToList( navigationBlock ).filter( + ( block ) => + isBlockSupportedInNav( block ) && ! getRecordIdFromBlock( block ) + ); const tasks = blocksWithoutRecordId.map( () => ( { saveEntityRecord } ) => saveEntityRecord( 'root', 'menuItem', { From 4ff8b162cac665842fe22b4bdd85782a6993ebc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 21 Sep 2021 11:15:17 +0200 Subject: [PATCH 57/57] Inline applyEdits --- packages/edit-navigation/src/store/actions.js | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/edit-navigation/src/store/actions.js b/packages/edit-navigation/src/store/actions.js index 8c391f96c1252..e77e82101c462 100644 --- a/packages/edit-navigation/src/store/actions.js +++ b/packages/edit-navigation/src/store/actions.js @@ -192,7 +192,6 @@ const batchInsertPlaceholderMenuItems = ( navigationBlock ) => async ( { */ const batchUpdateMenuItems = ( navigationBlock, menuId ) => async ( { registry, - dispatch, } ) => { const updatedMenuItems = blocksTreeToAnnotatedList( navigationBlock ) // Filter out unsupported blocks @@ -210,9 +209,18 @@ const batchUpdateMenuItems = ( navigationBlock, menuId ) => async ( { ) ) // Filter out menu items without any edits - .filter( ( menuItem ) => - dispatch( applyEdits( menuItem.id, menuItem ) ) - ); + .filter( ( menuItem ) => { + // Update an existing entity record. + registry + .dispatch( coreDataStore ) + .editEntityRecord( 'root', 'menuItem', menuItem.id, menuItem, { + undoIgnore: true, + } ); + + return registry + .select( coreDataStore ) + .hasEditsForEntityRecord( 'root', 'menuItem', menuItem.id ); + } ); // Map the edited menu items to batch tasks const tasks = updatedMenuItems.map( @@ -251,19 +259,6 @@ const batchUpdateMenuItems = ( navigationBlock, menuId ) => async ( { } ); }; -const applyEdits = ( id, edits ) => ( { registry } ) => { - // Update an existing entity record. - registry - .dispatch( coreDataStore ) - .editEntityRecord( 'root', 'menuItem', id, edits, { - undoIgnore: true, - } ); - - return registry - .select( coreDataStore ) - .hasEditsForEntityRecord( 'root', 'menuItem', id ); -}; - /** * Deletes multiple menu items. * Sends a batch request with one DELETE /wp/v2/menu-items for every deleted menu item.