Skip to content
105 changes: 104 additions & 1 deletion packages/core-data/src/hooks/test/use-entity-records.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import { render, waitFor } from '@testing-library/react';
* Internal dependencies
*/
import { store as coreDataStore } from '../../index';
import useEntityRecords from '../use-entity-records';
import useEntityRecords, {
useEntityRecordsWithPermissions,
} from '../use-entity-records';

describe( 'useEntityRecords', () => {
let registry;
Expand Down Expand Up @@ -74,3 +76,104 @@ describe( 'useEntityRecords', () => {
} );
} );
} );

describe( 'useEntityRecordsWithPermissions', () => {
let registry;

beforeEach( () => {
registry = createRegistry();
registry.register( coreDataStore );

// Mock the post entity configuration
registry.dispatch( coreDataStore ).addEntities( [
{
name: 'post',
kind: 'postType',
baseURL: '/wp/v2/posts',
baseURLParams: { context: 'edit' },
},
] );
} );

const TEST_RECORDS = [
{ id: 1, title: 'Post 1', slug: 'post-1' },
{ id: 2, title: 'Post 2', slug: 'post-2' },
];

const fieldsFromMock = Object.keys( TEST_RECORDS[ 0 ] ).join( ',' );

it( 'injects _links when _fields is provided', async () => {
// Mock the API response
triggerFetch.mockImplementation( () => TEST_RECORDS );

const TestComponent = () => {
useEntityRecordsWithPermissions( 'postType', 'post', {
_fields: fieldsFromMock,
} );
return <div />;
};
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);

// Should inject _links into the _fields parameter
await waitFor( () =>
expect( triggerFetch ).toHaveBeenCalledWith( {
path: `/wp/v2/posts?context=edit&_fields=${ encodeURIComponent(
fieldsFromMock + ',_links'
) }`,
} )
);
} );

it( 'does not modify query when _fields is not provided', async () => {
// Mock the API response
triggerFetch.mockImplementation( () => TEST_RECORDS );

const TestComponent = () => {
useEntityRecordsWithPermissions( 'postType', 'post', {} );
return <div />;
};
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);

// Should not add _fields when not originally provided
await waitFor( () =>
expect( triggerFetch ).toHaveBeenCalledWith( {
path: '/wp/v2/posts?context=edit',
} )
);
} );

it( 'avoids duplicate _links when already present in _fields', async () => {
// Mock the API response
triggerFetch.mockImplementation( () => TEST_RECORDS );

const fieldsWithLinks = fieldsFromMock + ',_links';
const TestComponent = () => {
useEntityRecordsWithPermissions( 'postType', 'post', {
_fields: fieldsWithLinks,
} );
return <div />;
};
render(
<RegistryProvider value={ registry }>
<TestComponent />
</RegistryProvider>
);

// Should not duplicate _links (deduplication working correctly)
await waitFor( () =>
expect( triggerFetch ).toHaveBeenCalledWith( {
path: `/wp/v2/posts?context=edit&_fields=${ encodeURIComponent(
fieldsWithLinks
) }`,
} )
);
} );
} );
18 changes: 17 additions & 1 deletion packages/core-data/src/hooks/use-entity-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { store as coreStore } from '../';
import type { Options } from './use-entity-record';
import type { Status } from './constants';
import { unlock } from '../lock-unlock';
import { getNormalizedCommaSeparable } from '../utils';

