Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reusable blocks: Improve UX for non-privileged users #12378

Merged
merged 16 commits into from
Jan 23, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should have been a corresponding change to the root package-lock.json. I'm not sure how this passed the build task which checks for local uncommitted changes, but it shouldn't have.

Copy link
Member Author

@noisysocks noisysocks Jan 23, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh! Looks like check-local-changes doesn't pick this up because precheck-local-changes doesn't run npm install. CI doesn't pick it up because it uses npm ci.

Will fix this up in a seperate PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"@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() {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I mark this as deprecated? I'm unsure on what the policy is post 5.0 🙂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know either. Might be worth pinging someone on slack. Will hold off approving until the answer is known.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably fine leaving this as is where we support both canUser and hasUploadPermissions. We can always deprecate later.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to use a soft deprecation by using @deprecated in JSDoc and maybe filter out all actions and selectors from the generated docs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, for now, we should leave those, we can add a deprecation message if needed but without specifying a version.

This is something we should discuss more in the next #core-js chats.

Copy link
Member Author

@noisysocks noisysocks Dec 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@noisysocks I noticed you @deprecated the selector, but the resolver hasn't been deprecated. I guess technically they're the same interface and the resolver wouldn't be called directly, but I wanted to double-check if it was intentional.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I think selectors are the public interface here but it wouldn't hurt to have the @deprecated attribute in the resolver as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4ea46a5!

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 }`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const path = id ? `/wp/v2/${ resource }/${ id }` : `/wp/v2/${ resource }`;
const path = '/wp/v2/${ resource }' + ( id ? `/${ id }` : '' );

would be shorter, not sure what reads better though :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that with the more verbose version that you can glance at the code and see the "shape" of the URL.


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.
Copy link
Contributor

@talldan talldan Dec 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that just for /post/:id of for the all resources? I mentioned that because the logic below happens for all resources (so doesn't match the comment).

Is there a ticket/issue covering the bug?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've only checked posts but I suspect the bug affects all REST endpoints. Will do some investigating and open up a Trac ticket 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the case, it should be fixed /cc @danielbachhuber @kadamwhite

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you just want the Allow header, what about doing a HEAD request instead of GET.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking into this further:

  1. The Allow header is generated by rest_send_allow_header(), which is called on rest_post_dispatch.
  2. rest_post_dispatch is called for every response (I think):

It seems like Allow should be present for OPTIONS. It's an officially supported header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS

Can you provide steps to reproduce?

Copy link
Member Author

@noisysocks noisysocks Dec 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I briefly debugged this when I noticed the issue and from memory it was something to do with rest_handle_options_request not calling $request->set_url_params() unlike dispatch which correctly parses and sets URL parameters.

Can you provide steps to reproduce?

  1. Create a reusable block
  2. Perform a GET http://local.wordpress.test/wp-json/wp/v2/blocks request and note the ID of the reusable block
  3. Perform a OPTIONS http://local.wordpress.test/wp-json/wp/v2/blocks/27 request, replacing 27 with the ID from step 2. Note that an Allow header is not sent back
  4. Perform a GET http://local.wordpress.test/wp-json/wp/v2/blocks/27 request, replacing 27 with the ID from step 2. Note that an Allow header is sent back

Above steps also work with posts: just change blocks to posts.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a link to the Trac ticket in cf2d701.

// 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😟 This seems like it should throw. Why are we catching an ignoring the error without doing anything with it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error here refers to the API error that resulted from our OPTIONS request. Doing nothing is correct in this case as we want to leave the store as is. I've added a comment to explain this in e0a7269.

}

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( '/' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice trick :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'd need template strings for the first part:

Suggested change
const key = compact( [ action, resource, id ] ).join( '/' );
const path = `/wp/v2/${ resource }` + ( id ? `/${ id }` : '' );

I think it's a bit easier to sort out though, yeah.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this suggestion meant to go in this thread? #12378 (comment)

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
talldan marked this conversation as resolved.
Show resolved Hide resolved
* 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 ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the expressiveness of the canUser selector 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Props to @gziolo for suggesting the API 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad you like it :)

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