From 1d80d4883d5b37b189c1a941b1137f0b2ea0cac6 Mon Sep 17 00:00:00 2001 From: Timothy Jacobs Date: Mon, 19 Oct 2020 08:55:04 -0400 Subject: [PATCH] First pass at using the new sidebars and widget endpoints. (#26086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Draft batch-processing module * committing/rudimentary API_FETCH integration * Make batch processing functional! * Roll back integration changes * Cleanup package.json * Update package-lock.json * Update terminology * yield from commitTransaction * Correct yield from * Move the comment where it belongs * First pass at enqueueItemAndWaitForResults. * First pass at using the new sidebars and widget endpoints. * Add test for saving multiple widgets in a row * Generate better error message when creating fails * Add basic batch integration. Works off of #26205. * Allow mixing HTTP methods in a batch. * Fix disappearing data, add basic error handling. We need to get the full list of widget ids from the client, we can't use the returned widget ids from the batch because not all widgets will be dirty. Also implements basic error handling for when the batch fails. A snackbar is added with the names of the widgets that failed to save. We also now propagate a dummy error to the apiFetch callers for the requests that didn't have a validation error, but need something to short circuit their handling. * yield from the widget area saving. * Move batch-processing into edit-widgets package Co-authored-by: Adam ZieliƄski --- lib/class-wp-rest-batch-controller.php | 10 +- lib/class-wp-rest-sidebars-controller.php | 5 + lib/class-wp-rest-widgets-controller.php | 31 ++- lib/widgets-page.php | 3 +- packages/batch-processing/package.json | 5 +- packages/core-data/src/entities.js | 10 +- packages/edit-widgets/package.json | 4 +- .../index.js | 1 + packages/edit-widgets/src/store/actions.js | 208 ++++++++++++----- .../src/store/batch-processing/README.md | 11 + .../src/store/batch-processing/actions.js | 174 ++++++++++++++ .../src/store/batch-processing/constants.js | 11 + .../src/store/batch-processing/controls.js | 132 +++++++++++ .../src/store/batch-processing/index.js | 31 +++ .../src/store/batch-processing/reducer.js | 221 ++++++++++++++++++ .../src/store/batch-processing/selectors.js | 11 + .../src/store/batch-processing/test/test.js | 83 +++++++ .../edit-widgets/src/store/batch-support.js | 97 ++++++++ packages/edit-widgets/src/store/controls.js | 28 ++- packages/edit-widgets/src/store/index.js | 1 + packages/edit-widgets/src/store/resolvers.js | 60 +++-- packages/edit-widgets/src/store/selectors.js | 12 +- packages/edit-widgets/src/store/utils.js | 11 + .../class-rest-sidebars-controller-test.php | 85 ++++++- .../class-rest-widgets-controller-test.php | 60 +++++ 25 files changed, 1203 insertions(+), 102 deletions(-) create mode 100644 packages/edit-widgets/src/store/batch-processing/README.md create mode 100644 packages/edit-widgets/src/store/batch-processing/actions.js create mode 100644 packages/edit-widgets/src/store/batch-processing/constants.js create mode 100644 packages/edit-widgets/src/store/batch-processing/controls.js create mode 100644 packages/edit-widgets/src/store/batch-processing/index.js create mode 100644 packages/edit-widgets/src/store/batch-processing/reducer.js create mode 100644 packages/edit-widgets/src/store/batch-processing/selectors.js create mode 100644 packages/edit-widgets/src/store/batch-processing/test/test.js create mode 100644 packages/edit-widgets/src/store/batch-support.js diff --git a/lib/class-wp-rest-batch-controller.php b/lib/class-wp-rest-batch-controller.php index b07262f9fc235..01a21de1d8570 100644 --- a/lib/class-wp-rest-batch-controller.php +++ b/lib/class-wp-rest-batch-controller.php @@ -25,7 +25,7 @@ public function register_routes() { array( 'callback' => array( $this, 'serve_batch_request' ), 'permission_callback' => '__return_true', - 'methods' => array( 'POST', 'PUT', 'PATCH', 'DELETE' ), + 'methods' => 'POST', 'args' => array( 'validation' => array( 'type' => 'string', @@ -39,6 +39,12 @@ public function register_routes() { 'items' => array( 'type' => 'object', 'properties' => array( + 'method' => array( + 'type' => 'string', + 'enum' => array( 'POST', 'PUT', 'PATCH', 'DELETE' ), + 'default' => 'POST', + 'required' => true, + ), 'path' => array( 'type' => 'string', 'required' => true, @@ -86,7 +92,7 @@ public function serve_batch_request( WP_REST_Request $batch_request ) { continue; } - $single_request = new WP_REST_Request( $batch_request->get_method(), $parsed_url['path'] ); + $single_request = new WP_REST_Request( $args['method'], $parsed_url['path'] ); if ( ! empty( $parsed_url['query'] ) ) { $query_args = null; // Satisfy linter. diff --git a/lib/class-wp-rest-sidebars-controller.php b/lib/class-wp-rest-sidebars-controller.php index 17381f0713948..95e4a2666e0c9 100644 --- a/lib/class-wp-rest-sidebars-controller.php +++ b/lib/class-wp-rest-sidebars-controller.php @@ -306,8 +306,13 @@ public function update_item( $request ) { foreach ( $sidebars as $sidebar_id => $widgets ) { foreach ( $widgets as $i => $widget_id ) { + // This automatically removes the passed widget ids from any other sidebars in use. if ( $sidebar_id !== $request['id'] && in_array( $widget_id, $request['widgets'], true ) ) { unset( $sidebars[ $sidebar_id ][ $i ] ); + } + + // This automatically removes omitted widget ids to the inactive sidebar. + if ( $sidebar_id === $request['id'] && ! in_array( $widget_id, $request['widgets'], true ) ) { $sidebars['wp_inactive_widgets'][] = $widget_id; } } diff --git a/lib/class-wp-rest-widgets-controller.php b/lib/class-wp-rest-widgets-controller.php index 69d780697c2dc..883b2856623fc 100644 --- a/lib/class-wp-rest-widgets-controller.php +++ b/lib/class-wp-rest-widgets-controller.php @@ -181,6 +181,11 @@ public function create_item( $request ) { $request['context'] = 'edit'; $response = $this->prepare_item_for_response( compact( 'sidebar_id', 'widget_id' ), $request ); + + if ( is_wp_error( $response ) ) { + return $response; + } + $response->set_status( 201 ); return $response; @@ -387,20 +392,14 @@ protected function save_widget( $request ) { // Just because we saved new widget doesn't mean it was added to $wp_registered_widgets. // Let's make sure it's there so that it's included in the response. if ( ! isset( $wp_registered_widgets[ $input_widget['id'] ] ) || 1 === $number ) { - $first_widget_id = substr( $input_widget['id'], 0, strrpos( $input_widget['id'], '-' ) ) . '-1'; - - if ( isset( $wp_registered_widgets[ $first_widget_id ] ) ) { - $wp_registered_widgets[ $input_widget['id'] ] = $wp_registered_widgets[ $first_widget_id ]; - - $widget_class = get_class( $update_control['callback'][0] ); - $new_object = new $widget_class( - $input_widget['id_base'], - $input_widget['name'], - $input_widget['settings'] - ); - $new_object->_register(); - $wp_registered_widgets[ $input_widget['id'] ]['callback'][0] = $new_object; - } + $widget_class = get_class( $update_control['callback'][0] ); + $new_object = new $widget_class( + $input_widget['id_base'], + $input_widget['name'], + $input_widget['settings'] + ); + $new_object->_set( $number ); + $new_object->_register(); } } else { $registered_widget_id = null; @@ -550,8 +549,8 @@ public function prepare_item_for_response( $item, $request ) { ) { $control = $wp_registered_widget_controls[ $widget_id ]; $arguments = array(); - if ( ! empty( $widget['number'] ) ) { - $arguments[0] = array( 'number' => $widget['number'] ); + if ( ! empty( $prepared['number'] ) ) { + $arguments[0] = array( 'number' => $prepared['number'] ); } ob_start(); call_user_func_array( $control['callback'], $arguments ); diff --git a/lib/widgets-page.php b/lib/widgets-page.php index c7a2b0e132ba3..f6d31ca5b79b0 100644 --- a/lib/widgets-page.php +++ b/lib/widgets-page.php @@ -85,7 +85,8 @@ function gutenberg_widgets_init( $hook ) { $preload_paths = array( array( '/wp/v2/media', 'OPTIONS' ), - '/__experimental/sidebars?context=edit&per_page=-1', + '/wp/v2/sidebars?context=edit&per_page=-1', + '/wp/v2/widgets?context=edit&per_page=-1', ); $preload_data = array_reduce( $preload_paths, diff --git a/packages/batch-processing/package.json b/packages/batch-processing/package.json index 5657cf3a72427..ae74d01774727 100644 --- a/packages/batch-processing/package.json +++ b/packages/batch-processing/package.json @@ -27,8 +27,9 @@ "!((src|build|build-module)/(components|utils)/**)" ], "dependencies": { - "@wordpress/data": "file:../data", - "uuid": "^8.3.1" + "@wordpress/data": "file:../data", + "lodash": "^4.17.20", + "uuid": "^8.3.1" }, "publishConfig": { "access": "public" diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 6bf3da89d05e9..9dfd507140492 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -57,11 +57,19 @@ export const defaultEntities = [ { name: 'sidebar', kind: 'root', - baseURL: '/__experimental/sidebars', + baseURL: '/wp/v2/sidebars', plural: 'sidebars', transientEdits: { blocks: true }, label: __( 'Widget areas' ), }, + { + name: 'widget', + kind: 'root', + baseURL: '/wp/v2/widgets', + plural: 'widgets', + transientEdits: { blocks: true }, + label: __( 'Widgets' ), + }, { label: __( 'User' ), name: 'user', diff --git a/packages/edit-widgets/package.json b/packages/edit-widgets/package.json index fb02c0a11bb41..87a7db97d79b3 100644 --- a/packages/edit-widgets/package.json +++ b/packages/edit-widgets/package.json @@ -29,6 +29,7 @@ "dependencies": { "@babel/runtime": "^7.11.2", "@wordpress/api-fetch": "file:../api-fetch", + "@wordpress/batch-processing": "file:../batch-processing", "@wordpress/block-editor": "file:../block-editor", "@wordpress/block-library": "file:../block-library", "@wordpress/blocks": "file:../blocks", @@ -53,7 +54,8 @@ "classnames": "^2.2.5", "lodash": "^4.17.19", "reakit": "^1.1.0", - "rememo": "^3.0.0" + "rememo": "^3.0.0", + "uuid": "^8.3.1" }, "publishConfig": { "access": "public" diff --git a/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js b/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js index 241ff16fd794b..c29107a394093 100644 --- a/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js +++ b/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js @@ -41,6 +41,7 @@ export default function WidgetAreasBlockEditorProvider( { true ), widgetAreas: select( 'core/edit-widgets' ).getWidgetAreas(), + widgets: select( 'core/edit-widgets' ).getWidgets(), reusableBlocks: select( 'core' ).getEntityRecords( 'postType', 'wp_block' diff --git a/packages/edit-widgets/src/store/actions.js b/packages/edit-widgets/src/store/actions.js index 34e69f0fcade1..695fde4770cf2 100644 --- a/packages/edit-widgets/src/store/actions.js +++ b/packages/edit-widgets/src/store/actions.js @@ -6,11 +6,13 @@ import { invert } from 'lodash'; /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; +import { dispatch as dataDispatch } from '@wordpress/data'; /** * Internal dependencies */ +import { STATE_SUCCESS } from './batch-processing/constants'; import { dispatch, select, getWidgetToClientIdMapping } from './controls'; import { transformBlockToWidget } from './transformers'; import { @@ -66,7 +68,8 @@ export function* saveEditedWidgetAreas() { yield dispatch( 'core/notices', 'createErrorNotice', - __( 'There was an error.' ), + /* translators: %s: The error message. */ + sprintf( __( 'There was an error. %s' ), e.message ), { type: 'snackbar', } @@ -76,73 +79,170 @@ export function* saveEditedWidgetAreas() { export function* saveWidgetAreas( widgetAreas ) { try { - const widgets = yield select( 'core/edit-widgets', 'getWidgets' ); - const widgetIdToClientId = yield getWidgetToClientIdMapping(); - const clientIdToWidgetId = invert( widgetIdToClientId ); - const usedReferenceWidgets = []; - - // @TODO: Batch save / concurrency for ( const widgetArea of widgetAreas ) { - const post = yield select( - 'core', - 'getEditedEntityRecord', - KIND, - POST_TYPE, - buildWidgetAreaPostId( widgetArea.id ) - ); - // Remove all duplicate reference widget instances - const widgetsBlocks = post.blocks.filter( - ( { attributes: { referenceWidgetName } } ) => { - if ( referenceWidgetName ) { - if ( - usedReferenceWidgets.includes( referenceWidgetName ) - ) { - return false; - } - usedReferenceWidgets.push( referenceWidgetName ); - } - return true; + yield* saveWidgetArea( widgetArea.id ); + } + } finally { + // saveEditedEntityRecord resets the resolution status, let's fix it manually + yield dispatch( + 'core', + 'finishResolution', + 'getEntityRecord', + KIND, + WIDGET_AREA_ENTITY_TYPE, + buildWidgetAreasQuery() + ); + } +} + +export function* saveWidgetArea( widgetAreaId ) { + const widgets = yield select( 'core/edit-widgets', 'getWidgets' ); + const widgetIdToClientId = yield getWidgetToClientIdMapping(); + const clientIdToWidgetId = invert( widgetIdToClientId ); + + const post = yield select( + 'core', + 'getEditedEntityRecord', + KIND, + POST_TYPE, + buildWidgetAreaPostId( widgetAreaId ) + ); + + // Remove all duplicate reference widget instances + const usedReferenceWidgets = []; + const widgetsBlocks = post.blocks.filter( + ( { attributes: { referenceWidgetName } } ) => { + if ( referenceWidgetName ) { + if ( usedReferenceWidgets.includes( referenceWidgetName ) ) { + return false; } - ); - const newWidgets = widgetsBlocks.map( ( block ) => { - const widgetId = clientIdToWidgetId[ block.clientId ]; - const oldWidget = widgets[ widgetId ]; - return transformBlockToWidget( block, oldWidget ); - } ); + usedReferenceWidgets.push( referenceWidgetName ); + } + return true; + } + ); + const widgetIds = []; + const newWidgetClientIds = []; + const savedBlocks = []; + + for ( let i = 0; i < widgetsBlocks.length; i++ ) { + const block = widgetsBlocks[ i ]; + const widgetId = clientIdToWidgetId[ block.clientId ]; + const oldWidget = widgets[ widgetId ]; + const widget = transformBlockToWidget( block, oldWidget ); + // We'll replace the null widgetId after save, but we track it here + // since order is important. + widgetIds.push( widgetId ); + if ( widgetId ) { yield dispatch( 'core', 'editEntityRecord', - KIND, - WIDGET_AREA_ENTITY_TYPE, - widgetArea.id, + 'root', + 'widget', + widgetId, { - widgets: newWidgets, + ...widget, + sidebar: widgetAreaId, } ); - yield* trySaveWidgetArea( widgetArea.id ); - - yield dispatch( + const hasEdits = yield select( 'core', - 'receiveEntityRecords', - KIND, - POST_TYPE, - post, - undefined + 'hasEditsForEntityRecord', + 'root', + 'widget', + widgetId ); + + if ( ! hasEdits ) { + continue; + } + + savedBlocks.push( block ); + dataDispatch( 'core' ).saveEditedEntityRecord( + 'root', + 'widget', + widgetId, + widget + ); + } else { + savedBlocks.push( block ); + newWidgetClientIds.push( { + position: i, + clientId: block.clientId, + } ); + // This is a new widget instance. + dataDispatch( 'core' ).saveEntityRecord( 'root', 'widget', { + ...widget, + sidebar: widgetAreaId, + } ); } - } finally { - // saveEditedEntityRecord resets the resolution status, let's fix it manually - yield dispatch( - 'core', - 'finishResolution', - 'getEntityRecord', - KIND, - WIDGET_AREA_ENTITY_TYPE, - buildWidgetAreasQuery() + } + + const batch = yield dispatch( + 'core/batch-processing', + 'processBatch', + 'WIDGETS_API_FETCH', + 'default' + ); + + if ( batch.state !== STATE_SUCCESS ) { + // This is unsafe. We can't rely on object key order. + const errors = Object.values( batch.errors ); + const failedWidgetNames = []; + + for ( let i = 0; i < errors.length; i++ ) { + if ( ! errors[ i ] ) { + continue; + } + + failedWidgetNames.push( + savedBlocks[ i ].attributes?.name || savedBlocks[ i ]?.name + ); + } + + throw new Error( + sprintf( + /* translators: %s: List of widget names */ + __( 'Could not save the following widgets: %s.' ), + failedWidgetNames.join( ', ' ) + ) ); } + + for ( const widget of Object.values( batch.results ) ) { + if ( widgetIdToClientId[ widget.id ] ) { + continue; + } + + const { clientId, position } = newWidgetClientIds.shift(); + + widgetIds[ position ] = widget.id; + yield setWidgetIdForClientId( clientId, widget.id ); + } + + yield dispatch( + 'core', + 'editEntityRecord', + KIND, + WIDGET_AREA_ENTITY_TYPE, + widgetAreaId, + { + widgets: widgetIds, + } + ); + + yield* trySaveWidgetArea( widgetAreaId ); + + yield dispatch( + 'core', + 'receiveEntityRecords', + KIND, + POST_TYPE, + post, + undefined + ); } function* trySaveWidgetArea( widgetAreaId ) { diff --git a/packages/edit-widgets/src/store/batch-processing/README.md b/packages/edit-widgets/src/store/batch-processing/README.md new file mode 100644 index 0000000000000..3b1423c68c148 --- /dev/null +++ b/packages/edit-widgets/src/store/batch-processing/README.md @@ -0,0 +1,11 @@ +# `batch-processing` + +> TODO: description + +## Usage + +``` +const batchProcessing = require('batch-processing'); + +// TODO: DEMONSTRATE API +``` diff --git a/packages/edit-widgets/src/store/batch-processing/actions.js b/packages/edit-widgets/src/store/batch-processing/actions.js new file mode 100644 index 0000000000000..a21a7433e44f0 --- /dev/null +++ b/packages/edit-widgets/src/store/batch-processing/actions.js @@ -0,0 +1,174 @@ +/** + * External dependencies + */ +import { v4 as uuid } from 'uuid'; + +/** + * Internal dependencies + */ +import { + select, + dispatch, + enqueueItemAndAutocommit as enqueueAutocommitControl, + processTransaction, +} from './controls'; +import { STATE_ERROR, STATE_SUCCESS } from './constants'; + +export const enqueueItemAndAutocommit = function* ( queue, context, item ) { + return yield enqueueAutocommitControl( queue, context, item ); +}; + +export const enqueueItemAndWaitForResults = function* ( queue, context, item ) { + const { itemId } = yield dispatch( 'enqueueItem', queue, context, item ); + const { promise } = yield* getOrSetupPromise( queue, context ); + + return { + wait: promise.then( ( batch ) => { + if ( batch.state === STATE_ERROR ) { + throw batch.errors[ itemId ]; + } + + return batch.results[ itemId ]; + } ), + }; +}; + +export const enqueueItem = function ( queue, context, item ) { + const itemId = uuid(); + return { + type: 'ENQUEUE_ITEM', + queue, + context, + item, + itemId, + }; +}; + +const setupPromise = function ( queue, context ) { + const action = { + type: 'SETUP_PROMISE', + queue, + context, + }; + + action.promise = new Promise( ( resolve, reject ) => { + action.resolve = resolve; + action.reject = reject; + } ); + + return action; +}; + +const getOrSetupPromise = function* ( queue, context ) { + const promise = yield select( 'getPromise', queue, context ); + + if ( promise ) { + return promise; + } + + yield setupPromise( queue, context ); + + return yield select( 'getPromise', queue, context ); +}; + +export const processBatch = function* ( queue, context, meta = {} ) { + const batchId = uuid(); + yield prepareBatchForProcessing( queue, context, batchId, meta ); + const { transactions } = yield select( 'getBatch', batchId ); + + yield { + queue, + context, + batchId, + type: 'BATCH_START', + }; + + let failed = false; + for ( const transactionId of Object.keys( transactions ) ) { + const result = yield* commitTransaction( batchId, transactionId ); + if ( result.state === STATE_ERROR ) { + failed = true; + // Don't break the loop as we still need results for any remaining transactions. + // Queue processor receives the batch object and may choose whether to + // process other transactions or short-circuit with an error. + } + } + + const promise = yield select( 'getPromise', queue, context ); + yield { + queue, + context, + batchId, + type: 'BATCH_FINISH', + state: failed ? STATE_ERROR : STATE_SUCCESS, + }; + const batch = yield select( 'getBatch', batchId ); + + if ( promise ) { + promise.resolve( batch ); + } + + return batch; +}; + +export function* commitTransaction( batchId, transactionId ) { + yield { + batchId, + transactionId, + type: 'COMMIT_TRANSACTION_START', + }; + const batch = yield select( 'getBatch', batchId ); + + let failed = false, + errors, + exception, + results; + try { + results = yield processTransaction( batch, transactionId ); + } catch ( _exception ) { + failed = true; + + // If the error isn't in the expected format, something is wrong - let's rethrow + if ( ! _exception.isTransactionError ) { + throw _exception; + } + exception = _exception; + errors = exception.errorsById; + } + + const finishedAction = { + batchId, + transactionId, + results, + errors, + exception, + type: 'COMMIT_TRANSACTION_FINISH', + state: failed ? STATE_ERROR : STATE_SUCCESS, + }; + + yield finishedAction; + return finishedAction; +} + +export function prepareBatchForProcessing( + queue, + context, + batchId, + meta = {} +) { + return { + type: 'PREPARE_BATCH_FOR_PROCESSING', + queue, + context, + batchId, + meta, + }; +} + +export const registerProcessor = function ( queue, callback ) { + return { + type: 'REGISTER_PROCESSOR', + queue, + callback, + }; +}; diff --git a/packages/edit-widgets/src/store/batch-processing/constants.js b/packages/edit-widgets/src/store/batch-processing/constants.js new file mode 100644 index 0000000000000..3f203fc16b09b --- /dev/null +++ b/packages/edit-widgets/src/store/batch-processing/constants.js @@ -0,0 +1,11 @@ +/** + * Module Constants + */ +export const MODULE_KEY = 'core/batch-processing'; + +export const BATCH_MAX_SIZE = 20; + +export const STATE_NEW = 'NEW'; +export const STATE_IN_PROGRESS = 'IN_PROGRESS'; +export const STATE_SUCCESS = 'SUCCESS'; +export const STATE_ERROR = 'ERROR'; diff --git a/packages/edit-widgets/src/store/batch-processing/controls.js b/packages/edit-widgets/src/store/batch-processing/controls.js new file mode 100644 index 0000000000000..431fc1a49bf94 --- /dev/null +++ b/packages/edit-widgets/src/store/batch-processing/controls.js @@ -0,0 +1,132 @@ +/** + * WordPress dependencies + */ +import { createRegistryControl } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { MODULE_KEY, STATE_ERROR } from './constants'; + +/** + * Calls a selector using chosen registry. + * + * @param {string} selectorName Selector name. + * @param {Array} args Selector arguments. + * @return {Object} control descriptor. + */ +export function select( selectorName, ...args ) { + return { + type: 'SELECT', + selectorName, + args, + }; +} + +/** + * Dispatches an action using chosen registry. + * + * @param {string} actionName Action name. + * @param {Array} args Selector arguments. + * @return {Object} control descriptor. + */ +export function dispatch( actionName, ...args ) { + return { + type: 'DISPATCH', + actionName, + args, + }; +} + +export function processTransaction( batch, transactionId ) { + return { + type: 'PROCESS_TRANSACTION', + batch, + transactionId, + }; +} + +export function enqueueItemAndAutocommit( queue, context, item ) { + return { + type: 'ENQUEUE_ITEM_AND_AUTOCOMMIT', + queue, + context, + item, + }; +} + +const controls = { + SELECT: createRegistryControl( + ( registry ) => ( { selectorName, args } ) => { + return registry.select( MODULE_KEY )[ selectorName ]( ...args ); + } + ), + + DISPATCH: createRegistryControl( + ( registry ) => ( { actionName, args } ) => { + return registry.dispatch( MODULE_KEY )[ actionName ]( ...args ); + } + ), + + ENQUEUE_ITEM_AND_AUTOCOMMIT: createRegistryControl( + ( registry ) => async ( { queue, context, item } ) => { + const { itemId } = await registry + .dispatch( MODULE_KEY ) + .enqueueItem( queue, context, item ); + + // @TODO autocommit when batch size exceeds the maximum or n milliseconds passes + const batch = await registry + .dispatch( MODULE_KEY ) + .processBatch( queue, context ); + + if ( batch.state === STATE_ERROR ) { + throw batch.errors[ itemId ]; + } + + return batch.results[ itemId ]; + } + ), + + PROCESS_TRANSACTION: createRegistryControl( + ( registry ) => async ( { batch, transactionId } ) => { + const { transactions, queue } = batch; + const transaction = transactions[ transactionId ]; + const processor = registry + .select( MODULE_KEY ) + .getProcessor( queue ); + if ( ! processor ) { + throw new Error( + `There is no batch processor registered for "${ queue }" queue. ` + + `Register one by dispatching registerProcessor() action on ${ MODULE_KEY } store.` + ); + } + const itemIds = transaction.items.map( ( { id } ) => id ); + const items = transaction.items.map( ( { item } ) => item ); + let results; + try { + results = await processor( items, batch ); + } catch ( exception ) { + const errorsById = {}; + for ( let i = 0, max = itemIds.length; i < max; i++ ) { + errorsById[ itemIds[ i ] ] = Array.isArray( exception ) + ? exception[ i ] + : exception; + } + throw { + isTransactionError: true, + exception, + errorsById, + }; + } + + // @TODO Assert results.length == items.length + const resultsById = {}; + for ( let i = 0, max = itemIds.length; i < max; i++ ) { + resultsById[ itemIds[ i ] ] = results[ i ]; + } + return resultsById; + } + ), +}; + +export default controls; diff --git a/packages/edit-widgets/src/store/batch-processing/index.js b/packages/edit-widgets/src/store/batch-processing/index.js new file mode 100644 index 0000000000000..c2319d7b58a68 --- /dev/null +++ b/packages/edit-widgets/src/store/batch-processing/index.js @@ -0,0 +1,31 @@ +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import * as actions from './actions'; +import reducer from './reducer'; +import controls from './controls'; +import * as selectors from './selectors'; +import { MODULE_KEY } from './constants'; + +/** + * Block editor data store configuration. + * + * @see https://github.com/WordPress/gutenberg/blob/master/packages/data/README.md#registerStore + * + * @type {Object} + */ +export const storeConfig = { + actions, + reducer, + controls, + selectors, +}; + +const store = registerStore( MODULE_KEY, storeConfig ); + +export default store; diff --git a/packages/edit-widgets/src/store/batch-processing/reducer.js b/packages/edit-widgets/src/store/batch-processing/reducer.js new file mode 100644 index 0000000000000..1426605d58832 --- /dev/null +++ b/packages/edit-widgets/src/store/batch-processing/reducer.js @@ -0,0 +1,221 @@ +/** + * External dependencies + */ +import { omit } from 'lodash'; + +/** + * Internal dependencies + */ +import { + BATCH_MAX_SIZE, + STATE_NEW, + STATE_IN_PROGRESS, + STATE_SUCCESS, + STATE_ERROR, +} from './constants'; + +const defaultState = { + lastBatchId: 0, + enqueuedItems: {}, + batches: {}, + processors: {}, + promises: {}, +}; + +export default function reducer( state = defaultState, action ) { + switch ( action.type ) { + case 'ENQUEUE_ITEM': { + const { queue, context, item, itemId } = action; + + const stateQueue = state.enqueuedItems[ queue ] || {}; + const stateItems = stateQueue[ context ] || []; + + return { + ...state, + enqueuedItems: { + ...state.enqueuedItems, + [ queue ]: { + ...stateQueue, + [ context ]: [ ...stateItems, { id: itemId, item } ], + }, + }, + }; + } + + case 'PREPARE_BATCH_FOR_PROCESSING': { + const { queue, context, batchId, meta } = action; + + if ( batchId in state.batches ) { + throw new Error( `Batch ${ batchId } already exists` ); + } + + const stateQueue = state.enqueuedItems[ queue ] || {}; + const enqueuedItems = [ ...( stateQueue[ context ] || [] ) ]; + const transactions = {}; + let transactionNb = 0; + while ( enqueuedItems.length ) { + const transactionId = `${ batchId }-${ transactionNb }`; + transactions[ transactionId ] = { + number: transactionNb, + id: transactionId, + items: enqueuedItems.splice( 0, BATCH_MAX_SIZE ), + }; + ++transactionNb; + } + + const batch = { + id: batchId, + state: STATE_NEW, + queue, + context, + transactions, + results: {}, + meta, + }; + + return { + ...state, + enqueuedItems: { + ...state.enqueuedItems, + [ queue ]: { + ...stateQueue, + [ context ]: [], + }, + }, + batches: { + ...state.batches, + [ batchId ]: batch, + }, + }; + } + + case 'SETUP_PROMISE': { + return { + ...state, + promises: { + ...state.promises, + [ action.queue ]: { + ...( state.promises[ action.queue ] || {} ), + [ action.context ]: { + promise: action.promise, + resolve: action.resolve, + reject: action.reject, + }, + }, + }, + }; + } + + case 'BATCH_START': { + const { batchId } = action; + return { + ...state, + batches: { + ...state.batches, + [ batchId ]: { + ...state.batches[ batchId ], + state: STATE_IN_PROGRESS, + }, + }, + }; + } + + case 'BATCH_FINISH': { + const { batchId, state: commitState } = action; + return { + ...state, + batches: { + ...state.batches, + [ batchId ]: { + ...state.batches[ batchId ], + state: + commitState === STATE_SUCCESS + ? STATE_SUCCESS + : STATE_ERROR, + }, + }, + promises: { + ...state.promises, + [ action.queue ]: omit( + state.promises[ action.queue ] || {}, + [ action.context ] + ), + }, + }; + } + + case 'COMMIT_TRANSACTION_START': { + const { batchId, transactionId } = action; + return { + ...state, + batches: { + ...state.batches, + [ batchId ]: { + ...state.batches[ batchId ], + transactions: { + ...state.batches[ batchId ].transactions, + [ transactionId ]: { + ...state.batches[ batchId ].transactions[ + transactionId + ], + state: STATE_IN_PROGRESS, + }, + }, + }, + }, + }; + } + + case 'COMMIT_TRANSACTION_FINISH': { + const { + batchId, + state: transactionState, + transactionId, + results = {}, + errors = {}, + exception, + } = action; + + const stateBatch = state.batches[ batchId ] || {}; + return { + ...state, + batches: { + ...state.batches, + [ batchId ]: { + ...stateBatch, + transactions: { + ...stateBatch.transactions, + [ transactionId ]: { + ...stateBatch.transactions[ transactionId ], + state: STATE_SUCCESS, + }, + }, + results: { + ...stateBatch.results, + ...results, + }, + state: + transactionState === STATE_SUCCESS + ? STATE_SUCCESS + : STATE_ERROR, + errors, + exception, + }, + }, + }; + } + + case 'REGISTER_PROCESSOR': + const { queue, callback } = action; + + return { + ...state, + processors: { + ...state.processors, + [ queue ]: callback, + }, + }; + } + + return state; +} diff --git a/packages/edit-widgets/src/store/batch-processing/selectors.js b/packages/edit-widgets/src/store/batch-processing/selectors.js new file mode 100644 index 0000000000000..2ed903e95bd79 --- /dev/null +++ b/packages/edit-widgets/src/store/batch-processing/selectors.js @@ -0,0 +1,11 @@ +export const getBatch = ( state, batchId ) => { + return state.batches[ batchId ]; +}; + +export const getProcessor = ( state, queue ) => { + return state.processors[ queue ]; +}; + +export const getPromise = ( state, queue, context ) => { + return state.promises[ queue ]?.[ context ]; +}; diff --git a/packages/edit-widgets/src/store/batch-processing/test/test.js b/packages/edit-widgets/src/store/batch-processing/test/test.js new file mode 100644 index 0000000000000..4d2e1dce23af5 --- /dev/null +++ b/packages/edit-widgets/src/store/batch-processing/test/test.js @@ -0,0 +1,83 @@ +/** + * Internal dependencies + */ +import store from '../store'; +import { + registerProcessor, + enqueueItemAndWaitForResults, + processBatch, +} from '../store/actions'; +import { STATE_ERROR, STATE_SUCCESS } from '../'; + +const TEST_QUEUE = 'TEST_QUEUE'; +const TEST_CONTEXT = 'default'; + +async function processor( requests, transaction ) { + if ( transaction.state === STATE_ERROR ) { + throw { + code: 'transaction_failed', + data: { status: 500 }, + message: 'Transaction failed.', + }; + } + + return await Promise.resolve( + requests.map( ( request ) => ( { + done: true, + name: request.name, + } ) ) + ); +} + +async function testItem( name ) { + const item = { name }; + const { wait } = await store.dispatch( + enqueueItemAndWaitForResults( TEST_QUEUE, TEST_CONTEXT, item ) + ); + + const expected = { done: true, name }; + + // We can't await this until the batch is processed. + // eslint-disable-next-line jest/valid-expect + const promise = expect( wait ).resolves.toEqual( expected ); + + return { expected, promise }; +} + +describe( 'waitForResults', function () { + store.dispatch( registerProcessor( TEST_QUEUE, processor ) ); + + it( 'works', async () => { + expect.assertions( 4 ); + + const { expected: i1, promise: p1 } = await testItem( 'i1' ); + const { expected: i2, promise: p2 } = await testItem( 'i2' ); + + const resolves = [ p1, p2 ]; + const batch = await store.dispatch( + processBatch( TEST_QUEUE, TEST_CONTEXT ) + ); + + expect( batch.state ).toEqual( STATE_SUCCESS ); + expect( Object.values( batch.results ) ).toEqual( [ i1, i2 ] ); + + await Promise.all( resolves ); + } ); + + it( 'can use the same context more than once', async () => { + expect.assertions( 4 ); + + const { promise: p1 } = await testItem( 'i1' ); + await store.dispatch( processBatch( TEST_QUEUE, TEST_CONTEXT ) ); + await p1; + + const { expected: i2, promise: p2 } = await testItem( 'i2' ); + const batch = await store.dispatch( + processBatch( TEST_QUEUE, TEST_CONTEXT ) + ); + + expect( batch.state ).toEqual( STATE_SUCCESS ); + expect( Object.values( batch.results ) ).toEqual( [ i2 ] ); + await p2; + } ); +} ); diff --git a/packages/edit-widgets/src/store/batch-support.js b/packages/edit-widgets/src/store/batch-support.js new file mode 100644 index 0000000000000..3f0fb24b1dd26 --- /dev/null +++ b/packages/edit-widgets/src/store/batch-support.js @@ -0,0 +1,97 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { dispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import './batch-processing'; +import { STATE_ERROR } from './batch-processing/constants'; + +function shoehornBatchSupport() { + apiFetch.use( async ( options, next ) => { + if ( + ! [ 'POST', 'PUT', 'PATCH', 'DELETE' ].includes( options.method ) || + ! isWidgetsEndpoint( options.path ) + ) { + return next( options ); + } + + const { wait } = await addToBatch( options ); + + return wait.catch( ( error ) => { + // If this item didn't encounter an error specifically, the REST API + // will return `null`. We need to provide an error object of some kind + // to the apiFetch caller as they expect either a valid response, or + // an error. Null wouldn't be acceptable. + if ( error === null ) { + error = { + code: 'transaction_failed', + data: { status: 400 }, + message: __( + 'This item could not be saved because another item encountered an error when trying to save.' + ), + }; + } + + throw error; + } ); + } ); + + dispatch( 'core/batch-processing' ).registerProcessor( + 'WIDGETS_API_FETCH', + batchProcessor + ); +} + +function isWidgetsEndpoint( path ) { + // This should be more sophisticated in reality: + return path.startsWith( '/wp/v2/widgets' ); +} + +function addToBatch( request ) { + return dispatch( 'core/batch-processing' ).enqueueItemAndWaitForResults( + 'WIDGETS_API_FETCH', + 'default', + request + ); +} + +async function batchProcessor( requests, transaction ) { + if ( transaction.state === STATE_ERROR ) { + throw { + code: 'transaction_failed', + data: { status: 500 }, + message: 'Transaction failed.', + }; + } + + const response = await apiFetch( { + path: '/__experimental/batch', + method: 'POST', + data: { + validation: 'require-all-validate', + requests: requests.map( ( options ) => ( { + path: options.path, + body: options.data, + method: options.method, + headers: options.headers, + } ) ), + }, + } ); + + if ( response.failed ) { + throw response.responses.map( ( itemResponse ) => { + // The REST API returns null if the request did not have an error. + return itemResponse === null ? null : itemResponse.body; + } ); + } + + return response.responses.map( ( { body } ) => body ); +} + +// setTimeout is a hack to ensure batch-processing store is available for dispatching +setTimeout( shoehornBatchSupport ); diff --git a/packages/edit-widgets/src/store/controls.js b/packages/edit-widgets/src/store/controls.js index 1b836b5be1aaf..8405ac328ade1 100644 --- a/packages/edit-widgets/src/store/controls.js +++ b/packages/edit-widgets/src/store/controls.js @@ -6,7 +6,12 @@ import { createRegistryControl } from '@wordpress/data'; /** * Internal dependencies */ -import { buildWidgetAreasQuery, KIND, WIDGET_AREA_ENTITY_TYPE } from './utils'; +import { + buildWidgetAreasQuery, + buildWidgetsQuery, + KIND, + WIDGET_AREA_ENTITY_TYPE, +} from './utils'; /** * Trigger an API Fetch request. @@ -76,7 +81,7 @@ export function getNavigationPostForMenu( menuId ) { } /** - * Resolves menu items for given menu id. + * Resolves widget areas. * * @param {Object} query Query. * @return {Object} Action. @@ -88,6 +93,19 @@ export function resolveWidgetAreas( query = buildWidgetAreasQuery() ) { }; } +/** + * Resolves widgets. + * + * @param {Object} query Query. + * @return {Object} Action. + */ +export function resolveWidgets( query = buildWidgetsQuery() ) { + return { + type: 'RESOLVE_WIDGETS', + query, + }; +} + /** * Calls a selector using chosen registry. * @@ -163,6 +181,12 @@ const controls = { .getEntityRecords( KIND, WIDGET_AREA_ENTITY_TYPE, query ); } ), + + RESOLVE_WIDGETS: createRegistryControl( ( registry ) => ( { query } ) => { + return registry + .__experimentalResolveSelect( 'core' ) + .getEntityRecords( 'root', 'widget', query ); + } ), }; const getState = ( registry ) => diff --git a/packages/edit-widgets/src/store/index.js b/packages/edit-widgets/src/store/index.js index 8fcbe2c4f6b42..06b847a9d2575 100644 --- a/packages/edit-widgets/src/store/index.js +++ b/packages/edit-widgets/src/store/index.js @@ -12,6 +12,7 @@ import * as resolvers from './resolvers'; import * as selectors from './selectors'; import * as actions from './actions'; import controls from './controls'; +import './batch-support'; /** * Module Constants diff --git a/packages/edit-widgets/src/store/resolvers.js b/packages/edit-widgets/src/store/resolvers.js index 95da9807cb438..33c032f00b55e 100644 --- a/packages/edit-widgets/src/store/resolvers.js +++ b/packages/edit-widgets/src/store/resolvers.js @@ -6,11 +6,12 @@ import { createBlock } from '@wordpress/blocks'; /** * Internal dependencies */ -import { resolveWidgetAreas, select } from './controls'; +import { resolveWidgets, resolveWidgetAreas, select } from './controls'; import { persistStubPost, setWidgetAreasOpenState } from './actions'; import { KIND, WIDGET_AREA_ENTITY_TYPE, + buildWidgetsQuery, buildWidgetAreasQuery, buildWidgetAreaPostId, buildWidgetAreasPostId, @@ -29,7 +30,6 @@ export function* getWidgetAreas() { ); const widgetAreaBlocks = []; - const widgetIdToClientId = {}; const sortedWidgetAreas = widgetAreas.sort( ( a, b ) => { if ( a.id === 'wp_inactive_widgets' ) { return 1; @@ -40,25 +40,18 @@ export function* getWidgetAreas() { return 0; } ); for ( const widgetArea of sortedWidgetAreas ) { - const widgetBlocks = []; - for ( const widget of widgetArea.widgets ) { - const block = transformWidgetToBlock( widget ); - widgetIdToClientId[ widget.id ] = block.clientId; - widgetBlocks.push( block ); - } - - // Persist the actual post containing the navigation block - yield persistStubPost( - buildWidgetAreaPostId( widgetArea.id ), - widgetBlocks - ); - widgetAreaBlocks.push( createBlock( 'core/widget-area', { id: widgetArea.id, name: widgetArea.name, } ) ); + + if ( ! widgetArea.widgets.length ) { + // If this widget area has no widgets, it won't get a post setup by + // the getWidgets resolver. + yield persistStubPost( buildWidgetAreaPostId( widgetArea.id ), [] ); + } } const widgetAreasOpenState = {}; @@ -68,10 +61,43 @@ export function* getWidgetAreas() { } ); yield setWidgetAreasOpenState( widgetAreasOpenState ); + yield persistStubPost( buildWidgetAreasPostId(), widgetAreaBlocks ); +} + +export function* getWidgets() { + const query = buildWidgetsQuery(); + yield resolveWidgets( query ); + const widgets = yield select( + 'core', + 'getEntityRecords', + 'root', + 'widget', + query + ); + + const widgetIdToClientId = {}; + const groupedBySidebar = {}; + + for ( const widget of widgets ) { + const block = transformWidgetToBlock( widget ); + widgetIdToClientId[ widget.id ] = block.clientId; + groupedBySidebar[ widget.sidebar ] = + groupedBySidebar[ widget.sidebar ] || []; + groupedBySidebar[ widget.sidebar ].push( block ); + } + + for ( const sidebarId in groupedBySidebar ) { + if ( groupedBySidebar.hasOwnProperty( sidebarId ) ) { + // Persist the actual post containing the widget block + yield persistStubPost( + buildWidgetAreaPostId( sidebarId ), + groupedBySidebar[ sidebarId ] + ); + } + } + yield { type: 'SET_WIDGET_TO_CLIENT_ID_MAPPING', mapping: widgetIdToClientId, }; - - yield persistStubPost( buildWidgetAreasPostId(), widgetAreaBlocks ); } diff --git a/packages/edit-widgets/src/store/selectors.js b/packages/edit-widgets/src/store/selectors.js index 279863100b57e..8d29a36ee39bb 100644 --- a/packages/edit-widgets/src/store/selectors.js +++ b/packages/edit-widgets/src/store/selectors.js @@ -12,6 +12,7 @@ import { createRegistrySelector } from '@wordpress/data'; * Internal dependencies */ import { + buildWidgetsQuery, buildWidgetAreasQuery, buildWidgetAreaPostId, KIND, @@ -20,12 +21,13 @@ import { } from './utils'; export const getWidgets = createRegistrySelector( ( select ) => () => { - const initialWidgetAreas = select( 'core/edit-widgets' ).getWidgetAreas(); - - return keyBy( - initialWidgetAreas.flatMap( ( area ) => area.widgets ), - ( widget ) => widget.id + const widgets = select( 'core' ).getEntityRecords( + 'root', + 'widget', + buildWidgetsQuery() ); + + return keyBy( widgets, 'id' ); } ); /** diff --git a/packages/edit-widgets/src/store/utils.js b/packages/edit-widgets/src/store/utils.js index 1e53520832b70..33ac9da463929 100644 --- a/packages/edit-widgets/src/store/utils.js +++ b/packages/edit-widgets/src/store/utils.js @@ -46,6 +46,17 @@ export function buildWidgetAreasQuery() { }; } +/** + * Builds a query to resolve widgets. + * + * @return {Object} Query. + */ +export function buildWidgetsQuery() { + return { + per_page: -1, + }; +} + /** * Creates a stub post with given id and set of blocks. Used as a governing entity records * for all widget areas. diff --git a/phpunit/class-rest-sidebars-controller-test.php b/phpunit/class-rest-sidebars-controller-test.php index cea5a984f4ba2..22df5e7cd5752 100644 --- a/phpunit/class-rest-sidebars-controller-test.php +++ b/phpunit/class-rest-sidebars-controller-test.php @@ -366,7 +366,7 @@ public function test_get_item_wrong_permission_subscriber() { } /** - * The test_update_item() method does not exist for sidebar. + * The test_create_item() method does not exist for sidebar. */ public function test_create_item() { } @@ -436,6 +436,89 @@ public function test_update_item() { ); } + /** + * + */ + public function test_update_item_removes_widget_from_existing_sidebar() { + $this->setup_widget( + 'widget_text', + 1, + array( + 'text' => 'Custom text test', + ) + ); + $this->setup_sidebar( + 'sidebar-1', + array( + 'name' => 'Test sidebar', + ), + array( 'text-1' ) + ); + $this->setup_sidebar( + 'sidebar-2', + array( + 'name' => 'Test sidebar 2', + ), + array() + ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sidebars/sidebar-2' ); + $request->set_body_params( + array( + 'widgets' => array( + 'text-1', + ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertContains( 'text-1', $data['widgets'] ); + + $this->assertNotContains( 'text-1', rest_do_request( '/wp/v2/sidebars/sidebar-1' )->get_data()['widgets'] ); + } + + /** + * + */ + public function test_update_item_moves_omitted_widget_to_inactive_sidebar() { + $this->setup_widget( + 'widget_text', + 1, + array( + 'text' => 'Custom text test', + ) + ); + $this->setup_widget( + 'widget_text', + 2, + array( + 'text' => 'Custom text test', + ) + ); + $this->setup_sidebar( + 'sidebar-1', + array( + 'name' => 'Test sidebar', + ), + array( 'text-1' ) + ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/sidebars/sidebar-1' ); + $request->set_body_params( + array( + 'widgets' => array( + 'text-2', + ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertContains( 'text-2', $data['widgets'] ); + $this->assertNotContains( 'text-1', $data['widgets'] ); + + $this->assertContains( 'text-1', rest_do_request( '/wp/v2/sidebars/wp_inactive_widgets' )->get_data()['widgets'] ); + } + /** * */ diff --git a/phpunit/class-rest-widgets-controller-test.php b/phpunit/class-rest-widgets-controller-test.php index 021b35b7620bc..a8aa0a768006c 100644 --- a/phpunit/class-rest-widgets-controller-test.php +++ b/phpunit/class-rest-widgets-controller-test.php @@ -426,6 +426,66 @@ public function test_create_item() { ); } + /** + * + */ + public function test_create_item_multiple_in_a_row() { + $this->setup_sidebar( + 'sidebar-1', + array( + 'name' => 'Test sidebar', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/widgets' ); + $request->set_body_params( + array( + 'sidebar' => 'sidebar-1', + 'settings' => array( 'text' => 'Text 1' ), + 'id_base' => 'text', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'text-1', $data['id'] ); + $this->assertEquals( 'sidebar-1', $data['sidebar'] ); + $this->assertEquals( 1, $data['number'] ); + $this->assertEqualSets( + array( + 'text' => 'Text 1', + 'title' => '', + 'filter' => false, + ), + $data['settings'] + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/widgets' ); + $request->set_body_params( + array( + 'sidebar' => 'sidebar-1', + 'settings' => array( 'text' => 'Text 2' ), + 'id_base' => 'text', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 'text-2', $data['id'] ); + $this->assertEquals( 'sidebar-1', $data['sidebar'] ); + $this->assertEquals( 2, $data['number'] ); + $this->assertEqualSets( + array( + 'text' => 'Text 2', + 'title' => '', + 'filter' => false, + ), + $data['settings'] + ); + + $sidebar = rest_do_request( '/wp/v2/sidebars/sidebar-1' ); + $this->assertContains( 'text-1', $sidebar->get_data()['widgets'] ); + $this->assertContains( 'text-2', $sidebar->get_data()['widgets'] ); + } + /** * */