diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index 757a92f98c78bd..f21513c0a5ffdb 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -620,6 +620,24 @@ _Returns_ - `boolean`: Whether the entity record has edits or not. +### hasEntityRecord + +Returns true if a record has been received for the given set of parameters, or false otherwise. + +Note: This action does not trigger a request for the entity record from the API if it's not available in the local state. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _key_ `EntityRecordKey`: Record's key. +- _query_ `GetRecordsHttpQuery`: Optional query. + +_Returns_ + +- `boolean`: Whether an entity record has been received. + ### hasEntityRecords Returns true if records have been received for the given set of parameters, or false otherwise. diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 4f0b549d4afd73..40b8916ca697f1 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -842,6 +842,24 @@ _Returns_ - `boolean`: Whether the entity record has edits or not. +### hasEntityRecord + +Returns true if a record has been received for the given set of parameters, or false otherwise. + +Note: This action does not trigger a request for the entity record from the API if it's not available in the local state. + +_Parameters_ + +- _state_ `State`: State tree +- _kind_ `string`: Entity kind. +- _name_ `string`: Entity name. +- _key_ `EntityRecordKey`: Record's key. +- _query_ `GetRecordsHttpQuery`: Optional query. + +_Returns_ + +- `boolean`: Whether an entity record has been received. + ### hasEntityRecords Returns true if records have been received for the given set of parameters, or false otherwise. diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 7d0740dea58a72..f93d66eae9ed78 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -141,37 +141,28 @@ 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, - } - ); - if ( query !== undefined && query._fields ) { - 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.hasEntityRecords( + // and without causing `getEntityRecord` resolution to occur. + const hasRecord = select.hasEntityRecord( kind, name, + key, query ); - if ( hasRecords ) { + if ( hasRecord ) { return; } } + const path = addQueryArgs( + entityConfig.baseURL + ( key ? '/' + key : '' ), + { + ...entityConfig.baseURLParams, + ...query, + } + ); const response = await apiFetch( { path, parse: false } ); const record = await response.json(); const permissions = getUserPermissionsFromAllowHeader( diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 48c25aaa81a3db..68ab56580564fa 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -420,6 +420,62 @@ getEntityRecord.__unstableNormalizeArgs = ( return newArgs; }; +/** + * Returns true if a record has been received for the given set of parameters, or false otherwise. + * + * Note: This action does not trigger a request for the entity record from the API + * if it's not available in the local state. + * + * @param state State tree + * @param kind Entity kind. + * @param name Entity name. + * @param key Record's key. + * @param query Optional query. + * + * @return Whether an entity record has been received. + */ +export function hasEntityRecord( + state: State, + kind: string, + name: string, + key?: EntityRecordKey, + query?: GetRecordsHttpQuery +): boolean { + const queriedState = + state.entities.records?.[ kind ]?.[ name ]?.queriedData; + if ( ! queriedState ) { + return false; + } + const context = query?.context ?? 'default'; + + // If expecting a complete item, validate that completeness. + if ( ! query || ! query._fields ) { + return !! queriedState.itemIsComplete[ context ]?.[ key ]; + } + + const item = queriedState.items[ context ]?.[ key ]; + if ( ! item ) { + return false; + } + + // When `query._fields` is provided, check that each requested field exists, + // including any nested paths, on the item; return false if any part is missing. + const fields = getNormalizedCommaSeparable( query._fields ) ?? []; + for ( let i = 0; i < fields.length; i++ ) { + const path = fields[ i ].split( '.' ); + let value = item; + for ( let p = 0; p < path.length; p++ ) { + const part = path[ p ]; + if ( ! value || ! Object.hasOwn( value, part ) ) { + return false; + } + value = value[ part ]; + } + } + + return true; +} + /** * Returns the Entity's record object by key. Doesn't trigger a resolver nor requests the entity records from the API if the entity record isn't available in the local state. * diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index b4b7a89d8ff683..6f241d29f202c0 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -79,10 +79,6 @@ describe( 'getEntityRecord', () => { it( 'accepts a query that overrides default api path', async () => { const query = { context: 'view', _envelope: '1' }; - const select = { - hasEntityRecords: jest.fn( () => {} ), - }; - // Provide response triggerFetch.mockImplementation( () => POST_TYPE_RESPONSE ); @@ -91,7 +87,7 @@ describe( 'getEntityRecord', () => { 'postType', 'post', query - )( { dispatch, select, registry, resolveSelect } ); + )( { dispatch, registry, resolveSelect } ); // Trigger apiFetch, test that the query is present in the url. expect( triggerFetch ).toHaveBeenCalledWith( { diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index f601bc9888e6ba..825b21b24506fd 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -8,6 +8,7 @@ import deepFreeze from 'deep-freeze'; */ import { getEntityRecord, + hasEntityRecord, hasEntityRecords, getEntityRecords, getRawEntityRecord, @@ -279,6 +280,133 @@ describe( 'getEntityRecord', () => { } ); } ); +describe( 'hasEntityRecord', () => { + it( 'returns false if entity record has not been received', () => { + const state = deepFreeze( { + entities: { + records: { + postType: { + post: { + queriedData: { + items: {}, + itemIsComplete: {}, + queries: {}, + }, + }, + }, + }, + }, + } ); + expect( hasEntityRecord( state, 'postType', 'post', 1 ) ).toBe( false ); + } ); + + it( 'returns true when full record exists and no fields query', () => { + const state = deepFreeze( { + entities: { + records: { + postType: { + post: { + queriedData: { + items: { + default: { + 1: { id: 1, content: 'hello' }, + }, + }, + itemIsComplete: { + default: { + 1: true, + }, + }, + queries: {}, + }, + }, + }, + }, + }, + } ); + expect( hasEntityRecord( state, 'postType', 'post', 1 ) ).toBe( true ); + } ); + + it( 'returns true when requested fields exist on the item', () => { + const state = deepFreeze( { + entities: { + records: { + postType: { + post: { + queriedData: { + items: { + default: { + 1: { + id: 1, + content: 'chicken', + title: { raw: 'egg' }, + author: 'bob', + }, + }, + }, + itemIsComplete: { + default: { + 1: true, + }, + }, + queries: {}, + }, + }, + }, + }, + }, + } ); + expect( + hasEntityRecord( state, 'postType', 'post', 1, { + _fields: [ 'id', 'content' ], + } ) + ).toBe( true ); + // Test nested field. + expect( + hasEntityRecord( state, 'postType', 'post', 1, { + _fields: [ 'id', 'title.raw' ], + } ) + ).toBe( true ); + } ); + + it( 'returns false when a requested fields are missing', () => { + const state = deepFreeze( { + entities: { + records: { + postType: { + post: { + queriedData: { + items: { + default: { + 1: { id: 1, author: 'bob' }, + }, + }, + itemIsComplete: { + default: { + 1: true, + }, + }, + queries: {}, + }, + }, + }, + }, + }, + } ); + expect( + hasEntityRecord( state, 'postType', 'post', 1, { + _fields: [ 'id', 'content' ], + } ) + ).toBe( false ); + // Test nested field. + expect( + hasEntityRecord( state, 'postType', 'post', 1, { + _fields: [ 'id', 'title.raw' ], + } ) + ).toBe( false ); + } ); +} ); + describe( 'hasEntityRecords', () => { it( 'returns false if entity records have not been received', () => { const state = deepFreeze( { diff --git a/packages/core-data/src/test/store.js b/packages/core-data/src/test/store.js new file mode 100644 index 00000000000000..80f1f306c2cf83 --- /dev/null +++ b/packages/core-data/src/test/store.js @@ -0,0 +1,114 @@ +/** + * WordPress dependencies + */ +import triggerFetch from '@wordpress/api-fetch'; +import { createRegistry } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as coreDataStore } from '../index'; + +jest.mock( '@wordpress/api-fetch' ); + +function createTestRegistry() { + const registry = createRegistry(); + + // Register the core-data store + registry.register( coreDataStore ); + + const postEntityConfig = { + kind: 'postType', + baseURL: '/wp/v2/posts', + baseURLParams: { + context: 'edit', + }, + name: 'post', + label: 'Posts', + transientEdits: { + blocks: true, + selection: true, + }, + mergedEdits: { + meta: true, + }, + rawAttributes: [ 'title', 'excerpt', 'content' ], + __unstable_rest_base: 'posts', + supportsPagination: true, + revisionKey: 'id', + }; + + // Add the post entity to the store + registry.dispatch( coreDataStore ).addEntities( [ postEntityConfig ] ); + + return registry; +} + +function createTestPost( id = 1, fields = [] ) { + const post = { + id, + author: 1, + content: { + raw: '\n
A paragraph
\n', + rendered: '\nA paragraph
\n', + }, + excerpt: { + raw: '', + rendered: 'A paragraph
\n', + }, + title: { + raw: 'Test', + rendered: 'Test', + }, + featured_media: 0, + type: 'post', + status: 'draft', + slug: '', + }; + + if ( fields.length > 0 ) { + return Object.fromEntries( + fields.map( ( field ) => [ field, post[ field ] ] ) + ); + } + + return post; +} + +describe( 'getEntityRecord', () => { + let registry; + + beforeEach( () => { + registry = createTestRegistry(); + triggerFetch.mockReset(); + } ); + + it( 'should not make a request if the record is already in store', async () => { + const { getEntityRecord } = registry.resolveSelect( coreDataStore ); + const post = createTestPost( 1 ); + triggerFetch.mockResolvedValue( { + async json() { + return post; + }, + } ); + + // Resolve the record. + await expect( + getEntityRecord( 'postType', 'post', post.id, { context: 'edit' } ) + ).resolves.toEqual( post ); + + triggerFetch.mockReset(); + + await expect( + getEntityRecord( 'postType', 'post', post.id, { + context: 'edit', + _fields: [ 'id', 'author', 'title' ], + } ) + ).resolves.toEqual( { + id: post.id, + author: post.author, + title: post.title, + } ); + expect( triggerFetch ).not.toHaveBeenCalled(); + } ); +} );