From 73198251f8358f376d06d186d918dfa864e29e9f Mon Sep 17 00:00:00 2001 From: Anne McCarthy Date: Tue, 6 Oct 2026 20:40:27 +0000 Subject: [PATCH 1/2] Pattern Creator: Show the same categories in the inspector and submission modal The inspector listed every category term, including retired ones, while the submission modal fetched without `per_page` and so only showed the first 10 terms. Both now read a single server-defined list of selectable category slugs, matching the categories registered by WordPress core. Also fixes layout issues in both category pickers: - Inspector: wrap the search field and list in a column Flex with a gap, as core does, so the list's negative top margin no longer overlaps the field. - Submission modal: list categories alphabetically down each column, and use a min-height for modal steps so the full list doesn't need a scrollbar. Fixes #611. Co-Authored-By: Claude Opus 5.5 --- .../pattern-creator/pattern-creator.php | 4 +- .../pattern-categories-control/index.js | 33 ++++------- .../src/components/submission-modal/index.js | 45 ++++++--------- .../components/submission-modal/style.scss | 10 ++-- .../src/hooks/test/use-pattern-categories.js | 32 +++++++++++ .../src/hooks/use-pattern-categories.js | 56 +++++++++++++++++++ .../includes/pattern-post-type.php | 39 +++++++++++++ 7 files changed, 164 insertions(+), 55 deletions(-) create mode 100644 public_html/wp-content/plugins/pattern-creator/src/hooks/test/use-pattern-categories.js create mode 100644 public_html/wp-content/plugins/pattern-creator/src/hooks/use-pattern-categories.js diff --git a/public_html/wp-content/plugins/pattern-creator/pattern-creator.php b/public_html/wp-content/plugins/pattern-creator/pattern-creator.php index 7fbcc07e6..9d59b9c07 100644 --- a/public_html/wp-content/plugins/pattern-creator/pattern-creator.php +++ b/public_html/wp-content/plugins/pattern-creator/pattern-creator.php @@ -16,6 +16,7 @@ use WP_Block_Editor_Context; use function WordPressdotorg\MU_Plugins\Global_Header_Footer\{ is_rosetta_site, get_rosetta_name }; +use function WordPressdotorg\Pattern_Directory\Pattern_Post_Type\get_selectable_category_slugs; use const WordPressdotorg\Pattern_Directory\Pattern_Post_Type\POST_TYPE; const AUTOSAVE_INTERVAL = 30; @@ -126,7 +127,8 @@ function pattern_creator_init() { rawurlencode( wp_json_encode( array( - 'siteUrl' => esc_url( home_url() ), + 'siteUrl' => esc_url( home_url() ), + 'categorySlugs' => get_selectable_category_slugs(), ) ) ) diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js index 7104d152a..372b01fd0 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js @@ -7,7 +7,7 @@ import { find, get, unescape as unescapeString } from 'lodash'; * WordPress dependencies */ import { __, _n, sprintf } from '@wordpress/i18n'; -import { CheckboxControl, TextControl } from '@wordpress/components'; +import { CheckboxControl, Flex, TextControl } from '@wordpress/components'; import { speak } from '@wordpress/a11y'; import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; @@ -19,18 +19,11 @@ import { useSelect } from '@wordpress/data'; * Internal dependencies */ import { CATEGORY_SLUG } from '../../store'; +import usePatternCategories from '../../hooks/use-pattern-categories'; /** * Module Constants */ -const DEFAULT_QUERY = { - per_page: -1, - orderby: 'name', - order: 'asc', - _fields: 'id,name,parent,slug', - context: 'view', -}; - const MIN_TERMS_COUNT_FOR_FILTER = 8; const EMPTY_ARRAY = []; @@ -113,20 +106,14 @@ function PatternCategoriesControl( { selectedTerms = EMPTY_ARRAY, setTerms } ) { const [ filteredTermsTree, setFilteredTermsTree ] = useState( [] ); const debouncedSpeak = useDebounce( speak, 500 ); - const { hasAssignAction, availableTerms } = useSelect( ( select ) => { + const availableTerms = usePatternCategories(); + const hasAssignAction = useSelect( ( select ) => { const { getCurrentPost } = select( editorStore ); - const { getTaxonomy, getEntityRecords } = select( coreStore ); - const _taxonomy = getTaxonomy( CATEGORY_SLUG ); - - const terms = getEntityRecords( 'taxonomy', CATEGORY_SLUG, DEFAULT_QUERY ) || EMPTY_ARRAY; + const _taxonomy = select( coreStore ).getTaxonomy( CATEGORY_SLUG ); - return { - hasAssignAction: _taxonomy - ? get( getCurrentPost(), [ '_links', 'wp:action-assign-' + _taxonomy.rest_base ], false ) - : false, - // Filter out the "Featured" category. - availableTerms: terms.filter( ( { slug } ) => 'featured' !== slug ), - }; + return _taxonomy + ? get( getCurrentPost(), [ '_links', 'wp:action-assign-' + _taxonomy.rest_base ], false ) + : false; }, [] ); const availableTermsTree = useMemo( @@ -182,7 +169,7 @@ function PatternCategoriesControl( { selectedTerms = EMPTY_ARRAY, setTerms } ) { const showFilter = availableTerms.length >= MIN_TERMS_COUNT_FOR_FILTER; return ( - <> + { showFilter && ( - + ); } diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js index ba9d70c4a..04f6c1718 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js @@ -1,16 +1,24 @@ /* global wporgBlockPattern, wporgLocale */ +/** + * External dependencies + */ +import { unescape as unescapeString } from 'lodash'; + /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { addQueryArgs } from '@wordpress/url'; -import apiFetch from '@wordpress/api-fetch'; import { Button, CheckboxControl, Modal, TextControl, TextareaControl } from '@wordpress/components'; import { createInterpolateElement, useEffect, useRef, useState } from '@wordpress/element'; import { store as editorStore } from '@wordpress/editor'; import { store as noticesStore } from '@wordpress/notices'; import { useDispatch, useSelect } from '@wordpress/data'; +/** + * Internal dependencies + */ +import usePatternCategories from '../../hooks/use-pattern-categories'; + const ForwardButton = ( { children, disabled, onClick } ) => ( { onSubmit(); goForward(); diff --git a/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php b/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php index 10a0bdacf..5c596c40e 100644 --- a/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php +++ b/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php @@ -12,6 +12,7 @@ use function WordPressdotorg\Pattern_Directory\Pattern_Post_Type\is_block_allowed_in_pattern; use function WordPressdotorg\Pattern_Directory\Pattern_Post_Type\decode_pattern_content; use function WordPressdotorg\Pattern_Directory\Pattern_Post_Type\get_moderated_status; +use function WordPressdotorg\Pattern_Directory\Pattern_Post_Type\get_selectable_category_slugs; use function WordPressdotorg\Pattern_Directory\Pattern_Flag_Post_Type\has_reached_flag_threshold; use const WordPressdotorg\Pattern_Directory\Pattern_Post_Type\{ POST_TYPE, UNLISTED_STATUS, SPAM_STATUS }; use const WordPressdotorg\Pattern_Directory\Pattern_Flag_Post_Type\TAX_TYPE as FLAG_REASON; @@ -30,6 +31,7 @@ add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_title', 11, 2 ); add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_status', 11, 2 ); add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_parent', 11, 2 ); +add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_categories', 11, 2 ); add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_flag_reason', 11, 2 ); // After the specific checks, so a submission they can name gets their message; this catches what they cannot see. add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\reject_unstable_blocks', 15 ); @@ -849,6 +851,55 @@ function validate_parent( $prepared_post, $request ) { return $prepared_post; } +/** + * Limit the categories an author can add to a pattern to the selectable ones. + * + * The pattern creator only offers `get_selectable_category_slugs()`, but core's assign-terms check allows any + * term in the taxonomy, including "Featured". Terms already on the pattern can be re-sent, so older patterns + * keep their retired categories. Moderators can assign any category. + * + * @param object|\WP_Error $prepared_post Prepared post or a preceding validation error. + * @param \WP_REST_Request $request Request being validated. + * @return object|\WP_Error The post, or an error if a category isn't selectable. + */ +function validate_categories( $prepared_post, $request ) { + if ( is_wp_error( $prepared_post ) ) { + return $prepared_post; + } + + if ( ! isset( $request['pattern-categories'] ) ) { + return $prepared_post; + } + + $post_type = get_post_type_object( POST_TYPE ); + if ( current_user_can( $post_type->cap->edit_others_posts ) ) { + return $prepared_post; + } + + $current_terms = isset( $prepared_post->ID ) + ? wp_get_object_terms( $prepared_post->ID, 'wporg-pattern-category', array( 'fields' => 'ids' ) ) + : array(); + if ( is_wp_error( $current_terms ) ) { + $current_terms = array(); + } + + $selectable_slugs = get_selectable_category_slugs(); + $added_terms = array_diff( wp_parse_id_list( $request['pattern-categories'] ), $current_terms ); + + foreach ( $added_terms as $term_id ) { + $term = get_term( $term_id, 'wporg-pattern-category' ); + if ( ! $term || is_wp_error( $term ) || ! in_array( $term->slug, $selectable_slugs, true ) ) { + return new \WP_Error( + 'rest_pattern_invalid_category', + __( 'One or more of the selected categories is not available.', 'wporg-patterns' ), + array( 'status' => 400 ) + ); + } + } + + return $prepared_post; +} + /** * Reserve the flag-reason taxonomy on a pattern to moderators. * diff --git a/public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-category-assignment-test.php b/public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-category-assignment-test.php new file mode 100644 index 000000000..7b98f3fbe --- /dev/null +++ b/public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-category-assignment-test.php @@ -0,0 +1,224 @@ +user->create( array( 'role' => 'editor' ) ); + self::$member = $factory->user->create( array( 'role' => 'subscriber' ) ); + + self::$pattern_id = $factory->post->create( + array( + 'post_type' => POST_TYPE, + 'post_author' => self::$member, + 'post_status' => 'draft', + ) + ); + + self::$selectable_term_id = $factory->term->create( + array( + 'taxonomy' => 'wporg-pattern-category', + 'name' => 'Headers', + 'slug' => 'header', + ) + ); + self::$retired_term_id = $factory->term->create( + array( + 'taxonomy' => 'wporg-pattern-category', + 'name' => 'Columns', + 'slug' => 'columns', + ) + ); + self::$featured_term_id = $factory->term->create( + array( + 'taxonomy' => 'wporg-pattern-category', + 'name' => 'Featured', + 'slug' => 'featured', + ) + ); + } + + /** + * Clean up shared fixtures. + */ + public static function tear_down_after_class(): void { + wp_delete_post( self::$pattern_id, true ); + wp_delete_term( self::$selectable_term_id, 'wporg-pattern-category' ); + wp_delete_term( self::$retired_term_id, 'wporg-pattern-category' ); + wp_delete_term( self::$featured_term_id, 'wporg-pattern-category' ); + wp_delete_user( self::$moderator ); + wp_delete_user( self::$member ); + + parent::tear_down_after_class(); + } + + /** + * Detach any assigned terms and reset the current user between tests. + */ + public function tear_down(): void { + wp_delete_object_term_relationships( self::$pattern_id, 'wporg-pattern-category' ); + wp_set_current_user( 0 ); + + parent::tear_down(); + } + + /** + * Dispatch a pattern update setting the given categories. + * + * @param int[] $term_ids Category term IDs. + * @return \WP_REST_Response + */ + protected function set_categories( array $term_ids ): \WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/wp/v2/' . POST_TYPE . '/' . self::$pattern_id ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'pattern-categories' => $term_ids ) ) ); + + return rest_do_request( $request ); + } + + /** + * The category term IDs currently on the pattern. + * + * @return int[] + */ + protected function get_pattern_categories(): array { + $term_ids = wp_get_object_terms( self::$pattern_id, 'wporg-pattern-category', array( 'fields' => 'ids' ) ); + sort( $term_ids ); + + return $term_ids; + } + + /** + * A member can assign a selectable category. + * + * @covers \WordPressdotorg\Pattern_Directory\Pattern_Validation\validate_categories + */ + public function test_member_can_assign_selectable_category(): void { + wp_set_current_user( self::$member ); + + $response = $this->set_categories( array( self::$selectable_term_id ) ); + + $this->assertFalse( $response->is_error() ); + $this->assertSame( array( self::$selectable_term_id ), $this->get_pattern_categories() ); + } + + /** + * A member cannot add a retired category or "Featured". + * + * @covers \WordPressdotorg\Pattern_Directory\Pattern_Validation\validate_categories + */ + public function test_member_cannot_add_unselectable_category(): void { + wp_set_current_user( self::$member ); + + foreach ( array( self::$retired_term_id, self::$featured_term_id ) as $term_id ) { + $response = $this->set_categories( array( self::$selectable_term_id, $term_id ) ); + + $this->assertTrue( $response->is_error() ); + $this->assertSame( 'rest_pattern_invalid_category', $response->get_data()['code'] ); + $this->assertSame( array(), $this->get_pattern_categories() ); + } + } + + /** + * A member can re-send categories already on their pattern, even retired ones, and can remove them. + * + * @covers \WordPressdotorg\Pattern_Directory\Pattern_Validation\validate_categories + */ + public function test_member_keeps_existing_unselectable_categories(): void { + wp_set_object_terms( self::$pattern_id, array( self::$retired_term_id, self::$featured_term_id ), 'wporg-pattern-category' ); + wp_set_current_user( self::$member ); + + $expected = array( self::$selectable_term_id, self::$retired_term_id, self::$featured_term_id ); + sort( $expected ); + $response = $this->set_categories( $expected ); + + $this->assertFalse( $response->is_error() ); + $this->assertSame( $expected, $this->get_pattern_categories() ); + + $response = $this->set_categories( array( self::$selectable_term_id ) ); + + $this->assertFalse( $response->is_error() ); + $this->assertSame( array( self::$selectable_term_id ), $this->get_pattern_categories() ); + } + + /** + * A moderator can assign any category. + * + * @covers \WordPressdotorg\Pattern_Directory\Pattern_Validation\validate_categories + */ + public function test_moderator_can_assign_any_category(): void { + wp_set_current_user( self::$moderator ); + + $expected = array( self::$retired_term_id, self::$featured_term_id ); + sort( $expected ); + $response = $this->set_categories( $expected ); + + $this->assertFalse( $response->is_error() ); + $this->assertSame( $expected, $this->get_pattern_categories() ); + } +} diff --git a/public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-keyword-assignment-test.php b/public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-keyword-assignment-test.php index 2c6d0e13c..d031523f8 100644 --- a/public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-keyword-assignment-test.php +++ b/public_html/wp-content/plugins/pattern-directory/tests/phpunit/class-keyword-assignment-test.php @@ -84,6 +84,7 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void array( 'taxonomy' => 'wporg-pattern-category', 'name' => 'Headers', + 'slug' => 'header', ) ); }