Skip to content

Commit

Permalink
Improve reusable block UX for non-privileged users
Browse files Browse the repository at this point in the history
Improves the UX of creating, editing, and deleting a reusable block when
logged in as an author or contributor by disabling the _Add to Reusable
Blocks_, _Edit_, and _Remove from Reusable Blocks_ buttons when
necessary.

This is accomplished under the hood by introducing the `canUser()`
selector to `core-data` which allows callers to query whether the REST
API supports performing a given action on a given resource, e.g. one can
query whether the logged in user can create posts by running
`wp.data.select( 'core' ).canUser( 'create', 'posts' )`.

The existing `hasUploadPermissions()` selector is changed to use
`canUser( 'create', 'media' )` under the hood.
  • Loading branch information
noisysocks committed Dec 5, 2018
1 parent f97617f commit 9a4cb69
Show file tree
Hide file tree
Showing 16 changed files with 309 additions and 29 deletions.
29 changes: 28 additions & 1 deletion docs/designers-developers/developers/data/data-core.md
Expand Up @@ -148,6 +148,23 @@ Return Upload Permissions.

Upload Permissions.

### canUser

Returns whether the current user can perform the given action on the given
REST resource.

*Parameters*

* state: Data state.
* action: Action to check. One of: 'create', 'read', 'update',
'delete'.
* resource: REST resource to check, e.g. 'media' or 'posts'.
* id: ID of the rest resource to check.

*Returns*

Whether or not the user can perform the action.

## Actions

### receiveUserQuery
Expand Down Expand Up @@ -213,4 +230,14 @@ Returns an action object used in signalling that Upload permissions have been re

*Parameters*

* hasUploadPermissions: Does the user have permission to upload files?
* hasUploadPermissions: Does the user have permission to upload files?

### receiveUserPermissions

Returns an action object used in signalling that the current user has
permission to perform an action on a REST resource.

*Parameters*

* key: A key that represents the action and REST resource.
* isAllowed: Whether or not the user can perform the action.
3 changes: 2 additions & 1 deletion packages/block-library/src/block/edit-panel/index.js
Expand Up @@ -53,7 +53,7 @@ class ReusableBlockEditPanel extends Component {
}

