From 93dfbf649a264d124fec6891086456d7d2977a29 Mon Sep 17 00:00:00 2001 From: ramon Date: Wed, 30 Aug 2023 14:35:55 +1000 Subject: [PATCH 1/9] Fetching entity revisions using loaded name argument e.g., post:id:revisions Revisions loaded into state tree under revisions[parent] Invalidate revisions after saved entity Revisions url params are contained in entity config Testing with global styles - modifying CPT to show in rest Implementing thin API layer for Revisions over the getEntityRecords implementation Duplicated the logic in the selectors for revisions functions Added tests --- docs/reference-guides/data/data-core.md | 33 ++++ lib/compat/wordpress-6.4/rest-api.php | 24 +++ packages/core-data/README.md | 37 ++++- packages/core-data/src/actions.js | 10 +- packages/core-data/src/entities.js | 21 ++- packages/core-data/src/queried-data/index.js | 1 + .../core-data/src/queried-data/reducer.js | 143 ++++++++++++++++++ packages/core-data/src/reducer.js | 14 +- packages/core-data/src/resolvers.js | 141 ++++++++++++++--- packages/core-data/src/selectors.ts | 101 +++++++++++++ packages/core-data/src/test/entities.js | 9 ++ packages/core-data/src/test/selectors.js | 98 ++++++++++++ packages/core-data/src/utils/index.js | 1 + .../core-data/src/utils/parse-entity-name.js | 11 ++ .../use-global-styles-revisions.js | 9 +- 15 files changed, 621 insertions(+), 32 deletions(-) create mode 100644 packages/core-data/src/utils/parse-entity-name.js diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index ea97ce28e4d85..2b67c20161dcb 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -342,6 +342,39 @@ _Returns_ - `number | null`: number | null. +### getEntityRevision + +Returns a specific Entity revision. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _key_ `EntityRecordKey`: The Revision's key. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + +_Returns_ + +- Record. + +### getEntityRevisions + +Returns an Entity's revisions. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + +_Returns_ + +- Record. + ### getLastEntityDeleteError Returns the specified entity record's last delete error. diff --git a/lib/compat/wordpress-6.4/rest-api.php b/lib/compat/wordpress-6.4/rest-api.php index 274eb3af94543..1581e108601d2 100644 --- a/lib/compat/wordpress-6.4/rest-api.php +++ b/lib/compat/wordpress-6.4/rest-api.php @@ -18,3 +18,27 @@ function gutenberg_register_rest_block_patterns_routes() { $block_patterns->register_routes(); } add_action( 'rest_api_init', 'gutenberg_register_rest_block_patterns_routes' ); + +/** + * Registers the Global Styles Revisions REST API routes. + */ +function gutenberg_register_global_styles_revisions_endpoints() { + $global_styles_revisions_controller = new Gutenberg_REST_Global_Styles_Revisions_Controller_6_4(); + $global_styles_revisions_controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_global_styles_revisions_endpoints' ); + +/** + * Updates `wp_global_styles` to show in rest api. This way it will appear in the post types response. + * + * @param array $args Array of arguments for registering a post type. + * @param string $post_type Post type key. + */ +function gutenberg_update_wp_global_styles_post_type( $args, $post_type ) { + if ( 'wp_global_styles' === $post_type ) { + $args['show_in_rest'] = true; + } + + return $args; +} +add_filter( 'register_post_type_args', 'gutenberg_update_wp_global_styles_post_type', 10, 2 ); diff --git a/packages/core-data/README.md b/packages/core-data/README.md index ef5d9c1197f09..6e8663c94a2d7 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -123,7 +123,7 @@ The package provides general methods to interact with the entities (`getEntityRe ```js // Get the record collection for the user entity. -wp.data.select( 'core' ).getEntityRecords( 'root' 'user' ); +wp.data.select( 'core' ).getEntityRecords( 'root', 'user' ); // Get a single record for the user entity. wp.data.select( 'core' ).getEntityRecord( 'root', 'user', recordId ); @@ -138,7 +138,7 @@ In addition to the general utilities (`getEntityRecords`, `getEntityRecord`, etc ```js // Collection -wp.data.select( 'core' ).getEntityRecords( 'root' 'user' ); +wp.data.select( 'core' ).getEntityRecords( 'root', 'user' ); wp.data.select( 'core' ).getUsers(); // Single record @@ -649,6 +649,39 @@ _Returns_ - `number | null`: number | null. +### getEntityRevision + +Returns a specific Entity revision. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _key_ `EntityRecordKey`: The Revision's key. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + +_Returns_ + +- Record. + +### getEntityRevisions + +Returns an Entity's revisions. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + +_Returns_ + +- Record. + ### getLastEntityDeleteError Returns the specified entity record's last delete error. diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 9e7277f35a62a..a95fd4a14a18d 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -14,7 +14,7 @@ import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ -import { getNestedValue, setNestedValue } from './utils'; +import { getNestedValue, setNestedValue, parseEntityName } from './utils'; import { receiveItems, removeItems, receiveQueriedItems } from './queried-data'; import { getOrLoadEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities'; import { createBatch } from './batch'; @@ -92,9 +92,11 @@ export function receiveEntityRecords( edits, meta ) { + const { isRevision } = parseEntityName( name ); + // Auto drafts should not have titles, but some plugins rely on them so we can't filter this // on the server. - if ( kind === 'postType' ) { + if ( ! isRevision && kind === 'postType' ) { records = ( Array.isArray( records ) ? records : [ records ] ).map( ( record ) => record.status === 'auto-draft' @@ -109,6 +111,10 @@ export function receiveEntityRecords( action = receiveItems( records, edits, meta ); } + if ( isRevision ) { + action.type = 'RECEIVE_ITEM_REVISIONS'; + } + return { ...action, kind, diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index af8829d0bc852..74240dbde8f34 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -207,6 +207,14 @@ export const rootEntitiesConfig = [ baseURLParams: { context: 'edit' }, plural: 'globalStylesVariations', // Should be different than name. getTitle: ( record ) => record?.title?.rendered || record?.title, + getRevisionsUrl: ( parentId, revisionId ) => + `/wp/v2/global-styles/${ parentId }/revisions${ + revisionId ? '/' + revisionId : '' + }`, + revisionURLParams: { context: 'view' }, + supports: { + revisions: true, + }, }, { label: __( 'Themes' ), @@ -276,8 +284,9 @@ export const prePersistPostType = ( persistedRecord, edits ) => { * @return {Promise} Entities promise */ async function loadPostTypeEntities() { + // @TODO 'edit' context required to get supports collection. const postTypes = await apiFetch( { - path: '/wp/v2/types?context=view', + path: '/wp/v2/types?context=edit', } ); return Object.entries( postTypes ?? {} ).map( ( [ name, postType ] ) => { const isTemplate = [ 'wp_template', 'wp_template_part' ].includes( @@ -295,6 +304,7 @@ async function loadPostTypeEntities() { selection: true, }, mergedEdits: { meta: true }, + supports: postType?.supports, rawAttributes: POST_RAW_ATTRIBUTES, getTitle: ( record ) => record?.title?.rendered || @@ -328,6 +338,15 @@ async function loadPostTypeEntities() { syncObjectType: 'postType/' + postType.name, getSyncObjectId: ( id ) => id, supportsPagination: true, + getRevisionsUrl: ( parentId, revisionId ) => + postType?.supports?.revisions + ? `/${ namespace }/${ + postType.rest_base + }/${ parentId }/revisions${ + revisionId ? '/' + revisionId : '' + }` + : undefined, + revisionURLParams: { context: 'view' }, }; } ); } diff --git a/packages/core-data/src/queried-data/index.js b/packages/core-data/src/queried-data/index.js index 57e124e445d87..768a654db5b59 100644 --- a/packages/core-data/src/queried-data/index.js +++ b/packages/core-data/src/queried-data/index.js @@ -1,3 +1,4 @@ export * from './actions'; export * from './selectors'; export { default as reducer } from './reducer'; +export { revisionsQueriedDataReducer } from './reducer'; diff --git a/packages/core-data/src/queried-data/reducer.js b/packages/core-data/src/queried-data/reducer.js index 3462f00b68569..1656620358fbf 100644 --- a/packages/core-data/src/queried-data/reducer.js +++ b/packages/core-data/src/queried-data/reducer.js @@ -12,6 +12,7 @@ import { ifMatchingAction, replaceAction, onSubKey, + parseEntityName, } from '../utils'; import { DEFAULT_ENTITY_KEY } from '../entities'; import getQueryParts from './get-query-parts'; @@ -284,6 +285,148 @@ const queries = ( state = {}, action ) => { } }; +/** + * Reducer tracking revision items state, keyed by parent ID. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Next state. + */ + +export function revisionItems( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_ITEM_REVISIONS': + const context = getContextFromAction( action ); + const key = action.key || DEFAULT_ENTITY_KEY; + + return { + ...state, + [ context ]: { + ...state[ context ], + ...action.items.reduce( ( accumulator, value ) => { + const itemId = value[ key ]; + accumulator[ itemId ] = conservativeMapItem( + state?.[ context ]?.[ itemId ], + value + ); + return accumulator; + }, {} ), + }, + }; + } + return state; +} + +const receiveRevisionQueries = compose( [ + // Limit to matching action type so we don't attempt to replace action on + // an unhandled action. + ifMatchingAction( ( action ) => 'query' in action ), + + // Inject query parts into action for use both in `onSubKey` and reducer. + replaceAction( ( action ) => { + // `ifMatchingAction` still passes on initialization, where state is + // undefined and a query is not assigned. Avoid attempting to parse + // parts. `onSubKey` will omit by lack of `stableKey`. + if ( action.query ) { + return { + ...action, + ...getQueryParts( action.query ), + }; + } + + return action; + } ), + + onSubKey( 'context' ), + + // Queries shape is shared, but keyed by query `stableKey` part. Original + // reducer tracks only a single query object. + onSubKey( 'stableKey' ), +] )( ( state = {}, action ) => { + const { type, page, perPage, key = DEFAULT_ENTITY_KEY } = action; + if ( type !== 'RECEIVE_ITEM_REVISIONS' ) { + return state; + } + + return { + itemIds: getMergedItemIds( + state?.itemIds || [], + action.items.map( ( item ) => item[ key ] ), + page, + perPage + ), + meta: action.meta, + }; +} ); + +export function revisionItemIsComplete( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_ITEM_REVISIONS': { + const context = getContextFromAction( action ); + const { query, key = DEFAULT_ENTITY_KEY } = action; + const queryParts = query ? getQueryParts( query ) : {}; + const isCompleteQuery = + ! query || ! Array.isArray( queryParts.fields ); + + return { + ...state, + [ context ]: { + ...state[ context ], + ...action.items.reduce( ( result, item ) => { + const itemId = item[ key ]; + + // Defer to completeness if already assigned. Technically the + // data may be outdated if receiving items for a field subset. + result[ itemId ] = + state?.[ context ]?.[ itemId ] || isCompleteQuery; + + return result; + }, {} ), + }, + }; + } + } + + return state; +} + +/** + * Reducer tracking queries state. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Next state. + */ +const revisionQueries = ( state = {}, action ) => { + switch ( action.type ) { + case 'RECEIVE_ITEM_REVISIONS': + return receiveRevisionQueries( state, action ); + default: + return state; + } +}; + +const revisionsReducer = combineReducers( { + items: revisionItems, + itemIsComplete: revisionItemIsComplete, + queries: revisionQueries, +} ); + +export const revisionsQueriedDataReducer = ( state = {}, action ) => { + switch ( action.type ) { + case 'RECEIVE_ITEM_REVISIONS': + const { key: parentId } = parseEntityName( action.name ); + return { + ...state, + [ parentId ]: revisionsReducer( state[ parentId ], action ), + }; + default: + return state; + } +}; + export default combineReducers( { items, itemIsComplete, diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index a21623d8ba89d..44b02c80dcf59 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -14,7 +14,10 @@ import { createUndoManager } from '@wordpress/undo-manager'; * Internal dependencies */ import { ifMatchingAction, replaceAction } from './utils'; -import { reducer as queriedDataReducer } from './queried-data'; +import { + reducer as queriedDataReducer, + revisionsQueriedDataReducer, +} from './queried-data'; import { rootEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities'; /** @typedef {import('./types').AnyFunction} AnyFunction */ @@ -231,7 +234,8 @@ function entity( entityConfig ) { ( action ) => action.name && action.kind && - action.name === entityConfig.name && + // @TODO Create predictable parsing rules for names like post:[key]:revisions. + action.name.split( ':' )[ 0 ] === entityConfig.name && action.kind === entityConfig.kind ), @@ -245,7 +249,11 @@ function entity( entityConfig ) { ] )( combineReducers( { queriedData: queriedDataReducer, - + // @TODO can this be filtered by supports above or elsewhere? + // @TODO We only want to add to state tree if revisions are supported by post type. + ...( entityConfig?.supports?.revisions + ? { revisions: revisionsQueriedDataReducer } + : {} ), edits: ( state = {}, action ) => { switch ( action.type ) { case 'RECEIVE_ITEMS': diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index cd2a65a60b013..6b18b2014a719 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -15,7 +15,11 @@ import apiFetch from '@wordpress/api-fetch'; */ import { STORE_NAME } from './name'; import { getOrLoadEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities'; -import { forwardResolver, getNormalizedCommaSeparable } from './utils'; +import { + forwardResolver, + getNormalizedCommaSeparable, + parseEntityName, +} from './utils'; import { getSyncProvider } from './sync'; /** @@ -59,9 +63,19 @@ export const getEntityRecord = ( kind, name, key = '', query ) => async ( { select, dispatch } ) => { const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const { + name: parsedName, + key: parsedKey, + isRevision, + } = parseEntityName( name ); const entityConfig = configs.find( - ( config ) => config.name === name && config.kind === kind + ( config ) => config.name === parsedName && config.kind === kind ); + + if ( isRevision && ! entityConfig?.supports?.revisions ) { + return; + } + if ( ! entityConfig || entityConfig?.__experimentalNoFetch ) { return; } @@ -74,10 +88,12 @@ export const getEntityRecord = try { // Entity supports configs, - // use the sync algorithm instead of the old fetch behavior. + // use the sync algorithm instead of the old fetch behavior, + // but not for revisions. @TODO check this. if ( window.__experimentalEnableSync && entityConfig.syncConfig && + ! isRevision && ! query ) { if ( process.env.IS_GUTENBERG_PLUGIN ) { @@ -133,20 +149,16 @@ export const getEntityRecord = }; } - // Disable reason: While true that an early return could leave `path` - // unused, it's important that path is derived using the query prior to - // additional query modifications in the condition below, since those - // modifications are relevant to how the data is tracked in state, and not - // for how the request is made to the REST API. - - // eslint-disable-next-line @wordpress/no-unused-vars-before-return - const path = addQueryArgs( - entityConfig.baseURL + ( key ? '/' + key : '' ), - { - ...entityConfig.baseURLParams, - ...query, - } - ); + const baseUrl = isRevision + ? entityConfig.getRevisionsUrl( parsedKey, key ) + : entityConfig.baseURL + ( key ? '/' + key : '' ); + + const path = addQueryArgs( baseUrl, { + ...( isRevision + ? entityConfig.revisionURLParams + : entityConfig.baseURLParams ), + ...query, + } ); if ( query !== undefined ) { query = { ...query, include: [ key ] }; @@ -154,11 +166,14 @@ export const getEntityRecord = // The resolution cache won't consider query as reusable based on the // fields, so it's tested here, prior to initiating the REST request, // and without causing `getEntityRecords` resolution to occur. + // @TODO how to handle revisions here? + // @TODO will it know if a new revision has been created? const hasRecords = select.hasEntityRecords( kind, name, query ); + if ( hasRecords ) { return; } @@ -194,9 +209,19 @@ export const getEntityRecords = ( kind, name, query = {} ) => async ( { dispatch } ) => { const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const { + name: parsedName, + key: parsedKey, + isRevision, + } = parseEntityName( name ); const entityConfig = configs.find( - ( config ) => config.name === name && config.kind === kind + ( config ) => config.name === parsedName && config.kind === kind ); + + if ( isRevision && ! entityConfig?.supports?.revisions ) { + return; + } + if ( ! entityConfig || entityConfig?.__experimentalNoFetch ) { return; } @@ -224,8 +249,14 @@ export const getEntityRecords = }; } - const path = addQueryArgs( entityConfig.baseURL, { - ...entityConfig.baseURLParams, + const baseUrl = isRevision + ? entityConfig.getRevisionsUrl( parsedKey ) + : entityConfig.baseURL; + + const path = addQueryArgs( baseUrl, { + ...( isRevision + ? entityConfig.revisionURLParams + : entityConfig.baseURLParams ), ...query, } ); @@ -244,7 +275,7 @@ export const getEntityRecords = // If we request fields but the result doesn't contain the fields, // explicitly set these fields as "undefined" - // that way we consider the query "fullfilled". + // that way we consider the query "fulfilled". if ( query._fields ) { records = records.map( ( record ) => { query._fields.split( ',' ).forEach( ( field ) => { @@ -293,6 +324,18 @@ export const getEntityRecords = }; getEntityRecords.shouldInvalidate = ( action, kind, name ) => { + // Invalidate cache when a new revision is created. + if ( action.type === 'SAVE_ENTITY_RECORD_FINISH' ) { + const { name: parsedName, key: parsedKey } = parseEntityName( name ); + + return ( + kind === action.kind && + parsedName === action.name && + ! action.error && + Number( parsedKey ) === action.recordId + ); + } + return ( ( action.type === 'RECEIVE_ITEMS' || action.type === 'REMOVE_ITEMS' ) && action.invalidateCache && @@ -322,7 +365,7 @@ export const getCurrentTheme = export const getThemeSupports = forwardResolver( 'getCurrentTheme' ); /** - * Requests a preview from the from the Embed API. + * Requests a preview from the Embed API. * * @param {string} url URL to get the preview for. */ @@ -718,3 +761,57 @@ export const getDefaultTemplateId = dispatch.receiveDefaultTemplateId( query, template.id ); } }; + +/** + * Requests an entity's revisions from the REST API. + * + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {number|string} parentId Record's key whose revisions you wish to fetch. + * @param {Object|undefined} query Optional object of query parameters to + * include with request. If requesting specific + * fields, fields must always include the ID. + */ +export const getEntityRevisions = + ( kind, name, parentId, query = {} ) => + async ( { resolveSelect } ) => { + await resolveSelect.getEntityRecords( + kind, + `${ name }:${ parentId }:revisions`, + query + ); + }; + +getEntityRevisions.shouldInvalidate = ( action, kind, name, key ) => { + // Invalidate cache when a new revision is created. + if ( action.type === 'SAVE_ENTITY_RECORD_FINISH' ) { + return ( + name === action.name && + kind === action.kind && + ! action.error && + key === action.recordId + ); + } +}; + +/** + * Requests a specific Entity revision from the REST API. + * + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {number|string} parentId Record's key whose revisions you wish to fetch. + * @param {number|string} key The Revision's key. + * @param {Object|undefined} query Optional object of query parameters to + * include with request. If requesting specific + * fields, fields must always include the ID. + */ +export const getEntityRevision = + ( kind, name, parentId, key, query = {} ) => + async ( { resolveSelect } ) => { + await resolveSelect.getEntityRecord( + kind, + `${ name }:${ parentId }:revisions`, + key, + query + ); + }; diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 2a046941611c7..cf65f407a7bee 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -21,6 +21,7 @@ import { isRawAttribute, setNestedValue, isNumericID, + parseEntityName, } from './utils'; import type * as ET from './entity-types'; import type { UndoManager } from '@wordpress/undo-manager'; @@ -70,6 +71,7 @@ interface EntityState< EntityRecord extends ET.EntityRecord > { >; deleting: Record< string, Partial< { pending: boolean; error: Error } > >; queriedData: QueriedData; + revisions: QueriedData; } interface EntityConfig { @@ -341,6 +343,7 @@ export const getEntityRecord = createSelector( if ( ! queriedState ) { return undefined; } + const context = query?.context ?? 'default'; if ( query === undefined ) { @@ -1373,3 +1376,101 @@ export function getDefaultTemplateId( ): string { return state.defaultTemplates[ JSON.stringify( query ) ]; } + +/** + * Returns an Entity's revisions. + * + * @param state State tree + * @param kind Entity kind. + * @param name Entity name. + * @param parentId Record's key whose revisions you wish to fetch. + * @param query Optional query. If requesting specific + * fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + * + * @return Record. + */ +export const getEntityRevisions = ( + state: State, + kind: string, + name: string, + parentId: EntityRecordKey, + query?: GetRecordsHttpQuery +) => { + const queriedStateRevisions = + state.entities.records?.[ kind ]?.[ name ]?.revisions[ parentId ]; + + if ( ! queriedStateRevisions ) { + return null; + } + + return getQueriedItems( queriedStateRevisions, query ); +}; + +/** + * Returns a specific Entity revision. + * + * @param state State tree + * @param kind Entity kind. + * @param name Entity name. + * @param parentId Record's key whose revisions you wish to fetch. + * @param key The Revision's key. + * @param query Optional query. If requesting specific + * fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + * + * @return Record. + */ +export const getEntityRevision = createSelector( + ( + state: State, + kind: string, + name: string, + parentId: EntityRecordKey, + key: EntityRecordKey, + query?: GetRecordsHttpQuery + ) => { + const queriedRevisionsState = + state.entities.records?.[ kind ]?.[ name ]?.revisions[ parentId ]; + + if ( ! queriedRevisionsState ) { + return undefined; + } + + const context = query?.context ?? 'default'; + + if ( query === undefined ) { + // If expecting a complete item, validate that completeness. + if ( ! queriedRevisionsState.itemIsComplete[ context ]?.[ key ] ) { + return undefined; + } + + return queriedRevisionsState.items[ context ][ key ]; + } + + const item = queriedRevisionsState.items[ context ]?.[ key ]; + + if ( item && query._fields ) { + const filteredItem = {}; + const fields = getNormalizedCommaSeparable( query._fields ) ?? []; + for ( let f = 0; f < fields.length; f++ ) { + const field = fields[ f ].split( '.' ); + let value = item; + field.forEach( ( fieldName ) => { + value = value?.[ fieldName ]; + } ); + setNestedValue( filteredItem, field, value ); + } + return filteredItem; + } + + return item; + }, + ( state: State, kind, name, parentId, key, query ) => { + const context = query?.context ?? 'default'; + return [ + state.entities.records?.[ kind ]?.[ name ]?.revisions[ parentId ] + ?.items[ key ], + state.entities.records?.[ kind ]?.[ name ]?.revisions[ parentId ] + ?.itemIsComplete[ context ]?.[ key ], + ]; + } +); diff --git a/packages/core-data/src/test/entities.js b/packages/core-data/src/test/entities.js index 9afbbb8de055e..c9a432c92c144 100644 --- a/packages/core-data/src/test/entities.js +++ b/packages/core-data/src/test/entities.js @@ -80,6 +80,9 @@ describe( 'getKindEntities', () => { labels: { singular_name: 'post', }, + supports: { + revisions: true, + }, }, ]; const dispatch = jest.fn(); @@ -95,6 +98,12 @@ describe( 'getKindEntities', () => { expect( dispatch.mock.calls[ 0 ][ 0 ].entities[ 0 ].baseURL ).toBe( '/wp/v2/posts' ); + expect( + dispatch.mock.calls[ 0 ][ 0 ].entities[ 0 ].getRevisionsUrl( 1 ) + ).toBe( '/wp/v2/posts/1/revisions' ); + expect( + dispatch.mock.calls[ 0 ][ 0 ].entities[ 0 ].getRevisionsUrl( 1, 2 ) + ).toBe( '/wp/v2/posts/1/revisions/2' ); } ); } ); diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index b7c583098c4a5..123084d18aeb6 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -22,6 +22,8 @@ import { getAutosave, getAutosaves, getCurrentUser, + getEntityRevisions, + getEntityRevision, } from '../selectors'; // getEntityRecord and __experimentalGetEntityRecordNoResolver selectors share the same tests. describe.each( [ @@ -896,3 +898,99 @@ describe( 'getCurrentUser', () => { expect( getCurrentUser( state ) ).toEqual( currentUser ); } ); } ); + +describe( 'getEntityRevisions', () => { + it( 'should return revisions', () => { + const state = deepFreeze( { + entities: { + records: { + postType: { + post: { + revisions: { + 1: { + items: { + default: { + 10: { + id: 10, + content: 'chicken', + author: 'bob', + parent: 1, + }, + }, + }, + itemIsComplete: { + default: { + 10: true, + }, + }, + queries: { + default: { + '': { itemIds: [ 10 ] }, + }, + }, + }, + }, + }, + }, + }, + }, + } ); + + expect( getEntityRevisions( state, 'postType', 'post', 1 ) ).toEqual( [ + { + id: 10, + content: 'chicken', + author: 'bob', + parent: 1, + }, + ] ); + } ); +} ); + +describe( 'getEntityRevision', () => { + it( 'should return a specific revision', () => { + const state = deepFreeze( { + entities: { + records: { + postType: { + post: { + revisions: { + 1: { + items: { + default: { + 10: { + id: 10, + content: 'chicken', + author: 'bob', + parent: 1, + }, + }, + }, + itemIsComplete: { + default: { + 10: true, + }, + }, + queries: { + default: { + '': [ 10 ], + }, + }, + }, + }, + }, + }, + }, + }, + } ); + + expect( getEntityRevision( state, 'postType', 'post', 1, 10 ) ).toEqual( + { + id: 10, + content: 'chicken', + author: 'bob', + parent: 1, + } + ); + } ); +} ); diff --git a/packages/core-data/src/utils/index.js b/packages/core-data/src/utils/index.js index d4d491fab9827..33d7bedbc0bb1 100644 --- a/packages/core-data/src/utils/index.js +++ b/packages/core-data/src/utils/index.js @@ -9,3 +9,4 @@ export { default as isRawAttribute } from './is-raw-attribute'; export { default as setNestedValue } from './set-nested-value'; export { default as getNestedValue } from './get-nested-value'; export { default as isNumericID } from './is-numeric-id'; +export { default as parseEntityName } from './parse-entity-name'; diff --git a/packages/core-data/src/utils/parse-entity-name.js b/packages/core-data/src/utils/parse-entity-name.js new file mode 100644 index 0000000000000..4cdc014266595 --- /dev/null +++ b/packages/core-data/src/utils/parse-entity-name.js @@ -0,0 +1,11 @@ +export default function parseEntityName( name = '' ) { + const [ postType, key, revisions ] = ( + typeof name === 'string' ? name : '' + )?.split( ':' ); + + return { + name: postType, + key, + isRevision: revisions === 'revisions', + }; +} diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js index 6e3573061a421..853d3394f10d4 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js @@ -32,14 +32,19 @@ export default function useGlobalStylesRevisions() { __experimentalGetDirtyEntityRecords, getCurrentUser, getUsers, - getCurrentThemeGlobalStylesRevisions, + getEntityRevisions, + __experimentalGetCurrentGlobalStylesId, isResolving, } = select( coreStore ); const dirtyEntityRecords = __experimentalGetDirtyEntityRecords(); const _currentUser = getCurrentUser(); const _isDirty = dirtyEntityRecords.length > 0; const globalStylesRevisions = - getCurrentThemeGlobalStylesRevisions() || EMPTY_ARRAY; + getEntityRevisions( + 'root', + 'globalStyles', + __experimentalGetCurrentGlobalStylesId() + ) || EMPTY_ARRAY; const _authors = getUsers( SITE_EDITOR_AUTHORS_QUERY ) || EMPTY_ARRAY; return { From 80cf76f482504ba1eaa86ad19ea4555f723ba4d4 Mon Sep 17 00:00:00 2001 From: ramon Date: Mon, 16 Oct 2023 13:23:53 +1100 Subject: [PATCH 2/9] Revert edit context query param when fetching post types Reverting timeout in performance tests. --- packages/core-data/src/entities.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 74240dbde8f34..92bc9b9a75f2b 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -284,9 +284,8 @@ export const prePersistPostType = ( persistedRecord, edits ) => { * @return {Promise} Entities promise */ async function loadPostTypeEntities() { - // @TODO 'edit' context required to get supports collection. const postTypes = await apiFetch( { - path: '/wp/v2/types?context=edit', + path: '/wp/v2/types?context=view', } ); return Object.entries( postTypes ?? {} ).map( ( [ name, postType ] ) => { const isTemplate = [ 'wp_template', 'wp_template_part' ].includes( @@ -304,7 +303,10 @@ async function loadPostTypeEntities() { selection: true, }, mergedEdits: { meta: true }, - supports: postType?.supports, + supports: { + // Supports information is also available in the "edit" context. + revisions: [ 'post', 'page' ].includes( postType?.slug ), + }, rawAttributes: POST_RAW_ATTRIBUTES, getTitle: ( record ) => record?.title?.rendered || From df9a29ab266828bc747396647f7c2969469b7c08 Mon Sep 17 00:00:00 2001 From: ramon Date: Mon, 16 Oct 2023 14:51:21 +1100 Subject: [PATCH 3/9] Tidying up reducer, allowing revisions for posts and pages only --- packages/core-data/src/entities.js | 12 +++++------- packages/core-data/src/reducer.js | 22 +++++++++++----------- packages/core-data/src/selectors.ts | 5 ++--- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 92bc9b9a75f2b..98a6e9748de47 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -341,13 +341,11 @@ async function loadPostTypeEntities() { getSyncObjectId: ( id ) => id, supportsPagination: true, getRevisionsUrl: ( parentId, revisionId ) => - postType?.supports?.revisions - ? `/${ namespace }/${ - postType.rest_base - }/${ parentId }/revisions${ - revisionId ? '/' + revisionId : '' - }` - : undefined, + `/${ namespace }/${ + postType.rest_base + }/${ parentId }/revisions${ + revisionId ? '/' + revisionId : '' + }`, revisionURLParams: { context: 'view' }, }; } ); diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 44b02c80dcf59..da4e5d070fe07 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -13,7 +13,7 @@ import { createUndoManager } from '@wordpress/undo-manager'; /** * Internal dependencies */ -import { ifMatchingAction, replaceAction } from './utils'; +import { ifMatchingAction, replaceAction, parseEntityName } from './utils'; import { reducer as queriedDataReducer, revisionsQueriedDataReducer, @@ -230,14 +230,14 @@ function entity( entityConfig ) { // Limit to matching action type so we don't attempt to replace action on // an unhandled action. - ifMatchingAction( - ( action ) => + ifMatchingAction( ( action ) => { + return ( action.name && action.kind && - // @TODO Create predictable parsing rules for names like post:[key]:revisions. - action.name.split( ':' )[ 0 ] === entityConfig.name && + parseEntityName( action.name )?.name === entityConfig.name && action.kind === entityConfig.kind - ), + ); + } ), // Inject the entity config into the action. replaceAction( ( action ) => { @@ -249,11 +249,6 @@ function entity( entityConfig ) { ] )( combineReducers( { queriedData: queriedDataReducer, - // @TODO can this be filtered by supports above or elsewhere? - // @TODO We only want to add to state tree if revisions are supported by post type. - ...( entityConfig?.supports?.revisions - ? { revisions: revisionsQueriedDataReducer } - : {} ), edits: ( state = {}, action ) => { switch ( action.type ) { case 'RECEIVE_ITEMS': @@ -363,6 +358,11 @@ function entity( entityConfig ) { return state; }, + + // Add revisions to the state tree if the post type supports it. + ...( entityConfig?.supports?.revisions + ? { revisions: revisionsQueriedDataReducer } + : {} ), } ) ); } diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index cf65f407a7bee..191d004f47c95 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -1397,8 +1397,7 @@ export const getEntityRevisions = ( query?: GetRecordsHttpQuery ) => { const queriedStateRevisions = - state.entities.records?.[ kind ]?.[ name ]?.revisions[ parentId ]; - + state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ parentId ]; if ( ! queriedStateRevisions ) { return null; } @@ -1429,7 +1428,7 @@ export const getEntityRevision = createSelector( query?: GetRecordsHttpQuery ) => { const queriedRevisionsState = - state.entities.records?.[ kind ]?.[ name ]?.revisions[ parentId ]; + state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ parentId ]; if ( ! queriedRevisionsState ) { return undefined; From ac7a34e3515536dd09a231b441ae9ebfee1b841c Mon Sep 17 00:00:00 2001 From: ramon Date: Wed, 18 Oct 2023 14:48:15 +1100 Subject: [PATCH 4/9] Adding types to the base entity records for revisions and global styles revisions. --- .../entity-types/global-styles-revision.ts | 47 ++++++++++ packages/core-data/src/entity-types/index.ts | 6 ++ .../src/entity-types/post-revision.ts | 93 +++++++++++++++++++ packages/core-data/src/selectors.ts | 11 ++- .../core-data/src/utils/parse-entity-name.js | 17 +++- .../src/utils/test/parse-entity-name.js | 34 +++++++ 6 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 packages/core-data/src/entity-types/global-styles-revision.ts create mode 100644 packages/core-data/src/entity-types/post-revision.ts create mode 100644 packages/core-data/src/utils/test/parse-entity-name.js diff --git a/packages/core-data/src/entity-types/global-styles-revision.ts b/packages/core-data/src/entity-types/global-styles-revision.ts new file mode 100644 index 0000000000000..1a89c164e313b --- /dev/null +++ b/packages/core-data/src/entity-types/global-styles-revision.ts @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import type { Context, ContextualField, OmitNevers } from './helpers'; + +import type { BaseEntityRecords as _BaseEntityRecords } from './base-entity-records'; + +declare module './base-entity-records' { + export namespace BaseEntityRecords { + export interface GlobalStylesRevision< C extends Context > { + /** + * The ID for the author of the global styles revision. + */ + author: number; + /** + * The date the post global styles revision published, in the site's timezone. + */ + date: string | null; + /** + * The date the global styles revision was published, as GMT. + */ + date_gmt: ContextualField< string | null, 'view' | 'edit', C >; + /** + * Unique identifier for the revision. + */ + id: number; + /** + * The date the global styles revision was last modified, in the site's timezone. + */ + modified: ContextualField< string, 'view' | 'edit', C >; + /** + * The date the global styles revision was last modified, as GMT. + */ + modified_gmt: ContextualField< string, 'view' | 'edit', C >; + /** + * Identifier for the parent of the revision. + */ + parent: number; + styles: Record< string, Object >; + settings: Record< string, Object >; + } + } +} + +export type GlobalStylesRevision< C extends Context = 'view' > = OmitNevers< + _BaseEntityRecords.GlobalStylesRevision< C > +>; diff --git a/packages/core-data/src/entity-types/index.ts b/packages/core-data/src/entity-types/index.ts index 19d10a28ad698..0e601137cbcb6 100644 --- a/packages/core-data/src/entity-types/index.ts +++ b/packages/core-data/src/entity-types/index.ts @@ -4,12 +4,14 @@ import type { Context, Updatable } from './helpers'; import type { Attachment } from './attachment'; import type { Comment } from './comment'; +import type { GlobalStylesRevision } from './global-styles-revision'; import type { MenuLocation } from './menu-location'; import type { NavMenu } from './nav-menu'; import type { NavMenuItem } from './nav-menu-item'; import type { Page } from './page'; import type { Plugin } from './plugin'; import type { Post } from './post'; +import type { PostRevision } from './post-revision'; import type { Settings } from './settings'; import type { Sidebar } from './sidebar'; import type { Taxonomy } from './taxonomy'; @@ -27,12 +29,14 @@ export type { Attachment, Comment, Context, + GlobalStylesRevision, MenuLocation, NavMenu, NavMenuItem, Page, Plugin, Post, + PostRevision, Settings, Sidebar, Taxonomy, @@ -82,12 +86,14 @@ export interface PerPackageEntityRecords< C extends Context > { core: | Attachment< C > | Comment< C > + | GlobalStylesRevision< C > | MenuLocation< C > | NavMenu< C > | NavMenuItem< C > | Page< C > | Plugin< C > | Post< C > + | PostRevision< C > | Settings< C > | Sidebar< C > | Taxonomy< C > diff --git a/packages/core-data/src/entity-types/post-revision.ts b/packages/core-data/src/entity-types/post-revision.ts new file mode 100644 index 0000000000000..354a3fc02af70 --- /dev/null +++ b/packages/core-data/src/entity-types/post-revision.ts @@ -0,0 +1,93 @@ +/** + * Internal dependencies + */ +import type { + Context, + ContextualField, + RenderedText, + OmitNevers, +} from './helpers'; + +import type { BaseEntityRecords as _BaseEntityRecords } from './base-entity-records'; + +declare module './base-entity-records' { + export namespace BaseEntityRecords { + export interface PostRevision< C extends Context > { + /** + * The ID for the author of the post revision. + */ + author: number; + /** + * The content for the post. + */ + content: ContextualField< + RenderedText< C > & { + /** + * Whether the content is protected with a password. + */ + is_protected: boolean; + /** + * Version of the content block format used by the post. + */ + block_version: ContextualField< string, 'edit', C >; + }, + 'view' | 'edit', + C + >; + /** + * The date the post was published, in the site's timezone. + */ + date: string | null; + /** + * The date the post was published, as GMT. + */ + date_gmt: ContextualField< string | null, 'view' | 'edit', C >; + /** + * The excerpt for the post revision. + */ + excerpt: RenderedText< C > & { + protected: boolean; + }; + /** + * The globally unique identifier for the post. + */ + guid: ContextualField< RenderedText< C >, 'view' | 'edit', C >; + /** + * Unique identifier for the revision. + */ + id: number; + /** + * Meta fields. + */ + meta: ContextualField< + Record< string, string >, + 'view' | 'edit', + C + >; + /** + * The date the post was last modified, in the site's timezone. + */ + modified: ContextualField< string, 'view' | 'edit', C >; + /** + * The date the post revision was last modified, as GMT. + */ + modified_gmt: ContextualField< string, 'view' | 'edit', C >; + /** + * Identifier for the parent of the revision. + */ + parent: number; + /** + * An alphanumeric identifier for the post unique to its type. + */ + slug: string; + /** + * The title for the post revision. + */ + title: RenderedText< C >; + } + } +} + +export type PostRevision< C extends Context = 'view' > = OmitNevers< + _BaseEntityRecords.PostRevision< C > +>; diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 191d004f47c95..e1e90d5fcd74d 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -21,7 +21,6 @@ import { isRawAttribute, setNestedValue, isNumericID, - parseEntityName, } from './utils'; import type * as ET from './entity-types'; import type { UndoManager } from '@wordpress/undo-manager'; @@ -63,6 +62,14 @@ interface QueriedData { queries: Record< ET.Context, Record< string, Array< number > > >; } +interface RevisionsQueriedData { + items: + | Record< ET.Context, Record< number, ET.PostRevision > > + | Record< ET.Context, Record< number, ET.GlobalStylesRevision > >; + itemIsComplete: Record< ET.Context, Record< number, boolean > >; + queries: Record< ET.Context, Record< string, Array< number > > >; +} + interface EntityState< EntityRecord extends ET.EntityRecord > { edits: Record< string, Partial< EntityRecord > >; saving: Record< @@ -71,7 +78,7 @@ interface EntityState< EntityRecord extends ET.EntityRecord > { >; deleting: Record< string, Partial< { pending: boolean; error: Error } > >; queriedData: QueriedData; - revisions: QueriedData; + revisions: RevisionsQueriedData; } interface EntityConfig { diff --git a/packages/core-data/src/utils/parse-entity-name.js b/packages/core-data/src/utils/parse-entity-name.js index 4cdc014266595..6df6da85d0483 100644 --- a/packages/core-data/src/utils/parse-entity-name.js +++ b/packages/core-data/src/utils/parse-entity-name.js @@ -1,11 +1,20 @@ +/** + * Parses an entity name into its component parts, assuming a string of + * either `'name'"'` or `'name:key:subEntity'`. + * + * @param {string} name The name of the entity to parse. + * @return {{name: string, isRevision: boolean, key: string}} The parsed entity name. + */ +const DEFAULT_DELIMITER = ':'; + export default function parseEntityName( name = '' ) { - const [ postType, key, revisions ] = ( + const [ postType, key, subEntity ] = ( typeof name === 'string' ? name : '' - )?.split( ':' ); + )?.split( DEFAULT_DELIMITER ); return { - name: postType, + name: postType || name, key, - isRevision: revisions === 'revisions', + isRevision: subEntity === 'revisions', }; } diff --git a/packages/core-data/src/utils/test/parse-entity-name.js b/packages/core-data/src/utils/test/parse-entity-name.js new file mode 100644 index 0000000000000..e623ce8e63768 --- /dev/null +++ b/packages/core-data/src/utils/test/parse-entity-name.js @@ -0,0 +1,34 @@ +/** + * Internal dependencies + */ +import parseEntityName from '../parse-entity-name'; + +describe( 'parseEntityName', () => { + test.each( [ + { + name: null, + expected: { name: null, key: undefined, isRevision: false }, + }, + { + name: 'name', + expected: { name: 'name', key: undefined, isRevision: false }, + }, + { + name: 'name|32', + expected: { name: 'name|32', key: undefined, isRevision: false }, + }, + { + name: 'name:23', + expected: { name: 'name', key: '23', isRevision: false }, + }, + { + name: 'name:23:revisions', + expected: { name: 'name', key: '23', isRevision: true }, + }, + ] )( + 'should return expected object when name is $name', + ( { name, expected } ) => { + expect( parseEntityName( name ) ).toEqual( expected ); + } + ); +} ); From e6792acd6f0ad1d34735d0f1fca62edc363c00ba Mon Sep 17 00:00:00 2001 From: ramon Date: Fri, 27 Oct 2023 11:36:08 +0200 Subject: [PATCH 5/9] Removed unnecessary REST API change for global styles: it was to pull in the CPT in the /types response, which we don't need for core-data since it's a root entity. --- lib/compat/wordpress-6.4/rest-api.php | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/lib/compat/wordpress-6.4/rest-api.php b/lib/compat/wordpress-6.4/rest-api.php index 1581e108601d2..7c81a6a274c03 100644 --- a/lib/compat/wordpress-6.4/rest-api.php +++ b/lib/compat/wordpress-6.4/rest-api.php @@ -27,18 +27,3 @@ function gutenberg_register_global_styles_revisions_endpoints() { $global_styles_revisions_controller->register_routes(); } add_action( 'rest_api_init', 'gutenberg_register_global_styles_revisions_endpoints' ); - -/** - * Updates `wp_global_styles` to show in rest api. This way it will appear in the post types response. - * - * @param array $args Array of arguments for registering a post type. - * @param string $post_type Post type key. - */ -function gutenberg_update_wp_global_styles_post_type( $args, $post_type ) { - if ( 'wp_global_styles' === $post_type ) { - $args['show_in_rest'] = true; - } - - return $args; -} -add_filter( 'register_post_type_args', 'gutenberg_update_wp_global_styles_post_type', 10, 2 ); From 9d5084b0e1b80a58112200d45518b555e5d827a9 Mon Sep 17 00:00:00 2001 From: ramon Date: Sun, 29 Oct 2023 10:00:11 +1100 Subject: [PATCH 6/9] Remove duplicated queried-data reducer functions for revisions, and use them in the main reducer Create dedicated resolvers for getRevision/s, thereby removing the split name parsing required (name:id:revisions) Create dedicated action receivers for revisions Rename from getEntityRevisions to getRevisions Fully remove parseEntityName Updated selector tests Use queriedDataReducer shape for revisions. The queriedDataReducer doesn't need to know about the revisions action type. Update doc comments Updated CHANGELOG.md --- docs/reference-guides/data/data-core.md | 100 ++++++--- packages/core-data/CHANGELOG.md | 1 + packages/core-data/README.md | 100 ++++++--- packages/core-data/src/actions.js | 43 +++- packages/core-data/src/entities.js | 9 +- packages/core-data/src/queried-data/index.js | 1 - .../core-data/src/queried-data/reducer.js | 143 ------------ packages/core-data/src/reducer.js | 35 ++- packages/core-data/src/resolvers.js | 206 +++++++++++------- packages/core-data/src/selectors.ts | 50 +++-- packages/core-data/src/test/selectors.js | 24 +- packages/core-data/src/utils/index.js | 1 - .../core-data/src/utils/parse-entity-name.js | 20 -- .../src/utils/test/parse-entity-name.js | 34 --- .../use-global-styles-revisions.js | 9 +- 15 files changed, 370 insertions(+), 406 deletions(-) delete mode 100644 packages/core-data/src/utils/parse-entity-name.js delete mode 100644 packages/core-data/src/utils/test/parse-entity-name.js diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index 2b67c20161dcb..bfd658173a46a 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -342,39 +342,6 @@ _Returns_ - `number | null`: number | null. -### getEntityRevision - -Returns a specific Entity revision. - -_Parameters_ - -- _state_ `State`: State tree -- _kind_ `string`: Entity kind. -- _name_ `string`: Entity name. -- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. -- _key_ `EntityRecordKey`: The Revision's key. -- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". - -_Returns_ - -- Record. - -### getEntityRevisions - -Returns an Entity's revisions. - -_Parameters_ - -- _state_ `State`: State tree -- _kind_ `string`: Entity kind. -- _name_ `string`: Entity name. -- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. -- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". - -_Returns_ - -- Record. - ### getLastEntityDeleteError Returns the specified entity record's last delete error. @@ -453,6 +420,39 @@ _Returns_ - A value whose reference will change only when an edit occurs. +### getRevision + +Returns a single, specific revision of a parent Entity. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _key_ `EntityRecordKey`: The revision's key. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + +_Returns_ + +- Record. + +### getRevisions + +Returns an Entity's revisions. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + +_Returns_ + +- Record. + ### getThemeSupports Return theme supports data in the index. @@ -560,6 +560,22 @@ _Returns_ - `boolean`: Whether there is a next edit or not. +### hasRevisions + +Returns true if revisions have been received for the given set of parameters, or false otherwise. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + +_Returns_ + +- `boolean`: Whether entity records have been received. + ### hasUndo Returns true if there is a previous edit from the current undo offset for the entity records edits history, and false otherwise. @@ -737,6 +753,24 @@ _Returns_ - `Object`: Action object. +### receiveRevisions + +Returns an action object used in signalling that revisions have been received. + +_Parameters_ + +- _kind_ `string`: Kind of the received entity record revisions. +- _name_ `string`: Name of the received entity record revisions. +- _parentId_ `number|string`: Record's key whose revisions you wish to fetch. +- _records_ `Array|Object`: Revisions received. +- _query_ `?Object`: Query Object. +- _invalidateCache_ `?boolean`: Should invalidate query caches. +- _meta_ `?Object`: Meta information about pagination. + +_Returns_ + +- `Object`: Action object. + ### receiveThemeSupports > **Deprecated** since WP 5.9, this is not useful anymore, use the selector direclty. diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index d6ee748693433..0e6ecc2a9db4b 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -9,6 +9,7 @@ ## Enhancements - Add `getEntityRecordsTotalItems` and `getEntityRecordsTotalPages` selectors. [#55164](https://github.com/WordPress/gutenberg/pull/55164). +- Revisions: add new selectors, `getRevisions` and `getRevision`, to fetch entity revisions. [#54046](https://github.com/WordPress/gutenberg/pull/54046). ## 6.20.0 (2023-10-05) diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 6e8663c94a2d7..b6e5e48e4d975 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -248,6 +248,24 @@ _Returns_ - `Object`: Action object. +### receiveRevisions + +Returns an action object used in signalling that revisions have been received. + +_Parameters_ + +- _kind_ `string`: Kind of the received entity record revisions. +- _name_ `string`: Name of the received entity record revisions. +- _parentId_ `number|string`: Record's key whose revisions you wish to fetch. +- _records_ `Array|Object`: Revisions received. +- _query_ `?Object`: Query Object. +- _invalidateCache_ `?boolean`: Should invalidate query caches. +- _meta_ `?Object`: Meta information about pagination. + +_Returns_ + +- `Object`: Action object. + ### receiveThemeSupports > **Deprecated** since WP 5.9, this is not useful anymore, use the selector direclty. @@ -649,39 +667,6 @@ _Returns_ - `number | null`: number | null. -### getEntityRevision - -Returns a specific Entity revision. - -_Parameters_ - -- _state_ `State`: State tree -- _kind_ `string`: Entity kind. -- _name_ `string`: Entity name. -- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. -- _key_ `EntityRecordKey`: The Revision's key. -- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". - -_Returns_ - -- Record. - -### getEntityRevisions - -Returns an Entity's revisions. - -_Parameters_ - -- _state_ `State`: State tree -- _kind_ `string`: Entity kind. -- _name_ `string`: Entity name. -- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. -- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". - -_Returns_ - -- Record. - ### getLastEntityDeleteError Returns the specified entity record's last delete error. @@ -760,6 +745,39 @@ _Returns_ - A value whose reference will change only when an edit occurs. +### getRevision + +Returns a single, specific revision of a parent Entity. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _key_ `EntityRecordKey`: The revision's key. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + +_Returns_ + +- Record. + +### getRevisions + +Returns an Entity's revisions. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + +_Returns_ + +- Record. + ### getThemeSupports Return theme supports data in the index. @@ -867,6 +885,22 @@ _Returns_ - `boolean`: Whether there is a next edit or not. +### hasRevisions + +Returns true if revisions have been received for the given set of parameters, or false otherwise. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + +_Returns_ + +- `boolean`: Whether entity records have been received. + ### hasUndo Returns true if there is a previous edit from the current undo offset for the entity records edits history, and false otherwise. diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index a95fd4a14a18d..cabcbc023a74a 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -14,7 +14,7 @@ import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ -import { getNestedValue, setNestedValue, parseEntityName } from './utils'; +import { getNestedValue, setNestedValue } from './utils'; import { receiveItems, removeItems, receiveQueriedItems } from './queried-data'; import { getOrLoadEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities'; import { createBatch } from './batch'; @@ -92,11 +92,9 @@ export function receiveEntityRecords( edits, meta ) { - const { isRevision } = parseEntityName( name ); - // Auto drafts should not have titles, but some plugins rely on them so we can't filter this // on the server. - if ( ! isRevision && kind === 'postType' ) { + if ( kind === 'postType' ) { records = ( Array.isArray( records ) ? records : [ records ] ).map( ( record ) => record.status === 'auto-draft' @@ -111,10 +109,6 @@ export function receiveEntityRecords( action = receiveItems( records, edits, meta ); } - if ( isRevision ) { - action.type = 'RECEIVE_ITEM_REVISIONS'; - } - return { ...action, kind, @@ -930,3 +924,36 @@ export function receiveDefaultTemplateId( query, templateId ) { templateId, }; } + +/** + * Returns an action object used in signalling that revisions have been received. + * + * @param {string} kind Kind of the received entity record revisions. + * @param {string} name Name of the received entity record revisions. + * @param {number|string} parentId Record's key whose revisions you wish to fetch. + * @param {Array|Object} records Revisions received. + * @param {?Object} query Query Object. + * @param {?boolean} invalidateCache Should invalidate query caches. + * @param {?Object} meta Meta information about pagination. + * @return {Object} Action object. + */ +export function receiveRevisions( + kind, + name, + parentId, + records, + query, + invalidateCache = false, + meta +) { + return { + type: 'RECEIVE_ITEM_REVISIONS', + items: Array.isArray( records ) ? records : [ records ], + parentId, + meta, + query, + kind, + name, + invalidateCache, + }; +} diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 98a6e9748de47..e6887f157b4b7 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -19,6 +19,9 @@ export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; +// Supports information is also available in the "edit" context of `/types`. +const POST_TYPES_WITH_REVISIONS_SUPPORT = [ 'post', 'page' ]; + export const rootEntitiesConfig = [ { label: __( 'Base' ), @@ -215,6 +218,7 @@ export const rootEntitiesConfig = [ supports: { revisions: true, }, + supportsPagination: true, }, { label: __( 'Themes' ), @@ -304,8 +308,9 @@ async function loadPostTypeEntities() { }, mergedEdits: { meta: true }, supports: { - // Supports information is also available in the "edit" context. - revisions: [ 'post', 'page' ].includes( postType?.slug ), + revisions: POST_TYPES_WITH_REVISIONS_SUPPORT.includes( + postType?.slug + ), }, rawAttributes: POST_RAW_ATTRIBUTES, getTitle: ( record ) => diff --git a/packages/core-data/src/queried-data/index.js b/packages/core-data/src/queried-data/index.js index 768a654db5b59..57e124e445d87 100644 --- a/packages/core-data/src/queried-data/index.js +++ b/packages/core-data/src/queried-data/index.js @@ -1,4 +1,3 @@ export * from './actions'; export * from './selectors'; export { default as reducer } from './reducer'; -export { revisionsQueriedDataReducer } from './reducer'; diff --git a/packages/core-data/src/queried-data/reducer.js b/packages/core-data/src/queried-data/reducer.js index 1656620358fbf..3462f00b68569 100644 --- a/packages/core-data/src/queried-data/reducer.js +++ b/packages/core-data/src/queried-data/reducer.js @@ -12,7 +12,6 @@ import { ifMatchingAction, replaceAction, onSubKey, - parseEntityName, } from '../utils'; import { DEFAULT_ENTITY_KEY } from '../entities'; import getQueryParts from './get-query-parts'; @@ -285,148 +284,6 @@ const queries = ( state = {}, action ) => { } }; -/** - * Reducer tracking revision items state, keyed by parent ID. - * - * @param {Object} state Current state. - * @param {Object} action Dispatched action. - * - * @return {Object} Next state. - */ - -export function revisionItems( state = {}, action ) { - switch ( action.type ) { - case 'RECEIVE_ITEM_REVISIONS': - const context = getContextFromAction( action ); - const key = action.key || DEFAULT_ENTITY_KEY; - - return { - ...state, - [ context ]: { - ...state[ context ], - ...action.items.reduce( ( accumulator, value ) => { - const itemId = value[ key ]; - accumulator[ itemId ] = conservativeMapItem( - state?.[ context ]?.[ itemId ], - value - ); - return accumulator; - }, {} ), - }, - }; - } - return state; -} - -const receiveRevisionQueries = compose( [ - // Limit to matching action type so we don't attempt to replace action on - // an unhandled action. - ifMatchingAction( ( action ) => 'query' in action ), - - // Inject query parts into action for use both in `onSubKey` and reducer. - replaceAction( ( action ) => { - // `ifMatchingAction` still passes on initialization, where state is - // undefined and a query is not assigned. Avoid attempting to parse - // parts. `onSubKey` will omit by lack of `stableKey`. - if ( action.query ) { - return { - ...action, - ...getQueryParts( action.query ), - }; - } - - return action; - } ), - - onSubKey( 'context' ), - - // Queries shape is shared, but keyed by query `stableKey` part. Original - // reducer tracks only a single query object. - onSubKey( 'stableKey' ), -] )( ( state = {}, action ) => { - const { type, page, perPage, key = DEFAULT_ENTITY_KEY } = action; - if ( type !== 'RECEIVE_ITEM_REVISIONS' ) { - return state; - } - - return { - itemIds: getMergedItemIds( - state?.itemIds || [], - action.items.map( ( item ) => item[ key ] ), - page, - perPage - ), - meta: action.meta, - }; -} ); - -export function revisionItemIsComplete( state = {}, action ) { - switch ( action.type ) { - case 'RECEIVE_ITEM_REVISIONS': { - const context = getContextFromAction( action ); - const { query, key = DEFAULT_ENTITY_KEY } = action; - const queryParts = query ? getQueryParts( query ) : {}; - const isCompleteQuery = - ! query || ! Array.isArray( queryParts.fields ); - - return { - ...state, - [ context ]: { - ...state[ context ], - ...action.items.reduce( ( result, item ) => { - const itemId = item[ key ]; - - // Defer to completeness if already assigned. Technically the - // data may be outdated if receiving items for a field subset. - result[ itemId ] = - state?.[ context ]?.[ itemId ] || isCompleteQuery; - - return result; - }, {} ), - }, - }; - } - } - - return state; -} - -/** - * Reducer tracking queries state. - * - * @param {Object} state Current state. - * @param {Object} action Dispatched action. - * - * @return {Object} Next state. - */ -const revisionQueries = ( state = {}, action ) => { - switch ( action.type ) { - case 'RECEIVE_ITEM_REVISIONS': - return receiveRevisionQueries( state, action ); - default: - return state; - } -}; - -const revisionsReducer = combineReducers( { - items: revisionItems, - itemIsComplete: revisionItemIsComplete, - queries: revisionQueries, -} ); - -export const revisionsQueriedDataReducer = ( state = {}, action ) => { - switch ( action.type ) { - case 'RECEIVE_ITEM_REVISIONS': - const { key: parentId } = parseEntityName( action.name ); - return { - ...state, - [ parentId ]: revisionsReducer( state[ parentId ], action ), - }; - default: - return state; - } -}; - export default combineReducers( { items, itemIsComplete, diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index da4e5d070fe07..302dd334b1516 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -13,11 +13,8 @@ import { createUndoManager } from '@wordpress/undo-manager'; /** * Internal dependencies */ -import { ifMatchingAction, replaceAction, parseEntityName } from './utils'; -import { - reducer as queriedDataReducer, - revisionsQueriedDataReducer, -} from './queried-data'; +import { ifMatchingAction, replaceAction } from './utils'; +import { reducer as queriedDataReducer } from './queried-data'; import { rootEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities'; /** @typedef {import('./types').AnyFunction} AnyFunction */ @@ -230,14 +227,13 @@ function entity( entityConfig ) { // Limit to matching action type so we don't attempt to replace action on // an unhandled action. - ifMatchingAction( ( action ) => { - return ( + ifMatchingAction( + ( action ) => action.name && action.kind && - parseEntityName( action.name )?.name === entityConfig.name && + action.name === entityConfig.name && action.kind === entityConfig.kind - ); - } ), + ), // Inject the entity config into the action. replaceAction( ( action ) => { @@ -361,7 +357,24 @@ function entity( entityConfig ) { // Add revisions to the state tree if the post type supports it. ...( entityConfig?.supports?.revisions - ? { revisions: revisionsQueriedDataReducer } + ? { + revisions: ( state, action ) => { + // Use the same queriedDataReducer shape for revisions. + if ( action.type === 'RECEIVE_ITEM_REVISIONS' ) { + return { + ...state, + [ action.parentId ]: queriedDataReducer( + state, + { + ...action, + type: 'RECEIVE_ITEMS', + } + ), + }; + } + return state; + }, + } : {} ), } ) ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 6b18b2014a719..2139518908a4c 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -15,11 +15,7 @@ import apiFetch from '@wordpress/api-fetch'; */ import { STORE_NAME } from './name'; import { getOrLoadEntitiesConfig, DEFAULT_ENTITY_KEY } from './entities'; -import { - forwardResolver, - getNormalizedCommaSeparable, - parseEntityName, -} from './utils'; +import { forwardResolver, getNormalizedCommaSeparable } from './utils'; import { getSyncProvider } from './sync'; /** @@ -63,19 +59,9 @@ export const getEntityRecord = ( kind, name, key = '', query ) => async ( { select, dispatch } ) => { const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); - const { - name: parsedName, - key: parsedKey, - isRevision, - } = parseEntityName( name ); const entityConfig = configs.find( - ( config ) => config.name === parsedName && config.kind === kind + ( config ) => config.name === name && config.kind === kind ); - - if ( isRevision && ! entityConfig?.supports?.revisions ) { - return; - } - if ( ! entityConfig || entityConfig?.__experimentalNoFetch ) { return; } @@ -88,12 +74,10 @@ export const getEntityRecord = try { // Entity supports configs, - // use the sync algorithm instead of the old fetch behavior, - // but not for revisions. @TODO check this. + // use the sync algorithm instead of the old fetch behavior. if ( window.__experimentalEnableSync && entityConfig.syncConfig && - ! isRevision && ! query ) { if ( process.env.IS_GUTENBERG_PLUGIN ) { @@ -149,16 +133,20 @@ export const getEntityRecord = }; } - const baseUrl = isRevision - ? entityConfig.getRevisionsUrl( parsedKey, key ) - : entityConfig.baseURL + ( key ? '/' + key : '' ); - - const path = addQueryArgs( baseUrl, { - ...( isRevision - ? entityConfig.revisionURLParams - : entityConfig.baseURLParams ), - ...query, - } ); + // Disable reason: While true that an early return could leave `path` + // unused, it's important that path is derived using the query prior to + // additional query modifications in the condition below, since those + // modifications are relevant to how the data is tracked in state, and not + // for how the request is made to the REST API. + + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const path = addQueryArgs( + entityConfig.baseURL + ( key ? '/' + key : '' ), + { + ...entityConfig.baseURLParams, + ...query, + } + ); if ( query !== undefined ) { query = { ...query, include: [ key ] }; @@ -166,14 +154,11 @@ export const getEntityRecord = // The resolution cache won't consider query as reusable based on the // fields, so it's tested here, prior to initiating the REST request, // and without causing `getEntityRecords` resolution to occur. - // @TODO how to handle revisions here? - // @TODO will it know if a new revision has been created? const hasRecords = select.hasEntityRecords( kind, name, query ); - if ( hasRecords ) { return; } @@ -209,19 +194,9 @@ export const getEntityRecords = ( kind, name, query = {} ) => async ( { dispatch } ) => { const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); - const { - name: parsedName, - key: parsedKey, - isRevision, - } = parseEntityName( name ); const entityConfig = configs.find( - ( config ) => config.name === parsedName && config.kind === kind + ( config ) => config.name === name && config.kind === kind ); - - if ( isRevision && ! entityConfig?.supports?.revisions ) { - return; - } - if ( ! entityConfig || entityConfig?.__experimentalNoFetch ) { return; } @@ -249,14 +224,8 @@ export const getEntityRecords = }; } - const baseUrl = isRevision - ? entityConfig.getRevisionsUrl( parsedKey ) - : entityConfig.baseURL; - - const path = addQueryArgs( baseUrl, { - ...( isRevision - ? entityConfig.revisionURLParams - : entityConfig.baseURLParams ), + const path = addQueryArgs( entityConfig.baseURL, { + ...entityConfig.baseURLParams, ...query, } ); @@ -324,18 +293,6 @@ export const getEntityRecords = }; getEntityRecords.shouldInvalidate = ( action, kind, name ) => { - // Invalidate cache when a new revision is created. - if ( action.type === 'SAVE_ENTITY_RECORD_FINISH' ) { - const { name: parsedName, key: parsedKey } = parseEntityName( name ); - - return ( - kind === action.kind && - parsedName === action.name && - ! action.error && - Number( parsedKey ) === action.recordId - ); - } - return ( ( action.type === 'RECEIVE_ITEMS' || action.type === 'REMOVE_ITEMS' ) && action.invalidateCache && @@ -772,24 +729,72 @@ export const getDefaultTemplateId = * include with request. If requesting specific * fields, fields must always include the ID. */ -export const getEntityRevisions = +export const getRevisions = ( kind, name, parentId, query = {} ) => - async ( { resolveSelect } ) => { - await resolveSelect.getEntityRecords( - kind, - `${ name }:${ parentId }:revisions`, - query + async ( { dispatch } ) => { + const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const entityConfig = configs.find( + ( config ) => config.name === name && config.kind === kind + ); + + if ( + ! entityConfig || + entityConfig?.__experimentalNoFetch || + ! entityConfig?.supports?.revisions + ) { + return; + } + + const lock = await dispatch.__unstableAcquireStoreLock( + STORE_NAME, + [ 'entities', 'records', kind, name, 'revisions', parentId ], + { exclusive: false } ); + + try { + const path = addQueryArgs( + entityConfig.getRevisionsUrl( parentId ), + { + ...entityConfig.revisionURLParams, + ...query, + } + ); + + let records, meta; + if ( entityConfig.supportsPagination && query.per_page !== -1 ) { + const response = await apiFetch( { path, parse: false } ); + records = Object.values( await response.json() ); + meta = { + totalItems: parseInt( + response.headers.get( 'X-WP-Total' ) + ), + }; + } else { + records = Object.values( await apiFetch( { path } ) ); + } + + dispatch.receiveRevisions( + kind, + name, + parentId, + records, + query, + false, + meta + ); + } finally { + dispatch.__unstableReleaseStoreLock( lock ); + } }; -getEntityRevisions.shouldInvalidate = ( action, kind, name, key ) => { +getRevisions.shouldInvalidate = ( action, kind, name, parentId ) => { // Invalidate cache when a new revision is created. if ( action.type === 'SAVE_ENTITY_RECORD_FINISH' ) { return ( name === action.name && kind === action.kind && ! action.error && - key === action.recordId + parentId === action.recordId ); } }; @@ -805,13 +810,58 @@ getEntityRevisions.shouldInvalidate = ( action, kind, name, key ) => { * include with request. If requesting specific * fields, fields must always include the ID. */ -export const getEntityRevision = +export const getRevision = ( kind, name, parentId, key, query = {} ) => - async ( { resolveSelect } ) => { - await resolveSelect.getEntityRecord( - kind, - `${ name }:${ parentId }:revisions`, - key, - query + async ( { select, dispatch } ) => { + const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const entityConfig = configs.find( + ( config ) => config.name === name && config.kind === kind + ); + + if ( + ! entityConfig || + entityConfig?.__experimentalNoFetch || + ! entityConfig?.supports?.revisions + ) { + return; + } + + const lock = await dispatch.__unstableAcquireStoreLock( + STORE_NAME, + [ 'entities', 'records', kind, name, 'revisions', parentId, key ], + { exclusive: false } ); + + try { + const path = addQueryArgs( + entityConfig.getRevisionsUrl( parentId, key ), + { + ...entityConfig.revisionURLParams, + ...query, + } + ); + + if ( query !== undefined ) { + query = { ...query, include: [ key ] }; + + // The resolution cache won't consider query as reusable based on the + // fields, so it's tested here, prior to initiating the REST request, + // and without causing `getEntityRecords` resolution to occur. + const hasRecords = select.hasRevisions( + kind, + name, + parentId, + query + ); + + if ( hasRecords ) { + return; + } + } + + const record = await apiFetch( { path } ); + dispatch.receiveRevisions( kind, name, parentId, record, query ); + } finally { + dispatch.__unstableReleaseStoreLock( lock ); + } }; diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index e1e90d5fcd74d..ae7fe397ca4c9 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -350,7 +350,6 @@ export const getEntityRecord = createSelector( if ( ! queriedState ) { return undefined; } - const context = query?.context ?? 'default'; if ( query === undefined ) { @@ -1396,7 +1395,7 @@ export function getDefaultTemplateId( * * @return Record. */ -export const getEntityRevisions = ( +export const getRevisions = ( state: State, kind: string, name: string, @@ -1413,19 +1412,19 @@ export const getEntityRevisions = ( }; /** - * Returns a specific Entity revision. + * Returns a single, specific revision of a parent Entity. * * @param state State tree * @param kind Entity kind. * @param name Entity name. * @param parentId Record's key whose revisions you wish to fetch. - * @param key The Revision's key. + * @param key The revision's key. * @param query Optional query. If requesting specific * fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". * * @return Record. */ -export const getEntityRevision = createSelector( +export const getRevision = createSelector( ( state: State, kind: string, @@ -1452,23 +1451,7 @@ export const getEntityRevision = createSelector( return queriedRevisionsState.items[ context ][ key ]; } - const item = queriedRevisionsState.items[ context ]?.[ key ]; - - if ( item && query._fields ) { - const filteredItem = {}; - const fields = getNormalizedCommaSeparable( query._fields ) ?? []; - for ( let f = 0; f < fields.length; f++ ) { - const field = fields[ f ].split( '.' ); - let value = item; - field.forEach( ( fieldName ) => { - value = value?.[ fieldName ]; - } ); - setNestedValue( filteredItem, field, value ); - } - return filteredItem; - } - - return item; + return queriedRevisionsState.items[ context ]?.[ key ]; }, ( state: State, kind, name, parentId, key, query ) => { const context = query?.context ?? 'default'; @@ -1480,3 +1463,26 @@ export const getEntityRevision = createSelector( ]; } ); + +/** + * Returns true if revisions have been received for the given set of parameters, + * or false otherwise. + * + * + * @param state State tree + * @param kind Entity kind. + * @param name Entity name. + * @param parentId Record's key whose revisions you wish to fetch. + * @param query Optional query. If requesting specific + * fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + * @return Whether entity records have been received. + */ +export function hasRevisions( + state: State, + kind: string, + name: string, + parentId: EntityRecordKey, + query?: GetRecordsHttpQuery +): boolean { + return Array.isArray( getRevisions( state, kind, name, parentId, query ) ); +} diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index 123084d18aeb6..43c84a3e97891 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -22,8 +22,8 @@ import { getAutosave, getAutosaves, getCurrentUser, - getEntityRevisions, - getEntityRevision, + getRevisions, + getRevision, } from '../selectors'; // getEntityRecord and __experimentalGetEntityRecordNoResolver selectors share the same tests. describe.each( [ @@ -899,7 +899,7 @@ describe( 'getCurrentUser', () => { } ); } ); -describe( 'getEntityRevisions', () => { +describe( 'getRevisions', () => { it( 'should return revisions', () => { const state = deepFreeze( { entities: { @@ -936,7 +936,7 @@ describe( 'getEntityRevisions', () => { }, } ); - expect( getEntityRevisions( state, 'postType', 'post', 1 ) ).toEqual( [ + expect( getRevisions( state, 'postType', 'post', 1 ) ).toEqual( [ { id: 10, content: 'chicken', @@ -947,7 +947,7 @@ describe( 'getEntityRevisions', () => { } ); } ); -describe( 'getEntityRevision', () => { +describe( 'getRevision', () => { it( 'should return a specific revision', () => { const state = deepFreeze( { entities: { @@ -984,13 +984,11 @@ describe( 'getEntityRevision', () => { }, } ); - expect( getEntityRevision( state, 'postType', 'post', 1, 10 ) ).toEqual( - { - id: 10, - content: 'chicken', - author: 'bob', - parent: 1, - } - ); + expect( getRevision( state, 'postType', 'post', 1, 10 ) ).toEqual( { + id: 10, + content: 'chicken', + author: 'bob', + parent: 1, + } ); } ); } ); diff --git a/packages/core-data/src/utils/index.js b/packages/core-data/src/utils/index.js index 33d7bedbc0bb1..d4d491fab9827 100644 --- a/packages/core-data/src/utils/index.js +++ b/packages/core-data/src/utils/index.js @@ -9,4 +9,3 @@ export { default as isRawAttribute } from './is-raw-attribute'; export { default as setNestedValue } from './set-nested-value'; export { default as getNestedValue } from './get-nested-value'; export { default as isNumericID } from './is-numeric-id'; -export { default as parseEntityName } from './parse-entity-name'; diff --git a/packages/core-data/src/utils/parse-entity-name.js b/packages/core-data/src/utils/parse-entity-name.js deleted file mode 100644 index 6df6da85d0483..0000000000000 --- a/packages/core-data/src/utils/parse-entity-name.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Parses an entity name into its component parts, assuming a string of - * either `'name'"'` or `'name:key:subEntity'`. - * - * @param {string} name The name of the entity to parse. - * @return {{name: string, isRevision: boolean, key: string}} The parsed entity name. - */ -const DEFAULT_DELIMITER = ':'; - -export default function parseEntityName( name = '' ) { - const [ postType, key, subEntity ] = ( - typeof name === 'string' ? name : '' - )?.split( DEFAULT_DELIMITER ); - - return { - name: postType || name, - key, - isRevision: subEntity === 'revisions', - }; -} diff --git a/packages/core-data/src/utils/test/parse-entity-name.js b/packages/core-data/src/utils/test/parse-entity-name.js deleted file mode 100644 index e623ce8e63768..0000000000000 --- a/packages/core-data/src/utils/test/parse-entity-name.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Internal dependencies - */ -import parseEntityName from '../parse-entity-name'; - -describe( 'parseEntityName', () => { - test.each( [ - { - name: null, - expected: { name: null, key: undefined, isRevision: false }, - }, - { - name: 'name', - expected: { name: 'name', key: undefined, isRevision: false }, - }, - { - name: 'name|32', - expected: { name: 'name|32', key: undefined, isRevision: false }, - }, - { - name: 'name:23', - expected: { name: 'name', key: '23', isRevision: false }, - }, - { - name: 'name:23:revisions', - expected: { name: 'name', key: '23', isRevision: true }, - }, - ] )( - 'should return expected object when name is $name', - ( { name, expected } ) => { - expect( parseEntityName( name ) ).toEqual( expected ); - } - ); -} ); diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js index 853d3394f10d4..6e3573061a421 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/use-global-styles-revisions.js @@ -32,19 +32,14 @@ export default function useGlobalStylesRevisions() { __experimentalGetDirtyEntityRecords, getCurrentUser, getUsers, - getEntityRevisions, - __experimentalGetCurrentGlobalStylesId, + getCurrentThemeGlobalStylesRevisions, isResolving, } = select( coreStore ); const dirtyEntityRecords = __experimentalGetDirtyEntityRecords(); const _currentUser = getCurrentUser(); const _isDirty = dirtyEntityRecords.length > 0; const globalStylesRevisions = - getEntityRevisions( - 'root', - 'globalStyles', - __experimentalGetCurrentGlobalStylesId() - ) || EMPTY_ARRAY; + getCurrentThemeGlobalStylesRevisions() || EMPTY_ARRAY; const _authors = getUsers( SITE_EDITOR_AUTHORS_QUERY ) || EMPTY_ARRAY; return { From 8425c478ccc0b6a88964cd03f41766331a051b97 Mon Sep 17 00:00:00 2001 From: ramon Date: Thu, 2 Nov 2023 15:21:53 +1100 Subject: [PATCH 7/9] This commit: - removes `hasRevisions` because it has no noticeable benefit, at least as far as I can see. I'm probably wrong. - removes the default revisions query context for the API call (the default is 'view' anyway) - fixes the revisions reducer - Add _fields logic to resolvers and selectors Adds basic reducer tests --- docs/reference-guides/data/data-core.md | 20 +-- packages/core-data/README.md | 20 +-- packages/core-data/src/entities.js | 8 +- packages/core-data/src/reducer.js | 15 +-- packages/core-data/src/resolvers.js | 94 ++++++++------ packages/core-data/src/selectors.ts | 72 +++++------ packages/core-data/src/test/reducer.js | 161 ++++++++++++++++++++++++ 7 files changed, 265 insertions(+), 125 deletions(-) diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index bfd658173a46a..61b2601613598 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -435,7 +435,7 @@ _Parameters_ _Returns_ -- Record. +- `RevisionRecord | Record< PropertyKey, never > | undefined`: Record. ### getRevisions @@ -451,7 +451,7 @@ _Parameters_ _Returns_ -- Record. +- `RevisionRecord[] | null`: Record. ### getThemeSupports @@ -560,22 +560,6 @@ _Returns_ - `boolean`: Whether there is a next edit or not. -### hasRevisions - -Returns true if revisions have been received for the given set of parameters, or false otherwise. - -_Parameters_ - -- _state_ `State`: State tree -- _kind_ `string`: Entity kind. -- _name_ `string`: Entity name. -- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. -- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". - -_Returns_ - -- `boolean`: Whether entity records have been received. - ### hasUndo Returns true if there is a previous edit from the current undo offset for the entity records edits history, and false otherwise. diff --git a/packages/core-data/README.md b/packages/core-data/README.md index b6e5e48e4d975..5f68472eba548 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -760,7 +760,7 @@ _Parameters_ _Returns_ -- Record. +- `RevisionRecord | Record< PropertyKey, never > | undefined`: Record. ### getRevisions @@ -776,7 +776,7 @@ _Parameters_ _Returns_ -- Record. +- `RevisionRecord[] | null`: Record. ### getThemeSupports @@ -885,22 +885,6 @@ _Returns_ - `boolean`: Whether there is a next edit or not. -### hasRevisions - -Returns true if revisions have been received for the given set of parameters, or false otherwise. - -_Parameters_ - -- _state_ `State`: State tree -- _kind_ `string`: Entity kind. -- _name_ `string`: Entity name. -- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. -- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". - -_Returns_ - -- `boolean`: Whether entity records have been received. - ### hasUndo Returns true if there is a previous edit from the current undo offset for the entity records edits history, and false otherwise. diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index e6887f157b4b7..01765177c1f68 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -210,11 +210,8 @@ export const rootEntitiesConfig = [ baseURLParams: { context: 'edit' }, plural: 'globalStylesVariations', // Should be different than name. getTitle: ( record ) => record?.title?.rendered || record?.title, - getRevisionsUrl: ( parentId, revisionId ) => - `/wp/v2/global-styles/${ parentId }/revisions${ - revisionId ? '/' + revisionId : '' - }`, - revisionURLParams: { context: 'view' }, + getRevisionsUrl: ( parentId ) => + `/wp/v2/global-styles/${ parentId }/revisions`, supports: { revisions: true, }, @@ -351,7 +348,6 @@ async function loadPostTypeEntities() { }/${ parentId }/revisions${ revisionId ? '/' + revisionId : '' }`, - revisionURLParams: { context: 'view' }, }; } ); } diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 302dd334b1516..e63b94ae75749 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -361,15 +361,16 @@ function entity( entityConfig ) { revisions: ( state, action ) => { // Use the same queriedDataReducer shape for revisions. if ( action.type === 'RECEIVE_ITEM_REVISIONS' ) { + const newState = queriedDataReducer( + state?.[ action.parentId ], + { + ...action, + type: 'RECEIVE_ITEMS', + } + ); return { ...state, - [ action.parentId ]: queriedDataReducer( - state, - { - ...action, - type: 'RECEIVE_ITEMS', - } - ), + [ action.parentId ]: newState, }; } return state; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 2139518908a4c..0715865a0f807 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -752,12 +752,25 @@ export const getRevisions = ); try { + if ( query._fields ) { + // If requesting specific fields, items and query association to said + // records are stored by ID reference. Thus, fields must always include + // the ID. + query = { + ...query, + _fields: [ + ...new Set( [ + ...( getNormalizedCommaSeparable( query._fields ) || + [] ), + entityConfig.key || DEFAULT_ENTITY_KEY, + ] ), + ].join(), + }; + } + const path = addQueryArgs( entityConfig.getRevisionsUrl( parentId ), - { - ...entityConfig.revisionURLParams, - ...query, - } + query ); let records, meta; @@ -773,6 +786,21 @@ export const getRevisions = records = Object.values( await apiFetch( { path } ) ); } + // If we request fields but the result doesn't contain the fields, + // explicitly set these fields as "undefined" + // that way we consider the query "fulfilled". + if ( query._fields ) { + records = records.map( ( record ) => { + query._fields.split( ',' ).forEach( ( field ) => { + if ( ! record.hasOwnProperty( field ) ) { + record[ field ] = undefined; + } + } ); + + return record; + } ); + } + dispatch.receiveRevisions( kind, name, @@ -787,17 +815,13 @@ export const getRevisions = } }; -getRevisions.shouldInvalidate = ( action, kind, name, parentId ) => { - // Invalidate cache when a new revision is created. - if ( action.type === 'SAVE_ENTITY_RECORD_FINISH' ) { - return ( - name === action.name && - kind === action.kind && - ! action.error && - parentId === action.recordId - ); - } -}; +// Invalidate cache when a new revision is created. +getRevisions.shouldInvalidate = ( action, kind, name, parentId ) => + action.type === 'SAVE_ENTITY_RECORD_FINISH' && + name === action.name && + kind === action.kind && + ! action.error && + parentId === action.recordId; /** * Requests a specific Entity revision from the REST API. @@ -812,7 +836,7 @@ getRevisions.shouldInvalidate = ( action, kind, name, parentId ) => { */ export const getRevision = ( kind, name, parentId, key, query = {} ) => - async ( { select, dispatch } ) => { + async ( { dispatch } ) => { const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); const entityConfig = configs.find( ( config ) => config.name === name && config.kind === kind @@ -833,32 +857,26 @@ export const getRevision = ); try { + if ( query !== undefined && query._fields ) { + // If requesting specific fields, items and query association to said + // records are stored by ID reference. Thus, fields must always include + // the ID. + query = { + ...query, + _fields: [ + ...new Set( [ + ...( getNormalizedCommaSeparable( query._fields ) || + [] ), + entityConfig.key || DEFAULT_ENTITY_KEY, + ] ), + ].join(), + }; + } const path = addQueryArgs( entityConfig.getRevisionsUrl( parentId, key ), - { - ...entityConfig.revisionURLParams, - ...query, - } + query ); - if ( query !== undefined ) { - query = { ...query, include: [ key ] }; - - // The resolution cache won't consider query as reusable based on the - // fields, so it's tested here, prior to initiating the REST request, - // and without causing `getEntityRecords` resolution to occur. - const hasRecords = select.hasRevisions( - kind, - name, - parentId, - query - ); - - if ( hasRecords ) { - return; - } - } - const record = await apiFetch( { path } ); dispatch.receiveRevisions( kind, name, parentId, record, query ); } finally { diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index ae7fe397ca4c9..11e383fd3a41a 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -62,10 +62,12 @@ interface QueriedData { queries: Record< ET.Context, Record< string, Array< number > > >; } +type RevisionRecord = + | Record< ET.Context, Record< number, ET.PostRevision > > + | Record< ET.Context, Record< number, ET.GlobalStylesRevision > >; + interface RevisionsQueriedData { - items: - | Record< ET.Context, Record< number, ET.PostRevision > > - | Record< ET.Context, Record< number, ET.GlobalStylesRevision > >; + items: RevisionRecord; itemIsComplete: Record< ET.Context, Record< number, boolean > >; queries: Record< ET.Context, Record< string, Array< number > > >; } @@ -78,7 +80,7 @@ interface EntityState< EntityRecord extends ET.EntityRecord > { >; deleting: Record< string, Partial< { pending: boolean; error: Error } > >; queriedData: QueriedData; - revisions: RevisionsQueriedData; + revisions?: RevisionsQueriedData; } interface EntityConfig { @@ -1401,7 +1403,7 @@ export const getRevisions = ( name: string, parentId: EntityRecordKey, query?: GetRecordsHttpQuery -) => { +): RevisionRecord[] | null => { const queriedStateRevisions = state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ parentId ]; if ( ! queriedStateRevisions ) { @@ -1432,11 +1434,11 @@ export const getRevision = createSelector( parentId: EntityRecordKey, key: EntityRecordKey, query?: GetRecordsHttpQuery - ) => { - const queriedRevisionsState = + ): RevisionRecord | Record< PropertyKey, never > | undefined => { + const queriedState = state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ parentId ]; - if ( ! queriedRevisionsState ) { + if ( ! queriedState ) { return undefined; } @@ -1444,45 +1446,39 @@ export const getRevision = createSelector( if ( query === undefined ) { // If expecting a complete item, validate that completeness. - if ( ! queriedRevisionsState.itemIsComplete[ context ]?.[ key ] ) { + if ( ! queriedState.itemIsComplete[ context ]?.[ key ] ) { return undefined; } - return queriedRevisionsState.items[ context ][ key ]; + return queriedState.items[ context ][ key ]; + } + + const item = queriedState.items[ context ]?.[ key ]; + if ( item && query._fields ) { + const filteredItem = {}; + const fields = getNormalizedCommaSeparable( query._fields ) ?? []; + + for ( let f = 0; f < fields.length; f++ ) { + const field = fields[ f ].split( '.' ); + let value = item; + field.forEach( ( fieldName ) => { + value = value?.[ fieldName ]; + } ); + setNestedValue( filteredItem, field, value ); + } + + return filteredItem; } - return queriedRevisionsState.items[ context ]?.[ key ]; + return item; }, ( state: State, kind, name, parentId, key, query ) => { const context = query?.context ?? 'default'; return [ - state.entities.records?.[ kind ]?.[ name ]?.revisions[ parentId ] - ?.items[ key ], - state.entities.records?.[ kind ]?.[ name ]?.revisions[ parentId ] - ?.itemIsComplete[ context ]?.[ key ], + state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ parentId ] + ?.items?.[ context ]?.[ key ], + state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ parentId ] + ?.itemIsComplete?.[ context ]?.[ key ], ]; } ); - -/** - * Returns true if revisions have been received for the given set of parameters, - * or false otherwise. - * - * - * @param state State tree - * @param kind Entity kind. - * @param name Entity name. - * @param parentId Record's key whose revisions you wish to fetch. - * @param query Optional query. If requesting specific - * fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". - * @return Whether entity records have been received. - */ -export function hasRevisions( - state: State, - kind: string, - name: string, - parentId: EntityRecordKey, - query?: GetRecordsHttpQuery -): boolean { - return Array.isArray( getRevisions( state, kind, name, parentId, query ) ); -} diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js index 4142f65af4c7c..52ce5eaf4d675 100644 --- a/packages/core-data/src/test/reducer.js +++ b/packages/core-data/src/test/reducer.js @@ -139,6 +139,167 @@ describe( 'entities', () => { .map( ( [ , cfg ] ) => cfg ) ).toEqual( [ { kind: 'postType', name: 'posts' } ] ); } ); + + describe( 'entity revisions', () => { + const stateWithConfig = entities( undefined, { + type: 'ADD_ENTITIES', + entities: [ + { + kind: 'root', + name: 'postType', + supports: { revisions: true }, + }, + ], + } ); + it( 'appends revisions state', () => { + expect( stateWithConfig.records.root.postType ).toHaveProperty( + 'revisions', + undefined + ); + } ); + + it( 'returns with received revisions', () => { + const initialState = deepFreeze( { + config: stateWithConfig.config, + records: {}, + } ); + const state = entities( initialState, { + type: 'RECEIVE_ITEM_REVISIONS', + items: [ { id: 1, parent: 2 } ], + kind: 'root', + name: 'postType', + parentId: 2, + } ); + expect( state.records.root.postType.revisions ).toEqual( { + 2: { + items: { + default: { + 1: { id: 1, parent: 2 }, + }, + }, + itemIsComplete: { + default: { + 1: true, + }, + }, + queries: {}, + }, + } ); + } ); + + it( 'returns with appended received revisions at the parent level', () => { + const initialState = deepFreeze( { + config: stateWithConfig.config, + records: { + root: { + postType: { + revisions: { + 2: { + items: { + default: { + 1: { id: 1, parent: 2 }, + }, + }, + itemIsComplete: { + default: { + 1: true, + }, + }, + queries: {}, + }, + }, + }, + }, + }, + } ); + const state = entities( initialState, { + type: 'RECEIVE_ITEM_REVISIONS', + items: [ { id: 3, parent: 4 } ], + kind: 'root', + name: 'postType', + parentId: 4, + } ); + expect( state.records.root.postType.revisions ).toEqual( { + 2: { + items: { + default: { + 1: { id: 1, parent: 2 }, + }, + }, + itemIsComplete: { + default: { + 1: true, + }, + }, + queries: {}, + }, + 4: { + items: { + default: { + 3: { id: 3, parent: 4 }, + }, + }, + itemIsComplete: { + default: { + 3: true, + }, + }, + queries: {}, + }, + } ); + } ); + + it( 'returns with appended received revision items', () => { + const initialState = deepFreeze( { + config: stateWithConfig.config, + records: { + root: { + postType: { + revisions: { + 2: { + items: { + default: { + 1: { id: 1, parent: 2 }, + }, + }, + itemIsComplete: { + default: { + 1: true, + }, + }, + queries: {}, + }, + }, + }, + }, + }, + } ); + const state = entities( initialState, { + type: 'RECEIVE_ITEM_REVISIONS', + items: [ { id: 7, parent: 2 } ], + kind: 'root', + name: 'postType', + parentId: 2, + } ); + expect( state.records.root.postType.revisions ).toEqual( { + 2: { + items: { + default: { + 1: { id: 1, parent: 2 }, + 7: { id: 7, parent: 2 }, + }, + }, + itemIsComplete: { + default: { + 1: true, + 7: true, + }, + }, + queries: {}, + }, + } ); + } ); + } ); } ); describe( 'embedPreviews()', () => { From 0c7241bc2cd633b4710b6b68d51fc14c0afa01a0 Mon Sep 17 00:00:00 2001 From: ramon Date: Sat, 4 Nov 2023 15:02:20 +1100 Subject: [PATCH 8/9] updated variable names: parentId > recordKey, key > revisionKey removing lock-related code deleting `recordKey` from action object before sending to queriedDataReducer updating docs --- docs/reference-guides/data/data-core.md | 14 +- lib/compat/wordpress-6.4/rest-api.php | 9 -- packages/core-data/README.md | 14 +- packages/core-data/src/actions.js | 6 +- packages/core-data/src/entities.js | 3 +- packages/core-data/src/reducer.js | 8 +- packages/core-data/src/resolvers.js | 192 +++++++++++------------- packages/core-data/src/selectors.ts | 58 +++---- packages/core-data/src/test/reducer.js | 8 +- 9 files changed, 143 insertions(+), 169 deletions(-) diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index 61b2601613598..6084eff930394 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -422,16 +422,16 @@ _Returns_ ### getRevision -Returns a single, specific revision of a parent Entity. +Returns a single, specific revision of a parent entity. _Parameters_ - _state_ `State`: State tree - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. -- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. -- _key_ `EntityRecordKey`: The revision's key. -- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". +- _recordKey_ `EntityRecordKey`: The key of the entity record whose revisions you want to fetch. +- _revisionKey_ `EntityRecordKey`: The revision's key. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [entity kind]". _Returns_ @@ -439,14 +439,14 @@ _Returns_ ### getRevisions -Returns an Entity's revisions. +Returns an entity's revisions. _Parameters_ - _state_ `State`: State tree - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. -- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _recordKey_ `EntityRecordKey`: The key of the entity record whose revisions you want to fetch. - _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". _Returns_ @@ -745,7 +745,7 @@ _Parameters_ - _kind_ `string`: Kind of the received entity record revisions. - _name_ `string`: Name of the received entity record revisions. -- _parentId_ `number|string`: Record's key whose revisions you wish to fetch. +- _recordKey_ `number|string`: The key of the entity record whose revisions you want to fetch. - _records_ `Array|Object`: Revisions received. - _query_ `?Object`: Query Object. - _invalidateCache_ `?boolean`: Should invalidate query caches. diff --git a/lib/compat/wordpress-6.4/rest-api.php b/lib/compat/wordpress-6.4/rest-api.php index 7c81a6a274c03..274eb3af94543 100644 --- a/lib/compat/wordpress-6.4/rest-api.php +++ b/lib/compat/wordpress-6.4/rest-api.php @@ -18,12 +18,3 @@ function gutenberg_register_rest_block_patterns_routes() { $block_patterns->register_routes(); } add_action( 'rest_api_init', 'gutenberg_register_rest_block_patterns_routes' ); - -/** - * Registers the Global Styles Revisions REST API routes. - */ -function gutenberg_register_global_styles_revisions_endpoints() { - $global_styles_revisions_controller = new Gutenberg_REST_Global_Styles_Revisions_Controller_6_4(); - $global_styles_revisions_controller->register_routes(); -} -add_action( 'rest_api_init', 'gutenberg_register_global_styles_revisions_endpoints' ); diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 5f68472eba548..f7a177b5c5587 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -256,7 +256,7 @@ _Parameters_ - _kind_ `string`: Kind of the received entity record revisions. - _name_ `string`: Name of the received entity record revisions. -- _parentId_ `number|string`: Record's key whose revisions you wish to fetch. +- _recordKey_ `number|string`: The key of the entity record whose revisions you want to fetch. - _records_ `Array|Object`: Revisions received. - _query_ `?Object`: Query Object. - _invalidateCache_ `?boolean`: Should invalidate query caches. @@ -747,16 +747,16 @@ _Returns_ ### getRevision -Returns a single, specific revision of a parent Entity. +Returns a single, specific revision of a parent entity. _Parameters_ - _state_ `State`: State tree - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. -- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. -- _key_ `EntityRecordKey`: The revision's key. -- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". +- _recordKey_ `EntityRecordKey`: The key of the entity record whose revisions you want to fetch. +- _revisionKey_ `EntityRecordKey`: The revision's key. +- _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [entity kind]". _Returns_ @@ -764,14 +764,14 @@ _Returns_ ### getRevisions -Returns an Entity's revisions. +Returns an entity's revisions. _Parameters_ - _state_ `State`: State tree - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. -- _parentId_ `EntityRecordKey`: Record's key whose revisions you wish to fetch. +- _recordKey_ `EntityRecordKey`: The key of the entity record whose revisions you want to fetch. - _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". _Returns_ diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index cabcbc023a74a..d71c5d6120089 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -930,7 +930,7 @@ export function receiveDefaultTemplateId( query, templateId ) { * * @param {string} kind Kind of the received entity record revisions. * @param {string} name Name of the received entity record revisions. - * @param {number|string} parentId Record's key whose revisions you wish to fetch. + * @param {number|string} recordKey The key of the entity record whose revisions you want to fetch. * @param {Array|Object} records Revisions received. * @param {?Object} query Query Object. * @param {?boolean} invalidateCache Should invalidate query caches. @@ -940,7 +940,7 @@ export function receiveDefaultTemplateId( query, templateId ) { export function receiveRevisions( kind, name, - parentId, + recordKey, records, query, invalidateCache = false, @@ -949,7 +949,7 @@ export function receiveRevisions( return { type: 'RECEIVE_ITEM_REVISIONS', items: Array.isArray( records ) ? records : [ records ], - parentId, + recordKey, meta, query, kind, diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 01765177c1f68..e85673492ef56 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -19,7 +19,8 @@ export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; -// Supports information is also available in the "edit" context of `/types`. +// A hardcoded list of post types that support revisions. +// @TODO: Ideally this should be fetched from the `/types` REST API's view context. const POST_TYPES_WITH_REVISIONS_SUPPORT = [ 'post', 'page' ]; export const rootEntitiesConfig = [ diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index e63b94ae75749..c478375e015b3 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -358,11 +358,13 @@ function entity( entityConfig ) { // Add revisions to the state tree if the post type supports it. ...( entityConfig?.supports?.revisions ? { - revisions: ( state, action ) => { + revisions: ( state = {}, action ) => { // Use the same queriedDataReducer shape for revisions. if ( action.type === 'RECEIVE_ITEM_REVISIONS' ) { + const recordKey = action.recordKey; + delete action.recordKey; const newState = queriedDataReducer( - state?.[ action.parentId ], + state[ recordKey ], { ...action, type: 'RECEIVE_ITEMS', @@ -370,7 +372,7 @@ function entity( entityConfig ) { ); return { ...state, - [ action.parentId ]: newState, + [ recordKey ]: newState, }; } return state; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 0715865a0f807..619e05102bf38 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -722,15 +722,15 @@ export const getDefaultTemplateId = /** * Requests an entity's revisions from the REST API. * - * @param {string} kind Entity kind. - * @param {string} name Entity name. - * @param {number|string} parentId Record's key whose revisions you wish to fetch. - * @param {Object|undefined} query Optional object of query parameters to - * include with request. If requesting specific - * fields, fields must always include the ID. + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {number|string} recordKey The key of the entity record whose revisions you want to fetch. + * @param {Object|undefined} query Optional object of query parameters to + * include with request. If requesting specific + * fields, fields must always include the ID. */ export const getRevisions = - ( kind, name, parentId, query = {} ) => + ( kind, name, recordKey, query = {} ) => async ( { dispatch } ) => { const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); const entityConfig = configs.find( @@ -745,74 +745,62 @@ export const getRevisions = return; } - const lock = await dispatch.__unstableAcquireStoreLock( - STORE_NAME, - [ 'entities', 'records', kind, name, 'revisions', parentId ], - { exclusive: false } - ); - - try { - if ( query._fields ) { - // If requesting specific fields, items and query association to said - // records are stored by ID reference. Thus, fields must always include - // the ID. - query = { - ...query, - _fields: [ - ...new Set( [ - ...( getNormalizedCommaSeparable( query._fields ) || - [] ), - entityConfig.key || DEFAULT_ENTITY_KEY, - ] ), - ].join(), - }; - } - - const path = addQueryArgs( - entityConfig.getRevisionsUrl( parentId ), - query - ); + if ( query._fields ) { + // If requesting specific fields, items and query association to said + // records are stored by ID reference. Thus, fields must always include + // the ID. + query = { + ...query, + _fields: [ + ...new Set( [ + ...( getNormalizedCommaSeparable( query._fields ) || + [] ), + DEFAULT_ENTITY_KEY, + ] ), + ].join(), + }; + } - let records, meta; - if ( entityConfig.supportsPagination && query.per_page !== -1 ) { - const response = await apiFetch( { path, parse: false } ); - records = Object.values( await response.json() ); - meta = { - totalItems: parseInt( - response.headers.get( 'X-WP-Total' ) - ), - }; - } else { - records = Object.values( await apiFetch( { path } ) ); - } + const path = addQueryArgs( + entityConfig.getRevisionsUrl( recordKey ), + query + ); - // If we request fields but the result doesn't contain the fields, - // explicitly set these fields as "undefined" - // that way we consider the query "fulfilled". - if ( query._fields ) { - records = records.map( ( record ) => { - query._fields.split( ',' ).forEach( ( field ) => { - if ( ! record.hasOwnProperty( field ) ) { - record[ field ] = undefined; - } - } ); + let records, meta; + if ( entityConfig.supportsPagination && query.per_page !== -1 ) { + const response = await apiFetch( { path, parse: false } ); + records = Object.values( await response.json() ); + meta = { + totalItems: parseInt( response.headers.get( 'X-WP-Total' ) ), + }; + } else { + records = Object.values( await apiFetch( { path } ) ); + } - return record; + // If we request fields but the result doesn't contain the fields, + // explicitly set these fields as "undefined" + // that way we consider the query "fulfilled". + if ( query._fields ) { + records = records.map( ( record ) => { + query._fields.split( ',' ).forEach( ( field ) => { + if ( ! record.hasOwnProperty( field ) ) { + record[ field ] = undefined; + } } ); - } - dispatch.receiveRevisions( - kind, - name, - parentId, - records, - query, - false, - meta - ); - } finally { - dispatch.__unstableReleaseStoreLock( lock ); + return record; + } ); } + + dispatch.receiveRevisions( + kind, + name, + recordKey, + records, + query, + false, + meta + ); }; // Invalidate cache when a new revision is created. @@ -826,16 +814,16 @@ getRevisions.shouldInvalidate = ( action, kind, name, parentId ) => /** * Requests a specific Entity revision from the REST API. * - * @param {string} kind Entity kind. - * @param {string} name Entity name. - * @param {number|string} parentId Record's key whose revisions you wish to fetch. - * @param {number|string} key The Revision's key. - * @param {Object|undefined} query Optional object of query parameters to - * include with request. If requesting specific - * fields, fields must always include the ID. + * @param {string} kind Entity kind. + * @param {string} name Entity name. + * @param {number|string} recordKey The key of the entity record whose revisions you want to fetch. + * @param {number|string} revisionKey The revision's key. + * @param {Object|undefined} query Optional object of query parameters to + * include with request. If requesting specific + * fields, fields must always include the ID. */ export const getRevision = - ( kind, name, parentId, key, query = {} ) => + ( kind, name, recordKey, revisionKey, query = {} ) => async ( { dispatch } ) => { const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); const entityConfig = configs.find( @@ -850,36 +838,26 @@ export const getRevision = return; } - const lock = await dispatch.__unstableAcquireStoreLock( - STORE_NAME, - [ 'entities', 'records', kind, name, 'revisions', parentId, key ], - { exclusive: false } + if ( query !== undefined && query._fields ) { + // If requesting specific fields, items and query association to said + // records are stored by ID reference. Thus, fields must always include + // the ID. + query = { + ...query, + _fields: [ + ...new Set( [ + ...( getNormalizedCommaSeparable( query._fields ) || + [] ), + DEFAULT_ENTITY_KEY, + ] ), + ].join(), + }; + } + const path = addQueryArgs( + entityConfig.getRevisionsUrl( recordKey, revisionKey ), + query ); - try { - if ( query !== undefined && query._fields ) { - // If requesting specific fields, items and query association to said - // records are stored by ID reference. Thus, fields must always include - // the ID. - query = { - ...query, - _fields: [ - ...new Set( [ - ...( getNormalizedCommaSeparable( query._fields ) || - [] ), - entityConfig.key || DEFAULT_ENTITY_KEY, - ] ), - ].join(), - }; - } - const path = addQueryArgs( - entityConfig.getRevisionsUrl( parentId, key ), - query - ); - - const record = await apiFetch( { path } ); - dispatch.receiveRevisions( kind, name, parentId, record, query ); - } finally { - dispatch.__unstableReleaseStoreLock( lock ); - } + const record = await apiFetch( { path } ); + dispatch.receiveRevisions( kind, name, recordKey, record, query ); }; diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 11e383fd3a41a..4a893f5557d86 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -1386,14 +1386,14 @@ export function getDefaultTemplateId( } /** - * Returns an Entity's revisions. + * Returns an entity's revisions. * - * @param state State tree - * @param kind Entity kind. - * @param name Entity name. - * @param parentId Record's key whose revisions you wish to fetch. - * @param query Optional query. If requesting specific - * fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + * @param state State tree + * @param kind Entity kind. + * @param name Entity name. + * @param recordKey The key of the entity record whose revisions you want to fetch. + * @param query Optional query. If requesting specific + * fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". * * @return Record. */ @@ -1401,11 +1401,11 @@ export const getRevisions = ( state: State, kind: string, name: string, - parentId: EntityRecordKey, + recordKey: EntityRecordKey, query?: GetRecordsHttpQuery ): RevisionRecord[] | null => { const queriedStateRevisions = - state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ parentId ]; + state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ recordKey ]; if ( ! queriedStateRevisions ) { return null; } @@ -1414,15 +1414,15 @@ export const getRevisions = ( }; /** - * Returns a single, specific revision of a parent Entity. + * Returns a single, specific revision of a parent entity. * - * @param state State tree - * @param kind Entity kind. - * @param name Entity name. - * @param parentId Record's key whose revisions you wish to fetch. - * @param key The revision's key. - * @param query Optional query. If requesting specific - * fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [Entity kind]". + * @param state State tree + * @param kind Entity kind. + * @param name Entity name. + * @param recordKey The key of the entity record whose revisions you want to fetch. + * @param revisionKey The revision's key. + * @param query Optional query. If requesting specific + * fields, fields must always include the ID. For valid query parameters see revisions schema in [the REST API Handbook](https://developer.wordpress.org/rest-api/reference/). Then see the arguments available "Retrieve a [entity kind]". * * @return Record. */ @@ -1431,12 +1431,14 @@ export const getRevision = createSelector( state: State, kind: string, name: string, - parentId: EntityRecordKey, - key: EntityRecordKey, + recordKey: EntityRecordKey, + revisionKey: EntityRecordKey, query?: GetRecordsHttpQuery ): RevisionRecord | Record< PropertyKey, never > | undefined => { const queriedState = - state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ parentId ]; + state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ + recordKey + ]; if ( ! queriedState ) { return undefined; @@ -1446,14 +1448,14 @@ export const getRevision = createSelector( if ( query === undefined ) { // If expecting a complete item, validate that completeness. - if ( ! queriedState.itemIsComplete[ context ]?.[ key ] ) { + if ( ! queriedState.itemIsComplete[ context ]?.[ revisionKey ] ) { return undefined; } - return queriedState.items[ context ][ key ]; + return queriedState.items[ context ][ revisionKey ]; } - const item = queriedState.items[ context ]?.[ key ]; + const item = queriedState.items[ context ]?.[ revisionKey ]; if ( item && query._fields ) { const filteredItem = {}; const fields = getNormalizedCommaSeparable( query._fields ) ?? []; @@ -1472,13 +1474,13 @@ export const getRevision = createSelector( return item; }, - ( state: State, kind, name, parentId, key, query ) => { + ( state: State, kind, name, recordKey, revisionKey, query ) => { const context = query?.context ?? 'default'; return [ - state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ parentId ] - ?.items?.[ context ]?.[ key ], - state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ parentId ] - ?.itemIsComplete?.[ context ]?.[ key ], + state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ recordKey ] + ?.items?.[ context ]?.[ revisionKey ], + state.entities.records?.[ kind ]?.[ name ]?.revisions?.[ recordKey ] + ?.itemIsComplete?.[ context ]?.[ revisionKey ], ]; } ); diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js index 52ce5eaf4d675..14f196a7accfd 100644 --- a/packages/core-data/src/test/reducer.js +++ b/packages/core-data/src/test/reducer.js @@ -154,7 +154,7 @@ describe( 'entities', () => { it( 'appends revisions state', () => { expect( stateWithConfig.records.root.postType ).toHaveProperty( 'revisions', - undefined + {} ); } ); @@ -168,7 +168,7 @@ describe( 'entities', () => { items: [ { id: 1, parent: 2 } ], kind: 'root', name: 'postType', - parentId: 2, + recordKey: 2, } ); expect( state.records.root.postType.revisions ).toEqual( { 2: { @@ -217,7 +217,7 @@ describe( 'entities', () => { items: [ { id: 3, parent: 4 } ], kind: 'root', name: 'postType', - parentId: 4, + recordKey: 4, } ); expect( state.records.root.postType.revisions ).toEqual( { 2: { @@ -279,7 +279,7 @@ describe( 'entities', () => { items: [ { id: 7, parent: 2 } ], kind: 'root', name: 'postType', - parentId: 2, + recordKey: 2, } ); expect( state.records.root.postType.revisions ).toEqual( { 2: { From 424b12ffadf768a444db68a8841f9def3e2311ad Mon Sep 17 00:00:00 2001 From: ramon Date: Thu, 16 Nov 2023 17:05:05 +1100 Subject: [PATCH 9/9] Removing revisions when items are removed. --- packages/core-data/src/reducer.js | 21 ++++++++ packages/core-data/src/resolvers.js | 4 +- packages/core-data/src/test/reducer.js | 74 ++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index c478375e015b3..34558fcfbb142 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -375,6 +375,27 @@ function entity( entityConfig ) { [ recordKey ]: newState, }; } + + if ( action.type === 'REMOVE_ITEMS' ) { + return Object.fromEntries( + Object.entries( state ).filter( + ( [ id ] ) => + ! action.itemIds.some( + ( itemId ) => { + if ( + Number.isInteger( + itemId + ) + ) { + return itemId === +id; + } + return itemId === id; + } + ) + ) + ); + } + return state; }, } diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 619e05102bf38..8735764a880b8 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -804,12 +804,12 @@ export const getRevisions = }; // Invalidate cache when a new revision is created. -getRevisions.shouldInvalidate = ( action, kind, name, parentId ) => +getRevisions.shouldInvalidate = ( action, kind, name, recordKey ) => action.type === 'SAVE_ENTITY_RECORD_FINISH' && name === action.name && kind === action.kind && ! action.error && - parentId === action.recordId; + recordKey === action.recordId; /** * Requests a specific Entity revision from the REST API. diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js index 14f196a7accfd..d5d5bc5c8692f 100644 --- a/packages/core-data/src/test/reducer.js +++ b/packages/core-data/src/test/reducer.js @@ -299,6 +299,80 @@ describe( 'entities', () => { }, } ); } ); + + it( 'returns with removed revision items', () => { + const initialState = deepFreeze( { + config: stateWithConfig.config, + records: { + root: { + postType: { + revisions: { + 2: { + items: { + default: { + 1: { id: 1, parent: 2 }, + }, + }, + itemIsComplete: { + default: { + 1: true, + }, + }, + queries: {}, + }, + 4: { + items: { + default: { + 3: { id: 3, parent: 4 }, + }, + }, + itemIsComplete: { + default: { + 3: true, + }, + }, + queries: {}, + }, + 6: { + items: { + default: { + 9: { id: 11, parent: 6 }, + }, + }, + itemIsComplete: { + default: { + 9: true, + }, + }, + queries: {}, + }, + }, + }, + }, + }, + } ); + const state = entities( initialState, { + type: 'REMOVE_ITEMS', + itemIds: [ 4, 6 ], + kind: 'root', + name: 'postType', + } ); + expect( state.records.root.postType.revisions ).toEqual( { + 2: { + items: { + default: { + 1: { id: 1, parent: 2 }, + }, + }, + itemIsComplete: { + default: { + 1: true, + }, + }, + queries: {}, + }, + } ); + } ); } ); } );