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' ], + }, }, }, },