Skip to content

Commit

Permalink
[Navigation screen] Atomic save using customizer API endpoint (#22603)
Browse files Browse the repository at this point in the history
* 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
adamziel authored May 29, 2020
1 parent a5cfd79 commit d3ad37b
Show file tree
Hide file tree
Showing 7 changed files with 390 additions and 105 deletions.
73 changes: 73 additions & 0 deletions lib/class-wp-rest-customizer-nonces.php
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(),
);
}

}
3 changes: 3 additions & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ function gutenberg_is_experiment_enabled( $name ) {
if ( ! class_exists( 'WP_REST_Menu_Locations_Controller' ) ) {
require_once dirname( __FILE__ ) . '/class-wp-rest-menu-locations-controller.php';
}
if ( ! class_exists( 'WP_Rest_Customizer_Nonces' ) ) {
require_once dirname( __FILE__ ) . '/class-wp-rest-customizer-nonces.php';
}
/**
* End: Include for phase 2
*/
Expand Down
10 changes: 10 additions & 0 deletions lib/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ function gutenberg_register_rest_menu_location() {
$nav_menu_location->register_routes();
}
add_action( 'rest_api_init', 'gutenberg_register_rest_menu_location' );

/**
* Registers the menu locations area REST API routes.
*/
function gutenberg_register_rest_customizer_nonces() {
$nav_menu_location = new WP_Rest_Customizer_Nonces();
$nav_menu_location->register_routes();
}
add_action( 'rest_api_init', 'gutenberg_register_rest_customizer_nonces' );

/**
* Hook in to the nav menu item post type and enable a post type rest endpoint.
*
Expand Down
108 changes: 108 additions & 0 deletions packages/edit-navigation/src/components/menu-editor/batch-save.js
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 );
} );
}
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();
}
}
}
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;
Loading

0 comments on commit d3ad37b

Please sign in to comment.