Skip to content

Commit

Permalink
Reusable blocks: Improve UX for non-privileged users (#12378)
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 authored and youknowriad committed Mar 6, 2019
1 parent 046fb0c commit 00ff3b7
Show file tree
Hide file tree
Showing 21 changed files with 400 additions and 74 deletions.
53 changes: 48 additions & 5 deletions docs/designers-developers/developers/data/data-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,50 @@ get back from the oEmbed preview API.

Is the preview for the URL an oEmbed link fallback.

### hasUploadPermissions
### hasUploadPermissions (deprecated)

Return Upload Permissions.
Returns whether the current user can upload media.

Calling this may trigger an OPTIONS request to the REST API via the
`canUser()` resolver.

https://developer.wordpress.org/rest-api/reference/

*Deprecated*

Deprecated since 5.0. Callers should use the more generic `canUser()` selector instead of
`hasUploadPermissions()`, e.g. `canUser( 'create', 'media' )`.

*Parameters*

* state: State tree.
* state: Data state.

*Returns*

Upload Permissions.
Whether or not the user can upload media. Defaults to `true` if the OPTIONS
request is being made.

### canUser

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

Calling this may trigger an OPTIONS request to the REST API via the
`canUser()` resolver.

https://developer.wordpress.org/rest-api/reference/

*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: Optional ID of the rest resource to check.

*Returns*

Whether or not the user can perform the action,
or `undefined` if the OPTIONS request is still being made.

## Actions

Expand Down Expand Up @@ -213,4 +246,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?

### receiveUserPermission

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.
1 change: 1 addition & 0 deletions lib/client-assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,7 @@ function gutenberg_editor_scripts_and_styles( $hook ) {
sprintf( '/wp/v2/types/%s?context=edit', $post_type ),
sprintf( '/wp/v2/users/me?post_type=%s&context=edit', $post_type ),
array( '/wp/v2/media', 'OPTIONS' ),
array( '/wp/v2/blocks', 'OPTIONS' ),
);

/**
Expand Down
1 change: 1 addition & 0 deletions lib/packages-dependencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
'lodash',
'wp-api-fetch',
'wp-data',
'wp-deprecated',
'wp-url',
),
'wp-data' => array(
Expand Down
3 changes: 2 additions & 1 deletion packages/block-library/src/block/edit-panel/index.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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: !! reusableBlock && ! reusableBlock.isTemporary && !! canUser( 'update', 'blocks', ref ),
};
} ),
withDispatch( ( dispatch, ownProps ) => {
Expand Down
1 change: 1 addition & 0 deletions packages/core-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@babel/runtime": "^7.0.0",
"@wordpress/api-fetch": "file:../api-fetch",
"@wordpress/data": "file:../data",
"@wordpress/deprecated": "file:../deprecated",
"@wordpress/url": "file:../url",
"equivalent-key-map": "^0.2.2",
"lodash": "^4.17.10",
Expand Down
22 changes: 20 additions & 2 deletions packages/core-data/src/actions.js
Original file line number Diff line number Diff line change
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_PERMISSION',
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 receiveUserPermission( key, isAllowed ) {
return {
type: 'RECEIVE_USER_PERMISSION',
key,
isAllowed,
};
}
18 changes: 11 additions & 7 deletions packages/core-data/src/reducer.js
Original file line number Diff line number Diff line change
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 = true, action ) {
export function userPermissions( state = {}, action ) {
switch ( action.type ) {
case 'RECEIVE_UPLOAD_PERMISSIONS':
return action.hasUploadPermissions;
case 'RECEIVE_USER_PERMISSION':
return {
...state,
[ action.key ]: action.isAllowed,
};
}

return state;
Expand All @@ -241,5 +245,5 @@ export default combineReducers( {
themeSupports,
entities,
embedPreviews,
hasUploadPermissions,
userPermissions,
} );
59 changes: 55 additions & 4 deletions packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/**
* External dependencies
*/
import { find, includes, get, hasIn } from 'lodash';
import { find, includes, get, hasIn, compact } from 'lodash';

/**
* WordPress dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import deprecated from '@wordpress/deprecated';

/**
* Internal dependencies
Expand All @@ -16,7 +17,7 @@ import {
receiveEntityRecords,
receiveThemeSupports,
receiveEmbedPreview,
receiveUploadPermissions,
receiveUserPermission,
} from './actions';
import { getKindEntities } from './entities';
import { apiFetch } from './controls';
Expand Down Expand Up @@ -101,9 +102,57 @@ export function* getEmbedPreview( url ) {

/**
* Requests Upload Permissions from the REST API.
*
* @deprecated since 5.0. Callers should use the more generic `canUser()` selector instead of
* `hasUploadPermissions()`, e.g. `canUser( 'create', 'media' )`.
*/
export function* hasUploadPermissions() {
const response = yield apiFetch( { path: '/wp/v2/media', method: 'OPTIONS', parse: false } );
deprecated( "select( 'core' ).hasUploadPermissions()", {
alternative: "select( 'core' ).canUser( 'create', 'media' )",
} );
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.
// https://core.trac.wordpress.org/ticket/45753
method: id ? 'GET' : 'OPTIONS',
parse: false,
} );
} catch ( error ) {
// Do nothing if our OPTIONS request comes back with an API error (4xx or
// 5xx). The previously determined isAllowed value will remain in the store.
return;
}