interface EntityRecordsResolution< RecordType > {
/** The requested entity record */
Expand Down Expand Up @@ -168,7 +169,22 @@ export function useEntityRecordsWithPermissions< RecordType >(
const { records: data, ...ret } = useEntityRecords(
kind,
name,
queryArgs,
{
...queryArgs,
// If _fields is provided, we need to include _links in the request for permission caching to work.
...( queryArgs._fields
? {
_fields: [
...new Set( [
...( getNormalizedCommaSeparable(
queryArgs._fields
) || [] ),
'_links',
] ),
].join(),
}
: {} ),
},
options
);
const ids = useMemo(
Expand Down
39 changes: 25 additions & 14 deletions packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -365,13 +365,19 @@ export const getEntityRecords =
// the `getEntityRecord` and `canUser` selectors in addition to `getEntityRecords`.
// See https://github.com/WordPress/gutenberg/pull/26575
// See https://github.com/WordPress/gutenberg/pull/64504
if ( ! query?._fields && ! query.context ) {
// See https://github.com/WordPress/gutenberg/pull/70738
if ( ! query.context ) {
const targetHints = records
.filter( ( record ) => record?.[ key ] )
.filter(
( record ) =>
!! record?.[ key ] &&
!! record?._links?.self?.[ 0 ]?.targetHints
?.allow
)
.map( ( record ) => ( {
id: record[ key ],
permissions: getUserPermissionsFromAllowHeader(
record?._links?.self?.[ 0 ].targetHints.allow
record._links.self[ 0 ].targetHints.allow
),
} ) );

Expand All @@ -394,17 +400,22 @@ export const getEntityRecords =
}
}

dispatch.receiveUserPermissions(
receiveUserPermissionArgs
);
dispatch.finishResolutions(
'getEntityRecord',
getResolutionsArgs( records )
);
dispatch.finishResolutions(
'canUser',
canUserResolutionsArgs
);
if ( targetHints.length > 0 ) {
dispatch.receiveUserPermissions(
receiveUserPermissionArgs
);
dispatch.finishResolutions(
'canUser',
canUserResolutionsArgs
);
}

if ( ! query?._fields ) {
dispatch.finishResolutions(
'getEntityRecord',
getResolutionsArgs( records )
);
}
}

dispatch.__unstableReleaseStoreLock( lock );
Expand Down
101 changes: 101 additions & 0 deletions packages/core-data/src/test/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ describe( 'getEntityRecords', () => {
baseURL: '/wp/v2/types',
baseURLParams: { context: 'edit' },
},
{
name: 'post',
kind: 'postType',
baseURL: '/wp/v2/posts',
baseURLParams: { context: 'edit' },
},
];
const registry = { batch: ( callback ) => callback() };
const resolveSelect = { getEntitiesConfig: jest.fn( () => ENTITIES ) };
Expand Down Expand Up @@ -234,6 +240,101 @@ describe( 'getEntityRecords', () => {
[ ENTITIES[ 1 ].kind, ENTITIES[ 1 ].name, 2 ],
] );
} );

it( 'caches permissions but does not mark entity records as resolved when using _fields', async () => {
const finishResolutions = jest.fn();
const dispatch = Object.assign( jest.fn(), {
receiveEntityRecords: jest.fn(),
receiveUserPermissions: jest.fn(),
__unstableAcquireStoreLock: jest.fn(),
__unstableReleaseStoreLock: jest.fn(),
finishResolutions,
} );

// Provide response with _links structure
const postsWithLinks = [
{
id: 1,
title: 'Hello World',
slug: 'hello-world',
_links: {
self: [
{
targetHints: {
allow: [ 'GET', 'POST', 'PUT', 'DELETE' ],
},
},
],
},
},
];

triggerFetch.mockImplementation( () => postsWithLinks );

await getEntityRecords( 'postType', 'post', {
_fields: Object.keys( postsWithLinks[ 0 ] ).join( ',' ),
} )( {
dispatch,
registry,
resolveSelect,
} );

// Permissions should have been cached
expect( dispatch.receiveUserPermissions ).toHaveBeenCalled();
expect( finishResolutions ).toHaveBeenCalledWith(
'canUser',
expect.any( Array )
);

// But individual entity records should NOT be marked as resolved
expect( finishResolutions ).not.toHaveBeenCalledWith(
'getEntityRecord',
expect.any( Array )
);
} );

it( 'does not cache permissions when _links field is missing from response', async () => {
const finishResolutions = jest.fn();
const dispatch = Object.assign( jest.fn(), {
receiveEntityRecords: jest.fn(),
receiveUserPermissions: jest.fn(),
__unstableAcquireStoreLock: jest.fn(),
__unstableReleaseStoreLock: jest.fn(),
finishResolutions,
} );

// Provide response without _links structure
const postsWithoutLinks = [
{
id: 1,
title: 'Hello World',
slug: 'hello-world',
},
];

triggerFetch.mockImplementation( () => postsWithoutLinks );

await getEntityRecords( 'postType', 'post', {
_fields: Object.keys( postsWithoutLinks[ 0 ] ).join( ',' ),
} )( {
dispatch,
registry,
resolveSelect,
} );

// Permissions should NOT have been cached
expect( dispatch.receiveUserPermissions ).not.toHaveBeenCalled();
expect( finishResolutions ).not.toHaveBeenCalledWith(
'canUser',
expect.any( Array )
);

// Individual entity records should NOT be marked as resolved
expect( finishResolutions ).not.toHaveBeenCalledWith(
'getEntityRecord',
expect.any( Array )
);
} );
} );

describe( 'getEmbedPreview', () => {
Expand Down
Loading