diff --git a/docs/designers-developers/developers/data/data-core.md b/docs/designers-developers/developers/data/data-core.md
index 3f823475fcca1c..ceb9daef907720 100644
--- a/docs/designers-developers/developers/data/data-core.md
+++ b/docs/designers-developers/developers/data/data-core.md
@@ -71,6 +71,22 @@ _Returns_
- `?Array`: An array of autosaves for the post, or undefined if there is none.
+# **getCurrentUndoOffset**
+
+Returns the current undo offset for the
+entity records edits history. The offset
+represents how many items from the end
+of the history stack we are at. 0 is the
+last edit, -1 is the second last, and so on.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+
+_Returns_
+
+- `number`: The current undo offset.
+
# **getCurrentUser**
Returns the current user.
@@ -83,6 +99,21 @@ _Returns_
- `Object`: Current user object.
+# **getEditedEntityRecord**
+
+Returns the specified entity record, merged with its edits.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: The entity record, merged with its edits.
+
# **getEmbedPreview**
Returns the embed preview for the given URL.
@@ -138,6 +169,40 @@ _Returns_
- `?Object`: Record.
+# **getEntityRecordEdits**
+
+Returns the specified entity record's edits.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: The entity record's edits.
+
+# **getEntityRecordNonTransientEdits**
+
+Returns the specified entity record's non transient edits.
+
+Transient edits don't create an undo level, and
+are not considered for change detection.
+They are defined in the entity's config.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: The entity record's non transient edits.
+
# **getEntityRecords**
Returns the Entity's records.
@@ -153,6 +218,34 @@ _Returns_
- `Array`: Records.
+# **getLastEntitySaveError**
+
+Returns the specified entity record's last save error.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: The entity record's save error.
+
+# **getRedoEdit**
+
+Returns the next edit from the current undo offset
+for the entity records edits history, if any.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+
+_Returns_
+
+- `?Object`: The edit.
+
# **getThemeSupports**
Return theme supports data in the index.
@@ -165,6 +258,19 @@ _Returns_
- `*`: Index data.
+# **getUndoEdit**
+
+Returns the previous edit from the current undo offset
+for the entity records edits history, if any.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+
+_Returns_
+
+- `?Object`: The edit.
+
# **getUserQueryResults**
Returns all the users returned by a query ID.
@@ -178,6 +284,22 @@ _Returns_
- `Array`: Users list.
+# **hasEditsForEntityRecord**
+
+Returns true if the specified entity record has edits,
+and false otherwise.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `boolean`: Whether the entity record has edits or not.
+
# **hasFetchedAutosaves**
Returns true if the REST request for autosaves has completed.
@@ -192,6 +314,32 @@ _Returns_
- `boolean`: True if the REST request was completed. False otherwise.
+# **hasRedo**
+
+Returns true if there is a next edit from the current undo offset
+for the entity records edits history, and false otherwise.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+
+_Returns_
+
+- `boolean`: Whether there is a next edit or not.
+
+# **hasUndo**
+
+Returns true if there is a previous edit from the current undo offset
+for the entity records edits history, and false otherwise.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+
+_Returns_
+
+- `boolean`: Whether there is a previous edit or not.
+
# **hasUploadPermissions**
> **Deprecated** since 5.0. Callers should use the more generic `canUser()` selector instead of `hasUploadPermissions()`, e.g. `canUser( 'create', 'media' )`.
@@ -242,6 +390,21 @@ _Returns_
- `boolean`: Whether a request is in progress for an embed preview.
+# **isSavingEntityRecord**
+
+Returns true if the specified entity record is saving, and false otherwise.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: Whether the entity record is saving or not.
+
@@ -261,6 +424,22 @@ _Returns_
- `Object`: Action object.
+# **editEntityRecord**
+
+Returns an action object that triggers an
+edit to an entity record.
+
+_Parameters_
+
+- _kind_ `string`: Kind of the edited entity record.
+- _name_ `string`: Name of the edited entity record.
+- _recordId_ `number`: Record ID of the edited entity record.
+- _edits_ `Object`: The edits.
+
+_Returns_
+
+- `Object`: Action object.
+
# **receiveAutosaves**
Returns an action object used in signalling that the autosaves for a
@@ -368,6 +547,21 @@ _Returns_
- `Object`: Action object.
+# **redo**
+
+Action triggered to redo the last undoed
+edit to an entity record, if any.
+
+# **saveEditedEntityRecord**
+
+Action triggered to save an entity record's edits.
+
+_Parameters_
+
+- _kind_ `string`: Kind of the entity.
+- _name_ `string`: Name of the entity.
+- _recordId_ `Object`: ID of the record.
+
# **saveEntityRecord**
Action triggered to save an entity record.
@@ -378,8 +572,9 @@ _Parameters_
- _name_ `string`: Name of the received entity.
- _record_ `Object`: Record to be saved.
-_Returns_
+# **undo**
-- `Object`: Updated record.
+Action triggered to undo the last edit to
+an entity record, if any.
diff --git a/packages/core-data/README.md b/packages/core-data/README.md
index a2fb271017fcd7..cf9f0a9989b28f 100644
--- a/packages/core-data/README.md
+++ b/packages/core-data/README.md
@@ -54,6 +54,22 @@ _Returns_
- `Object`: Action object.
+# **editEntityRecord**
+
+Returns an action object that triggers an
+edit to an entity record.
+
+_Parameters_
+
+- _kind_ `string`: Kind of the edited entity record.
+- _name_ `string`: Name of the edited entity record.
+- _recordId_ `number`: Record ID of the edited entity record.
+- _edits_ `Object`: The edits.
+
+_Returns_
+
+- `Object`: Action object.
+
# **receiveAutosaves**
Returns an action object used in signalling that the autosaves for a
@@ -161,6 +177,21 @@ _Returns_
- `Object`: Action object.
+# **redo**
+
+Action triggered to redo the last undoed
+edit to an entity record, if any.
+
+# **saveEditedEntityRecord**
+
+Action triggered to save an entity record's edits.
+
+_Parameters_
+
+- _kind_ `string`: Kind of the entity.
+- _name_ `string`: Name of the entity.
+- _recordId_ `Object`: ID of the record.
+
# **saveEntityRecord**
Action triggered to save an entity record.
@@ -171,9 +202,10 @@ _Parameters_
- _name_ `string`: Name of the received entity.
- _record_ `Object`: Record to be saved.
-_Returns_
+# **undo**
-- `Object`: Updated record.
+Action triggered to undo the last edit to
+an entity record, if any.
@@ -248,6 +280,22 @@ _Returns_
- `?Array`: An array of autosaves for the post, or undefined if there is none.
+# **getCurrentUndoOffset**
+
+Returns the current undo offset for the
+entity records edits history. The offset
+represents how many items from the end
+of the history stack we are at. 0 is the
+last edit, -1 is the second last, and so on.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+
+_Returns_
+
+- `number`: The current undo offset.
+
# **getCurrentUser**
Returns the current user.
@@ -260,6 +308,21 @@ _Returns_
- `Object`: Current user object.
+# **getEditedEntityRecord**
+
+Returns the specified entity record, merged with its edits.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: The entity record, merged with its edits.
+
# **getEmbedPreview**
Returns the embed preview for the given URL.
@@ -315,6 +378,40 @@ _Returns_
- `?Object`: Record.
+# **getEntityRecordEdits**
+
+Returns the specified entity record's edits.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: The entity record's edits.
+
+# **getEntityRecordNonTransientEdits**
+
+Returns the specified entity record's non transient edits.
+
+Transient edits don't create an undo level, and
+are not considered for change detection.
+They are defined in the entity's config.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: The entity record's non transient edits.
+
# **getEntityRecords**
Returns the Entity's records.
@@ -330,6 +427,34 @@ _Returns_
- `Array`: Records.
+# **getLastEntitySaveError**
+
+Returns the specified entity record's last save error.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: The entity record's save error.
+
+# **getRedoEdit**
+
+Returns the next edit from the current undo offset
+for the entity records edits history, if any.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+
+_Returns_
+
+- `?Object`: The edit.
+
# **getThemeSupports**
Return theme supports data in the index.
@@ -342,6 +467,19 @@ _Returns_
- `*`: Index data.
+# **getUndoEdit**
+
+Returns the previous edit from the current undo offset
+for the entity records edits history, if any.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+
+_Returns_
+
+- `?Object`: The edit.
+
# **getUserQueryResults**
Returns all the users returned by a query ID.
@@ -355,6 +493,22 @@ _Returns_
- `Array`: Users list.
+# **hasEditsForEntityRecord**
+
+Returns true if the specified entity record has edits,
+and false otherwise.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `boolean`: Whether the entity record has edits or not.
+
# **hasFetchedAutosaves**
Returns true if the REST request for autosaves has completed.
@@ -369,6 +523,32 @@ _Returns_
- `boolean`: True if the REST request was completed. False otherwise.
+# **hasRedo**
+
+Returns true if there is a next edit from the current undo offset
+for the entity records edits history, and false otherwise.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+
+_Returns_
+
+- `boolean`: Whether there is a next edit or not.
+
+# **hasUndo**
+
+Returns true if there is a previous edit from the current undo offset
+for the entity records edits history, and false otherwise.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+
+_Returns_
+
+- `boolean`: Whether there is a previous edit or not.
+
# **hasUploadPermissions**
> **Deprecated** since 5.0. Callers should use the more generic `canUser()` selector instead of `hasUploadPermissions()`, e.g. `canUser( 'create', 'media' )`.
@@ -419,6 +599,21 @@ _Returns_
- `boolean`: Whether a request is in progress for an embed preview.
+# **isSavingEntityRecord**
+
+Returns true if the specified entity record is saving, and false otherwise.
+
+_Parameters_
+
+- _state_ `Object`: State tree.
+- _kind_ `string`: Entity kind.
+- _name_ `string`: Entity name.
+- _recordId_ `number`: Record ID.
+
+_Returns_
+
+- `?Object`: Whether the entity record is saving or not.
+
diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js
index f9c7940d09db53..8e78957bbb6756 100644
--- a/packages/core-data/src/actions.js
+++ b/packages/core-data/src/actions.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { castArray, find } from 'lodash';
+import { castArray, merge, isEqual, find } from 'lodash';
/**
* Internal dependencies
@@ -11,7 +11,7 @@ import {
receiveQueriedItems,
} from './queried-data';
import { getKindEntities, DEFAULT_ENTITY_KEY } from './entities';
-import { apiFetch } from './controls';
+import { select, apiFetch } from './controls';
/**
* Returns an action object used in signalling that authors have been received.
@@ -115,14 +115,100 @@ export function receiveEmbedPreview( url, preview ) {
};
}
+/**
+ * Returns an action object that triggers an
+ * edit to an entity record.
+ *
+ * @param {string} kind Kind of the edited entity record.
+ * @param {string} name Name of the edited entity record.
+ * @param {number} recordId Record ID of the edited entity record.
+ * @param {Object} edits The edits.
+ *
+ * @return {Object} Action object.
+ */
+export function* editEntityRecord( kind, name, recordId, edits ) {
+ const { transientEdits = {}, mergedEdits = {} } = yield select( 'getEntity', kind, name );
+ const record = yield select( 'getEntityRecord', kind, name, recordId );
+ const editedRecord = yield select(
+ 'getEditedEntityRecord',
+ kind,
+ name,
+ recordId
+ );
+
+ const edit = {
+ kind,
+ name,
+ recordId,
+ // Clear edits when they are equal to their persisted counterparts
+ // so that the property is not considered dirty.
+ edits: Object.keys( edits ).reduce( ( acc, key ) => {
+ const value = mergedEdits[ key ] ?
+ merge( record[ key ], edits[ key ] ) :
+ edits[ key ];
+ acc[ key ] = isEqual( record[ key ], value ) ? undefined : value;
+ return acc;
+ }, {} ),
+ transientEdits,
+ };
+ return {
+ type: 'EDIT_ENTITY_RECORD',
+ ...edit,
+ meta: {
+ undo: {
+ ...edit,
+ // Send the current values for things like the first undo stack entry.
+ edits: Object.keys( edits ).reduce( ( acc, key ) => {
+ acc[ key ] = editedRecord[ key ];
+ return acc;
+ }, {} ),
+ },
+ },
+ };
+}
+
+/**
+ * Action triggered to undo the last edit to
+ * an entity record, if any.
+ */
+export function* undo() {
+ const undoEdit = yield select( 'getUndoEdit' );
+ if ( ! undoEdit ) {
+ return;
+ }
+ yield {
+ type: 'EDIT_ENTITY_RECORD',
+ ...undoEdit,
+ meta: {
+ isUndo: true,
+ },
+ };
+}
+
+/**
+ * Action triggered to redo the last undoed
+ * edit to an entity record, if any.
+ */
+export function* redo() {
+ const redoEdit = yield select( 'getRedoEdit' );
+ if ( ! redoEdit ) {
+ return;
+ }
+ yield {
+ type: 'EDIT_ENTITY_RECORD',
+ ...redoEdit,
+ meta: {
+ isRedo: true,
+ },
+ };
+}
+
/**
* Action triggered to save an entity record.
*
* @param {string} kind Kind of the received entity.
* @param {string} name Name of the received entity.
* @param {Object} record Record to be saved.
- *
- * @return {Object} Updated record.
*/
export function* saveEntityRecord( kind, name, record ) {
const entities = yield getKindEntities( kind );
@@ -132,14 +218,41 @@ export function* saveEntityRecord( kind, name, record ) {
}
const key = entity.key || DEFAULT_ENTITY_KEY;
const recordId = record[ key ];
- const updatedRecord = yield apiFetch( {
- path: `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`,
- method: recordId ? 'PUT' : 'POST',
- data: record,
- } );
- yield receiveEntityRecords( kind, name, updatedRecord, undefined, true );
-
- return updatedRecord;
+
+ yield { type: 'SAVE_ENTITY_RECORD_START', kind, name, recordId };
+ let error;
+ try {
+ const updatedRecord = yield apiFetch( {
+ path: `${ entity.baseURL }${ recordId ? '/' + recordId : '' }`,
+ method: recordId ? 'PUT' : 'POST',
+ data: record,
+ } );
+ yield receiveEntityRecords( kind, name, updatedRecord, undefined, true );
+ } catch ( _error ) {
+ error = _error;
+ }
+ yield { type: 'SAVE_ENTITY_RECORD_FINISH', kind, name, recordId, error };
+}
+
+/**
+ * Action triggered to save an entity record's edits.
+ *
+ * @param {string} kind Kind of the entity.
+ * @param {string} name Name of the entity.
+ * @param {Object} recordId ID of the record.
+ */
+export function* saveEditedEntityRecord( kind, name, recordId ) {
+ if ( ! ( yield select( 'hasEditsForEntityRecord', kind, name, recordId ) ) ) {
+ return;
+ }
+ const edits = yield select(
+ 'getEntityRecordNonTransientEdits',
+ kind,
+ name,
+ recordId
+ );
+ const record = { id: recordId, ...edits };
+ yield* saveEntityRecord( kind, name, record );
}
/**
diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js
index 280e54a5297f3b..a78142b3360f9a 100644
--- a/packages/core-data/src/entities.js
+++ b/packages/core-data/src/entities.js
@@ -35,6 +35,8 @@ function* loadPostTypeEntities() {
kind: 'postType',
baseURL: '/wp/v2/' + postType.rest_base,
name,
+ transientEdits: { blocks: true },
+ mergedEdits: { meta: true },
};
} );
}
diff --git a/packages/core-data/src/queried-data/reducer.js b/packages/core-data/src/queried-data/reducer.js
index 8e78544dcdbe2f..0c3256e9a8f987 100644
--- a/packages/core-data/src/queried-data/reducer.js
+++ b/packages/core-data/src/queried-data/reducer.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { keyBy, map, flowRight } from 'lodash';
+import { map, flowRight } from 'lodash';
/**
* WordPress dependencies
@@ -12,6 +12,7 @@ import { combineReducers } from '@wordpress/data';
* Internal dependencies
*/
import {
+ conservativeMapItem,
ifMatchingAction,
replaceAction,
onSubKey,
@@ -70,9 +71,14 @@ export function getMergedItemIds( itemIds, nextItemIds, page, perPage ) {
function items( state = {}, action ) {
switch ( action.type ) {
case 'RECEIVE_ITEMS':
+ const key = action.key || DEFAULT_ENTITY_KEY;
return {
...state,
- ...keyBy( action.items, action.key || DEFAULT_ENTITY_KEY ),
+ ...action.items.reduce( ( acc, value ) => {
+ const itemId = value[ key ];
+ acc[ itemId ] = conservativeMapItem( state[ itemId ], value );
+ return acc;
+ }, {} ),
};
}
diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js
index 272ab20e2693ec..96ed12e5ef78c7 100644
--- a/packages/core-data/src/reducer.js
+++ b/packages/core-data/src/reducer.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { keyBy, map, groupBy, flowRight } from 'lodash';
+import { keyBy, map, groupBy, flowRight, isEqual, get } from 'lodash';
/**
* WordPress dependencies
@@ -121,7 +121,9 @@ export function themeSupports( state = {}, action ) {
/**
* Higher Order Reducer for a given entity config. It supports:
*
- * - Fetching a record by primary key
+ * - Fetching
+ * - Editing
+ * - Saving
*
* @param {Object} entityConfig Entity config.
*
@@ -145,7 +147,80 @@ function entity( entityConfig ) {
key: entityConfig.key || DEFAULT_ENTITY_KEY,
};
} ),
- ] )( queriedDataReducer );
+ ] )(
+ combineReducers( {
+ queriedData: queriedDataReducer,
+
+ edits: ( state = {}, action ) => {
+ switch ( action.type ) {
+ case 'RECEIVE_ITEMS':
+ const nextState = { ...state };
+
+ for ( const record of action.items ) {
+ const recordId = record[ action.key ];
+ const edits = nextState[ recordId ];
+ if ( ! edits ) {
+ continue;
+ }
+
+ const nextEdits = Object.keys( edits ).reduce( ( acc, key ) => {
+ // If the edited value is still different to the persisted value,
+ // keep the edited value in edits.
+ if (
+ ! isEqual( edits[ key ], get( record[ key ], 'raw', record[ key ] ) )
+ ) {
+ acc[ key ] = edits[ key ];
+ }
+ return acc;
+ }, {} );
+
+ if ( Object.keys( nextEdits ).length ) {
+ nextState[ recordId ] = nextEdits;
+ } else {
+ delete nextState[ recordId ];
+ }
+ }
+
+ return nextState;
+
+ case 'EDIT_ENTITY_RECORD':
+ const nextEdits = {
+ ...state[ action.recordId ],
+ ...action.edits,
+ };
+ Object.keys( nextEdits ).forEach( ( key ) => {
+ // Delete cleared edits so that the properties
+ // are not considered dirty.
+ if ( nextEdits[ key ] === undefined ) {
+ delete nextEdits[ key ];
+ }
+ } );
+ return {
+ ...state,
+ [ action.recordId ]: nextEdits,
+ };
+ }
+
+ return state;
+ },
+
+ saving: ( state = {}, action ) => {
+ switch ( action.type ) {
+ case 'SAVE_ENTITY_RECORD_START':
+ case 'SAVE_ENTITY_RECORD_FINISH':
+ return {
+ ...state,
+ [ action.recordId ]: {
+ pending: action.type === 'SAVE_ENTITY_RECORD_START',
+ error: action.error,
+ },
+ };
+ }
+
+ return state;
+ },
+ } )
+ );
}
/**
@@ -214,6 +289,66 @@ export const entities = ( state = {}, action ) => {
};
};
+/**
+ * Reducer keeping track of entity edit undo history.
+ *
+ * @param {Object} state Current state.
+ * @param {Object} action Dispatched action.
+ *
+ * @return {Object} Updated state.
+ */
+const UNDO_INITIAL_STATE = [];
+UNDO_INITIAL_STATE.offset = 0;
+export function undo( state = UNDO_INITIAL_STATE, action ) {
+ switch ( action.type ) {
+ case 'EDIT_ENTITY_RECORD':
+ if ( action.meta.isUndo || action.meta.isRedo ) {
+ const nextState = [ ...state ];
+ nextState.offset = state.offset + ( action.meta.isUndo ? -1 : 1 );
+ return nextState;
+ }
+
+ // Transient edits don't create an undo level, but are
+ // reachable in the next meaningful edit to which they
+ // are merged. They are defined in the entity's config.
+ if ( ! Object.keys( action.edits ).some( ( key ) => ! action.transientEdits[ key ] ) ) {
+ const nextState = [ ...state ];
+ nextState.flattenedUndo = { ...state.flattenedUndo, ...action.edits };
+ nextState.offset = state.offset;
+ return nextState;
+ }
+
+ let nextState;
+ if ( state.length === 0 ) {
+ // Create an initial entry so that we can undo to it.
+ nextState = [
+ {
+ kind: action.meta.undo.kind,
+ name: action.meta.undo.name,
+ recordId: action.meta.undo.recordId,
+ edits: { ...state.flattenedUndo, ...action.meta.undo.edits },
+ },
+ ];
+ } else {
+ // Clear potential redos, because this only supports linear history.
+ nextState = state.slice( 0, state.offset || undefined );
+ nextState.flattenedUndo = state.flattenedUndo;
+ }
+ nextState.offset = 0;
+
+ nextState.push( {
+ kind: action.kind,
+ name: action.name,
+ recordId: action.recordId,
+ edits: { ...nextState.flattenedUndo, ...action.edits },
+ } );
+
+ return nextState;
+ }
+
+ return state;
+}
+
/**
* Reducer managing embed preview data.
*
@@ -284,6 +419,7 @@ export default combineReducers( {
taxonomies,
themeSupports,
entities,
+ undo,
embedPreviews,
userPermissions,
autosaves,
diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js
index 42e4a1f23d982d..e0ac2aa856ae1c 100644
--- a/packages/core-data/src/selectors.js
+++ b/packages/core-data/src/selectors.js
@@ -104,7 +104,7 @@ export function getEntity( state, kind, name ) {
* @return {Object?} Record.
*/
export function getEntityRecord( state, kind, name, key ) {
- return get( state.entities.data, [ kind, name, 'items', key ] );
+ return get( state.entities.data, [ kind, name, 'queriedData', 'items', key ] );
}
/**
@@ -118,13 +118,190 @@ export function getEntityRecord( state, kind, name, key ) {
* @return {Array} Records.
*/
export function getEntityRecords( state, kind, name, query ) {
- const queriedState = get( state.entities.data, [ kind, name ] );
+ const queriedState = get( state.entities.data, [ kind, name, 'queriedData' ] );
if ( ! queriedState ) {
return [];
}
return getQueriedItems( queriedState, query );
}
+/**
+ * Returns the specified entity record's edits.
+ *
+ * @param {Object} state State tree.
+ * @param {string} kind Entity kind.
+ * @param {string} name Entity name.
+ * @param {number} recordId Record ID.
+ *
+ * @return {Object?} The entity record's edits.
+ */
+export function getEntityRecordEdits( state, kind, name, recordId ) {
+ return get( state.entities.data, [ kind, name, 'edits', recordId ] );
+}
+
+/**
+ * Returns the specified entity record's non transient edits.
+ *
+ * Transient edits don't create an undo level, and
+ * are not considered for change detection.
+ * They are defined in the entity's config.
+ *
+ * @param {Object} state State tree.
+ * @param {string} kind Entity kind.
+ * @param {string} name Entity name.
+ * @param {number} recordId Record ID.
+ *
+ * @return {Object?} The entity record's non transient edits.
+ */
+export const getEntityRecordNonTransientEdits = createSelector(
+ ( state, kind, name, recordId ) => {
+ const { transientEdits = {} } = getEntity( state, kind, name );
+ const edits =
+ getEntityRecordEdits( state, kind, name, recordId ) || [];
+ return Object.keys( edits ).reduce( ( acc, key ) => {
+ if ( ! transientEdits[ key ] ) {
+ acc[ key ] = edits[ key ];
+ }
+ return acc;
+ }, {} );
+ },
+ ( state ) => [ state.entities.config, state.entities.data ]
+);
+
+/**
+ * Returns true if the specified entity record has edits,
+ * and false otherwise.
+ *
+ * @param {Object} state State tree.
+ * @param {string} kind Entity kind.
+ * @param {string} name Entity name.
+ * @param {number} recordId Record ID.
+ *
+ * @return {boolean} Whether the entity record has edits or not.
+ */
+export function hasEditsForEntityRecord( state, kind, name, recordId ) {
+ return Object.keys( getEntityRecordNonTransientEdits( state, kind, name, recordId ) ).length > 0;
+}
+
+/**
+ * Returns the specified entity record, merged with its edits.
+ *
+ * @param {Object} state State tree.
+ * @param {string} kind Entity kind.
+ * @param {string} name Entity name.
+ * @param {number} recordId Record ID.
+ *
+ * @return {Object?} The entity record, merged with its edits.
+ */
+export const getEditedEntityRecord = createSelector(
+ ( state, kind, name, recordId ) => {
+ const record = getEntityRecord( state, kind, name, recordId );
+ return {
+ ...Object.keys( record ).reduce( ( acc, key ) => {
+ acc[ key ] = get( record[ key ], 'raw', record[ key ] );
+ return acc;
+ }, {} ),
+ ...getEntityRecordEdits( state, kind, name, recordId ),
+ };
+ },
+ ( state ) => [ state.entities.data ]
+);
+
+/**
+ * Returns true if the specified entity record is saving, and false otherwise.
+ *
+ * @param {Object} state State tree.
+ * @param {string} kind Entity kind.
+ * @param {string} name Entity name.
+ * @param {number} recordId Record ID.
+ *
+ * @return {Object?} Whether the entity record is saving or not.
+ */
+export function isSavingEntityRecord( state, kind, name, recordId ) {
+ return get(
+ state.entities.data,
+ [ kind, name, 'saving', recordId, 'pending' ],
+ false
+ );
+}
+
+/**
+ * Returns the specified entity record's last save error.
+ *
+ * @param {Object} state State tree.
+ * @param {string} kind Entity kind.
+ * @param {string} name Entity name.
+ * @param {number} recordId Record ID.
+ *
+ * @return {Object?} The entity record's save error.
+ */
+export function getLastEntitySaveError( state, kind, name, recordId ) {
+ return get( state.entities.data, [ kind, name, 'saving', recordId, 'error' ] );
+}
+
+/**
+ * Returns the current undo offset for the
+ * entity records edits history. The offset
+ * represents how many items from the end
+ * of the history stack we are at. 0 is the
+ * last edit, -1 is the second last, and so on.
+ *
+ * @param {Object} state State tree.
+ *
+ * @return {number} The current undo offset.
+ */
+export function getCurrentUndoOffset( state ) {
+ return state.undo.offset;
+}
+
+/**
+ * Returns the previous edit from the current undo offset
+ * for the entity records edits history, if any.
+ *
+ * @param {Object} state State tree.
+ *
+ * @return {Object?} The edit.
+ */
+export function getUndoEdit( state ) {
+ return state.undo[ state.undo.length - 2 + getCurrentUndoOffset( state ) ];
+}
+
+/**
+ * Returns the next edit from the current undo offset
+ * for the entity records edits history, if any.
+ *
+ * @param {Object} state State tree.
+ *
+ * @return {Object?} The edit.
+ */
+export function getRedoEdit( state ) {
+ return state.undo[ state.undo.length + getCurrentUndoOffset( state ) ];
+}
+
+/**
+ * Returns true if there is a previous edit from the current undo offset
+ * for the entity records edits history, and false otherwise.
+ *
+ * @param {Object} state State tree.
+ *
+ * @return {boolean} Whether there is a previous edit or not.
+ */
+export function hasUndo( state ) {
+ return Boolean( getUndoEdit( state ) );
+}
+
+/**
+ * Returns true if there is a next edit from the current undo offset
+ * for the entity records edits history, and false otherwise.
+ *
+ * @param {Object} state State tree.
+ *
+ * @return {boolean} Whether there is a next edit or not.
+ */
+export function hasRedo( state ) {
+ return Boolean( getRedoEdit( state ) );
+}
+
/**
* Return theme supports data in the index.
*
diff --git a/packages/core-data/src/test/actions.js b/packages/core-data/src/test/actions.js
index 7ef1abd0dc43c7..16ac5776be1976 100644
--- a/packages/core-data/src/test/actions.js
+++ b/packages/core-data/src/test/actions.js
@@ -11,7 +11,10 @@ describe( 'saveEntityRecord', () => {
// Trigger generator
fulfillment.next();
// Provide entities and trigger apiFetch
- const { value: apiFetchAction } = fulfillment.next( entities );
+ expect( fulfillment.next( entities ).value.type ).toBe(
+ 'SAVE_ENTITY_RECORD_START'
+ );
+ const { value: apiFetchAction } = fulfillment.next();
expect( apiFetchAction.request ).toEqual( {
path: '/wp/v2/posts',
method: 'POST',
@@ -20,6 +23,7 @@ describe( 'saveEntityRecord', () => {
// Provide response and trigger action
const { value: received } = fulfillment.next( { ...post, id: 10 } );
expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', { ...post, id: 10 }, undefined, true ) );
+ expect( fulfillment.next().value.type ).toBe( 'SAVE_ENTITY_RECORD_FINISH' );
} );
it( 'triggers a PUT request for an existing record', async () => {
@@ -29,7 +33,10 @@ describe( 'saveEntityRecord', () => {
// Trigger generator
fulfillment.next();
// Provide entities and trigger apiFetch
- const { value: apiFetchAction } = fulfillment.next( entities );
+ expect( fulfillment.next( entities ).value.type ).toBe(
+ 'SAVE_ENTITY_RECORD_START'
+ );
+ const { value: apiFetchAction } = fulfillment.next();
expect( apiFetchAction.request ).toEqual( {
path: '/wp/v2/posts/10',
method: 'PUT',
@@ -38,6 +45,7 @@ describe( 'saveEntityRecord', () => {
// Provide response and trigger action
const { value: received } = fulfillment.next( post );
expect( received ).toEqual( receiveEntityRecords( 'postType', 'post', post, undefined, true ) );
+ expect( fulfillment.next().value.type ).toBe( 'SAVE_ENTITY_RECORD_FINISH' );
} );
it( 'triggers a PUT request for an existing record with a custom key', async () => {
@@ -47,7 +55,10 @@ describe( 'saveEntityRecord', () => {
// Trigger generator
fulfillment.next();
// Provide entities and trigger apiFetch
- const { value: apiFetchAction } = fulfillment.next( entities );
+ expect( fulfillment.next( entities ).value.type ).toBe(
+ 'SAVE_ENTITY_RECORD_START'
+ );
+ const { value: apiFetchAction } = fulfillment.next();
expect( apiFetchAction.request ).toEqual( {
path: '/wp/v2/types/page',
method: 'PUT',
@@ -56,6 +67,7 @@ describe( 'saveEntityRecord', () => {
// Provide response and trigger action
const { value: received } = fulfillment.next( postType );
expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', postType, undefined, true ) );
+ expect( fulfillment.next().value.type ).toBe( 'SAVE_ENTITY_RECORD_FINISH' );
} );
} );
diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js
index ec8349d0a65d36..eea3054bc64684 100644
--- a/packages/core-data/src/test/reducer.js
+++ b/packages/core-data/src/test/reducer.js
@@ -34,7 +34,7 @@ describe( 'entities', () => {
it( 'returns the default state for all defined entities', () => {
const state = entities( undefined, {} );
- expect( state.data.root.postType ).toEqual( { items: {}, queries: {} } );
+ expect( state.data.root.postType.queriedData ).toEqual( { items: {}, queries: {} } );
} );
it( 'returns with received post types by slug', () => {
@@ -46,7 +46,7 @@ describe( 'entities', () => {
name: 'postType',
} );
- expect( state.data.root.postType ).toEqual( {
+ expect( state.data.root.postType.queriedData ).toEqual( {
items: {
b: { slug: 'b', title: 'beach' },
s: { slug: 's', title: 'sun' },
@@ -60,10 +60,12 @@ describe( 'entities', () => {
data: {
root: {
postType: {
- items: {
- w: { slug: 'w', title: 'water' },
+ queriedData: {
+ items: {
+ w: { slug: 'w', title: 'water' },
+ },
+ queries: {},
},
- queries: {},
},
},
},
@@ -75,7 +77,7 @@ describe( 'entities', () => {
name: 'postType',
} );
- expect( state.data.root.postType ).toEqual( {
+ expect( state.data.root.postType.queriedData ).toEqual( {
items: {
w: { slug: 'w', title: 'water' },
b: { slug: 'b', title: 'beach' },
diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js
index 1335b744e90528..eb488c040e8fb9 100644
--- a/packages/core-data/src/test/selectors.js
+++ b/packages/core-data/src/test/selectors.js
@@ -24,8 +24,10 @@ describe( 'getEntityRecord', () => {
data: {
root: {
postType: {
- items: {},
- queries: {},
+ queriedData: {
+ items: {},
+ queries: {},
+ },
},
},
},
@@ -40,10 +42,12 @@ describe( 'getEntityRecord', () => {
data: {
root: {
postType: {
- items: {
- post: { slug: 'post' },
+ queriedData: {
+ items: {
+ post: { slug: 'post' },
+ },
+ queries: {},
},
- queries: {},
},
},
},
@@ -60,8 +64,10 @@ describe( 'getEntityRecords', () => {
data: {
root: {
postType: {
- items: {},
- queries: {},
+ queriedData: {
+ items: {},
+ queries: {},
+ },
},
},
},
@@ -76,12 +82,14 @@ describe( 'getEntityRecords', () => {
data: {
root: {
postType: {
- items: {
- post: { slug: 'post' },
- page: { slug: 'page' },
- },
- queries: {
- '': [ 'post', 'page' ],
+ queriedData: {
+ items: {
+ post: { slug: 'post' },
+ page: { slug: 'page' },
+ },
+ queries: {
+ '': [ 'post', 'page' ],
+ },
},
},
},