let allowHeader;
if ( hasIn( response, [ 'headers', 'get' ] ) ) {
Expand All @@ -116,5 +165,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 receiveUserPermission( key, isAllowed );
}
45 changes: 40 additions & 5 deletions packages/core-data/src/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
* External dependencies
*/
import createSelector from 'rememo';
import { map, find, get, filter } from 'lodash';
import { map, find, get, filter, compact, defaultTo } from 'lodash';

/**
* WordPress dependencies
*/
import { select } from '@wordpress/data';
import deprecated from '@wordpress/deprecated';

/**
* Internal dependencies
Expand Down Expand Up @@ -171,12 +172,46 @@ export function isPreviewEmbedFallback( state, url ) {
}

/**
* Return Upload Permissions.
* Returns whether the current user can upload media.
*
* @param {Object} state State tree.
* Calling this may trigger an OPTIONS request to the REST API via the
* `canUser()` resolver.
*
* @return {boolean} Upload Permissions.
* https://developer.wordpress.org/rest-api/reference/
*
* @deprecated since 5.0. Callers should use the more generic `canUser()` selector instead of
* `hasUploadPermissions()`, e.g. `canUser( 'create', 'media' )`.
*
* @param {Object} state Data state.
*
* @return {boolean} Whether or not the user can upload media. Defaults to `true` if the OPTIONS
* request is being made.
*/
export function hasUploadPermissions( state ) {
return state.hasUploadPermissions;
deprecated( "select( 'core' ).hasUploadPermissions()", {
alternative: "select( 'core' ).canUser( 'create', 'media' )",
} );
return defaultTo( canUser( state, 'create', 'media' ), true );
}

/**
* Returns whether the current user can perform the given action on the given
* REST resource.
*
* Calling this may trigger an OPTIONS request to the REST API via the
* `canUser()` resolver.
*
* https://developer.wordpress.org/rest-api/reference/
*
* @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 Optional ID of the rest resource to check.
*
* @return {boolean|undefined} Whether or not the user can perform the action,
* or `undefined` if the OPTIONS request is still being made.
*/
export function canUser( state, action, resource, id ) {
const key = compact( [ action, resource, id ] ).join( '/' );
return get( state, [ 'userPermissions', key ] );
}
12 changes: 11 additions & 1 deletion packages/core-data/src/test/actions.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Internal dependencies
*/
import { saveEntityRecord, receiveEntityRecords } from '../actions';
import { saveEntityRecord, receiveEntityRecords, receiveUserPermission } 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( 'receiveUserPermission', () => {
it( 'builds an action object', () => {
expect( receiveUserPermission( 'create/media', true ) ).toEqual( {
type: 'RECEIVE_USER_PERMISSION',
key: 'create/media',
isAllowed: true,
} );
} );
} );
Loading

0 comments on commit 00ff3b7

Please sign in to comment.