diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index ba77f065584cfe..0ddd3858c97603 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -18,7 +18,7 @@ _Parameters_ - _state_ `State`: Data state. - _action_ `string`: Action to check. One of: 'create', 'read', 'update', 'delete'. -- _resource_ `string`: REST resource to check, e.g. 'media' or 'posts'. +- _resource_ `string | EntityResource`: Entity resource to check. Accepts entity object `{ kind: 'root', name: 'media', id: 1 }` or REST base as a string - `media`. - _id_ `EntityRecordKey`: Optional ID of the rest resource to check. _Returns_ diff --git a/packages/block-library/src/post-title/edit.js b/packages/block-library/src/post-title/edit.js index 5b11ec3a9452ae..3963016e342822 100644 --- a/packages/block-library/src/post-title/edit.js +++ b/packages/block-library/src/post-title/edit.js @@ -42,11 +42,11 @@ export default function PostTitleEdit( { if ( isDescendentOfQueryLoop ) { return false; } - return select( coreStore ).canUserEditEntityRecord( - 'postType', - postType, - postId - ); + return select( coreStore ).canUser( 'update', { + kind: 'postType', + name: postType, + id: postId, + } ); }, [ isDescendentOfQueryLoop, postType, postId ] ); diff --git a/packages/block-library/src/utils/hooks.js b/packages/block-library/src/utils/hooks.js index 43733d7f49a046..f9ad52297c53e1 100644 --- a/packages/block-library/src/utils/hooks.js +++ b/packages/block-library/src/utils/hooks.js @@ -18,7 +18,11 @@ import { useViewportMatch } from '@wordpress/compose'; export function useCanEditEntity( kind, name, recordId ) { return useSelect( ( select ) => - select( coreStore ).canUserEditEntityRecord( kind, name, recordId ), + select( coreStore ).canUser( 'update', { + kind, + name, + id: recordId, + } ), [ kind, name, recordId ] ); } diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 694f780dafb99d..8c262ebeee8d19 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -339,7 +339,7 @@ _Parameters_ - _state_ `State`: Data state. - _action_ `string`: Action to check. One of: 'create', 'read', 'update', 'delete'. -- _resource_ `string`: REST resource to check, e.g. 'media' or 'posts'. +- _resource_ `string | EntityResource`: Entity resource to check. Accepts entity object `{ kind: 'root', name: 'media', id: 1 }` or REST base as a string - `media`. - _id_ `EntityRecordKey`: Optional ID of the rest resource to check. _Returns_ diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index bff8c8cb0f6780..d3ca23221eadd0 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -352,23 +352,48 @@ export const getEmbedPreview = * Checks whether the current user can perform the given action on the given * REST resource. * - * @param {string} requestedAction Action to check. One of: 'create', 'read', 'update', - * 'delete'. - * @param {string} resource REST resource to check, e.g. 'media' or 'posts'. - * @param {?string} id ID of the rest resource to check. + * @param {string} requestedAction Action to check. One of: 'create', 'read', 'update', + * 'delete'. + * @param {string|Object} resource Entity resource to check. Accepts entity object `{ kind: 'root', name: 'media', id: 1 }` + * or REST base as a string - `media`. + * @param {?string} id ID of the rest resource to check. */ export const canUser = ( requestedAction, resource, id ) => async ( { dispatch, registry } ) => { - const { hasStartedResolution } = registry.select( STORE_NAME ); - - const resourcePath = id ? `${ resource }/${ id }` : resource; const retrievedActions = [ 'create', 'read', 'update', 'delete' ]; if ( ! retrievedActions.includes( requestedAction ) ) { throw new Error( `'${ requestedAction }' is not a valid action.` ); } + let resourcePath = null; + if ( typeof resource === 'object' ) { + if ( ! resource.kind || ! resource.name ) { + throw new Error( 'The entity resource object is not valid.' ); + } + + const configs = await dispatch( + getOrLoadEntitiesConfig( resource.kind, resource.name ) + ); + const entityConfig = configs.find( + ( config ) => + config.name === resource.name && + config.kind === resource.kind + ); + if ( ! entityConfig ) { + return; + } + + resourcePath = + entityConfig.baseURL + ( resource.id ? '/' + resource.id : '' ); + } else { + // @todo: Maybe warn when detecting a legacy usage. + resourcePath = `/wp/v2/${ resource }` + ( id ? '/' + id : '' ); + } + + const { hasStartedResolution } = registry.select( STORE_NAME ); + // Prevent resolving the same resource twice. for ( const relatedAction of retrievedActions ) { if ( relatedAction === requestedAction ) { @@ -387,7 +412,7 @@ export const canUser = let response; try { response = await apiFetch( { - path: `/wp/v2/${ resourcePath }`, + path: resourcePath, method: 'OPTIONS', parse: false, } ); @@ -416,10 +441,15 @@ export const canUser = registry.batch( () => { for ( const action of retrievedActions ) { - dispatch.receiveUserPermission( - `${ action }/${ resourcePath }`, - permissions[ action ] - ); + const key = ( + typeof resource === 'object' + ? [ action, resource.kind, resource.name, resource.id ] + : [ action, resource, id ] + ) + .filter( Boolean ) + .join( '/' ); + + dispatch.receiveUserPermission( key, permissions[ action ] ); } } ); }; @@ -435,16 +465,7 @@ export const canUser = export const canUserEditEntityRecord = ( kind, name, recordId ) => async ( { dispatch } ) => { - const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); - const entityConfig = configs.find( - ( config ) => config.name === name && config.kind === kind - ); - if ( ! entityConfig ) { - return; - } - - const resource = entityConfig.__unstable_rest_base; - await dispatch( canUser( 'update', resource, recordId ) ); + await dispatch( canUser( 'update', { kind, name, id: recordId } ) ); }; /** diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 425a537cad7363..6ff8e26d3684e7 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -120,6 +120,8 @@ type EntityRecordArgs = | [ string, string, EntityRecordKey ] | [ string, string, EntityRecordKey, GetRecordsHttpQuery ]; +type EntityResource = { kind: string; name: string; id?: EntityRecordKey }; + /** * Shared reference to an empty object for cases where it is important to avoid * returning a new object reference on every invocation, as in a connected or @@ -1136,7 +1138,8 @@ export function isPreviewEmbedFallback( state: State, url: string ): boolean { * * @param state Data state. * @param action Action to check. One of: 'create', 'read', 'update', 'delete'. - * @param resource REST resource to check, e.g. 'media' or 'posts'. + * @param resource Entity resource to check. Accepts entity object `{ kind: 'root', name: 'media', id: 1 }` + * or REST base as a string - `media`. * @param id Optional ID of the rest resource to check. * * @return Whether or not the user can perform the action, @@ -1145,10 +1148,22 @@ export function isPreviewEmbedFallback( state: State, url: string ): boolean { export function canUser( state: State, action: string, - resource: string, + resource: string | EntityResource, id?: EntityRecordKey ): boolean | undefined { - const key = [ action, resource, id ].filter( Boolean ).join( '/' ); + const isEntity = typeof resource === 'object'; + if ( isEntity && ( ! resource.kind || ! resource.name ) ) { + return false; + } + + const key = ( + isEntity + ? [ action, resource.kind, resource.name, resource.id ] + : [ action, resource, id ] + ) + .filter( Boolean ) + .join( '/' ); + return state.userPermissions[ key ]; } @@ -1173,13 +1188,12 @@ export function canUserEditEntityRecord( name: string, recordId: EntityRecordKey ): boolean | undefined { - const entityConfig = getEntityConfig( state, kind, name ); - if ( ! entityConfig ) { - return false; - } - const resource = entityConfig.__unstable_rest_base; + deprecated( `wp.data.select( 'core' ).canUserEditEntityRecord()`, { + since: '6.7', + alternative: `wp.data.select( 'core' ).canUser( 'update', { kind, name, id } )`, + } ); - return canUser( state, 'update', resource, recordId ); + return canUser( state, 'update', { kind, name, id: recordId } ); } /** diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index 4e900615df3868..95a70b5e5c45f9 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -283,7 +283,22 @@ describe( 'getEmbedPreview', () => { } ); describe( 'canUser', () => { - let registry; + const ENTITIES = [ + { + name: 'media', + kind: 'root', + baseURL: '/wp/v2/media', + baseURLParams: { context: 'edit' }, + }, + { + name: 'wp_block', + kind: 'postType', + baseURL: '/wp/v2/blocks', + baseURLParams: { context: 'edit' }, + }, + ]; + + let dispatch, registry; beforeEach( async () => { registry = { select: jest.fn( () => ( { @@ -291,19 +306,23 @@ describe( 'canUser', () => { } ) ), batch: ( callback ) => callback(), }; + dispatch = Object.assign( jest.fn(), { + receiveUserPermission: jest.fn(), + } ); + dispatch.mockReturnValue( ENTITIES ); triggerFetch.mockReset(); } ); it( 'does nothing when there is an API error', async () => { - const dispatch = Object.assign( jest.fn(), { - receiveUserPermission: jest.fn(), - } ); - triggerFetch.mockImplementation( () => Promise.reject( { status: 404 } ) ); await canUser( 'create', 'media' )( { dispatch, registry } ); + await canUser( 'create', { kind: 'root', name: 'media' } )( { + dispatch, + registry, + } ); expect( triggerFetch ).toHaveBeenCalledWith( { path: '/wp/v2/media', @@ -314,11 +333,16 @@ describe( 'canUser', () => { expect( dispatch.receiveUserPermission ).not.toHaveBeenCalled(); } ); - it( 'receives false when the user is not allowed to perform an action', async () => { - const dispatch = Object.assign( jest.fn(), { - receiveUserPermission: jest.fn(), - } ); + it( 'throws an error when an entity resource object is malformed', async () => { + await expect( + canUser( 'create', { name: 'wp_block' } )( { + dispatch, + registry, + } ) + ).rejects.toThrow( 'The entity resource object is not valid.' ); + } ); + it( 'receives false when the user is not allowed to perform an action', async () => { triggerFetch.mockImplementation( () => ( { headers: new Map( [ [ 'allow', 'GET' ] ] ), } ) ); @@ -337,11 +361,29 @@ describe( 'canUser', () => { ); } ); - it( 'receives true when the user is allowed to perform an action', async () => { - const dispatch = Object.assign( jest.fn(), { - receiveUserPermission: jest.fn(), + it( 'receives false when the user is not allowed to perform an action on entities', async () => { + triggerFetch.mockImplementation( () => ( { + headers: new Map( [ [ 'allow', 'GET' ] ] ), + } ) ); + + await canUser( 'create', { kind: 'root', name: 'media' } )( { + dispatch, + registry, } ); + expect( triggerFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/media', + method: 'OPTIONS', + parse: false, + } ); + + expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith( + 'create/root/media', + false + ); + } ); + + it( 'receives true when the user is allowed to perform an action', async () => { triggerFetch.mockImplementation( () => ( { headers: new Map( [ [ 'allow', 'POST, GET, PUT, DELETE' ] ] ), } ) ); @@ -360,11 +402,29 @@ describe( 'canUser', () => { ); } ); - it( 'receives true when the user is allowed to perform an action on a specific resource', async () => { - const dispatch = Object.assign( jest.fn(), { - receiveUserPermission: jest.fn(), + it( 'receives true when the user is allowed to perform an action on entities', async () => { + triggerFetch.mockImplementation( () => ( { + headers: new Map( [ [ 'allow', 'POST, GET, PUT, DELETE' ] ] ), + } ) ); + + await canUser( 'create', { kind: 'root', name: 'media' } )( { + dispatch, + registry, + } ); + + expect( triggerFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/media', + method: 'OPTIONS', + parse: false, } ); + expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith( + 'create/root/media', + true + ); + } ); + + it( 'receives true when the user is allowed to perform an action on a specific resource', async () => { triggerFetch.mockImplementation( () => ( { headers: new Map( [ [ 'allow', 'POST, GET, PUT, DELETE' ] ] ), } ) ); @@ -383,11 +443,33 @@ describe( 'canUser', () => { ); } ); - it( 'runs apiFetch only once per resource', async () => { - const dispatch = Object.assign( jest.fn(), { - receiveUserPermission: jest.fn(), + it( 'receives true when the user is allowed to perform an action on a specific entity', async () => { + triggerFetch.mockImplementation( () => ( { + headers: new Map( [ [ 'allow', 'POST, GET, PUT, DELETE' ] ] ), + } ) ); + + await canUser( 'create', { + kind: 'postType', + name: 'wp_block', + id: 123, + } )( { + dispatch, + registry, + } ); + + expect( triggerFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/blocks/123', + method: 'OPTIONS', + parse: false, } ); + expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith( + 'create/postType/wp_block/123', + true + ); + } ); + + it( 'runs apiFetch only once per resource', async () => { registry = { ...registry, select: () => ( { @@ -414,11 +496,46 @@ describe( 'canUser', () => { ); } ); - it( 'retrieves all permissions even when ID is not given', async () => { - const dispatch = Object.assign( jest.fn(), { - receiveUserPermission: jest.fn(), + it( 'runs apiFetch only once per entity', async () => { + registry = { + ...registry, + select: () => ( { + hasStartedResolution: ( _, [ action ] ) => action === 'read', + } ), + }; + + triggerFetch.mockImplementation( () => ( { + headers: new Map( [ [ 'allow', 'POST, GET' ] ] ), + } ) ); + + await canUser( 'create', { + kind: 'postType', + name: 'wp_block', + } )( { + dispatch, + registry, + } ); + await canUser( 'read', { + kind: 'postType', + name: 'wp_block', + } )( { + dispatch, + registry, } ); + expect( triggerFetch ).toHaveBeenCalledTimes( 1 ); + + expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith( + 'create/postType/wp_block', + true + ); + expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith( + 'read/postType/wp_block', + true + ); + } ); + + it( 'retrieves all permissions even when ID is not given', async () => { registry = { ...registry, select: () => ( { @@ -454,10 +571,6 @@ describe( 'canUser', () => { } ); it( 'runs apiFetch only once per resource ID', async () => { - const dispatch = Object.assign( jest.fn(), { - receiveUserPermission: jest.fn(), - } ); - registry = { ...registry, select: () => ( { @@ -493,6 +606,59 @@ describe( 'canUser', () => { true ); } ); + + it( 'runs apiFetch only once per entity ID', async () => { + registry = { + ...registry, + select: () => ( { + hasStartedResolution: ( _, [ action ] ) => action === 'create', + } ), + }; + + triggerFetch.mockImplementation( () => ( { + headers: new Map( [ [ 'allow', 'POST, GET, PUT, DELETE' ] ] ), + } ) ); + + await canUser( 'create', { + kind: 'postType', + name: 'wp_block', + id: 123, + } )( { dispatch, registry } ); + await canUser( 'read', { + kind: 'postType', + name: 'wp_block', + id: 123, + } )( { dispatch, registry } ); + await canUser( 'update', { + kind: 'postType', + name: 'wp_block', + id: 123, + } )( { dispatch, registry } ); + await canUser( 'delete', { + kind: 'postType', + name: 'wp_block', + id: 123, + } )( { dispatch, registry } ); + + expect( triggerFetch ).toHaveBeenCalledTimes( 1 ); + + expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith( + 'create/postType/wp_block/123', + true + ); + expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith( + 'read/postType/wp_block/123', + true + ); + expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith( + 'update/postType/wp_block/123', + true + ); + expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith( + 'delete/postType/wp_block/123', + true + ); + } ); } ); describe( 'getAutosaves', () => { diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index 43c84a3e978917..4b5e8417ad2028 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -18,7 +18,6 @@ import { getEmbedPreview, isPreviewEmbedFallback, canUser, - canUserEditEntityRecord, getAutosave, getAutosaves, getCurrentUser, @@ -690,79 +689,43 @@ describe( 'canUser', () => { userPermissions: {}, } ); expect( canUser( state, 'create', 'media' ) ).toBe( undefined ); + expect( + canUser( state, 'create', { kind: 'root', name: 'media' } ) + ).toBe( undefined ); + } ); + + it( 'returns null when entity kind or name is missing', () => { + const state = deepFreeze( { + userPermissions: {}, + } ); + expect( canUser( state, 'create', { name: 'media' } ) ).toBe( false ); + expect( canUser( state, 'create', { kind: 'root' } ) ).toBe( false ); } ); it( 'returns whether an action can be performed', () => { const state = deepFreeze( { userPermissions: { 'create/media': false, + 'create/root/media': false, }, } ); expect( canUser( state, 'create', 'media' ) ).toBe( false ); + expect( + canUser( state, 'create', { kind: 'root', name: 'media' } ) + ).toBe( false ); } ); it( 'returns whether an action can be performed for a given resource', () => { const state = deepFreeze( { userPermissions: { 'create/media/123': false, + 'create/root/media/123': false, }, } ); expect( canUser( state, 'create', 'media', 123 ) ).toBe( false ); - } ); -} ); - -describe( 'canUserEditEntityRecord', () => { - it( 'returns false by default', () => { - const state = deepFreeze( { - userPermissions: {}, - entities: { records: {} }, - } ); - expect( canUserEditEntityRecord( state, 'postType', 'post' ) ).toBe( - false - ); - } ); - - it( 'returns whether the user can edit', () => { - const state = deepFreeze( { - userPermissions: { - 'create/posts': false, - 'update/posts/1': true, - }, - entities: { - config: [ - { - kind: 'postType', - name: 'post', - __unstable_rest_base: 'posts', - }, - ], - records: { - root: { - postType: { - queriedData: { - items: { - default: { - post: { - slug: 'post', - __unstable: 'posts', - }, - }, - }, - itemIsComplete: { - default: { - post: true, - }, - }, - queries: {}, - }, - }, - }, - }, - }, - } ); expect( - canUserEditEntityRecord( state, 'postType', 'post', '1' ) - ).toBe( true ); + canUser( state, 'create', { kind: 'root', name: 'media', id: 123 } ) + ).toBe( false ); } ); } ); diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js index 0b46e58542a359..a2fb5964663978 100644 --- a/packages/editor/src/bindings/post-meta.js +++ b/packages/editor/src/bindings/post-meta.js @@ -59,11 +59,11 @@ export default { } // Check that the user has the capability to edit post meta. - const canUserEdit = select( coreDataStore ).canUserEditEntityRecord( - 'postType', - context?.postType, - context?.postId - ); + const canUserEdit = select( coreDataStore ).canUser( 'update', { + kind: 'postType', + name: context?.postType, + id: context?.postId, + } ); if ( ! canUserEdit ) { return false; }