-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Navigation screen] Atomic save using customizer API endpoint (#22603)
* Squash * Refactor useNavigationBlocks * First version of functional batch saving * Call receiveEntityRecords with proper query * Rename /save-hierarchy to /batch * Restore the original version of create_item function * Call proper hooks and actions during batch delete and update * Cleanup batch processing code * Remove MySQL transaction for now * Add actions * Clean up naming, add a few comments * Add more documentation * sort menu items received from the API * Simplify validate functions signatures * Restore the previous version of prepare_item_for_database * Formatting * Formatting * Remove the Operation abstraction * Formatting * Remove additional input argument, use just request * Formatting * input->request * Provide information to the client about the specific input that caused WP_Error * Clean pass through phpcs * Clean pass through existing unit tests * Add initial unit test * Add a few more tests * Use the existing customizer endpoint for batch saving of menu items * Basic batch save * Revert PHP changes * Add Nonce endpoint, simplify the batch save handler * Properly fetch nonce * Simplify batchSave even further * Silence eslint in uuidv4() * Update comment in WP_Rest_Customizer_Nonces endpoint * Lint * Simplify PromiseQueue * Unshift -> shift * Correctly send information about deleted menu items * Keep track of deleted menu items in a hacky way * Update comment * Update comment * Update comments * Whitespace * Update comments and simplify * Fix re-appearing deleted menu items * Use uniq() to de-duplicate items returned from select() - due to a bug it duplicates {n} results where n is the amount of the deleted items. The underlying state is intact and we should address the root cause in a separate PR. * Remove uniq and filter * Add permissions_check to the nonce endpoint * Lint
- Loading branch information
Showing
7 changed files
with
390 additions
and
105 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
<?php | ||
/** | ||
* WP_Rest_Customizer_Nonces class. | ||
* | ||
* @package gutenberg | ||
*/ | ||
|
||
/** | ||
* Class that returns the customizer "save" nonce that's required for the | ||
* batch save operation using the customizer API endpoint. | ||
*/ | ||
class WP_Rest_Customizer_Nonces extends WP_REST_Controller { | ||
|
||
/** | ||
* Constructor. | ||
*/ | ||
public function __construct() { | ||
$this->namespace = '__experimental'; | ||
$this->rest_base = 'customizer-nonces'; | ||
} | ||
|
||
/** | ||
* Registers the necessary REST API routes. | ||
* | ||
* @access public | ||
*/ | ||
public function register_routes() { | ||
register_rest_route( | ||
$this->namespace, | ||
'/' . $this->rest_base . '/get-save-nonce', | ||
array( | ||
array( | ||
'methods' => WP_REST_Server::READABLE, | ||
'callback' => array( $this, 'get_save_nonce' ), | ||
'permission_callback' => array( $this, 'permissions_check' ), | ||
'args' => $this->get_collection_params(), | ||
), | ||
'schema' => array( $this, 'get_public_item_schema' ), | ||
) | ||
); | ||
} | ||
|
||
/** | ||
* Checks if a given request has access to read menu items if they have access to edit them. | ||
* | ||
* @param WP_REST_Request $request Full details about the request. | ||
* @return true|WP_Error True if the request has read access, WP_Error object otherwise. | ||
*/ | ||
public function permissions_check( $request ) { | ||
$post_type = get_post_type_object( 'nav_menu_item' ); | ||
if ( ! current_user_can( $post_type->cap->edit_posts ) ) { | ||
return new WP_Error( 'rest_forbidden_context', __( 'Sorry, you are not allowed to edit posts in this post type.', 'gutenberg' ), array( 'status' => rest_authorization_required_code() ) ); | ||
} | ||
return true; | ||
} | ||
|
||
/** | ||
* Returns the nonce required to request the customizer API endpoint. | ||
* | ||
* @access public | ||
*/ | ||
public function get_save_nonce() { | ||
require_once ABSPATH . 'wp-includes/class-wp-customize-manager.php'; | ||
$wp_customize = new WP_Customize_Manager(); | ||
$nonce = wp_create_nonce( 'save-customize_' . $wp_customize->get_stylesheet() ); | ||
return array( | ||
'success' => true, | ||
'nonce' => $nonce, | ||
'stylesheet' => $wp_customize->get_stylesheet(), | ||
); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
108 changes: 108 additions & 0 deletions
108
packages/edit-navigation/src/components/menu-editor/batch-save.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import { keyBy, omit } from 'lodash'; | ||
|
||
/** | ||
* WordPress dependencies | ||
*/ | ||
import apiFetch from '@wordpress/api-fetch'; | ||
|
||
export default async function batchSave( | ||
menuId, | ||
menuItemsRef, | ||
navigationBlock | ||
) { | ||
const { nonce, stylesheet } = await apiFetch( { | ||
path: '/__experimental/customizer-nonces/get-save-nonce', | ||
} ); | ||
|
||
// eslint-disable-next-line no-undef | ||
const body = new FormData(); | ||
body.append( 'wp_customize', 'on' ); | ||
body.append( 'customize_theme', stylesheet ); | ||
body.append( 'nonce', nonce ); | ||
body.append( 'customize_changeset_uuid', uuidv4() ); | ||
body.append( 'customize_autosaved', 'on' ); | ||
body.append( 'customize_changeset_status', 'publish' ); | ||
body.append( 'action', 'customize_save' ); | ||
body.append( | ||
'customized', | ||
computeCustomizedAttribute( | ||
navigationBlock.innerBlocks, | ||
menuId, | ||
menuItemsRef | ||
) | ||
); | ||
|
||
return await apiFetch( { | ||
url: '/wp-admin/admin-ajax.php', | ||
method: 'POST', | ||
body, | ||
} ); | ||
} | ||
|
||
function computeCustomizedAttribute( blocks, menuId, menuItemsRef ) { | ||
const blocksList = blocksTreeToFlatList( blocks ); | ||
const dataList = blocksList.map( ( { block, parentId, position } ) => | ||
linkBlockToRequestItem( block, parentId, position ) | ||
); | ||
|
||
// Create an object like { "nav_menu_item[12]": {...}} } | ||
const computeKey = ( item ) => `nav_menu_item[${ item.id }]`; | ||
const dataObject = keyBy( dataList, computeKey ); | ||
|
||
// Deleted menu items should be sent as false, e.g. { "nav_menu_item[13]": false } | ||
for ( const clientId in menuItemsRef.current ) { | ||
const key = computeKey( menuItemsRef.current[ clientId ] ); | ||
if ( ! ( key in dataObject ) ) { | ||
dataObject[ key ] = false; | ||
} | ||
} | ||
|
||
return JSON.stringify( dataObject ); | ||
|
||
function blocksTreeToFlatList( innerBlocks, parentId = 0 ) { | ||
return innerBlocks.flatMap( ( block, index ) => | ||
[ { block, parentId, position: index + 1 } ].concat( | ||
blocksTreeToFlatList( | ||
block.innerBlocks, | ||
getMenuItemForBlock( block )?.id | ||
) | ||
) | ||
); | ||
} | ||
|
||
function linkBlockToRequestItem( block, parentId, position ) { | ||
const menuItem = omit( getMenuItemForBlock( block ), 'menus', 'meta' ); | ||
return { | ||
...menuItem, | ||
position, | ||
title: block.attributes?.label, | ||
url: block.attributes.url, | ||
original_title: '', | ||
classes: ( menuItem.classes || [] ).join( ' ' ), | ||
xfn: ( menuItem.xfn || [] ).join( ' ' ), | ||
nav_menu_term_id: menuId, | ||
menu_item_parent: parentId, | ||
status: 'publish', | ||
_invalid: false, | ||
}; | ||
} | ||
|
||
function getMenuItemForBlock( block ) { | ||
return omit( menuItemsRef.current[ block.clientId ] || {}, '_links' ); | ||
} | ||
} | ||
|
||
function uuidv4() { | ||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( /[xy]/g, ( c ) => { | ||
// eslint-disable-next-line no-restricted-syntax | ||
const a = Math.random() * 16; | ||
// eslint-disable-next-line no-bitwise | ||
const r = a | 0; | ||
// eslint-disable-next-line no-bitwise | ||
const v = c === 'x' ? r : ( r & 0x3 ) | 0x8; | ||
return v.toString( 16 ); | ||
} ); | ||
} |
51 changes: 51 additions & 0 deletions
51
packages/edit-navigation/src/components/menu-editor/promise-queue.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
/** | ||
* A concurrency primitive that runs at most `concurrency` async tasks at once. | ||
*/ | ||
export default class PromiseQueue { | ||
constructor( concurrency = 1 ) { | ||
this.concurrency = concurrency; | ||
this.queue = []; | ||
this.active = []; | ||
this.listeners = []; | ||
} | ||
|
||
enqueue( action ) { | ||
this.queue.push( action ); | ||
this.run(); | ||
} | ||
|
||
run() { | ||
while ( this.queue.length && this.active.length <= this.concurrency ) { | ||
const action = this.queue.shift(); | ||
const promise = action().then( () => { | ||
this.active.splice( this.active.indexOf( promise ), 1 ); | ||
this.run(); | ||
this.notifyIfEmpty(); | ||
} ); | ||
this.active.push( promise ); | ||
} | ||
} | ||
|
||
notifyIfEmpty() { | ||
if ( this.active.length === 0 && this.queue.length === 0 ) { | ||
for ( const l of this.listeners ) { | ||
l(); | ||
} | ||
this.listeners = []; | ||
} | ||
} | ||
|
||
/** | ||
* Calls `callback` once all async actions in the queue are finished, | ||
* or immediately if no actions are running. | ||
* | ||
* @param {Function} callback Callback to call | ||
*/ | ||
then( callback ) { | ||
if ( this.active.length ) { | ||
this.listeners.push( callback ); | ||
} else { | ||
callback(); | ||
} | ||
} | ||
} |
18 changes: 18 additions & 0 deletions
18
packages/edit-navigation/src/components/menu-editor/use-debounced-value.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
/** | ||
* WordPress dependencies | ||
*/ | ||
import { useEffect, useState } from '@wordpress/element'; | ||
|
||
const useDebouncedValue = ( value, timeout ) => { | ||
const [ state, setState ] = useState( value ); | ||
|
||
useEffect( () => { | ||
const handler = setTimeout( () => setState( value ), timeout ); | ||
|
||
return () => clearTimeout( handler ); | ||
}, [ value, timeout ] ); | ||
|
||
return state; | ||
}; | ||
|
||
export default useDebouncedValue; |
Oops, something went wrong.