From 4c90150fb3069e6c2785a0df267a4f344ace4a03 Mon Sep 17 00:00:00 2001
From: Gary Pendergast <gary@pento.net>
Date: Mon, 16 Apr 2018 08:46:10 +1000
Subject: [PATCH] Add Editable Permalinks (#5756)

What a wild ride. Thanks for the memories.

Closes #5414, #1285.
---
 components/button/index.js                    |   4 +
 editor/components/post-permalink/editor.js    |  98 +++++++++++++
 editor/components/post-permalink/index.js     | 137 ++++++++++++------
 editor/components/post-permalink/style.scss   |  41 +++++-
 editor/components/post-title/index.js         |   8 +-
 editor/store/actions.js                       |   6 +
 editor/store/effects.js                       |  18 +++
 editor/store/reducer.js                       |   7 +-
 editor/store/selectors.js                     |  51 +++++++
 editor/store/test/selectors.js                |  92 ++++++++++++
 lib/compat.php                                |  73 ++++++++++
 phpunit/class-rest-blocks-controller-test.php |  59 +++++---
 12 files changed, 525 insertions(+), 69 deletions(-)
 create mode 100644 editor/components/post-permalink/editor.js

diff --git a/components/button/index.js b/components/button/index.js
index 98a076fc3d8d7e..49713d398a8245 100644
--- a/components/button/index.js
+++ b/components/button/index.js
@@ -29,6 +29,10 @@ class Button extends Component {
 		this.ref = ref;
 	}
 
+	focus() {
+		this.ref.focus();
+	}
+
 	render() {
 		const {
 			href,
diff --git a/editor/components/post-permalink/editor.js b/editor/components/post-permalink/editor.js
new file mode 100644
index 00000000000000..64a5a2f87a9fd9
--- /dev/null
+++ b/editor/components/post-permalink/editor.js
@@ -0,0 +1,98 @@
+/**
+ * WordPress dependencies
+ */
+import { withDispatch, withSelect } from '@wordpress/data';
+import { Component, compose } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Button } from '@wordpress/components';
+
+/**
+ * Internal Dependencies
+ */
+import './style.scss';
+
+class PostPermalinkEditor extends Component {
+	constructor( { permalinkParts } ) {
+		super( ...arguments );
+
+		this.state = {
+			editedPostName: permalinkParts.postName,
+		};
+
+		this.onSavePermalink = this.onSavePermalink.bind( this );
+	}
+
+	onSavePermalink( event ) {
+		const postName = this.state.editedPostName.replace( /\s+/g, '-' );
+
+		event.preventDefault();
+
+		this.props.onSave();
+
+		if ( ! postName || postName === this.props.postName ) {
+			return;
+		}
+
+		this.props.editPost( {
+			slug: postName,
+		} );
+
+		this.setState( {
+			editedPostName: postName,
+		} );
+	}
+
+	render() {
+		const { prefix, suffix } = this.props.permalinkParts;
+		const { editedPostName } = this.state;
+
+		/* eslint-disable jsx-a11y/no-autofocus */
+		// Autofocus is allowed here, as this mini-UI is only loaded when the user clicks to open it.
+		return (
+			<form
+				className="editor-post-permalink-editor"
+				onSubmit={ this.onSavePermalink }
+			>
+				<span>
+					<span className="editor-post-permalink-editor__prefix">
+						{ prefix }
+					</span>
+					<input
+						className="editor-post-permalink-editor__edit"
+						aria-label={ __( 'Edit post permalink' ) }
+						value={ editedPostName }
+						onChange={ ( event ) => this.setState( { editedPostName: event.target.value } ) }
+						required
+						autoFocus
+					/>
+					<span className="editor-post-permalink-editor__suffix">
+						{ suffix }
+					</span>
+					&lrm;
+				</span>
+				<Button
+					className="editor-post-permalink-editor__save"
+					isLarge
+					onClick={ this.onSavePermalink }
+				>
+					{ __( 'OK' ) }
+				</Button>
+			</form>
+		);
+		/* eslint-enable jsx-a11y/no-autofocus */
+	}
+}
+
+export default compose( [
+	withSelect( ( select ) => {
+		const { getPermalinkParts } = select( 'core/editor' );
+		return {
+			permalinkParts: getPermalinkParts(),
+		};
+	} ),
+	withDispatch( ( dispatch ) => {
+		const { editPost } = dispatch( 'core/editor' );
+		return { editPost };
+	} ),
+] )( PostPermalinkEditor );
+
diff --git a/editor/components/post-permalink/index.js b/editor/components/post-permalink/index.js
index c7c19f8b5e5a4c..b479e7aac288f0 100644
--- a/editor/components/post-permalink/index.js
+++ b/editor/components/post-permalink/index.js
@@ -1,79 +1,134 @@
-/**
- * External dependencies
- */
-import { connect } from 'react-redux';
-
 /**
  * WordPress dependencies
  */
-import { Component } from '@wordpress/element';
+import { withDispatch, withSelect } from '@wordpress/data';
+import { Component, compose } from '@wordpress/element';
 import { __ } from '@wordpress/i18n';
-import { Dashicon, ClipboardButton, Button } from '@wordpress/components';
+import { Dashicon, Button, ClipboardButton, Tooltip } from '@wordpress/components';
 
 /**
  * Internal Dependencies
  */
 import './style.scss';
-import { isEditedPostNew, getEditedPostAttribute } from '../../store/selectors';
+import PostPermalinkEditor from './editor.js';
+import { getWPAdminURL } from '../../utils/url';
 
 class PostPermalink extends Component {
 	constructor() {
 		super( ...arguments );
+
+		this.addVisibilityCheck = this.addVisibilityCheck.bind( this );
+		this.onVisibilityChange = this.onVisibilityChange.bind( this );
+
 		this.state = {
-			showCopyConfirmation: false,
+			iconClass: '',
+			isEditingPermalink: false,
 		};
-		this.onCopy = this.onCopy.bind( this );
-		this.onFinishCopy = this.onFinishCopy.bind( this );
 	}
 
-	componentWillUnmount() {
-		clearTimeout( this.dismissCopyConfirmation );
+	addVisibilityCheck() {
+		window.addEventListener( 'visibilitychange', this.onVisibilityChange );
+	}
+
+	onVisibilityChange() {
+		const { isEditable, refreshPost } = this.props;
+		// If the user just returned after having clicked the "Change Permalinks" button,
+		// fetch a new copy of the post from the server, just in case they enabled permalinks.
+		if ( ! isEditable && 'visible' === document.visibilityState ) {
+			refreshPost();
+		}
 	}
 
-	onCopy() {
-		this.setState( {
-			showCopyConfirmation: true,
-		} );
+	componentDidUpdate( prevProps, prevState ) {
+		// If we've just stopped editing the permalink, focus on the new permalink.
+		if ( prevState.isEditingPermalink && ! this.state.isEditingPermalink ) {
+			this.permalinkButton.focus();
+		}
 	}
 
-	onFinishCopy() {
-		this.setState( {
-			showCopyConfirmation: false,
-		} );
+	componentWillUnmount() {
+		window.removeEventListener( 'visibilitychange', this.addVisibilityCheck );
 	}
 
 	render() {
-		const { isNew, link } = this.props;
-		if ( isNew || ! link ) {
+		const { isNew, previewLink, isEditable, samplePermalink } = this.props;
+		const { iconClass, isEditingPermalink } = this.state;
+
+		if ( isNew || ! previewLink ) {
 			return null;
 		}
 
 		return (
 			<div className="editor-post-permalink">
-				<Dashicon icon="admin-links" />
+				<Tooltip text={ __( 'Copy the permalink to your clipboard' ) }>
+					<ClipboardButton
+						className="editor-post-permalink__copy"
+						text={ samplePermalink }
+						onCopy={ () => this.setState( { iconClass: 'is-copied' } ) }
+					>
+						<Dashicon icon="admin-links" className={ iconClass } />
+					</ClipboardButton>
+				</Tooltip>
+
 				<span className="editor-post-permalink__label">{ __( 'Permalink:' ) }</span>
-				<Button className="editor-post-permalink__link" href={ link } target="_blank">
-					{ decodeURI( link ) }
-				</Button>
-				<ClipboardButton
-					className="button"
-					text={ link }
-					onCopy={ this.onCopy }
-					onFinishCopy={ this.onFinishCopy }
-				>
-					{ this.state.showCopyConfirmation ? __( 'Copied!' ) : __( 'Copy' ) }
-				</ClipboardButton>
+
+				{ ! isEditingPermalink &&
+					<Button
+						className="editor-post-permalink__link"
+						href={ previewLink }
+						target="_blank"
+						ref={ ( permalinkButton ) => this.permalinkButton = permalinkButton }
+					>
+						{ decodeURI( samplePermalink ) }
+						&lrm;
+					</Button>
+				}
+
+				{ isEditingPermalink &&
+					<PostPermalinkEditor
+						onSave={ () => this.setState( { isEditingPermalink: false } ) }
+					/>
+				}
+
+				{ isEditable && ! isEditingPermalink &&
+					<Button
+						className="editor-post-permalink__edit"
+						isLarge
+						onClick={ () => this.setState( { isEditingPermalink: true } ) }
+					>
+						{ __( 'Edit' ) }
+					</Button>
+				}
+
+				{ ! isEditable &&
+					<Button
+						className="editor-post-permalink__change"
+						isLarge
+						href={ getWPAdminURL( 'options-permalink.php' ) }
+						onClick={ this.addVisibilityCheck }
+						target="_blank"
+					>
+						{ __( 'Change Permalinks' ) }
+					</Button>
+				}
 			</div>
 		);
 	}
 }
 
-export default connect(
-	( state ) => {
+export default compose( [
+	withSelect( ( select ) => {
+		const { isEditedPostNew, isPermalinkEditable, getEditedPostPreviewLink, getPermalink } = select( 'core/editor' );
 		return {
-			isNew: isEditedPostNew( state ),
-			link: getEditedPostAttribute( state, 'link' ),
+			isNew: isEditedPostNew(),
+			previewLink: getEditedPostPreviewLink(),
+			isEditable: isPermalinkEditable(),
+			samplePermalink: getPermalink(),
 		};
-	}
-)( PostPermalink );
+	} ),
+	withDispatch( ( dispatch ) => {
+		const { refreshPost } = dispatch( 'core/editor' );
+		return { refreshPost };
+	} ),
+] )( PostPermalink );
 
diff --git a/editor/components/post-permalink/style.scss b/editor/components/post-permalink/style.scss
index 92213eb18813ae..1747133f5b8966 100644
--- a/editor/components/post-permalink/style.scss
+++ b/editor/components/post-permalink/style.scss
@@ -17,6 +17,14 @@
 	}
 }
 
+.editor-post-permalink__copy {
+	margin-top: 4px;
+}
+
+.editor-post-permalink__copy .is-copied {
+	opacity: 0.3;
+}
+
 .editor-post-permalink__label {
 	margin: 0 10px;
 }
@@ -31,6 +39,37 @@
 	white-space: nowrap;
 
 	&:after {
-		@include long-content-fade( $size: 20% );
+		@include long-content-fade( $size: 20%, $edge: 1px );
+	}
+}
+
+.editor-post-permalink-editor {
+	width: 100%;
+	min-width: 20%;
+	display: inline-flex;
+	align-items: center;
+
+	// Higher specificity required to override core margin styles
+	.editor-post-permalink-editor__save {
+		margin-left: auto;
 	}
 }
+
+.editor-post-permalink-editor__prefix {
+	color: $dark-gray-300;
+	min-width: 20%;
+	overflow: hidden;
+	position: relative;
+	white-space: nowrap;
+	text-overflow: ellipsis;
+}
+
+.editor-post-permalink-editor__edit {
+	min-width: 20%;
+	margin: 0 5px;
+}
+
+.editor-post-permalink-editor__suffix {
+	color: $dark-gray-300;
+	margin-right: 10px;
+}
diff --git a/editor/components/post-title/index.js b/editor/components/post-title/index.js
index 060320dc293a9e..79d04da5a7387f 100644
--- a/editor/components/post-title/index.js
+++ b/editor/components/post-title/index.js
@@ -3,6 +3,7 @@
  */
 import Textarea from 'react-autosize-textarea';
 import classnames from 'classnames';
+import { get } from 'lodash';
 
 /**
  * WordPress dependencies
@@ -88,7 +89,7 @@ class PostTitle extends Component {
 	}
 
 	render() {
-		const { title, placeholder, instanceId } = this.props;
+		const { title, placeholder, instanceId, isPostTypeViewable } = this.props;
 		const { isSelected } = this.state;
 		const className = classnames( 'editor-post-title', { 'is-selected': isSelected } );
 		const decodedPlaceholder = decodeEntities( placeholder );
@@ -96,7 +97,6 @@ class PostTitle extends Component {
 		return (
 			<PostTypeSupportCheck supportKeys="title">
 				<div className={ className }>
-					{ isSelected && <PostPermalink /> }
 					<KeyboardShortcuts
 						shortcuts={ {
 							'mod+z': this.redirectHistory,
@@ -117,6 +117,7 @@ class PostTitle extends Component {
 							onKeyPress={ this.onUnselect }
 						/>
 					</KeyboardShortcuts>
+					{ isSelected && isPostTypeViewable && <PostPermalink /> }
 				</div>
 			</PostTypeSupportCheck>
 		);
@@ -125,9 +126,12 @@ class PostTitle extends Component {
 
 const applyWithSelect = withSelect( ( select ) => {
 	const { getEditedPostAttribute } = select( 'core/editor' );
+	const { getPostType } = select( 'core' );
+	const postType = getPostType( getEditedPostAttribute( 'type' ) );
 
 	return {
 		title: getEditedPostAttribute( 'title' ),
+		isPostTypeViewable: get( postType, [ 'viewable' ], false ),
 	};
 } );
 
diff --git a/editor/store/actions.js b/editor/store/actions.js
index 47005fd67a4ad3..d80417db2227b4 100644
--- a/editor/store/actions.js
+++ b/editor/store/actions.js
@@ -359,6 +359,12 @@ export function savePost() {
 	};
 }
 
+export function refreshPost() {
+	return {
+		type: 'REFRESH_POST',
+	};
+}
+
 export function trashPost( postId, postType ) {
 	return {
 		type: 'TRASH_POST',
diff --git a/editor/store/effects.js b/editor/store/effects.js
index bf40697c05d5d7..cf8ac1774354b8 100644
--- a/editor/store/effects.js
+++ b/editor/store/effects.js
@@ -117,6 +117,7 @@ export default {
 					type: 'REQUEST_POST_UPDATE_SUCCESS',
 					previousPost: post,
 					post: newPost,
+					edits: toSend,
 					optimist: { type: COMMIT, id: POST_UPDATE_TRANSACTION_ID },
 				} );
 			},
@@ -238,6 +239,23 @@ export default {
 		const message = action.error.message && action.error.code !== 'unknown_error' ? action.error.message : __( 'Trashing failed' );
 		store.dispatch( createErrorNotice( message, { id: TRASH_POST_NOTICE_ID } ) );
 	},
+	REFRESH_POST( action, store ) {
+		const { dispatch, getState } = store;
+
+		const state = getState();
+		const post = getCurrentPost( state );
+		const basePath = wp.api.getPostTypeRoute( getCurrentPostType( state ) );
+
+		const data = {
+			context: 'edit',
+		};
+
+		wp.apiRequest( { path: `/wp/v2/${ basePath }/${ post.id }`, data } ).then(
+			( newPost ) => {
+				dispatch( resetPost( newPost ) );
+			}
+		);
+	},
 	MERGE_BLOCKS( action, store ) {
 		const { dispatch } = store;
 		const state = store.getState();
diff --git a/editor/store/reducer.js b/editor/store/reducer.js
index d36fbc32fe7494..6d981f278de586 100644
--- a/editor/store/reducer.js
+++ b/editor/store/reducer.js
@@ -254,9 +254,14 @@ export const editor = flow( [
 
 				return state;
 
+			case 'UPDATE_POST':
 			case 'RESET_POST':
+				const getCanonicalValue = action.type === 'UPDATE_POST' ?
+					( key ) => action.edits[ key ] :
+					( key ) => getPostRawValue( action.post[ key ] );
+
 				return reduce( state, ( result, value, key ) => {
-					if ( value !== getPostRawValue( action.post[ key ] ) ) {
+					if ( value !== getCanonicalValue( key ) ) {
 						return result;
 					}
 
diff --git a/editor/store/selectors.js b/editor/store/selectors.js
index 7285e02d7e9511..9bbe5e78b15947 100644
--- a/editor/store/selectors.js
+++ b/editor/store/selectors.js
@@ -31,6 +31,7 @@ import { deprecated } from '@wordpress/utils';
  */
 const MAX_RECENT_BLOCKS = 9;
 export const POST_UPDATE_TRANSACTION_ID = 'post-update';
+const PERMALINK_POSTNAME_REGEX = /%(?:postname|pagename)%/;
 
 /**
  * Shared reference to an empty array for cases where it is important to avoid
@@ -1516,3 +1517,53 @@ export function isPublishingPost( state ) {
 export function getProvisionalBlockUID( state ) {
 	return state.provisionalBlockUID;
 }
+
+/**
+ * Returns whether the permalink is editable or not.
+ *
+ * @param {Object} state Editor state.
+ *
+ * @return {boolean} Whether or not the permalink is editable.
+ */
+export function isPermalinkEditable( state ) {
+	const permalinkTemplate = getEditedPostAttribute( state, 'permalink_template' );
+
+	return PERMALINK_POSTNAME_REGEX.test( permalinkTemplate );
+}
+
+/**
+ * Returns the permalink for the post.
+ *
+ * @param {Object} state Editor state.
+ *
+ * @return {string} The permalink.
+ */
+export function getPermalink( state ) {
+	const { prefix, postName, suffix } = getPermalinkParts( state );
+
+	if ( isPermalinkEditable( state ) ) {
+		return prefix + postName + suffix;
+	}
+
+	return prefix;
+}
+
+/**
+ * Returns the permalink for a post, split into it's three parts: the prefix, the postName, and the suffix.
+ *
+ * @param {Object} state Editor state.
+ *
+ * @return {Object} The prefix, postName, and suffix for the permalink.
+ */
+export function getPermalinkParts( state ) {
+	const permalinkTemplate = getEditedPostAttribute( state, 'permalink_template' );
+	const postName = getEditedPostAttribute( state, 'slug' ) || getEditedPostAttribute( state, 'draft_slug' );
+
+	const [ prefix, suffix ] = permalinkTemplate.split( PERMALINK_POSTNAME_REGEX );
+
+	return {
+		prefix,
+		postName,
+		suffix,
+	};
+}
diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js
index e89c0f445f0ab2..a3c6d7e1ca3209 100644
--- a/editor/store/test/selectors.js
+++ b/editor/store/test/selectors.js
@@ -84,6 +84,9 @@ const {
 	getTemplate,
 	getTemplateLock,
 	POST_UPDATE_TRANSACTION_ID,
+	isPermalinkEditable,
+	getPermalink,
+	getPermalinkParts,
 } = selectors;
 
 describe( 'selectors', () => {
@@ -3093,4 +3096,93 @@ describe( 'selectors', () => {
 			expect( getTemplateLock( state ) ).toBe( 'all' );
 		} );
 	} );
+
+	describe( 'isPermalinkEditable', () => {
+		it( 'should be false if there is no permalink', () => {
+			const state = {
+				currentPost: { permalink_template: '' },
+			};
+
+			expect( isPermalinkEditable( state ) ).toBe( false );
+		} );
+
+		it( 'should be false if the permalink is not of an editable kind', () => {
+			const state = {
+				currentPost: { permalink_template: 'http://foo.test/bar/%baz%/' },
+			};
+
+			expect( isPermalinkEditable( state ) ).toBe( false );
+		} );
+
+		it( 'should be true if the permalink has %postname%', () => {
+			const state = {
+				currentPost: { permalink_template: 'http://foo.test/bar/%postname%/' },
+			};
+
+			expect( isPermalinkEditable( state ) ).toBe( true );
+		} );
+
+		it( 'should be true if the permalink has %pagename%', () => {
+			const state = {
+				currentPost: { permalink_template: 'http://foo.test/bar/%pagename%/' },
+			};
+
+			expect( isPermalinkEditable( state ) ).toBe( true );
+		} );
+	} );
+
+	describe( 'getPermalink', () => {
+		it( 'should work if the permalink is not of an editable kind', () => {
+			const url = 'http://foo.test/?post=1';
+			const state = {
+				currentPost: { permalink_template: url },
+			};
+
+			expect( getPermalink( state ) ).toBe( url );
+		} );
+
+		it( 'should return the permalink if it is editable', () => {
+			const state = {
+				currentPost: {
+					permalink_template: 'http://foo.test/bar/%postname%/',
+					slug: 'baz',
+				},
+			};
+
+			expect( getPermalink( state ) ).toBe( 'http://foo.test/bar/baz/' );
+		} );
+	} );
+
+	describe( 'getPermalinkParts', () => {
+		it( 'should split the permalink correctly', () => {
+			const parts = {
+				prefix: 'http://foo.test/bar/',
+				postName: 'baz',
+				suffix: '/',
+			};
+			const state = {
+				currentPost: {
+					permalink_template: 'http://foo.test/bar/%postname%/',
+					slug: 'baz',
+				},
+			};
+
+			expect( getPermalinkParts( state ) ).toEqual( parts );
+		} );
+
+		it( 'should leave an uneditable permalink in the prefix', () => {
+			const parts = {
+				prefix: 'http://foo.test/?post=1',
+				postName: 'baz',
+			};
+			const state = {
+				currentPost: {
+					permalink_template: 'http://foo.test/?post=1',
+					slug: 'baz',
+				},
+			};
+
+			expect( getPermalinkParts( state ) ).toEqual( parts );
+		} );
+	} );
 } );
diff --git a/lib/compat.php b/lib/compat.php
index c53ad6847e1a0b..23ceedb0f011b6 100644
--- a/lib/compat.php
+++ b/lib/compat.php
@@ -437,3 +437,76 @@ function gutenberg_get_taxonomy_visibility_data( $object ) {
 }
 
 add_action( 'rest_api_init', 'gutenberg_add_taxonomy_visibility_field' );
+
+/**
+ * Add a permalink template to posts in the post REST API response.
+ *
+ * @param WP_REST_Response $response WP REST API response of a post.
+ * @param WP_Post          $post The post being returned.
+ * @param WP_REST_Request  $request WP REST API request.
+ * @return WP_REST_Response Response containing the permalink_template.
+ */
+function gutenberg_add_permalink_template_to_posts( $response, $post, $request ) {
+	if ( 'edit' !== $request['context'] ) {
+		return $response;
+	}
+
+	if ( ! function_exists( 'get_sample_permalink' ) ) {
+		require_once ABSPATH . '/wp-admin/includes/post.php';
+	}
+
+	$sample_permalink = get_sample_permalink( $post->ID );
+
+	$response->data['permalink_template'] = $sample_permalink[0];
+
+	if ( 'draft' === $post->post_status && ! $post->post_name ) {
+		$response->data['draft_slug'] = $sample_permalink[1];
+	}
+
+	return $response;
+}
+
+/**
+ * Whenever a post type is registered, ensure we're hooked into it's WP REST API response.
+ *
+ * @param string $post_type The newly registered post type.
+ * @return string That same post type.
+ */
+function gutenberg_register_permalink_template_function( $post_type ) {
+	add_filter( "rest_prepare_{$post_type}", 'gutenberg_add_permalink_template_to_posts', 10, 3 );
+	return $post_type;
+}
+add_filter( 'registered_post_type', 'gutenberg_register_permalink_template_function' );
+
+/**
+ * Includes the value for the 'viewable' attribute of a post type resource.
+ *
+ * @see https://core.trac.wordpress.org/ticket/43739
+ *
+ * @param object $post_type Post type response object.
+ * @return boolean Whether or not the post type can be viewed.
+ */
+function gutenberg_get_post_type_viewable( $post_type ) {
+	return is_post_type_viewable( $post_type['slug'] );
+}
+
+/**
+ * Adds the 'viewable' attribute to the REST API response of a post type.
+ *
+ * @see https://core.trac.wordpress.org/ticket/43739
+ */
+function gutenberg_register_rest_api_post_type_viewable() {
+	register_rest_field( 'type',
+		'viewable',
+		array(
+			'get_callback' => 'gutenberg_get_post_type_viewable',
+			'schema'       => array(
+				'description' => __( 'Whether or not the post type can be viewed', 'gutenberg' ),
+				'type'        => 'boolean',
+				'context'     => array( 'edit' ),
+				'readonly'    => true,
+			),
+		)
+	);
+}
+add_action( 'rest_api_init', 'gutenberg_register_rest_api_post_type_viewable' );
diff --git a/phpunit/class-rest-blocks-controller-test.php b/phpunit/class-rest-blocks-controller-test.php
index f29ff5c2f767cb..bafcb2b31b6a7d 100644
--- a/phpunit/class-rest-blocks-controller-test.php
+++ b/phpunit/class-rest-blocks-controller-test.php
@@ -124,13 +124,16 @@ public function test_create_item() {
 		$response = $this->server->dispatch( $request );
 
 		$this->assertEquals( 200, $response->get_status() );
-		$this->assertEquals(
-			array(
-				'id'      => self::$post_id,
-				'title'   => 'New cool block',
-				'content' => '<!-- wp:core/paragraph --><p>Wow!</p><!-- /wp:core/paragraph -->',
-			), $response->get_data()
-		);
+
+		$data = $response->get_data();
+
+		$this->assertArrayHasKey( 'id', $data );
+		$this->assertArrayHasKey( 'title', $data );
+		$this->assertArrayHasKey( 'content', $data );
+
+		$this->assertEquals( self::$post_id, $data['id'] );
+		$this->assertEquals( 'New cool block', $data['title'] );
+		$this->assertEquals( '<!-- wp:core/paragraph --><p>Wow!</p><!-- /wp:core/paragraph -->', $data['content'] );
 	}
 
 	/**
@@ -150,13 +153,16 @@ public function test_update_item() {
 		$response = $this->server->dispatch( $request );
 
 		$this->assertEquals( 200, $response->get_status() );
-		$this->assertEquals(
-			array(
-				'id'      => self::$post_id,
-				'title'   => 'Updated cool block',
-				'content' => '<!-- wp:core/paragraph --><p>Nice!</p><!-- /wp:core/paragraph -->',
-			), $response->get_data()
-		);
+
+		$data = $response->get_data();
+
+		$this->assertArrayHasKey( 'id', $data );
+		$this->assertArrayHasKey( 'title', $data );
+		$this->assertArrayHasKey( 'content', $data );
+
+		$this->assertEquals( self::$post_id, $data['id'] );
+		$this->assertEquals( 'Updated cool block', $data['title'] );
+		$this->assertEquals( '<!-- wp:core/paragraph --><p>Nice!</p><!-- /wp:core/paragraph -->', $data['content'] );
 	}
 
 	/**
@@ -170,16 +176,21 @@ public function test_delete_item() {
 		$response = $this->server->dispatch( $request );
 
 		$this->assertEquals( 200, $response->get_status() );
-		$this->assertEquals(
-			array(
-				'deleted'  => true,
-				'previous' => array(
-					'id'      => self::$post_id,
-					'title'   => 'My cool block',
-					'content' => '<!-- wp:core/paragraph --><p>Hello!</p><!-- /wp:core/paragraph -->',
-				),
-			), $response->get_data()
-		);
+
+		$data = $response->get_data();
+
+		$this->assertArrayHasKey( 'deleted', $data );
+		$this->assertArrayHasKey( 'previous', $data );
+
+		$this->assertTrue( $data['deleted'] );
+
+		$this->assertArrayHasKey( 'id', $data['previous'] );
+		$this->assertArrayHasKey( 'title', $data['previous'] );
+		$this->assertArrayHasKey( 'content', $data['previous'] );
+
+		$this->assertEquals( self::$post_id, $data['previous']['id'] );
+		$this->assertEquals( 'My cool block', $data['previous']['title'] );
+		$this->assertEquals( '<!-- wp:core/paragraph --><p>Hello!</p><!-- /wp:core/paragraph -->', $data['previous']['content'] );
 	}
 
 	/**