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..0ae88c18d 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-creator/src/components/submission-modal/style.scss b/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/style.scss index 5f5d4ce27..8d947b389 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/style.scss +++ b/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/style.scss @@ -48,7 +48,7 @@ $modal-height: 375px; display: grid; grid-template-columns: 30% 70%; margin: 0 auto; - height: $modal-height; + min-height: $modal-height; color: #32373c; fieldset { @@ -101,13 +101,15 @@ h3.submission-modal__title-sidebar { } .submission-modal__checkbox-list { - display: grid; - grid-template-columns: 50% 50%; - grid-gap: $grid-unit-05; + // Use columns rather than a grid so the categories read alphabetically down each column. + columns: 2; + column-gap: $grid-unit-05; margin: 0; li { list-style: none; + margin-bottom: $grid-unit-05; + break-inside: avoid; } } diff --git a/public_html/wp-content/plugins/pattern-creator/src/hooks/test/use-pattern-categories.js b/public_html/wp-content/plugins/pattern-creator/src/hooks/test/use-pattern-categories.js new file mode 100644 index 000000000..6be9f0314 --- /dev/null +++ b/public_html/wp-content/plugins/pattern-creator/src/hooks/test/use-pattern-categories.js @@ -0,0 +1,32 @@ +/** + * Internal dependencies + */ +import { filterSelectableCategories } from '../use-pattern-categories'; + +describe( 'filterSelectableCategories', () => { + const terms = [ + { id: 47, name: 'About', slug: 'about' }, + { id: 3, name: 'Columns', slug: 'columns' }, + { id: 26, name: 'Featured', slug: 'featured' }, + { id: 38, name: 'Posts', slug: 'query' }, + { id: 6, name: 'Text', slug: 'text' }, + ]; + + it( 'keeps only terms with an allowed slug, in their original order', () => { + expect( filterSelectableCategories( terms, [ 'text', 'query', 'about' ] ) ).toEqual( [ + { id: 47, name: 'About', slug: 'about' }, + { id: 38, name: 'Posts', slug: 'query' }, + { id: 6, name: 'Text', slug: 'text' }, + ] ); + } ); + + it( 'ignores allowed slugs that have no matching term', () => { + expect( filterSelectableCategories( terms, [ 'video', 'about' ] ) ).toEqual( [ + { id: 47, name: 'About', slug: 'about' }, + ] ); + } ); + + it( 'returns nothing when no slugs are allowed', () => { + expect( filterSelectableCategories( terms, [] ) ).toEqual( [] ); + } ); +} ); diff --git a/public_html/wp-content/plugins/pattern-creator/src/hooks/use-pattern-categories.js b/public_html/wp-content/plugins/pattern-creator/src/hooks/use-pattern-categories.js new file mode 100644 index 000000000..2bd5d9341 --- /dev/null +++ b/public_html/wp-content/plugins/pattern-creator/src/hooks/use-pattern-categories.js @@ -0,0 +1,56 @@ +/* global wporgBlockPattern */ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { CATEGORY_SLUG } from '../store'; + +const QUERY = { + per_page: -1, + orderby: 'name', + order: 'asc', + _fields: 'id,name,parent,slug', + context: 'view', +}; + +const EMPTY_ARRAY = []; + +/** + * Filter a list of category terms down to the ones authors can select. + * + * @param {Object[]} terms Category terms. + * @param {string[]} allowedSlugs Slugs of the selectable categories. + * + * @return {Object[]} Selectable category terms. + */ +export function filterSelectableCategories( terms, allowedSlugs ) { + return terms.filter( ( { slug } ) => allowedSlugs.includes( slug ) ); +} + +/** + * A hook to get the pattern categories that authors can assign to a pattern. + * + * The list of selectable slugs comes from the server, so that every category picker shows the same options. + * + * @return {Object[]} Category terms, sorted by name. + */ +export default function usePatternCategories() { + const terms = useSelect( + ( select ) => select( coreStore ).getEntityRecords( 'taxonomy', CATEGORY_SLUG, QUERY ), + [] + ); + + return useMemo( + () => + terms + ? filterSelectableCategories( terms, wporgBlockPattern.categorySlugs || EMPTY_ARRAY ) + : EMPTY_ARRAY, + [ terms ] + ); +} diff --git a/public_html/wp-content/plugins/pattern-directory/includes/pattern-post-type.php b/public_html/wp-content/plugins/pattern-directory/includes/pattern-post-type.php index b67b3e938..bd309e1fe 100644 --- a/public_html/wp-content/plugins/pattern-directory/includes/pattern-post-type.php +++ b/public_html/wp-content/plugins/pattern-directory/includes/pattern-post-type.php @@ -38,6 +38,45 @@ remove_filter( 'the_content', 'do_shortcode', 11 ); add_filter( 'the_content', __NAMESPACE__ . '\do_shortcode_except_in_patterns', 11 ); +/** + * Get the slugs of the categories that authors can assign to a pattern. + * + * This list mirrors the pattern categories registered by WordPress core. Other terms in the taxonomy + * (for example, retired categories like "Columns", or "Featured") are kept on existing patterns, but + * are not offered in the pattern creator. + * + * @see https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/block-patterns.php + * + * @return string[] List of term slugs in the `wporg-pattern-category` taxonomy. + */ +function get_selectable_category_slugs() { + $slugs = array( + 'about', + 'audio', + 'banner', + 'call-to-action', + 'contact', + 'footer', + 'gallery', + 'header', + 'portfolio', + 'query', // "Posts". + 'services', + 'team', + 'testimonials', + 'text', + 'video', + 'wireframe', + ); + + /** + * Filters the category slugs that authors can assign to a pattern. + * + * @param string[] $slugs List of term slugs. + */ + return apply_filters( 'wporg_pattern_selectable_category_slugs', $slugs ); +} + /** * Registers post types and associated taxonomies, meta data, etc. */ 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', ) ); }