From 2555e94c4ad4c3bece433a0b3ceb5a474b8e92b7 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Thu, 14 Sep 2023 16:54:41 +1200 Subject: [PATCH 01/20] Add category selection option to create pattern modal --- lib/compat/wordpress-6.4/block-patterns.php | 27 ++-- package-lock.json | 2 + packages/patterns/package.json | 1 + .../src/components/category-selector.js | 127 ++++++++++++++++++ .../src/components/create-pattern-modal.js | 23 +++- packages/patterns/src/store/actions.js | 10 +- 6 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 packages/patterns/src/components/category-selector.js diff --git a/lib/compat/wordpress-6.4/block-patterns.php b/lib/compat/wordpress-6.4/block-patterns.php index aa4c5ef378bc7..a5234645ed9b8 100644 --- a/lib/compat/wordpress-6.4/block-patterns.php +++ b/lib/compat/wordpress-6.4/block-patterns.php @@ -15,21 +15,20 @@ * @return void */ function gutenberg_register_taxonomy_patterns() { - $args = array( - array( - 'public' => 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' => false, - '_builtin' => true, - 'show_in_nav_menus' => false, - 'show_in_rest' => true, + $args = array( + 'public' => true, + '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, ); register_taxonomy( 'wp_pattern_category', array( 'wp_block' ), $args ); } diff --git a/package-lock.json b/package-lock.json index 6786f1006a98b..124be7f72bd69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56023,6 +56023,7 @@ "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", "@wordpress/element": "file:../element", + "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", @@ -68574,6 +68575,7 @@ "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", "@wordpress/element": "file:../element", + "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", diff --git a/packages/patterns/package.json b/packages/patterns/package.json index 499eedb8db0a7..e9dda03569353 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -39,6 +39,7 @@ "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", "@wordpress/element": "file:../element", + "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", diff --git a/packages/patterns/src/components/category-selector.js b/packages/patterns/src/components/category-selector.js new file mode 100644 index 0000000000000..55ef0fc5d072d --- /dev/null +++ b/packages/patterns/src/components/category-selector.js @@ -0,0 +1,127 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useMemo, useState } from '@wordpress/element'; +import { FormTokenField } from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useDebounce } from '@wordpress/compose'; +import { decodeEntities } from '@wordpress/html-entities'; + +export const unescapeString = ( arg ) => { + return decodeEntities( arg ); +}; +/** + * Returns a term object with name unescaped. + * + * @param {Object} term The term object to unescape. + * + * @return {Object} Term object with name property unescaped. + */ +export const unescapeTerm = ( term ) => { + return { + ...term, + name: unescapeString( term.name ), + }; +}; +/** + * Shared reference to an empty array for cases where it is important to avoid + * returning a new array reference on every invocation. + * + * @type {Array} + */ +const EMPTY_ARRAY = []; + +/** + * Module constants + */ +const MAX_TERMS_SUGGESTIONS = 20; +const DEFAULT_QUERY = { + per_page: MAX_TERMS_SUGGESTIONS, + _fields: 'id,name', + context: 'view', +}; +const slug = 'wp_pattern_category'; + +export default function CategorySelector( { onCategorySelection } ) { + const [ values, setValues ] = useState( [] ); + const [ search, setSearch ] = useState( '' ); + const debouncedSearch = useDebounce( setSearch, 500 ); + + const { searchResults } = useSelect( + ( select ) => { + const { getEntityRecords } = select( coreStore ); + + return { + searchResults: !! search + ? getEntityRecords( 'taxonomy', slug, { + ...DEFAULT_QUERY, + search, + } ) + : EMPTY_ARRAY, + }; + }, + [ search ] + ); + + const suggestions = useMemo( () => { + return ( searchResults ?? [] ).map( ( term ) => + unescapeString( term.name ) + ); + }, [ searchResults ] ); + + const { saveEntityRecord } = useDispatch( coreStore ); + + async function findOrCreateTerm( term ) { + try { + const newTerm = await saveEntityRecord( 'taxonomy', slug, term, { + throwOnError: true, + } ); + return unescapeTerm( newTerm ); + } catch ( error ) { + if ( error.code !== 'term_exists' ) { + throw error; + } + + return { + id: error.data.term_id, + name: term.name, + }; + } + } + + function onChange( termNames ) { + const uniqueTerms = termNames.reduce( ( acc, name ) => { + if ( + ! acc.some( ( n ) => n.toLowerCase() === name.toLowerCase() ) + ) { + acc.push( name ); + } + return acc; + }, [] ); + + setValues( uniqueTerms ); + + Promise.all( + uniqueTerms.map( ( termName ) => + findOrCreateTerm( { name: termName } ) + ) + ).then( ( newTerms ) => { + onCategorySelection( newTerms ); + } ); + } + + return ( + <> + + + ); +} diff --git a/packages/patterns/src/components/create-pattern-modal.js b/packages/patterns/src/components/create-pattern-modal.js index 5623a04fcba61..5f079c1ed7b06 100644 --- a/packages/patterns/src/components/create-pattern-modal.js +++ b/packages/patterns/src/components/create-pattern-modal.js @@ -25,6 +25,7 @@ export const SYNC_TYPES = { * Internal dependencies */ import { store } from '../store'; +import CategorySelector from './category-selector'; export default function CreatePatternModal( { onSuccess, @@ -34,6 +35,7 @@ export default function CreatePatternModal( { className = 'patterns-menu-items__convert-modal', } ) { const [ syncType, setSyncType ] = useState( SYNC_TYPES.full ); + const [ categories, setCategories ] = useState( [] ); const [ title, setTitle ] = useState( '' ); const { createPattern } = useDispatch( store ); @@ -44,7 +46,8 @@ export default function CreatePatternModal( { const newPattern = await createPattern( patternTitle, sync, - clientIds + clientIds, + categories ); onSuccess( { pattern: newPattern, @@ -58,8 +61,20 @@ export default function CreatePatternModal( { onError(); } }, - [ createPattern, clientIds, onSuccess, createErrorNotice, onError ] + [ + createPattern, + clientIds, + onSuccess, + createErrorNotice, + onError, + categories, + ] ); + + const handleCategorySelection = ( selectedCategories ) => { + setCategories( selectedCategories.map( ( cat ) => cat.id ) ); + }; + return ( - + + ( title, syncType, clientIds, categories ) => async ( { registry, dispatch } ) => { const meta = syncType === 'unsynced' @@ -34,6 +35,7 @@ export const createPattern = : undefined, status: 'publish', meta, + wp_pattern_category: categories, }; const updatedRecord = await registry From 35b255cef5bd1cd1c5531e8bf52491b114eeb8b3 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Mon, 21 Aug 2023 13:26:45 +1200 Subject: [PATCH 02/20] Fix linting error --- lib/compat/wordpress-6.4/block-patterns.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/compat/wordpress-6.4/block-patterns.php b/lib/compat/wordpress-6.4/block-patterns.php index a5234645ed9b8..3a0a9ed1a9e52 100644 --- a/lib/compat/wordpress-6.4/block-patterns.php +++ b/lib/compat/wordpress-6.4/block-patterns.php @@ -15,7 +15,7 @@ * @return void */ function gutenberg_register_taxonomy_patterns() { - $args = array( + $args = array( 'public' => true, 'hierarchical' => false, 'labels' => array( From 25df1b38a2fe02fade150519c45280aff87e3b87 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Mon, 21 Aug 2023 13:39:02 +1200 Subject: [PATCH 03/20] Limit width of categories field --- packages/patterns/src/components/category-selector.js | 1 + packages/patterns/src/components/style.scss | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/patterns/src/components/category-selector.js b/packages/patterns/src/components/category-selector.js index 55ef0fc5d072d..e83d928aa4ac1 100644 --- a/packages/patterns/src/components/category-selector.js +++ b/packages/patterns/src/components/category-selector.js @@ -115,6 +115,7 @@ export default function CategorySelector( { onCategorySelection } ) { return ( <> Date: Mon, 21 Aug 2023 13:49:26 +1200 Subject: [PATCH 04/20] Tidy up category selector file --- .../src/components/category-selector.js | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/packages/patterns/src/components/category-selector.js b/packages/patterns/src/components/category-selector.js index e83d928aa4ac1..368cbb238ddc6 100644 --- a/packages/patterns/src/components/category-selector.js +++ b/packages/patterns/src/components/category-selector.js @@ -9,33 +9,18 @@ import { store as coreStore } from '@wordpress/core-data'; import { useDebounce } from '@wordpress/compose'; import { decodeEntities } from '@wordpress/html-entities'; -export const unescapeString = ( arg ) => { +const unescapeString = ( arg ) => { return decodeEntities( arg ); }; -/** - * Returns a term object with name unescaped. - * - * @param {Object} term The term object to unescape. - * - * @return {Object} Term object with name property unescaped. - */ -export const unescapeTerm = ( term ) => { + +const unescapeTerm = ( term ) => { return { ...term, name: unescapeString( term.name ), }; }; -/** - * Shared reference to an empty array for cases where it is important to avoid - * returning a new array reference on every invocation. - * - * @type {Array} - */ -const EMPTY_ARRAY = []; -/** - * Module constants - */ +const EMPTY_ARRAY = []; const MAX_TERMS_SUGGESTIONS = 20; const DEFAULT_QUERY = { per_page: MAX_TERMS_SUGGESTIONS, From 61d04ea39d40950dc4f02e9eadd3d852b9acc7e2 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Thu, 24 Aug 2023 16:01:13 +1200 Subject: [PATCH 05/20] Add user pattern categories to the core data store --- docs/reference-guides/data/data-core.md | 12 ++++++ packages/core-data/README.md | 12 ++++++ packages/core-data/src/reducer.js | 9 ++++ packages/core-data/src/resolvers.js | 17 ++++++++ packages/core-data/src/selectors.ts | 42 +++++++++++++++++++ .../provider/use-block-editor-settings.js | 8 +++- 6 files changed, 99 insertions(+), 1 deletion(-) diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index 95401834ad439..a3d6bd5b6f392 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -403,6 +403,18 @@ _Returns_ - `Optional< any >`: The edit. +### getUserPatternCategories + +Retrieve the registered user pattern categories. + +_Parameters_ + +- _state_ `State`: Data state. + +_Returns_ + +- `UserPatternCategories`: User patterns category array and map keyed by id. + ### getUserQueryResults Returns all the users returned by a query ID. diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 18e131cd7ab6f..43ce17f98f8e4 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -580,6 +580,18 @@ _Returns_ - `Optional< any >`: The edit. +### getUserPatternCategories + +Retrieve the registered user pattern categories. + +_Parameters_ + +- _state_ `State`: Data state. + +_Returns_ + +- `UserPatternCategories`: User patterns category array and map keyed by id. + ### getUserQueryResults Returns all the users returned by a query ID. diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index f097d07d04774..68c0cc233d7b6 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -535,6 +535,14 @@ export function blockPatternCategories( state = [], action ) { return state; } +export function userPatternCategories( state = [], action ) { + switch ( action.type ) { + case 'RECEIVE_USER_PATTERN_CATEGORIES': + return action.patternCategories; + } + return state; +} + export function navigationFallbackId( state = null, action ) { switch ( action.type ) { case 'RECEIVE_NAVIGATION_FALLBACK_ID': @@ -582,5 +590,6 @@ export default combineReducers( { autosaves, blockPatterns, blockPatternCategories, + userPatternCategories, navigationFallbackId, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index a9bd6adfcdbff..9891223f5f551 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -619,6 +619,23 @@ export const getBlockPatternCategories = dispatch( { type: 'RECEIVE_BLOCK_PATTERN_CATEGORIES', categories } ); }; +export const getUserPatternCategories = + () => + async ( { dispatch, resolveSelect } ) => { + const patternCategories = await resolveSelect.getEntityRecords( + 'taxonomy', + 'wp_pattern_category', + { + per_page: -1, + _fields: 'id,name,description,slug', + } + ); + dispatch( { + type: 'RECEIVE_USER_PATTERN_CATEGORIES', + patternCategories, + } ); + }; + export const getNavigationFallbackId = () => async ( { dispatch, select } ) => { diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 8a5268773483d..2384fbfe76406 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -44,6 +44,7 @@ export interface State { userPermissions: Record< string, boolean >; users: UserState; navigationFallbackId: EntityRecordKey; + userPatternCategories: Array< UserPatternCategory >; } type EntityRecordKey = string | number; @@ -79,6 +80,19 @@ interface UserState { byId: Record< EntityRecordKey, ET.User< 'edit' > >; } +interface UserPatternCategory { + id: number; + name: string; + label: string; + slug: string; + description: string; +} + +export interface UserPatternCategories { + patternCategories: Array< UserPatternCategory >; + patternCatogoriesMap: Map< number, UserPatternCategory >; +} + type Optional< T > = T | undefined; /** @@ -1222,6 +1236,34 @@ export function getBlockPatternCategories( state: State ): Array< any > { return state.blockPatternCategories; } +/** + * Retrieve the registered user pattern categories. + * + * @param state Data state. + * + * @return User patterns category array and map keyed by id. + */ + +export function getUserPatternCategories( + state: State +): UserPatternCategories { + const patternCatogoriesMap = new Map< number, UserPatternCategory >(); + state.userPatternCategories?.forEach( + ( userCategory: UserPatternCategory ) => + patternCatogoriesMap.set( userCategory.id, userCategory ) + ); + return { + patternCategories: + state.userPatternCategories?.map( + ( userCategory: UserPatternCategory ) => ( { + ...userCategory, + label: userCategory.name, + } ) + ) || [], + patternCatogoriesMap, + }; +} + /** * Returns the revisions of the current global styles theme. * diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 400e6799996cc..d20a00c279bcb 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -95,11 +95,13 @@ function useBlockEditorSettings( settings, hasTemplate ) { userCanCreatePages, pageOnFront, postType, + userPatternCategories, } = useSelect( ( select ) => { const { canUserUseUnfilteredHTML, getCurrentPostType } = select( editorStore ); const isWeb = Platform.OS === 'web'; - const { canUser, getEntityRecord } = select( coreStore ); + const { canUser, getEntityRecord, getUserPatternCategories } = + select( coreStore ); const siteSettings = canUser( 'read', 'settings' ) ? getEntityRecord( 'root', 'site' ) @@ -118,6 +120,7 @@ function useBlockEditorSettings( settings, hasTemplate ) { userCanCreatePages: canUser( 'create', 'pages' ), pageOnFront: siteSettings?.page_on_front, postType: getCurrentPostType(), + userPatternCategories: getUserPatternCategories(), }; }, [] ); @@ -200,6 +203,7 @@ function useBlockEditorSettings( settings, hasTemplate ) { __experimentalReusableBlocks: reusableBlocks, __experimentalBlockPatterns: blockPatterns, __experimentalBlockPatternCategories: blockPatternCategories, + __experimentalUserPatternCategories: userPatternCategories, __experimentalFetchLinkSuggestions: ( search, searchOptions ) => fetchLinkSuggestions( search, searchOptions, settings ), inserterMediaCategories, @@ -218,9 +222,11 @@ function useBlockEditorSettings( settings, hasTemplate ) { reusableBlocks, blockPatterns, blockPatternCategories, + userPatternCategories, canUseUnfilteredHTML, undo, hasTemplate, + createPageEntity, userCanCreatePages, pageOnFront, ] From 53a756ff18f265bd2cc47f4f9f2d45123b4ee1fe Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Thu, 24 Aug 2023 16:46:51 +1200 Subject: [PATCH 06/20] Make accumulator terms a bit more explicit --- packages/patterns/src/components/category-selector.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/patterns/src/components/category-selector.js b/packages/patterns/src/components/category-selector.js index 368cbb238ddc6..970ef1c3974a6 100644 --- a/packages/patterns/src/components/category-selector.js +++ b/packages/patterns/src/components/category-selector.js @@ -77,13 +77,15 @@ export default function CategorySelector( { onCategorySelection } ) { } function onChange( termNames ) { - const uniqueTerms = termNames.reduce( ( acc, name ) => { + const uniqueTerms = termNames.reduce( ( terms, newTerm ) => { if ( - ! acc.some( ( n ) => n.toLowerCase() === name.toLowerCase() ) + ! terms.some( + ( term ) => term.toLowerCase() === newTerm.toLowerCase() + ) ) { - acc.push( name ); + terms.push( newTerm ); } - return acc; + return terms; }, [] ); setValues( uniqueTerms ); From 869d4164dcc1c72ba2b05e3f35e93a0fee5253e6 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Fri, 25 Aug 2023 10:23:33 +1200 Subject: [PATCH 07/20] Fix category name mapping --- packages/core-data/src/selectors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 2384fbfe76406..418e60886f402 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -1258,6 +1258,7 @@ export function getUserPatternCategories( ( userCategory: UserPatternCategory ) => ( { ...userCategory, label: userCategory.name, + name: userCategory.slug, } ) ) || [], patternCatogoriesMap, From cec04145cc64fabaaad6391f00964b99c200bc7b Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Fri, 25 Aug 2023 14:37:48 +1200 Subject: [PATCH 08/20] Invalidate category resolver when new category added --- packages/patterns/src/components/category-selector.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/patterns/src/components/category-selector.js b/packages/patterns/src/components/category-selector.js index 970ef1c3974a6..f8295af2a79dc 100644 --- a/packages/patterns/src/components/category-selector.js +++ b/packages/patterns/src/components/category-selector.js @@ -33,6 +33,7 @@ export default function CategorySelector( { onCategorySelection } ) { const [ values, setValues ] = useState( [] ); const [ search, setSearch ] = useState( '' ); const debouncedSearch = useDebounce( setSearch, 500 ); + const { invalidateResolution } = useDispatch( coreStore ); const { searchResults } = useSelect( ( select ) => { @@ -63,6 +64,7 @@ export default function CategorySelector( { onCategorySelection } ) { const newTerm = await saveEntityRecord( 'taxonomy', slug, term, { throwOnError: true, } ); + invalidateResolution( 'getUserPatternCategories' ); return unescapeTerm( newTerm ); } catch ( error ) { if ( error.code !== 'term_exists' ) { From 8a08e2b0cbab96d8b4fb40dfca5899c816a2e606 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Tue, 29 Aug 2023 16:39:18 +1200 Subject: [PATCH 09/20] Fix typos --- packages/core-data/src/selectors.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 418e60886f402..5a701a27161c5 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -90,7 +90,7 @@ interface UserPatternCategory { export interface UserPatternCategories { patternCategories: Array< UserPatternCategory >; - patternCatogoriesMap: Map< number, UserPatternCategory >; + patternCategoriesMap: Map< number, UserPatternCategory >; } type Optional< T > = T | undefined; @@ -1247,10 +1247,10 @@ export function getBlockPatternCategories( state: State ): Array< any > { export function getUserPatternCategories( state: State ): UserPatternCategories { - const patternCatogoriesMap = new Map< number, UserPatternCategory >(); + const patternCategoriesMap = new Map< number, UserPatternCategory >(); state.userPatternCategories?.forEach( ( userCategory: UserPatternCategory ) => - patternCatogoriesMap.set( userCategory.id, userCategory ) + patternCategoriesMap.set( userCategory.id, userCategory ) ); return { patternCategories: @@ -1261,7 +1261,7 @@ export function getUserPatternCategories( name: userCategory.slug, } ) ) || [], - patternCatogoriesMap, + patternCategoriesMap, }; } From 5109013310231d7e253e6331a13a06929754e8e2 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Thu, 14 Sep 2023 16:56:02 +1200 Subject: [PATCH 10/20] Components: FormTokenField - add prop to allow saving of tokens onBlur --- .../components/src/form-token-field/README.md | 1 + .../components/src/form-token-field/index.tsx | 6 ++- .../src/form-token-field/test/index.tsx | 37 ++++++++++++++++++- .../components/src/form-token-field/types.ts | 6 +++ .../src/components/category-selector.js | 1 + 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/components/src/form-token-field/README.md b/packages/components/src/form-token-field/README.md index 90fe84e56f2ee..3a4ce2a143624 100644 --- a/packages/components/src/form-token-field/README.md +++ b/packages/components/src/form-token-field/README.md @@ -62,6 +62,7 @@ The `value` property is handled in a manner similar to controlled form component - `__experimentalValidateInput` - If passed, all introduced values will be validated before being added as tokens. - `__experimentalAutoSelectFirstMatch` - If true, the select the first matching suggestion when the user presses the Enter key (or space when tokenizeOnSpace is true). - `__nextHasNoMarginBottom` - Start opting into the new margin-free styles that will become the default in a future version, currently scheduled to be WordPress 6.5. (The prop can be safely removed once this happens.) +- `tokenizeOnBlur` - If true, add any incompleteTokenValue as a new token when the field loses focus. ## Usage diff --git a/packages/components/src/form-token-field/index.tsx b/packages/components/src/form-token-field/index.tsx index e378188037062..b3a5c5a53d4c0 100644 --- a/packages/components/src/form-token-field/index.tsx +++ b/packages/components/src/form-token-field/index.tsx @@ -73,6 +73,7 @@ export function FormTokenField( props: FormTokenFieldProps ) { __next40pxDefaultSize = false, __experimentalAutoSelectFirstMatch = false, __nextHasNoMarginBottom = false, + tokenizeOnBlur = false, } = useDeprecated36pxDefaultSizeProp< FormTokenFieldProps >( props, 'wp.components.FormTokenField' @@ -167,6 +168,9 @@ export function FormTokenField( props: FormTokenFieldProps ) { __experimentalValidateInput( incompleteTokenValue ) ) { setIsActive( false ); + if ( tokenizeOnBlur && inputHasValidValue() ) { + addNewToken( incompleteTokenValue ); + } } else { // Reset to initial state setIncompleteTokenValue( '' ); @@ -451,7 +455,7 @@ export function FormTokenField( props: FormTokenFieldProps ) { setSelectedSuggestionScroll( false ); setIsExpanded( ! __experimentalExpandOnFocus ); - if ( isActive ) { + if ( isActive && ! tokenizeOnBlur ) { focus(); } } diff --git a/packages/components/src/form-token-field/test/index.tsx b/packages/components/src/form-token-field/test/index.tsx index e62e851f1cf22..76e308d5993be 100644 --- a/packages/components/src/form-token-field/test/index.tsx +++ b/packages/components/src/form-token-field/test/index.tsx @@ -205,7 +205,42 @@ describe( 'FormTokenField', () => { ] ); } ); - it( "should not add a token with the input's value when pressing the tab key", async () => { + it( 'should add a token with the input value with onBlur when `tokenizeOnBlur` prop is `true`', async () => { + const user = userEvent.setup(); + + const onChangeSpy = jest.fn(); + + const { rerender } = render( + + ); + + const input = screen.getByRole( 'combobox' ); + + // Add 'grapefruit' token by typing it and check blur of field does not tokenize it. + await user.type( input, 'grapefruit' ); + await user.click( document.body ); + expect( onChangeSpy ).toHaveBeenCalledTimes( 0 ); + expectTokensNotToBeInTheDocument( [ 'grapefruit' ] ); + + rerender( + + ); + await user.clear( input ); + + // Add 'grapefruit' token by typing it and check blur of field tokenizes it. + await user.type( input, 'grapefruit' ); + + await user.click( document.body ); + expect( onChangeSpy ).toHaveBeenNthCalledWith( 1, [ + 'grapefruit', + ] ); + expectTokensToBeInTheDocument( [ 'grapefruit' ] ); + } ); + + it( "should not add a token with the input's value when tokenizeOnBlur is not set and pressing the tab key", async () => { const user = userEvent.setup(); const onChangeSpy = jest.fn(); diff --git a/packages/components/src/form-token-field/types.ts b/packages/components/src/form-token-field/types.ts index fe466ffbe59f8..e343601106f41 100644 --- a/packages/components/src/form-token-field/types.ts +++ b/packages/components/src/form-token-field/types.ts @@ -182,6 +182,12 @@ export interface FormTokenFieldProps * @default false */ __nextHasNoMarginBottom?: boolean; + /** + * If true, add any incompleteTokenValue as a new token when the field loses focus. + * + * @default false + */ + tokenizeOnBlur?: boolean; } /** diff --git a/packages/patterns/src/components/category-selector.js b/packages/patterns/src/components/category-selector.js index f8295af2a79dc..c9305806c7e13 100644 --- a/packages/patterns/src/components/category-selector.js +++ b/packages/patterns/src/components/category-selector.js @@ -111,6 +111,7 @@ export default function CategorySelector( { onCategorySelection } ) { onInputChange={ debouncedSearch } maxSuggestions={ MAX_TERMS_SUGGESTIONS } label={ __( 'Categories' ) } + tokenizeOnBlur={ true } /> ); From 6fd3ceec7fbf0214bcd2cca3255c80321fc8b634 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Thu, 14 Sep 2023 16:56:51 +1200 Subject: [PATCH 11/20] Revert "Components: FormTokenField - add prop to allow saving of tokens onBlur --- .../components/src/form-token-field/README.md | 1 - .../components/src/form-token-field/index.tsx | 6 +-- .../src/form-token-field/test/index.tsx | 37 +------------------ .../components/src/form-token-field/types.ts | 6 --- .../src/components/category-selector.js | 1 - 5 files changed, 2 insertions(+), 49 deletions(-) diff --git a/packages/components/src/form-token-field/README.md b/packages/components/src/form-token-field/README.md index 3a4ce2a143624..90fe84e56f2ee 100644 --- a/packages/components/src/form-token-field/README.md +++ b/packages/components/src/form-token-field/README.md @@ -62,7 +62,6 @@ The `value` property is handled in a manner similar to controlled form component - `__experimentalValidateInput` - If passed, all introduced values will be validated before being added as tokens. - `__experimentalAutoSelectFirstMatch` - If true, the select the first matching suggestion when the user presses the Enter key (or space when tokenizeOnSpace is true). - `__nextHasNoMarginBottom` - Start opting into the new margin-free styles that will become the default in a future version, currently scheduled to be WordPress 6.5. (The prop can be safely removed once this happens.) -- `tokenizeOnBlur` - If true, add any incompleteTokenValue as a new token when the field loses focus. ## Usage diff --git a/packages/components/src/form-token-field/index.tsx b/packages/components/src/form-token-field/index.tsx index b3a5c5a53d4c0..e378188037062 100644 --- a/packages/components/src/form-token-field/index.tsx +++ b/packages/components/src/form-token-field/index.tsx @@ -73,7 +73,6 @@ export function FormTokenField( props: FormTokenFieldProps ) { __next40pxDefaultSize = false, __experimentalAutoSelectFirstMatch = false, __nextHasNoMarginBottom = false, - tokenizeOnBlur = false, } = useDeprecated36pxDefaultSizeProp< FormTokenFieldProps >( props, 'wp.components.FormTokenField' @@ -168,9 +167,6 @@ export function FormTokenField( props: FormTokenFieldProps ) { __experimentalValidateInput( incompleteTokenValue ) ) { setIsActive( false ); - if ( tokenizeOnBlur && inputHasValidValue() ) { - addNewToken( incompleteTokenValue ); - } } else { // Reset to initial state setIncompleteTokenValue( '' ); @@ -455,7 +451,7 @@ export function FormTokenField( props: FormTokenFieldProps ) { setSelectedSuggestionScroll( false ); setIsExpanded( ! __experimentalExpandOnFocus ); - if ( isActive && ! tokenizeOnBlur ) { + if ( isActive ) { focus(); } } diff --git a/packages/components/src/form-token-field/test/index.tsx b/packages/components/src/form-token-field/test/index.tsx index 76e308d5993be..e62e851f1cf22 100644 --- a/packages/components/src/form-token-field/test/index.tsx +++ b/packages/components/src/form-token-field/test/index.tsx @@ -205,42 +205,7 @@ describe( 'FormTokenField', () => { ] ); } ); - it( 'should add a token with the input value with onBlur when `tokenizeOnBlur` prop is `true`', async () => { - const user = userEvent.setup(); - - const onChangeSpy = jest.fn(); - - const { rerender } = render( - - ); - - const input = screen.getByRole( 'combobox' ); - - // Add 'grapefruit' token by typing it and check blur of field does not tokenize it. - await user.type( input, 'grapefruit' ); - await user.click( document.body ); - expect( onChangeSpy ).toHaveBeenCalledTimes( 0 ); - expectTokensNotToBeInTheDocument( [ 'grapefruit' ] ); - - rerender( - - ); - await user.clear( input ); - - // Add 'grapefruit' token by typing it and check blur of field tokenizes it. - await user.type( input, 'grapefruit' ); - - await user.click( document.body ); - expect( onChangeSpy ).toHaveBeenNthCalledWith( 1, [ - 'grapefruit', - ] ); - expectTokensToBeInTheDocument( [ 'grapefruit' ] ); - } ); - - it( "should not add a token with the input's value when tokenizeOnBlur is not set and pressing the tab key", async () => { + it( "should not add a token with the input's value when pressing the tab key", async () => { const user = userEvent.setup(); const onChangeSpy = jest.fn(); diff --git a/packages/components/src/form-token-field/types.ts b/packages/components/src/form-token-field/types.ts index e343601106f41..fe466ffbe59f8 100644 --- a/packages/components/src/form-token-field/types.ts +++ b/packages/components/src/form-token-field/types.ts @@ -182,12 +182,6 @@ export interface FormTokenFieldProps * @default false */ __nextHasNoMarginBottom?: boolean; - /** - * If true, add any incompleteTokenValue as a new token when the field loses focus. - * - * @default false - */ - tokenizeOnBlur?: boolean; } /** diff --git a/packages/patterns/src/components/category-selector.js b/packages/patterns/src/components/category-selector.js index c9305806c7e13..f8295af2a79dc 100644 --- a/packages/patterns/src/components/category-selector.js +++ b/packages/patterns/src/components/category-selector.js @@ -111,7 +111,6 @@ export default function CategorySelector( { onCategorySelection } ) { onInputChange={ debouncedSearch } maxSuggestions={ MAX_TERMS_SUGGESTIONS } label={ __( 'Categories' ) } - tokenizeOnBlur={ true } /> ); From 982c246a2f36ada4c707baeb6e6ff7fd013f1697 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Fri, 15 Sep 2023 16:52:14 +1200 Subject: [PATCH 12/20] Patterns: Add user categories to site editor sidebar navigation screen --- .../page-patterns/duplicate-menu-item.js | 63 ++++++++++++++---- .../src/components/page-patterns/grid-item.js | 15 ++--- .../src/components/page-patterns/header.js | 13 +--- .../components/page-patterns/patterns-list.js | 4 +- .../components/page-patterns/search-items.js | 14 +++- .../components/page-patterns/use-patterns.js | 66 ++++++++++++++----- .../src/components/page-patterns/utils.js | 5 +- .../index.js | 52 +++------------ .../use-my-patterns.js | 24 ------- .../use-pattern-categories.js | 54 +++++++++++++-- 10 files changed, 182 insertions(+), 128 deletions(-) delete mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-my-patterns.js diff --git a/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js b/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js index 994ed168cd186..324b22e460447 100644 --- a/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js +++ b/packages/edit-site/src/components/page-patterns/duplicate-menu-item.js @@ -11,19 +11,14 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; /** * Internal dependencies */ -import { - TEMPLATE_PARTS, - PATTERNS, - SYNC_TYPES, - USER_PATTERNS, - USER_PATTERN_CATEGORY, -} from './utils'; +import { TEMPLATE_PARTS, PATTERNS, SYNC_TYPES, USER_PATTERNS } from './utils'; import { useExistingTemplateParts, getUniqueTemplatePartTitle, getCleanTemplatePartSlug, } from '../../utils/template-part-create'; import { unlock } from '../../lock-unlock'; +import usePatternCategories from '../sidebar-navigation-screen-patterns/use-pattern-categories'; const { useHistory } = unlock( routerPrivateApis ); @@ -32,11 +27,11 @@ function getPatternMeta( item ) { return { wp_pattern_sync_status: SYNC_TYPES.unsynced }; } - const syncStatus = item.reusableBlock.wp_pattern_sync_status; + const syncStatus = item.patternBlock.wp_pattern_sync_status; const isUnsynced = syncStatus === SYNC_TYPES.unsynced; return { - ...item.reusableBlock.meta, + ...item.patternBlock.meta, wp_pattern_sync_status: isUnsynced ? syncStatus : undefined, }; } @@ -47,12 +42,13 @@ export default function DuplicateMenuItem( { label = __( 'Duplicate' ), onClose, } ) { - const { saveEntityRecord } = useDispatch( coreStore ); + const { saveEntityRecord, invalidateResolution } = useDispatch( coreStore ); const { createErrorNotice, createSuccessNotice } = useDispatch( noticesStore ); const history = useHistory(); const existingTemplateParts = useExistingTemplateParts(); + const { patternCategories } = usePatternCategories(); async function createTemplatePart() { try { @@ -111,6 +107,45 @@ export default function DuplicateMenuItem( { } } + async function findOrCreateTerm( term ) { + try { + const newTerm = await saveEntityRecord( + 'taxonomy', + 'wp_pattern_category', + { + name: term.label, + slug: term.name, + description: term.description, + }, + { + throwOnError: true, + } + ); + invalidateResolution( 'getUserPatternCategories' ); + return newTerm.id; + } catch ( error ) { + if ( error.code !== 'term_exists' ) { + throw error; + } + + return error.data.term_id; + } + } + + async function getCategories( categories ) { + const terms = categories.map( ( category ) => { + const fullCategory = patternCategories.find( + ( cat ) => cat.name === category + ); + if ( fullCategory.id ) { + return fullCategory.id; + } + return findOrCreateTerm( fullCategory ); + } ); + + return Promise.all( terms ); + } + async function createPattern() { try { const isThemePattern = item.type === PATTERNS; @@ -119,6 +154,7 @@ export default function DuplicateMenuItem( { __( '%s (Copy)' ), item.title ); + const categories = await getCategories( item.categories ); const result = await saveEntityRecord( 'postType', @@ -126,10 +162,11 @@ export default function DuplicateMenuItem( { { content: isThemePattern ? item.content - : item.reusableBlock.content, + : item.patternBlock.content, meta: getPatternMeta( item ), status: 'publish', title, + wp_pattern_category: categories, }, { throwOnError: true } ); @@ -147,8 +184,8 @@ export default function DuplicateMenuItem( { ); history.push( { - categoryType: USER_PATTERNS, - categoryId: USER_PATTERN_CATEGORY, + categoryType: PATTERNS, + categoryId, postType: USER_PATTERNS, postId: result?.id, } ); diff --git a/packages/edit-site/src/components/page-patterns/grid-item.js b/packages/edit-site/src/components/page-patterns/grid-item.js index 98ddc54f1a0b1..25aab2646cdfd 100644 --- a/packages/edit-site/src/components/page-patterns/grid-item.js +++ b/packages/edit-site/src/components/page-patterns/grid-item.js @@ -119,9 +119,12 @@ function GridItem( { categoryId, item, ...props } ) { ); } - const itemIcon = - templatePartIcons[ categoryId ] || - ( item.syncStatus === SYNC_TYPES.full ? symbol : undefined ); + let itemIcon; + if ( ! isUserPattern && templatePartIcons[ categoryId ] ) { + itemIcon = templatePartIcons[ categoryId ]; + } else { + itemIcon = item.syncStatus === SYNC_TYPES.full ? symbol : undefined; + } const confirmButtonText = hasThemeFile ? __( 'Clear' ) : __( 'Delete' ); const confirmPrompt = hasThemeFile @@ -246,11 +249,7 @@ function GridItem( { categoryId, item, ...props } ) { categoryId={ categoryId } item={ item } onClose={ onClose } - label={ - isNonUserPattern - ? __( 'Copy to My patterns' ) - : __( 'Duplicate' ) - } + label={ __( 'Duplicate' ) } /> { isCustomPattern && ( area.area === categoryId ); diff --git a/packages/edit-site/src/components/page-patterns/patterns-list.js b/packages/edit-site/src/components/page-patterns/patterns-list.js index 01525fc5dccab..57c342ca3caa9 100644 --- a/packages/edit-site/src/components/page-patterns/patterns-list.js +++ b/packages/edit-site/src/components/page-patterns/patterns-list.js @@ -27,7 +27,7 @@ import usePatterns from './use-patterns'; import SidebarButton from '../sidebar-button'; import useDebouncedInput from '../../utils/use-debounced-input'; import { unlock } from '../../lock-unlock'; -import { SYNC_TYPES, USER_PATTERN_CATEGORY, PATTERNS } from './utils'; +import { SYNC_TYPES, PATTERNS } from './utils'; import Pagination from './pagination'; const { useLocation, useHistory } = unlock( routerPrivateApis ); @@ -155,7 +155,7 @@ export default function PatternsList( { categoryId, type } ) { __nextHasNoMarginBottom /> - { categoryId === USER_PATTERN_CATEGORY && ( + { type === PATTERNS && ( item.name || ''; const defaultGetTitle = ( item ) => item.title; @@ -84,7 +89,9 @@ const removeMatchingTerms = ( unmatchedTerms, unprocessedTerms ) => { */ export const searchItems = ( items = [], searchInput = '', config = {} ) => { const normalizedSearchTerms = getNormalizedSearchTerms( searchInput ); - const onlyFilterByCategory = ! normalizedSearchTerms.length; + const onlyFilterByCategory = + config.categoryId !== ALL_PATTERNS_CATEGORY && + ! normalizedSearchTerms.length; const searchRankConfig = { ...config, onlyFilterByCategory }; // If we aren't filtering on search terms, matching on category is satisfactory. @@ -131,7 +138,10 @@ function getItemSearchRank( item, searchTerm, config ) { onlyFilterByCategory, } = config; - let rank = hasCategory( item, categoryId ) ? 1 : 0; + let rank = + categoryId === ALL_PATTERNS_CATEGORY || hasCategory( item, categoryId ) + ? 1 + : 0; // If an item doesn't belong to the current category or we don't have // search terms to filter by, return the initial rank value. diff --git a/packages/edit-site/src/components/page-patterns/use-patterns.js b/packages/edit-site/src/components/page-patterns/use-patterns.js index 4aeb6527bca26..25919d5556710 100644 --- a/packages/edit-site/src/components/page-patterns/use-patterns.js +++ b/packages/edit-site/src/components/page-patterns/use-patterns.js @@ -87,7 +87,7 @@ const selectTemplatePartsAsPatterns = ( return { patterns, isResolving }; }; -const selectThemePatterns = ( select, { categoryId, search = '' } = {} ) => { +const selectThemePatterns = ( select ) => { const { getSettings } = unlock( select( editSiteStore ) ); const settings = getSettings(); const blockPatterns = @@ -96,7 +96,7 @@ const selectThemePatterns = ( select, { categoryId, search = '' } = {} ) => { const restBlockPatterns = select( coreStore ).getBlockPatterns(); - let patterns = [ + const patterns = [ ...( blockPatterns || [] ), ...( restBlockPatterns || [] ), ] @@ -114,6 +114,23 @@ const selectThemePatterns = ( select, { categoryId, search = '' } = {} ) => { } ), } ) ); + return { patterns, isResolving: false }; +}; +const selectPatterns = ( + select, + { categoryId, search = '', syncStatus } = {} +) => { + const { patterns: themePatterns } = selectThemePatterns( select ); + const { patterns: userPatterns } = selectUserPatterns( select ); + + let patterns = [ ...( themePatterns || [] ), ...( userPatterns || [] ) ]; + + if ( syncStatus ) { + patterns = patterns.filter( + ( pattern ) => pattern.syncStatus === syncStatus + ); + } + if ( categoryId ) { patterns = searchItems( patterns, search, { categoryId, @@ -125,32 +142,43 @@ const selectThemePatterns = ( select, { categoryId, search = '' } = {} ) => { hasCategory: ( item ) => ! item.hasOwnProperty( 'categories' ), } ); } - return { patterns, isResolving: false }; }; -const reusableBlockToPattern = ( reusableBlock ) => ( { - blocks: parse( reusableBlock.content.raw, { +const patternBlockToPattern = ( patternBlock, categories ) => ( { + blocks: parse( patternBlock.content.raw, { __unstableSkipMigrationLogs: true, } ), - categories: reusableBlock.wp_pattern, - id: reusableBlock.id, - name: reusableBlock.slug, - syncStatus: reusableBlock.wp_pattern_sync_status || SYNC_TYPES.full, - title: reusableBlock.title.raw, - type: reusableBlock.type, - reusableBlock, + ...( patternBlock.wp_pattern_category.length > 0 && { + categories: patternBlock.wp_pattern_category.map( + ( patternCategoryId ) => + categories && categories.get( patternCategoryId ) + ? categories.get( patternCategoryId ).slug + : patternCategoryId + ), + } ), + id: patternBlock.id, + name: patternBlock.slug, + syncStatus: patternBlock.wp_pattern_sync_status || SYNC_TYPES.full, + title: patternBlock.title.raw, + type: USER_PATTERNS, + patternBlock, } ); const selectUserPatterns = ( select, { search = '', syncStatus } = {} ) => { - const { getEntityRecords, getIsResolving } = select( coreStore ); + const { getEntityRecords, getIsResolving, getUserPatternCategories } = + select( coreStore ); const query = { per_page: -1 }; const records = getEntityRecords( 'postType', USER_PATTERNS, query ); + const categories = getUserPatternCategories(); let patterns = records - ? records.map( ( record ) => reusableBlockToPattern( record ) ) + ? records.map( ( record ) => + patternBlockToPattern( record, categories.patternCategoriesMap ) + ) : EMPTY_PATTERN_LIST; + const isResolving = getIsResolving( 'getEntityRecords', [ 'postType', USER_PATTERNS, @@ -170,13 +198,13 @@ const selectUserPatterns = ( select, { search = '', syncStatus } = {} ) => { hasCategory: () => true, } ); - return { patterns, isResolving }; + return { patterns, isResolving, categories: categories.patternCategories }; }; export const usePatterns = ( categoryType, categoryId, - { search = '', syncStatus } + { search = '', syncStatus } = {} ) => { return useSelect( ( select ) => { @@ -186,7 +214,11 @@ export const usePatterns = ( search, } ); } else if ( categoryType === PATTERNS ) { - return selectThemePatterns( select, { categoryId, search } ); + return selectPatterns( select, { + categoryId, + search, + syncStatus, + } ); } else if ( categoryType === USER_PATTERNS ) { return selectUserPatterns( select, { search, syncStatus } ); } diff --git a/packages/edit-site/src/components/page-patterns/utils.js b/packages/edit-site/src/components/page-patterns/utils.js index bbdff872fe355..ee22f4715c63a 100644 --- a/packages/edit-site/src/components/page-patterns/utils.js +++ b/packages/edit-site/src/components/page-patterns/utils.js @@ -1,6 +1,7 @@ -export const DEFAULT_CATEGORY = 'my-patterns'; -export const DEFAULT_TYPE = 'wp_block'; +export const ALL_PATTERNS_CATEGORY = 'all-patterns'; +export const DEFAULT_CATEGORY = ALL_PATTERNS_CATEGORY; export const PATTERNS = 'pattern'; +export const DEFAULT_TYPE = PATTERNS; export const TEMPLATE_PARTS = 'wp_template_part'; export const USER_PATTERNS = 'wp_block'; export const USER_PATTERN_CATEGORY = 'my-patterns'; diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js index 41e3068bb89d0..a6e1d9a9008d9 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/index.js @@ -4,16 +4,13 @@ import { __experimentalItemGroup as ItemGroup, __experimentalItem as Item, - Flex, - Icon, - Tooltip, __experimentalHeading as Heading, } from '@wordpress/components'; import { useViewportMatch } from '@wordpress/compose'; import { getTemplatePartIcon } from '@wordpress/editor'; -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { getQueryArgs } from '@wordpress/url'; -import { file, starFilled, lockSmall } from '@wordpress/icons'; +import { file } from '@wordpress/icons'; /** * Internal dependencies @@ -25,7 +22,6 @@ import CategoryItem from './category-item'; import { DEFAULT_CATEGORY, DEFAULT_TYPE } from '../page-patterns/utils'; import { useLink } from '../routes/link'; import usePatternCategories from './use-pattern-categories'; -import useMyPatterns from './use-my-patterns'; import useTemplatePartAreas from './use-template-part-areas'; function TemplatePartGroup( { areas, currentArea, currentType } ) { @@ -56,7 +52,11 @@ function TemplatePartGroup( { areas, currentArea, currentType } ) { ); } -function ThemePatternsGroup( { categories, currentCategory, currentType } ) { +function PatternCategoriesGroup( { + categories, + currentCategory, + currentType, +} ) { return ( <> @@ -64,21 +64,7 @@ function ThemePatternsGroup( { categories, currentCategory, currentType } ) { - { category.label } - - - - - } + label={ category.label } icon={ file } id={ category.name } type="pattern" @@ -102,7 +88,6 @@ export default function SidebarNavigationScreenPatterns() { const { templatePartAreas, hasTemplateParts, isLoading } = useTemplatePartAreas(); const { patternCategories, hasPatterns } = usePatternCategories(); - const { myPatterns } = useMyPatterns(); const templatePartsLink = useLink( { path: '/wp_template_part/all' } ); const footer = ! isMobileViewport ? ( @@ -142,27 +127,8 @@ export default function SidebarNavigationScreenPatterns() { ) } - - - { hasPatterns && ( - - select( coreStore ).getEntityRecords( 'postType', 'wp_block', { - per_page: -1, - } )?.length ?? 0 - ); - - return { - myPatterns: { - count: myPatternsCount, - name: 'my-patterns', - label: __( 'My patterns' ), - }, - hasPatterns: myPatternsCount > 0, - }; -} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js index da4732f5be448..e3ab86e8b0615 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js @@ -9,6 +9,8 @@ import { __ } from '@wordpress/i18n'; */ import useDefaultPatternCategories from './use-default-pattern-categories'; import useThemePatterns from './use-theme-patterns'; +import usePatterns from '../page-patterns/use-patterns'; +import { USER_PATTERNS, ALL_PATTERNS_CATEGORY } from '../page-patterns/utils'; export default function usePatternCategories() { const defaultCategories = useDefaultPatternCategories(); @@ -17,6 +19,8 @@ export default function usePatternCategories() { label: __( 'Uncategorized' ), } ); const themePatterns = useThemePatterns(); + const { patterns: userPatterns, categories: userPatternCategories } = + usePatterns( USER_PATTERNS ); const patternCategories = useMemo( () => { const categoryMap = {}; @@ -28,6 +32,11 @@ export default function usePatternCategories() { categoryMap[ category.name ] = { ...category, count: 0 }; } } ); + userPatternCategories.forEach( ( category ) => { + if ( ! categoryMap[ category.name ] ) { + categoryMap[ category.name ] = { ...category, count: 0 }; + } + } ); // Update the category counts to reflect theme registered patterns. themePatterns.forEach( ( pattern ) => { @@ -42,15 +51,48 @@ export default function usePatternCategories() { } } ); - // Filter categories so we only have those containing patterns. - defaultCategories.forEach( ( category ) => { - if ( categoryMap[ category.name ].count ) { - categoriesWithCounts.push( categoryMap[ category.name ] ); + // Update the category counts to reflect user registered patterns. + userPatterns.forEach( ( pattern ) => { + pattern.categories?.forEach( ( category ) => { + if ( categoryMap[ category ] ) { + categoryMap[ category ].count += 1; + } + } ); + // If the pattern has no categories, add it to uncategorized. + if ( ! pattern.categories?.length ) { + categoryMap.uncategorized.count += 1; } } ); - return categoriesWithCounts; - }, [ defaultCategories, themePatterns ] ); + // Filter categories so we only have those containing patterns. + [ ...defaultCategories, ...userPatternCategories ].forEach( + ( category ) => { + if ( + categoryMap[ category.name ].count && + ! categoriesWithCounts.find( + ( cat ) => cat.name === category.name + ) + ) { + categoriesWithCounts.push( categoryMap[ category.name ] ); + } + } + ); + const sortedCategories = categoriesWithCounts.sort( ( a, b ) => + a.label.localeCompare( b.label ) + ); + sortedCategories.unshift( { + name: ALL_PATTERNS_CATEGORY, + label: __( 'All Patterns' ), + description: __( 'A list of all patterns from all sources' ), + count: themePatterns.length + userPatterns.length, + } ); + return sortedCategories; + }, [ + defaultCategories, + themePatterns, + userPatternCategories, + userPatterns, + ] ); return { patternCategories, hasPatterns: !! patternCategories.length }; } From 6708eda939493526ce9ceb416b74085647bc5849 Mon Sep 17 00:00:00 2001 From: Glen Davies Date: Thu, 14 Sep 2023 16:48:07 +1200 Subject: [PATCH 13/20] Patterns: Add user pattern categories to post editor inserter patterns tab (#53933) Co-authored-by: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Co-authored-by: James Koster --- .../components/block-patterns-list/index.js | 39 ++- .../components/block-patterns-list/style.scss | 35 ++- .../components/block-patterns-paging/index.js | 92 +++++++ .../block-patterns-paging/style.scss | 42 +++ .../block-patterns-explorer/explorer.js | 48 +++- .../block-patterns-explorer/patterns-list.js | 111 ++++++-- .../block-patterns-explorer/sidebar.js | 31 ++- .../inserter/block-patterns-source-filter.js | 40 +++ .../inserter/block-patterns-sync-filter.js | 48 ++++ .../components/inserter/block-patterns-tab.js | 257 ++++++++++++++---- .../inserter/hooks/use-patterns-paging.js | 65 +++++ .../inserter/hooks/use-patterns-state.js | 46 ++-- .../src/components/inserter/menu.js | 32 +-- .../src/components/inserter/quick-inserter.js | 2 +- .../inserter/reusable-blocks-tab.js | 84 ------ .../src/components/inserter/search-results.js | 10 +- .../src/components/inserter/style.scss | 25 +- .../src/components/inserter/tabs.js | 14 +- .../inserter/test/reusable-blocks-tab.js | 73 ----- packages/block-editor/src/store/selectors.js | 51 ++-- packages/block-editor/src/style.scss | 1 + packages/core-data/src/resolvers.js | 10 +- packages/core-data/src/selectors.ts | 9 +- packages/e2e-test-utils/README.md | 8 - packages/e2e-test-utils/src/index.js | 1 - packages/e2e-test-utils/src/inserter.js | 11 - ...st.js.snap => pattern-blocks.test.js.snap} | 4 +- ...-blocks.test.js => pattern-blocks.test.js} | 30 +- .../edit-post/src/components/layout/index.js | 8 +- .../provider/use-block-editor-settings.js | 2 +- 30 files changed, 833 insertions(+), 396 deletions(-) create mode 100644 packages/block-editor/src/components/block-patterns-paging/index.js create mode 100644 packages/block-editor/src/components/block-patterns-paging/style.scss create mode 100644 packages/block-editor/src/components/inserter/block-patterns-source-filter.js create mode 100644 packages/block-editor/src/components/inserter/block-patterns-sync-filter.js create mode 100644 packages/block-editor/src/components/inserter/hooks/use-patterns-paging.js delete mode 100644 packages/block-editor/src/components/inserter/reusable-blocks-tab.js delete mode 100644 packages/block-editor/src/components/inserter/test/reusable-blocks-tab.js rename packages/e2e-tests/specs/editor/various/__snapshots__/{reusable-blocks.test.js.snap => pattern-blocks.test.js.snap} (53%) rename packages/e2e-tests/specs/editor/various/{reusable-blocks.test.js => pattern-blocks.test.js} (94%) diff --git a/packages/block-editor/src/components/block-patterns-list/index.js b/packages/block-editor/src/components/block-patterns-list/index.js index ec2aad3da2b7a..302fe731d2187 100644 --- a/packages/block-editor/src/components/block-patterns-list/index.js +++ b/packages/block-editor/src/components/block-patterns-list/index.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ @@ -8,9 +13,11 @@ import { __unstableUseCompositeState as useCompositeState, __unstableCompositeItem as CompositeItem, Tooltip, + __experimentalHStack as HStack, } from '@wordpress/components'; import { useInstanceId } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; +import { Icon, symbol } from '@wordpress/icons'; /** * Internal dependencies @@ -63,14 +70,20 @@ function BlockPattern( { } } > { onClick( pattern, blocks ); onHover?.( null ); @@ -91,11 +104,23 @@ function BlockPattern( { blocks={ blocks } viewportWidth={ viewportWidth } /> - { ! showTooltip && ( -
- { pattern.title } -
- ) } + + + { pattern.id && ! pattern.syncStatus && ( +
+ +
+ ) } + { ( ! showTooltip || pattern.id ) && ( +
+ { pattern.title } +
+ ) } +
+ { !! pattern.description && ( { pattern.description } diff --git a/packages/block-editor/src/components/block-patterns-list/style.scss b/packages/block-editor/src/components/block-patterns-list/style.scss index ab80fc71d36df..e3b38deff5ef7 100644 --- a/packages/block-editor/src/components/block-patterns-list/style.scss +++ b/packages/block-editor/src/components/block-patterns-list/style.scss @@ -11,7 +11,7 @@ min-height: 100px; } - &[draggable="true"] .block-editor-block-preview__container { + &[draggable="true"] { cursor: grab; } } @@ -27,22 +27,39 @@ } .block-editor-block-patterns-list__item-title { - padding-top: $grid-unit-10; - font-size: 12px; - text-align: center; + text-align: left; + flex-grow: 1; } &:hover .block-editor-block-preview__container { - box-shadow: 0 0 0 2px var(--wp-admin-theme-color); + box-shadow: 0 0 0 2px $gray-900; } &:focus .block-editor-block-preview__container { - @include button-style-outset__focus(var(--wp-admin-theme-color)); + @include button-style-outset__focus($gray-900); } + &.block-editor-block-patterns-list__list-item-synced { + &:hover, + &:focus { + .block-editor-block-preview__container { + box-shadow: + 0 0 0 2px var(--wp-block-synced-color), + 0 15px 25px rgb(0 0 0 / 7%); + } + } + } + + .block-editor-patterns__pattern-details { + align-items: center; + margin-top: $grid-unit-10; + } - &:hover .block-editor-block-patterns-list__item-title, - &:focus .block-editor-block-patterns-list__item-title { - color: var(--wp-admin-theme-color); + .block-editor-patterns__pattern-icon-wrapper { + min-width: 24px; + height: 24px; + .block-editor-patterns__pattern-icon { + fill: var(--wp-block-synced-color); + } } } diff --git a/packages/block-editor/src/components/block-patterns-paging/index.js b/packages/block-editor/src/components/block-patterns-paging/index.js new file mode 100644 index 0000000000000..610ff304e186e --- /dev/null +++ b/packages/block-editor/src/components/block-patterns-paging/index.js @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import { + __experimentalVStack as VStack, + __experimentalHStack as HStack, + __experimentalText as Text, + Button, +} from '@wordpress/components'; +import { __, _x, _n, sprintf } from '@wordpress/i18n'; + +export default function Pagination( { + currentPage, + numPages, + changePage, + totalItems, +} ) { + return ( + + + { + // translators: %s: Total number of patterns. + sprintf( + // translators: %s: Total number of patterns. + _n( '%s item', '%s items', totalItems ), + totalItems + ) + } + + + + + + + + { sprintf( + // translators: %1$s: Current page number, %2$s: Total number of pages. + _x( '%1$s of %2$s', 'paging' ), + currentPage, + numPages + ) } + + + + + + + + ); +} diff --git a/packages/block-editor/src/components/block-patterns-paging/style.scss b/packages/block-editor/src/components/block-patterns-paging/style.scss new file mode 100644 index 0000000000000..7de651f1511b6 --- /dev/null +++ b/packages/block-editor/src/components/block-patterns-paging/style.scss @@ -0,0 +1,42 @@ +.block-editor-patterns__grid-pagination { + border-top: 1px solid $gray-800; + padding: $grid-unit-05; + + .components-button.is-tertiary { + width: auto; + height: $button-size-compact; + justify-content: center; + + &:disabled { + color: $gray-600; + background: none; + } + + &:hover:not(:disabled) { + color: $white; + background-color: $gray-700; + } + } +} + +.show-icon-labels { + .block-editor-patterns__grid-pagination { + flex-direction: column; + .block-editor-patterns__grid-pagination-previous, + .block-editor-patterns__grid-pagination-next { + flex-direction: column; + } + .components-button { + width: auto; + // Hide the button icons when labels are set to display... + span { + display: none; + } + // ... and display labels. + // Uses ::before as ::after is already used for active tab styling. + &::before { + content: attr(aria-label); + } + } + } +} diff --git a/packages/block-editor/src/components/inserter/block-patterns-explorer/explorer.js b/packages/block-editor/src/components/inserter/block-patterns-explorer/explorer.js index 914177941821f..4a4bcab3397ae 100644 --- a/packages/block-editor/src/components/inserter/block-patterns-explorer/explorer.js +++ b/packages/block-editor/src/components/inserter/block-patterns-explorer/explorer.js @@ -2,33 +2,69 @@ * WordPress dependencies */ import { Modal } from '@wordpress/components'; -import { useState } from '@wordpress/element'; +import { useState, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { usePrevious } from '@wordpress/compose'; /** * Internal dependencies */ import PatternExplorerSidebar from './sidebar'; import PatternList from './patterns-list'; +import { usePatternsCategories } from '../block-patterns-tab'; +import { store as blockEditorStore } from '../../../store'; -function PatternsExplorer( { initialCategory, patternCategories } ) { - const [ filterValue, setFilterValue ] = useState( '' ); +function PatternsExplorer( { initialCategory, rootClientId } ) { + const [ searchValue, setSearchValue ] = useState( '' ); + const [ patternSourceFilter, setPatternSourceFilter ] = useState( 'all' ); + const patternSyncFilter = useSelect( ( select ) => { + const { getSettings } = select( blockEditorStore ); + const settings = getSettings(); + return settings.patternsSyncFilter || 'all'; + }, [] ); const [ selectedCategory, setSelectedCategory ] = useState( initialCategory?.name ); + + const previousSyncFilter = usePrevious( patternSyncFilter ); + + // If the sync filter changes, we need to select the "All" category to avoid + // showing a confusing no results screen. + useEffect( () => { + if ( patternSyncFilter && patternSyncFilter !== previousSyncFilter ) { + setSelectedCategory( initialCategory?.name ); + } + }, [ + patternSyncFilter, + previousSyncFilter, + patternSourceFilter, + initialCategory?.name, + ] ); + + const patternCategories = usePatternsCategories( + rootClientId, + patternSourceFilter, + patternSyncFilter + ); + return (
); diff --git a/packages/block-editor/src/components/inserter/block-patterns-explorer/patterns-list.js b/packages/block-editor/src/components/inserter/block-patterns-explorer/patterns-list.js index fda1a00c1a07d..fa65507be4ec4 100644 --- a/packages/block-editor/src/components/inserter/block-patterns-explorer/patterns-list.js +++ b/packages/block-editor/src/components/inserter/block-patterns-explorer/patterns-list.js @@ -1,9 +1,9 @@ /** * WordPress dependencies */ -import { useMemo, useEffect } from '@wordpress/element'; +import { useMemo, useEffect, useRef } from '@wordpress/element'; import { _n, sprintf } from '@wordpress/i18n'; -import { useDebounce, useAsyncList } from '@wordpress/compose'; +import { useDebounce } from '@wordpress/compose'; import { __experimentalHeading as Heading } from '@wordpress/components'; import { speak } from '@wordpress/a11y'; @@ -16,8 +16,14 @@ import useInsertionPoint from '../hooks/use-insertion-point'; import usePatternsState from '../hooks/use-patterns-state'; import InserterListbox from '../../inserter-listbox'; import { searchItems } from '../search-items'; - -const INITIAL_INSERTER_RESULTS = 2; +import BlockPatternsPaging from '../../block-patterns-paging'; +import usePatternsPaging from '../hooks/use-patterns-paging'; +import { allPatternsCategory, isPatternFiltered } from '../block-patterns-tab'; +import { BlockPatternsSyncFilter } from '../block-patterns-sync-filter'; +import { + PATTERN_TYPES, + PATTERN_SOURCE_FILTERS, +} from '../block-patterns-source-filter'; function PatternsListHeader( { filterValue, filteredBlockPatternsLength } ) { if ( ! filterValue ) { @@ -43,12 +49,19 @@ function PatternsListHeader( { filterValue, filteredBlockPatternsLength } ) { ); } -function PatternList( { filterValue, selectedCategory, patternCategories } ) { +function PatternList( { + searchValue, + patternSourceFilter, + selectedCategory, + patternCategories, + patternSyncFilter, +} ) { + const container = useRef(); const debouncedSpeak = useDebounce( speak, 500 ); const [ destinationRootClientId, onInsertBlocks ] = useInsertionPoint( { shouldFocusBlock: true, } ); - const [ allPatterns, , onSelectBlockPattern ] = usePatternsState( + const { patterns: allPatterns, onClickPattern } = usePatternsState( onInsertBlocks, destinationRootClientId ); @@ -62,30 +75,54 @@ function PatternList( { filterValue, selectedCategory, patternCategories } ) { ); const filteredBlockPatterns = useMemo( () => { - if ( ! filterValue ) { - return allPatterns.filter( ( pattern ) => - selectedCategory === 'uncategorized' - ? ! pattern.categories?.length || - pattern.categories.every( - ( category ) => - ! registeredPatternCategories.includes( - category - ) - ) - : pattern.categories?.includes( selectedCategory ) - ); + const filteredPatterns = allPatterns.filter( ( pattern ) => { + if ( + isPatternFiltered( + pattern, + patternSourceFilter, + patternSyncFilter + ) + ) { + return false; + } + + if ( selectedCategory === allPatternsCategory.name ) { + return true; + } + + if ( selectedCategory === 'uncategorized' ) { + const hasKnownCategory = pattern.categories.some( + ( category ) => + registeredPatternCategories.includes( category ) + ); + + return ! pattern.categories?.length || ! hasKnownCategory; + } + + return pattern.categories?.includes( selectedCategory ); + } ); + + if ( ! searchValue ) { + return filteredPatterns; } - return searchItems( allPatterns, filterValue ); + + return searchItems( + filteredPatterns, + searchValue, + patternSourceFilter + ); }, [ - filterValue, + searchValue, + patternSourceFilter, allPatterns, selectedCategory, registeredPatternCategories, + patternSyncFilter, ] ); // Announce search results on change. useEffect( () => { - if ( ! filterValue ) { + if ( ! searchValue ) { return; } const count = filteredBlockPatterns.length; @@ -95,31 +132,45 @@ function PatternList( { filterValue, selectedCategory, patternCategories } ) { count ); debouncedSpeak( resultsFoundMessage ); - }, [ filterValue, debouncedSpeak, filteredBlockPatterns.length ] ); + }, [ searchValue, debouncedSpeak, filteredBlockPatterns.length ] ); - const currentShownPatterns = useAsyncList( filteredBlockPatterns, { - step: INITIAL_INSERTER_RESULTS, - } ); + const pagingProps = usePatternsPaging( + filteredBlockPatterns, + selectedCategory, + container, + patternSourceFilter + ); const hasItems = !! filteredBlockPatterns?.length; return ( -
+
{ hasItems && ( ) } { ! hasItems && } + { patternSourceFilter === PATTERN_TYPES.user && + ! searchValue && } { hasItems && ( ) } + { pagingProps.numPages > 1 && ( + + ) }
); diff --git a/packages/block-editor/src/components/inserter/block-patterns-explorer/sidebar.js b/packages/block-editor/src/components/inserter/block-patterns-explorer/sidebar.js index 7143134222122..06ea794aa4dac 100644 --- a/packages/block-editor/src/components/inserter/block-patterns-explorer/sidebar.js +++ b/packages/block-editor/src/components/inserter/block-patterns-explorer/sidebar.js @@ -4,6 +4,12 @@ import { Button, SearchControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { default as BlockPatternsSourceFilter } from '../block-patterns-source-filter'; +import { allPatternsCategory } from '../block-patterns-tab'; + function PatternCategoriesList( { selectedCategory, patternCategories, @@ -31,14 +37,14 @@ function PatternCategoriesList( { ); } -function PatternsExplorerSearch( { filterValue, setFilterValue } ) { +function PatternsExplorerSearch( { searchValue, setSearchValue } ) { const baseClassName = 'block-editor-block-patterns-explorer__search'; return (
@@ -50,17 +56,26 @@ function PatternExplorerSidebar( { selectedCategory, patternCategories, onClickCategory, - filterValue, - setFilterValue, + patternSourceFilter, + setPatternSourceFilter, + searchValue, + setSearchValue, } ) { const baseClassName = 'block-editor-block-patterns-explorer__sidebar'; return (
+ { + setPatternSourceFilter( value ); + onClickCategory( allPatternsCategory.name ); + } } /> - { ! filterValue && ( + { ! searchValue && ( { + patternSourceFilters[ value ] = label; + return patternSourceFilters; + }, + {} +); + +export default function BlockPatternsSourceFilter( { onChange, value } ) { + return ( + + ); +} diff --git a/packages/block-editor/src/components/inserter/block-patterns-sync-filter.js b/packages/block-editor/src/components/inserter/block-patterns-sync-filter.js new file mode 100644 index 0000000000000..07c4c0d8fa1b1 --- /dev/null +++ b/packages/block-editor/src/components/inserter/block-patterns-sync-filter.js @@ -0,0 +1,48 @@ +/** + * WordPress dependencies + */ +import { SelectControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useDispatch, useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; +export const SYNC_TYPES = { + full: 'fully', + unsynced: 'unsynced', +}; + +const patternSyncOptions = [ + { value: 'all', label: __( 'All' ) }, + { value: SYNC_TYPES.full, label: __( 'Synced' ) }, + { value: SYNC_TYPES.unsynced, label: __( 'Standard' ) }, +]; + +export function BlockPatternsSyncFilter() { + const { updateSettings } = useDispatch( blockEditorStore ); + + const syncFilter = useSelect( ( select ) => { + const { getSettings } = select( blockEditorStore ); + const settings = getSettings(); + return settings.patternsSyncFilter || 'all'; + }, [] ); + + const handleUpdateSyncFilter = ( value ) => { + updateSettings( { + patternsSyncFilter: value, + } ); + }; + + return ( + handleUpdateSyncFilter( value ) } + aria-label={ __( 'Filter patterns by sync type' ) } + /> + ); +} diff --git a/packages/block-editor/src/components/inserter/block-patterns-tab.js b/packages/block-editor/src/components/inserter/block-patterns-tab.js index f66d27ac06170..e5432fd9373f5 100644 --- a/packages/block-editor/src/components/inserter/block-patterns-tab.js +++ b/packages/block-editor/src/components/inserter/block-patterns-tab.js @@ -8,8 +8,8 @@ import { useRef, useEffect, } from '@wordpress/element'; -import { _x, __, isRTL } from '@wordpress/i18n'; -import { useAsyncList, useViewportMatch } from '@wordpress/compose'; +import { _x, __, _n, isRTL, sprintf } from '@wordpress/i18n'; +import { useViewportMatch, usePrevious } from '@wordpress/compose'; import { __experimentalItemGroup as ItemGroup, __experimentalItem as Item, @@ -19,6 +19,8 @@ import { } from '@wordpress/components'; import { Icon, chevronRight, chevronLeft } from '@wordpress/icons'; import { focus } from '@wordpress/dom'; +import { speak } from '@wordpress/a11y'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -27,29 +29,77 @@ import usePatternsState from './hooks/use-patterns-state'; import BlockPatternList from '../block-patterns-list'; import PatternsExplorerModal from './block-patterns-explorer/explorer'; import MobileTabNavigation from './mobile-tab-navigation'; +import BlockPatternsPaging from '../block-patterns-paging'; +import usePatternsPaging from './hooks/use-patterns-paging'; +import { + PATTERN_TYPES, + default as BlockPatternsSourceFilter, +} from './block-patterns-source-filter'; +import { + BlockPatternsSyncFilter, + SYNC_TYPES, +} from './block-patterns-sync-filter'; +import { store as blockEditorStore } from '../../store'; const noop = () => {}; -// Preferred order of pattern categories. Any other categories should -// be at the bottom without any re-ordering. -const patternCategoriesOrder = [ - 'custom', - 'featured', - 'posts', - 'text', - 'gallery', - 'call-to-action', - 'banner', - 'header', - 'footer', -]; +export const allPatternsCategory = { + name: 'allPatterns', + label: __( 'All categories' ), +}; -function usePatternsCategories( rootClientId ) { - const [ allPatterns, allCategories ] = usePatternsState( +export function isPatternFiltered( pattern, sourceFilter, syncFilter ) { + if ( + sourceFilter === PATTERN_TYPES.theme && + pattern.name.startsWith( 'core/block' ) + ) { + return true; + } + if ( sourceFilter === PATTERN_TYPES.user && ! pattern.id ) { + return true; + } + if ( + sourceFilter === PATTERN_TYPES.user && + syncFilter === SYNC_TYPES.full && + pattern.syncStatus !== '' + ) { + return true; + } + if ( + sourceFilter === PATTERN_TYPES.user && + syncFilter === SYNC_TYPES.unsynced && + pattern.syncStatus !== 'unsynced' + ) { + return true; + } + return false; +} + +export function usePatternsCategories( + rootClientId, + sourceFilter = 'all', + syncFilter +) { + const { patterns: allPatterns, allCategories } = usePatternsState( undefined, rootClientId ); + const filteredPatterns = useMemo( + () => + sourceFilter === 'all' + ? allPatterns + : allPatterns.filter( + ( pattern ) => + ! isPatternFiltered( + pattern, + sourceFilter, + syncFilter + ) + ), + [ sourceFilter, syncFilter, allPatterns ] + ); + const hasRegisteredCategory = useCallback( ( pattern ) => { if ( ! pattern.categories || ! pattern.categories.length ) { @@ -67,22 +117,14 @@ function usePatternsCategories( rootClientId ) { const populatedCategories = useMemo( () => { const categories = allCategories .filter( ( category ) => - allPatterns.some( ( pattern ) => + filteredPatterns.some( ( pattern ) => pattern.categories?.includes( category.name ) ) ) - .sort( ( { name: aName }, { name: bName } ) => { - // Sort categories according to `patternCategoriesOrder`. - let aIndex = patternCategoriesOrder.indexOf( aName ); - let bIndex = patternCategoriesOrder.indexOf( bName ); - // All other categories should come after that. - if ( aIndex < 0 ) aIndex = patternCategoriesOrder.length; - if ( bIndex < 0 ) bIndex = patternCategoriesOrder.length; - return aIndex - bIndex; - } ); + .sort( ( a, b ) => a.label.localeCompare( b.label ) ); if ( - allPatterns.some( + filteredPatterns.some( ( pattern ) => ! hasRegisteredCategory( pattern ) ) && ! categories.find( @@ -94,9 +136,25 @@ function usePatternsCategories( rootClientId ) { label: _x( 'Uncategorized' ), } ); } - + if ( filteredPatterns.length > 0 ) { + categories.unshift( { + name: allPatternsCategory.name, + label: allPatternsCategory.label, + } ); + } + speak( + sprintf( + /* translators: %d: number of categories . */ + _n( + '%d category button displayed.', + '%d category buttons displayed.', + categories.length + ), + categories.length + ) + ); return categories; - }, [ allCategories, allPatterns, hasRegisteredCategory ] ); + }, [ allCategories, filteredPatterns, hasRegisteredCategory ] ); return populatedCategories; } @@ -107,6 +165,7 @@ export function BlockPatternsCategoryDialog( { onHover, category, showTitlesAsTooltip, + patternFilter, } ) { const container = useRef(); @@ -129,6 +188,7 @@ export function BlockPatternsCategoryDialog( { onHover={ onHover } category={ category } showTitlesAsTooltip={ showTitlesAsTooltip } + patternFilter={ patternFilter } />
); @@ -140,16 +200,39 @@ export function BlockPatternsCategoryPanel( { onHover = noop, category, showTitlesAsTooltip, + patternFilter, } ) { - const [ allPatterns, , onClick ] = usePatternsState( + const { patterns: allPatterns, onClickPattern } = usePatternsState( onInsert, rootClientId ); - - const availableCategories = usePatternsCategories( rootClientId ); + const patternSyncFilter = useSelect( ( select ) => { + const { getSettings } = select( blockEditorStore ); + const settings = getSettings(); + return settings.patternsSyncFilter || 'all'; + }, [] ); + const availableCategories = usePatternsCategories( + rootClientId, + patternFilter, + patternSyncFilter + ); + const container = useRef(); const currentCategoryPatterns = useMemo( () => allPatterns.filter( ( pattern ) => { + if ( + isPatternFiltered( + pattern, + patternFilter, + patternSyncFilter + ) + ) { + return false; + } + + if ( category.name === allPatternsCategory.name ) { + return true; + } if ( category.name !== 'uncategorized' ) { return pattern.categories?.includes( category.name ); } @@ -166,35 +249,56 @@ export function BlockPatternsCategoryPanel( { return availablePatternCategories.length === 0; } ), - [ allPatterns, availableCategories, category.name ] + [ + allPatterns, + availableCategories, + category.name, + patternFilter, + patternSyncFilter, + ] ); - const categoryPatternsList = useAsyncList( currentCategoryPatterns ); + const pagingProps = usePatternsPaging( + currentCategoryPatterns, + category, + container + ); // Hide block pattern preview on unmount. useEffect( () => () => onHover( null ), [] ); - if ( ! currentCategoryPatterns.length ) { - return null; - } - return ( -
+
{ category.label }

{ category.description }

- + { patternFilter === PATTERN_TYPES.user && ( + + ) } + { ! currentCategoryPatterns.length && ( +
{ __( 'No results found' ) }
+ ) } + { currentCategoryPatterns.length > 0 && ( + + ) } + { pagingProps.numPages > 1 && ( + + ) }
); } @@ -206,24 +310,60 @@ function BlockPatternsTabs( { rootClientId, } ) { const [ showPatternsExplorer, setShowPatternsExplorer ] = useState( false ); - const categories = usePatternsCategories( rootClientId ); + const [ patternSourceFilter, setPatternSourceFilter ] = useState( 'all' ); + const patternSyncFilter = useSelect( ( select ) => { + const { getSettings } = select( blockEditorStore ); + const settings = getSettings(); + return settings.patternsSyncFilter; + }, [] ); + const previousSyncFilter = usePrevious( patternSyncFilter ); + + // If the sync filter changes, we need to select the "All" category to avoid + // showing a confusing no results screen. + useEffect( () => { + if ( patternSyncFilter && patternSyncFilter !== previousSyncFilter ) { + onSelectCategory( allPatternsCategory, patternSourceFilter ); + } + }, [ + patternSyncFilter, + previousSyncFilter, + onSelectCategory, + patternSourceFilter, + ] ); + + const categories = usePatternsCategories( + rootClientId, + patternSourceFilter, + patternSyncFilter + ); + const initialCategory = selectedCategory || categories[ 0 ]; const isMobile = useViewportMatch( 'medium', '<' ); return ( <> { ! isMobile && (
-