render() {
const { isEditing, title, isSaving, onEdit, instanceId } = this.props;
const { isEditing, title, isSaving, isEditDisabled, onEdit, instanceId } = this.props;

return (
<Fragment>
Expand All @@ -66,6 +66,7 @@ class ReusableBlockEditPanel extends Component {
ref={ this.editButton }
isLarge
className="reusable-block-edit-panel__button"
disabled={ isEditDisabled }
onClick={ onEdit }
>
{ __( 'Edit' ) }
Expand Down
6 changes: 5 additions & 1 deletion packages/block-library/src/block/edit.js
Expand Up @@ -97,7 +97,7 @@ class ReusableBlockEdit extends Component {
}

render() {
const { isSelected, reusableBlock, block, isFetching, isSaving } = this.props;
const { isSelected, reusableBlock, block, isFetching, isSaving, canUpdateBlock } = this.props;
const { isEditing, title, changedAttributes } = this.state;

if ( ! reusableBlock && isFetching ) {
Expand Down Expand Up @@ -130,6 +130,7 @@ class ReusableBlockEdit extends Component {
isEditing={ isEditing }
title={ title !== null ? title : reusableBlock.title }
isSaving={ isSaving && ! reusableBlock.isTemporary }
isEditDisabled={ ! canUpdateBlock }
onEdit={ this.startEditing }
onChangeTitle={ this.setTitle }
onSave={ this.save }
Expand All @@ -151,6 +152,8 @@ export default compose( [
__experimentalIsSavingReusableBlock: isSavingReusableBlock,
getBlock,
} = select( 'core/editor' );
const { canUser } = select( 'core' );

const { ref } = ownProps.attributes;
const reusableBlock = getReusableBlock( ref );

Expand All @@ -159,6 +162,7 @@ export default compose( [
isFetching: isFetchingReusableBlock( ref ),
isSaving: isSavingReusableBlock( ref ),
block: reusableBlock ? getBlock( reusableBlock.clientId ) : null,
canUpdateBlock: canUser( 'update', 'blocks', ref ),
};
} ),
withDispatch( ( dispatch, ownProps ) => {
Expand Down
22 changes: 20 additions & 2 deletions packages/core-data/src/actions.js
Expand Up @@ -137,7 +137,25 @@ export function* saveEntityRecord( kind, name, record ) {
*/
export function receiveUploadPermissions( hasUploadPermissions ) {
return {
type: 'RECEIVE_UPLOAD_PERMISSIONS',
hasUploadPermissions,
type: 'RECEIVE_USER_PERMISSIONS',
key: 'create/media',
isAllowed: hasUploadPermissions,
};
}

/**
* Returns an action object used in signalling that the current user has
* permission to perform an action on a REST resource.
*
* @param {string} key A key that represents the action and REST resource.
* @param {boolean} isAllowed Whether or not the user can perform the action.
*
* @return {Object} Action object.
*/
export function receiveUserPermissions( key, isAllowed ) {
return {
type: 'RECEIVE_USER_PERMISSIONS',
key,
isAllowed,
};
}
18 changes: 11 additions & 7 deletions packages/core-data/src/reducer.js
Expand Up @@ -218,17 +218,21 @@ export function embedPreviews( state = {}, action ) {
}

/**
* Reducer managing Upload permissions.
* State which tracks whether the user can perform an action on a REST
* resource.
*
* @param {Object} state Current state.
* @param {Object} action Dispatched action.
* @param {Object} state Current state.
* @param {Object} action Dispatched action.
*
* @return {Object} Updated state.
*/
export function hasUploadPermissions( state = {}, action ) {
export function userPermissions( state = {}, action ) {
switch ( action.type ) {
case 'RECEIVE_UPLOAD_PERMISSIONS':
return action.hasUploadPermissions;
case 'RECEIVE_USER_PERMISSIONS':
return {
...state,
[ action.key ]: action.isAllowed,
};
}

return state;
Expand All @@ -241,5 +245,5 @@ export default combineReducers( {
themeSupports,
entities,
embedPreviews,
hasUploadPermissions,
userPermissions,
} );
49 changes: 45 additions & 4 deletions packages/core-data/src/resolvers.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { find, includes, get, hasIn } from 'lodash';
import { find, includes, get, hasIn, compact } from 'lodash';

/**
* WordPress dependencies
Expand All @@ -16,7 +16,7 @@ import {
receiveEntityRecords,
receiveThemeSupports,
receiveEmbedPreview,
receiveUploadPermissions,
receiveUserPermissions,
} from './actions';
import { getKindEntities } from './entities';
import { apiFetch } from './controls';
Expand Down Expand Up @@ -103,7 +103,46 @@ export function* getEmbedPreview( url ) {
* Requests Upload Permissions from the REST API.
*/
export function* hasUploadPermissions() {
const response = yield apiFetch( { path: '/wp/v2/media', method: 'OPTIONS', parse: false } );
yield* canUser( 'create', 'media' );
}

/**
* Checks whether the current user can perform the given action on the given
* REST resource.
*
* @param {string} action 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.
*/
export function* canUser( action, resource, id ) {
const methods = {
create: 'POST',
read: 'GET',
update: 'PUT',
delete: 'DELETE',
};

const method = methods[ action ];
if ( ! method ) {
throw new Error( `'${ action }' is not a valid action` );
}

const path = id ? `/wp/v2/${ resource }/${ id }` : `/wp/v2/${ resource }`;

let response;
try {
response = yield apiFetch( {
path,
// Ideally this would always be an OPTIONS request, but unfortunately there's
// a bug in the REST API which causes the Allow header to not be sent on
// OPTIONS requests to /posts/:id routes.
method: id ? 'GET' : 'OPTIONS',
parse: false,
} );
} catch ( error ) {
return;
}

let allowHeader;
if ( hasIn( response, [ 'headers', 'get' ] ) ) {
Expand All @@ -116,5 +155,7 @@ export function* hasUploadPermissions() {
allowHeader = get( response, [ 'headers', 'Allow' ], '' );
}

yield receiveUploadPermissions( includes( allowHeader, 'POST' ) );
const key = compact( [ action, resource, id ] ).join( '/' );
const isAllowed = includes( allowHeader, method );
yield receiveUserPermissions( key, isAllowed );
}
21 changes: 19 additions & 2 deletions packages/core-data/src/selectors.js
Expand Up @@ -2,7 +2,7 @@
* External dependencies
*/
import createSelector from 'rememo';
import { map, find, get, filter } from 'lodash';
import { map, find, get, filter, compact } from 'lodash';

/**
* WordPress dependencies
Expand Down Expand Up @@ -178,5 +178,22 @@ export function isPreviewEmbedFallback( state, url ) {
* @return {boolean} Upload Permissions.
*/
export function hasUploadPermissions( state ) {
return state.hasUploadPermissions;
return canUser( state, 'create', 'media' );
}

/**
* Returns whether the current user can perform the given action on the given
* REST resource.
*
* @param {Object} state Data state.
* @param {string} action 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.
*
* @return {boolean} Whether or not the user can perform the action.
*/
export function canUser( state, action, resource, id ) {
const key = compact( [ action, resource, id ] ).join( '/' );
return get( state, [ 'userPermissions', key ], true );
}
12 changes: 11 additions & 1 deletion packages/core-data/src/test/actions.js
@@ -1,7 +1,7 @@
/**
* Internal dependencies
*/
import { saveEntityRecord, receiveEntityRecords } from '../actions';
import { saveEntityRecord, receiveEntityRecords, receiveUserPermissions } from '../actions';

describe( 'saveEntityRecord', () => {
it( 'triggers a POST request for a new record', async () => {
Expand Down Expand Up @@ -58,3 +58,13 @@ describe( 'saveEntityRecord', () => {
expect( received ).toEqual( receiveEntityRecords( 'root', 'postType', postType, undefined, true ) );
} );
} );

describe( 'receiveUserPermissions', () => {
it( 'builds an action object', () => {
expect( receiveUserPermissions( 'create/media', true ) ).toEqual( {
type: 'RECEIVE_USER_PERMISSIONS',
key: 'create/media',
isAllowed: true,
} );
} );
} );
25 changes: 24 additions & 1 deletion packages/core-data/src/test/reducer.js
Expand Up @@ -7,7 +7,7 @@ import { filter } from 'lodash';
/**
* Internal dependencies
*/
import { terms, entities, embedPreviews } from '../reducer';
import { terms, entities, embedPreviews, userPermissions } from '../reducer';

describe( 'terms()', () => {
it( 'returns an empty object by default', () => {
Expand Down Expand Up @@ -117,3 +117,26 @@ describe( 'embedPreviews()', () => {
} );
} );
} );

describe( 'userPermissions()', () => {
it( 'defaults to an empty object', () => {
const state = userPermissions( undefined, {} );
expect( state ).toEqual( {} );
} );

it( 'updates state with whether an action is allowed', () => {
const original = deepFreeze( {
'create/media': false,
} );

const state = userPermissions( original, {
type: 'RECEIVE_USER_PERMISSIONS',
key: 'create/media',
isAllowed: true,
} );

expect( state ).toEqual( {
'create/media': true,
} );
} );
} );

0 comments on commit 9a4cb69

Please sign in to comment.