From 5b10d27ec406cbdec84e14408cd350ba9e8c544a Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Fri, 20 Oct 2023 22:11:15 +1300 Subject: [PATCH] Patterns: fix capabilities settings for pattern categories (#55379) Co-authored-by: Daniel Richards --- lib/compat/wordpress-6.4/block-patterns.php | 23 ++-- lib/compat/wordpress-6.4/blocks.php | 24 ++++ ...s-gutenberg-rest-blocks-controller-6-4.php | 75 +++++++++++ ...erg-rest-pattern-categories-controller.php | 45 +++++++ lib/load.php | 2 + packages/core-data/src/resolvers.js | 1 + packages/editor/src/components/index.js | 1 + .../pattern-categories-selector.js | 119 ++++++++++++++++++ .../src/components/category-editor.js | 97 ++++++++++++++ .../src/components/category-selector.js | 89 ++++++------- .../src/components/create-pattern-modal.js | 75 ++++++----- .../rename-pattern-category-modal.js | 2 +- packages/patterns/src/components/style.scss | 23 ++++ packages/patterns/src/private-apis.js | 3 + 14 files changed, 483 insertions(+), 96 deletions(-) create mode 100644 lib/compat/wordpress-6.4/class-gutenberg-rest-blocks-controller-6-4.php create mode 100644 lib/compat/wordpress-6.4/class-gutenberg-rest-pattern-categories-controller.php create mode 100644 packages/editor/src/components/post-taxonomies/pattern-categories-selector.js create mode 100644 packages/patterns/src/components/category-editor.js diff --git a/lib/compat/wordpress-6.4/block-patterns.php b/lib/compat/wordpress-6.4/block-patterns.php index 922dea910b47a0..bbb910ff400d01 100644 --- a/lib/compat/wordpress-6.4/block-patterns.php +++ b/lib/compat/wordpress-6.4/block-patterns.php @@ -16,20 +16,21 @@ */ function gutenberg_register_taxonomy_patterns() { $args = array( - 'public' => true, - 'publicly_queryable' => false, - 'hierarchical' => false, - 'labels' => array( + 'public' => true, + 'publicly_queryable' => false, + 'hierarchical' => false, + 'labels' => array( 'name' => _x( 'Pattern Categories', 'taxonomy general name' ), 'singular_name' => _x( 'Pattern Category', 'taxonomy singular name' ), ), - 'query_var' => false, - 'rewrite' => false, - 'show_ui' => true, - '_builtin' => true, - 'show_in_nav_menus' => false, - 'show_in_rest' => true, - 'show_admin_column' => true, + 'query_var' => false, + 'rewrite' => false, + 'show_ui' => true, + '_builtin' => true, + 'show_in_nav_menus' => false, + 'show_in_rest' => true, + 'show_admin_column' => true, + 'rest_controller_class' => 'Gutenberg_REST_Pattern_Categories_Controller', ); register_taxonomy( 'wp_pattern_category', array( 'wp_block' ), $args ); } diff --git a/lib/compat/wordpress-6.4/blocks.php b/lib/compat/wordpress-6.4/blocks.php index 74fa9253e45d50..073302dbab65f8 100644 --- a/lib/compat/wordpress-6.4/blocks.php +++ b/lib/compat/wordpress-6.4/blocks.php @@ -21,3 +21,27 @@ function gutenberg_add_custom_capabilities_to_wp_block( $args ) { return $args; } add_filter( 'register_wp_block_post_type_args', 'gutenberg_add_custom_capabilities_to_wp_block', 10, 1 ); + +/** + * Updates the wp_block REST enpoint in order to modify the wp_pattern_category action + * links that are returned because as although the taxonomy is flat Author level users + * are only allowed to assign categories. + * + * Note: This should be removed when the minimum required WP version is >= 6.4. + * + * @see https://github.com/WordPress/gutenberg/pull/55379 + * + * @param array $args Register post type args. + * @param string $post_type The post type string. + * + * @return array Register post type args. + */ +function gutenberg_update_patterns_block_rest_controller_class( $args, $post_type ) { + if ( 'wp_block' === $post_type ) { + $args['rest_controller_class'] = 'Gutenberg_REST_Blocks_Controller_6_4'; + } + + return $args; +} + +add_filter( 'register_post_type_args', 'gutenberg_update_patterns_block_rest_controller_class', 11, 2 ); diff --git a/lib/compat/wordpress-6.4/class-gutenberg-rest-blocks-controller-6-4.php b/lib/compat/wordpress-6.4/class-gutenberg-rest-blocks-controller-6-4.php new file mode 100644 index 00000000000000..bc91492e269791 --- /dev/null +++ b/lib/compat/wordpress-6.4/class-gutenberg-rest-blocks-controller-6-4.php @@ -0,0 +1,75 @@ +post_type ); + + if ( 'attachment' !== $this->post_type && current_user_can( $post_type->cap->publish_posts ) ) { + $rels[] = 'https://api.w.org/action-publish'; + } + + if ( current_user_can( 'unfiltered_html' ) ) { + $rels[] = 'https://api.w.org/action-unfiltered-html'; + } + + if ( 'post' === $post_type->name ) { + if ( current_user_can( $post_type->cap->edit_others_posts ) && current_user_can( $post_type->cap->publish_posts ) ) { + $rels[] = 'https://api.w.org/action-sticky'; + } + } + + if ( post_type_supports( $post_type->name, 'author' ) ) { + if ( current_user_can( $post_type->cap->edit_others_posts ) ) { + $rels[] = 'https://api.w.org/action-assign-author'; + } + } + + $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) ); + + foreach ( $taxonomies as $tax ) { + $tax_base = ! empty( $tax->rest_base ) ? $tax->rest_base : $tax->name; + + if ( current_user_can( $tax->cap->edit_terms ) ) { + $rels[] = 'https://api.w.org/action-create-' . $tax_base; + } + + if ( current_user_can( $tax->cap->assign_terms ) ) { + $rels[] = 'https://api.w.org/action-assign-' . $tax_base; + } + } + + return $rels; + } +} diff --git a/lib/compat/wordpress-6.4/class-gutenberg-rest-pattern-categories-controller.php b/lib/compat/wordpress-6.4/class-gutenberg-rest-pattern-categories-controller.php new file mode 100644 index 00000000000000..e249d67e8acaa4 --- /dev/null +++ b/lib/compat/wordpress-6.4/class-gutenberg-rest-pattern-categories-controller.php @@ -0,0 +1,45 @@ +check_is_taxonomy_allowed( $this->taxonomy ) ) { + return false; + } + + $taxonomy_obj = get_taxonomy( $this->taxonomy ); + + // Patterns categories are a flat hierarchy (like tags), but work more like post categories in terms of permissions. + if ( ! current_user_can( $taxonomy_obj->cap->edit_terms ) ) { + return new WP_Error( + 'rest_cannot_create', + __( 'Sorry, you are not allowed to create terms in this taxonomy.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } +} diff --git a/lib/load.php b/lib/load.php index 2b178af5fb9bfe..381248e0f44bf0 100644 --- a/lib/load.php +++ b/lib/load.php @@ -53,6 +53,8 @@ function gutenberg_is_experiment_enabled( $name ) { // WordPress 6.4 compat. require_once __DIR__ . '/compat/wordpress-6.4/class-gutenberg-rest-global-styles-revisions-controller-6-4.php'; require_once __DIR__ . '/compat/wordpress-6.4/class-gutenberg-rest-block-patterns-controller.php'; + require_once __DIR__ . '/compat/wordpress-6.4/class-gutenberg-rest-blocks-controller-6-4.php'; + require_once __DIR__ . '/compat/wordpress-6.4/class-gutenberg-rest-pattern-categories-controller.php'; require_once __DIR__ . '/compat/wordpress-6.4/rest-api.php'; require_once __DIR__ . '/compat/wordpress-6.4/theme-previews.php'; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 07e9cd98cb5ec3..5fc7cd14f35c0b 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -651,6 +651,7 @@ export const getUserPatternCategories = { per_page: -1, _fields: 'id,name,description,slug', + context: 'view', } ); diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index 39b562806c109a..99de8c83c6fbe1 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -62,6 +62,7 @@ export { export { default as PostTaxonomies } from './post-taxonomies'; export { FlatTermSelector as PostTaxonomiesFlatTermSelector } from './post-taxonomies/flat-term-selector'; export { HierarchicalTermSelector as PostTaxonomiesHierarchicalTermSelector } from './post-taxonomies/hierarchical-term-selector'; +export { PatternCategoriesSelector as PostPatternCategoriesSelector } from './post-taxonomies/pattern-categories-selector'; export { default as PostTaxonomiesCheck } from './post-taxonomies/check'; export { default as PostTextEditor } from './post-text-editor'; export { default as PostTitle } from './post-title'; diff --git a/packages/editor/src/components/post-taxonomies/pattern-categories-selector.js b/packages/editor/src/components/post-taxonomies/pattern-categories-selector.js new file mode 100644 index 00000000000000..ac6a60aa009349 --- /dev/null +++ b/packages/editor/src/components/post-taxonomies/pattern-categories-selector.js @@ -0,0 +1,119 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { addFilter } from '@wordpress/hooks'; +import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { store as editorStore } from '../../store'; + +const { CategorySelector } = unlock( patternsPrivateApis ); + +const EMPTY_ARRAY = []; + +const DEFAULT_QUERY = { + per_page: -1, + orderby: 'name', + order: 'asc', + _fields: 'id,name,parent', + context: 'view', +}; + +/* + * Pattern categories are a flat taxonomy but do not allow Author users and below to create + * new categories, so this selector overrides the default flat taxonomy selector for + * wp_block post types and users without 'create' capability for wp_pattern_category. + */ +export function PatternCategoriesSelector( { slug } ) { + const { hasAssignAction, terms, availableTerms, taxonomy, loading } = + useSelect( + ( select ) => { + const { getCurrentPost, getEditedPostAttribute } = + select( editorStore ); + const { getTaxonomy, getEntityRecords, isResolving } = + select( coreStore ); + const _taxonomy = getTaxonomy( slug ); + const post = getCurrentPost(); + + return { + hasAssignAction: _taxonomy + ? post._links?.[ + 'wp:action-assign-' + _taxonomy.rest_base + ] ?? false + : false, + terms: _taxonomy + ? getEditedPostAttribute( _taxonomy.rest_base ) + : EMPTY_ARRAY, + loading: isResolving( 'getEntityRecords', [ + 'taxonomy', + slug, + DEFAULT_QUERY, + ] ), + availableTerms: + getEntityRecords( 'taxonomy', slug, DEFAULT_QUERY ) || + EMPTY_ARRAY, + taxonomy: _taxonomy, + }; + }, + [ slug ] + ); + + const { editPost } = useDispatch( editorStore ); + + if ( ! hasAssignAction || loading || availableTerms.length === 0 ) { + return null; + } + + const onUpdateTerms = ( termIds ) => { + editPost( { [ taxonomy.rest_base ]: termIds } ); + }; + + const onChange = ( term ) => { + const hasTerm = terms.includes( term.id ); + const newTerms = hasTerm + ? terms.filter( ( id ) => id !== term.id ) + : [ ...terms, term.id ]; + onUpdateTerms( newTerms ); + }; + + const isCategorySelected = ( term ) => terms.includes( term.id ); + + const categoryOptions = availableTerms.map( ( term ) => ( { + ...term, + label: term.name, + } ) ); + + return ( + + ); +} + +export default function patternCategorySelector( OriginalComponent ) { + return function ( props ) { + const canAddCategories = useSelect( ( select ) => { + const { canUser } = select( coreStore ); + return canUser( 'create', 'wp_pattern_category' ); + } ); + if ( props.slug === 'wp_pattern_category' && ! canAddCategories ) { + return ; + } + + return ; + }; +} + +addFilter( + 'editor.PostTaxonomyType', + 'core/pattern-category-selector', + patternCategorySelector +); diff --git a/packages/patterns/src/components/category-editor.js b/packages/patterns/src/components/category-editor.js new file mode 100644 index 00000000000000..a394013af333b2 --- /dev/null +++ b/packages/patterns/src/components/category-editor.js @@ -0,0 +1,97 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useMemo, useState } from '@wordpress/element'; +import { FormTokenField } from '@wordpress/components'; +import { useDebounce } from '@wordpress/compose'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import CategorySelector from './category-selector'; + +const unescapeString = ( arg ) => decodeEntities( arg ); + +export const CATEGORY_SLUG = 'wp_pattern_category'; + +export default function CategoryEditor( { + categoryTerms, + onChange, + categoryMap, + canAddCategories, +} ) { + const categoryOptions = Array.from( categoryMap.values() ); + const [ search, setSearch ] = useState( '' ); + const debouncedSearch = useDebounce( setSearch, 500 ); + + const suggestions = useMemo( () => { + return Array.from( categoryMap.values() ) + .map( ( category ) => unescapeString( category.label ) ) + .filter( ( category ) => { + if ( search !== '' ) { + return category + .toLowerCase() + .includes( search.toLowerCase() ); + } + return true; + } ) + .sort( ( a, b ) => a.localeCompare( b ) ); + }, [ search, categoryMap ] ); + + function handleChange( termNames ) { + const uniqueTerms = termNames.reduce( ( terms, newTerm ) => { + if ( + ! terms.some( + ( term ) => term.toLowerCase() === newTerm.toLowerCase() + ) + ) { + terms.push( newTerm ); + } + return terms; + }, [] ); + + onChange( uniqueTerms ); + } + const isCategorySelected = ( selectedCategory ) => + categoryTerms.includes( selectedCategory.label ); + + const onCategorySelectChange = ( selectedCategory ) => { + if ( categoryTerms.includes( selectedCategory.label ) ) { + onChange( + categoryTerms.filter( + ( categoryTerm ) => categoryTerm !== selectedCategory.label + ) + ); + } else { + onChange( [ ...categoryTerms, selectedCategory.label ] ); + } + }; + + return ( + <> + { canAddCategories && ( + + ) } + { ! canAddCategories && categoryOptions.length > 0 && ( + + ) } + + ); +} diff --git a/packages/patterns/src/components/category-selector.js b/packages/patterns/src/components/category-selector.js index 7f00350e278ecf..84dae97e232f89 100644 --- a/packages/patterns/src/components/category-selector.js +++ b/packages/patterns/src/components/category-selector.js @@ -2,65 +2,48 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useMemo, useState } from '@wordpress/element'; -import { FormTokenField } from '@wordpress/components'; -import { useDebounce } from '@wordpress/compose'; +import { CheckboxControl, BaseControl } from '@wordpress/components'; import { decodeEntities } from '@wordpress/html-entities'; -const unescapeString = ( arg ) => { - return decodeEntities( arg ); -}; - -export const CATEGORY_SLUG = 'wp_pattern_category'; - export default function CategorySelector( { - categoryTerms, onChange, - categoryMap, + isCategorySelected, + categoryOptions, + showLabel = true, } ) { - const [ search, setSearch ] = useState( '' ); - const debouncedSearch = useDebounce( setSearch, 500 ); - - const suggestions = useMemo( () => { - return Array.from( categoryMap.values() ) - .map( ( category ) => unescapeString( category.label ) ) - .filter( ( category ) => { - if ( search !== '' ) { - return category - .toLowerCase() - .includes( search.toLowerCase() ); - } - return true; - } ) - .sort( ( a, b ) => a.localeCompare( b ) ); - }, [ search, categoryMap ] ); - - function handleChange( termNames ) { - const uniqueTerms = termNames.reduce( ( terms, newTerm ) => { - if ( - ! terms.some( - ( term ) => term.toLowerCase() === newTerm.toLowerCase() - ) - ) { - terms.push( newTerm ); - } - return terms; - }, [] ); - - onChange( uniqueTerms ); - } + const renderTerms = ( renderedTerms ) => { + return renderedTerms.map( ( category ) => { + return ( +
+ onChange( category ) } + label={ decodeEntities( category.label ) } + /> +
+ ); + } ); + }; return ( - + + { showLabel && ( + + { __( 'Categories' ) } + + ) } +
+ { renderTerms( categoryOptions ) } +
+
); } diff --git a/packages/patterns/src/components/create-pattern-modal.js b/packages/patterns/src/components/create-pattern-modal.js index 22d20fd0372657..67953ecdee6685 100644 --- a/packages/patterns/src/components/create-pattern-modal.js +++ b/packages/patterns/src/components/create-pattern-modal.js @@ -24,7 +24,7 @@ import { PATTERN_DEFAULT_CATEGORY, PATTERN_SYNC_TYPES } from '../constants'; * Internal dependencies */ import { store as patternsStore } from '../store'; -import CategorySelector, { CATEGORY_SLUG } from './category-selector'; +import CategoryEditor, { CATEGORY_SLUG } from './category-editor'; import { unlock } from '../lock-unlock'; export default function CreatePatternModal( { @@ -48,41 +48,46 @@ export default function CreatePatternModal( { const { saveEntityRecord, invalidateResolution } = useDispatch( coreStore ); const { createErrorNotice } = useDispatch( noticesStore ); - const { corePatternCategories, userPatternCategories } = useSelect( - ( select ) => { - const { getUserPatternCategories, getBlockPatternCategories } = - select( coreStore ); + const { corePatternCategories, userPatternCategories, canAddCategories } = + useSelect( ( select ) => { + const { + getUserPatternCategories, + getBlockPatternCategories, + canUser, + } = select( coreStore ); return { corePatternCategories: getBlockPatternCategories(), userPatternCategories: getUserPatternCategories(), + canAddCategories: canUser( 'create', 'wp_pattern_category' ), }; - } - ); + } ); const categoryMap = useMemo( () => { // Merge the user and core pattern categories and remove any duplicates. const uniqueCategories = new Map(); - [ ...userPatternCategories, ...corePatternCategories ].forEach( - ( category ) => { - if ( - ! uniqueCategories.has( category.label ) && - // There are two core categories with `Post` label so explicitly remove the one with - // the `query` slug to avoid any confusion. - category.name !== 'query' - ) { - // We need to store the name separately as this is used as the slug in the - // taxonomy and may vary from the label. - uniqueCategories.set( category.label, { - label: category.label, - value: category.label, - name: category.name, - } ); - } + [ + ...userPatternCategories, + ...( canAddCategories ? corePatternCategories : [] ), + ].forEach( ( category ) => { + if ( + ! uniqueCategories.has( category.label ) && + // There are two core categories with `Post` label so explicitly remove the one with + // the `query` slug to avoid any confusion. + category.name !== 'query' + ) { + // We need to store the name separately as this is used as the slug in the + // taxonomy and may vary from the label. + uniqueCategories.set( category.label, { + label: category.label, + value: category.label, + name: category.name, + id: category.id, + } ); } - ); + } ); return uniqueCategories; - }, [ userPatternCategories, corePatternCategories ] ); + }, [ userPatternCategories, corePatternCategories, canAddCategories ] ); async function onCreate( patternTitle, sync ) { if ( ! title || isSaving ) { @@ -91,11 +96,18 @@ export default function CreatePatternModal( { try { setIsSaving( true ); - const categories = await Promise.all( - categoryTerms.map( ( termName ) => - findOrCreateTerm( termName ) - ) - ); + let categories; + if ( canAddCategories ) { + categories = await Promise.all( + categoryTerms.map( ( termName ) => + findOrCreateTerm( termName ) + ) + ); + } else { + categories = categoryTerms.map( + ( term ) => categoryMap.get( term ).id + ); + } const newPattern = await createPattern( patternTitle, @@ -173,10 +185,11 @@ export default function CreatePatternModal( { placeholder={ __( 'My pattern' ) } className="patterns-create-modal__name-input" /> -