From 68caf865f0f5b65fba1a1bc284c826380c512b7a Mon Sep 17 00:00:00 2001 From: Nik Tsekouras Date: Thu, 25 Feb 2021 12:41:33 +0200 Subject: [PATCH] Query block setup with block patterns integration (#28891) * first stab * first iterations from design Keyboard navigation, style changes, refactor LayoutStep, create `usePostTypes` util hook * remove duplicate imports from rebase * design iteration part 2 + keyboard navigation * Matching block patterns API v1 * change API * move and change block patterns * Polish * __experimentalGetScopedBlockPatterns doc and tests * jsdoc usePostTypes * show start empty at the end * Improve thumbnails. * fix empty variation * Polish the labels. Co-authored-by: jasmussen --- lib/block-patterns.php | 64 ++++++ lib/load.php | 1 + .../inserter/hooks/use-patterns-state.js | 7 +- packages/block-editor/src/store/selectors.js | 26 +++ .../block-editor/src/store/test/selectors.js | 48 +++++ .../src/query/edit/block-setup/index.js | 49 +++++ .../src/query/edit/block-setup/layout-step.js | 204 ++++++++++++++++++ .../block-library/src/query/edit/index.js | 7 +- .../src/query/edit/query-block-setup.js | 87 ++++++++ .../query/edit/query-inspector-controls.js | 66 ++---- packages/block-library/src/query/editor.scss | 118 ++++++++++ packages/block-library/src/query/utils.js | 44 ++++ 12 files changed, 669 insertions(+), 52 deletions(-) create mode 100644 lib/block-patterns.php create mode 100644 packages/block-library/src/query/edit/block-setup/index.js create mode 100644 packages/block-library/src/query/edit/block-setup/layout-step.js create mode 100644 packages/block-library/src/query/edit/query-block-setup.js diff --git a/lib/block-patterns.php b/lib/block-patterns.php new file mode 100644 index 0000000000000..d376fb59aea1e --- /dev/null +++ b/lib/block-patterns.php @@ -0,0 +1,64 @@ + __( 'Large', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'block' => array( 'core/query' ), + ), + 'content' => ' + + + +
+ + ', + ) +); + +register_block_pattern( + 'query/medium-posts', + array( + 'title' => __( 'Medium', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'block' => array( 'core/query' ), + ), + 'content' => ' +
+
+ + +
+
+
+ ', + ) +); + +register_block_pattern( + 'query/small-posts', + array( + 'title' => __( 'Small', 'gutenberg' ), + 'scope' => array( + 'inserter' => false, + 'block' => array( 'core/query' ), + ), + 'content' => ' +
+
+ + +
+
+ ', + ) +); diff --git a/lib/load.php b/lib/load.php index 8027efa785f1e..feb404433c6e5 100644 --- a/lib/load.php +++ b/lib/load.php @@ -109,6 +109,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/full-site-editing/edit-site-export.php'; require __DIR__ . '/blocks.php'; +require __DIR__ . '/block-patterns.php'; require __DIR__ . '/client-assets.php'; require __DIR__ . '/demo.php'; require __DIR__ . '/widgets.php'; diff --git a/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js b/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js index 5b5090e6d7b2b..1b14286c821bf 100644 --- a/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js +++ b/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js @@ -31,8 +31,13 @@ const usePatternsState = ( onInsert, rootClientId ) => { const { __experimentalGetAllowedPatterns, getSettings } = select( blockEditorStore ); + const inserterPatterns = __experimentalGetAllowedPatterns( + rootClientId + ).filter( + ( pattern ) => ! pattern.scope || pattern.scope.inserter + ); return { - patterns: __experimentalGetAllowedPatterns( rootClientId ), + patterns: inserterPatterns, patternCategories: getSettings() .__experimentalBlockPatternCategories, }; diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 8dcac3f9cfb8c..1e6e1a2073ae3 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -1801,6 +1801,32 @@ export const __experimentalGetAllowedPatterns = createSelector( ] ); +/** + * Returns the list of patterns based on specific `scope` and + * a block's name. + * `inserter` scope should be handled differently, probably in + * combination with `__experimentalGetAllowedPatterns`. + * For now `__experimentalGetScopedBlockPatterns` handles properly + * all other scopes. + * Since both APIs are experimental we should revisit this. + * + * @param {Object} state Editor state. + * @param {string} scope Block pattern scope. + * @param {string} blockName Block's name. + * + * @return {Array} The list of matched block patterns based on provided scope and block name. + */ +export const __experimentalGetScopedBlockPatterns = createSelector( + ( state, scope, blockName ) => { + if ( ! scope && ! blockName ) return EMPTY_ARRAY; + const patterns = state.settings.__experimentalBlockPatterns; + return patterns.filter( ( pattern ) => + pattern.scope?.[ scope ]?.includes?.( blockName ) + ); + }, + ( state ) => [ state.settings.__experimentalBlockPatterns ] +); + /** * Returns the Block List settings of a block, if any exist. * diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index 6ea84f777df21..3d2e058dd844f 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -72,6 +72,7 @@ const { __experimentalGetActiveBlockIdByBlockNames: getActiveBlockIdByBlockNames, __experimentalGetParsedReusableBlock, __experimentalGetAllowedPatterns, + __experimentalGetScopedBlockPatterns, } = selectors; describe( 'selectors', () => { @@ -3402,6 +3403,53 @@ describe( 'selectors', () => { ).toHaveLength( 0 ); } ); } ); + describe( '__experimentalGetScopedBlockPatterns', () => { + const state = { + blocks: {}, + settings: { + __experimentalBlockPatterns: [ + { + title: 'pattern a', + scope: { block: [ 'test/block-a' ] }, + }, + { + title: 'pattern b', + scope: { block: [ 'test/block-b' ] }, + }, + ], + }, + }; + it( 'should return empty array if no scope and block name is provided', () => { + expect( __experimentalGetScopedBlockPatterns( state ) ).toEqual( + [] + ); + expect( + __experimentalGetScopedBlockPatterns( state, 'block' ) + ).toEqual( [] ); + } ); + it( 'shoud return empty array if no match is found', () => { + const patterns = __experimentalGetScopedBlockPatterns( + state, + 'block', + 'test/block-not-exists' + ); + expect( patterns ).toEqual( [] ); + } ); + it( 'should return proper results when there are matched block patterns', () => { + const patterns = __experimentalGetScopedBlockPatterns( + state, + 'block', + 'test/block-a' + ); + expect( patterns ).toHaveLength( 1 ); + expect( patterns[ 0 ] ).toEqual( + expect.objectContaining( { + title: 'pattern a', + scope: { block: [ 'test/block-a' ] }, + } ) + ); + } ); + } ); } ); describe( '__experimentalGetParsedReusableBlock', () => { diff --git a/packages/block-library/src/query/edit/block-setup/index.js b/packages/block-library/src/query/edit/block-setup/index.js new file mode 100644 index 0000000000000..37008b6010ff7 --- /dev/null +++ b/packages/block-library/src/query/edit/block-setup/index.js @@ -0,0 +1,49 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { useBlockProps } from '@wordpress/block-editor'; +import { store as blocksStore } from '@wordpress/blocks'; +import { Placeholder } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import LayoutSetupStep from './layout-step'; + +const BlockSetup = ( { + blockName, + useLayoutSetup, + onVariationSelect = () => {}, + onBlockPatternSelect = () => {}, + children, +} ) => { + const { blockType } = useSelect( + ( select ) => { + const { getBlockType } = select( blocksStore ); + return { blockType: getBlockType( blockName ) }; + }, + [ blockName ] + ); + const blockProps = useBlockProps(); + return ( +
+ + { useLayoutSetup && ( + + ) } + { children } + +
+ ); +}; + +export default BlockSetup; diff --git a/packages/block-library/src/query/edit/block-setup/layout-step.js b/packages/block-library/src/query/edit/block-setup/layout-step.js new file mode 100644 index 0000000000000..a2a206dbb0dde --- /dev/null +++ b/packages/block-library/src/query/edit/block-setup/layout-step.js @@ -0,0 +1,204 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { useState, useMemo } from '@wordpress/element'; +import { parse, store as blocksStore } from '@wordpress/blocks'; +import { useInstanceId } from '@wordpress/compose'; +import { + BlockPreview, + store as blockEditorStore, +} from '@wordpress/block-editor'; +import { __, isRTL } from '@wordpress/i18n'; +import { + Button, + Icon, + VisuallyHidden, + __unstableComposite as Composite, + __unstableUseCompositeState as useCompositeState, + __unstableCompositeItem as CompositeItem, +} from '@wordpress/components'; +import { chevronRight, chevronLeft } from '@wordpress/icons'; + +const LayoutSetupStep = ( { + blockType, + onVariationSelect, + onBlockPatternSelect, +} ) => { + const [ showBack, setShowBack ] = useState( false ); + const { + defaultVariation, + blockVariations, + patterns, + hasBlockVariations, + } = useSelect( + ( select ) => { + const { getBlockVariations, getDefaultBlockVariation } = select( + blocksStore + ); + const { __experimentalGetScopedBlockPatterns } = select( + blockEditorStore + ); + const { name } = blockType; + const _patterns = __experimentalGetScopedBlockPatterns( + 'block', + name + ); + const _blockVariations = getBlockVariations( name, 'block' ); + return { + defaultVariation: getDefaultBlockVariation( name, 'block' ), + blockVariations: _blockVariations, + patterns: _patterns, + hasBlockVariations: !! _blockVariations?.length, + }; + }, + [ blockType ] + ); + const [ showBlockVariations, setShowBlockVariations ] = useState( + ! patterns?.length && hasBlockVariations + ); + const composite = useCompositeState(); + // Show nothing if block variations and block pattterns do not exist. + if ( ! hasBlockVariations && ! patterns?.length ) return null; + + const showPatternsList = ! showBlockVariations && !! patterns.length; + return ( + <> + { showBack && ( + + ) } + + { showBlockVariations && ( + <> + { blockVariations.map( ( variation ) => ( + onVariationSelect( nextVariation ) } + composite={ composite } + /> + ) ) } + + ) } + { showPatternsList && ( + <> + { patterns.map( ( pattern ) => ( + + ) ) } + + ) } + { ! showBlockVariations && hasBlockVariations && ( + { + setShowBack( true ); + setShowBlockVariations( true ); + } } + composite={ composite } + /> + ) } + + + ); +}; + +function BlockPattern( { pattern, onSelect, composite } ) { + const { content, viewportWidth } = pattern; + const blocks = useMemo( () => parse( content ), [ content ] ); + const descriptionId = useInstanceId( + BlockPattern, + 'block-setup-block-layout-list__item-description' + ); + return ( +
+ onSelect( blocks ) } + > + + +
+ { pattern.title } +
+ { !! pattern.description && ( + + { pattern.description } + + ) } +
+ ); +} + +function BlockVariation( { variation, onSelect, composite } ) { + const descriptionId = useInstanceId( + BlockVariation, + 'block-setup-block-layout-list__item-description' + ); + return ( +
+ onSelect( variation ) } + label={ variation.description || variation.title } + > + { variation.icon && ( +
+ +
+ ) } +
+
+ { variation.title } +
+ { !! variation.description && ( + + { variation.description } + + ) } +
+ ); +} + +export default LayoutSetupStep; diff --git a/packages/block-library/src/query/edit/index.js b/packages/block-library/src/query/edit/index.js index 9b9c2a388604c..5d6b59c033e2a 100644 --- a/packages/block-library/src/query/edit/index.js +++ b/packages/block-library/src/query/edit/index.js @@ -7,8 +7,8 @@ import { useEffect } from '@wordpress/element'; import { BlockControls, useBlockProps, - __experimentalUseInnerBlocksProps as useInnerBlocksProps, store as blockEditorStore, + __experimentalUseInnerBlocksProps as useInnerBlocksProps, } from '@wordpress/block-editor'; /** @@ -17,7 +17,7 @@ import { import QueryToolbar from './query-toolbar'; import QueryProvider from './query-provider'; import QueryInspectorControls from './query-inspector-controls'; -import QueryPlaceholder from './query-placeholder'; +import QueryBlockSetup from './query-block-setup'; import { DEFAULTS_POSTS_PER_PAGE } from '../constants'; const TEMPLATE = [ [ 'core/query-loop' ] ]; @@ -93,8 +93,7 @@ const QueryEdit = ( props ) => { !! select( blockEditorStore ).getBlocks( clientId ).length, [ clientId ] ); - const Component = hasInnerBlocks ? QueryContent : QueryPlaceholder; - + const Component = hasInnerBlocks ? QueryContent : QueryBlockSetup; return ; }; diff --git a/packages/block-library/src/query/edit/query-block-setup.js b/packages/block-library/src/query/edit/query-block-setup.js new file mode 100644 index 0000000000000..2a89734275d67 --- /dev/null +++ b/packages/block-library/src/query/edit/query-block-setup.js @@ -0,0 +1,87 @@ +/** + * WordPress dependencies + */ +import { useDispatch } from '@wordpress/data'; +import { __, _x } from '@wordpress/i18n'; +import { SelectControl, ToggleControl } from '@wordpress/components'; +import { createBlocksFromInnerBlocksTemplate } from '@wordpress/blocks'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import BlockSetup from './block-setup'; +import { usePostTypes } from '../utils'; + +const QueryBlockSetup = ( { + clientId, + attributes: { query }, + setAttributes, + name: blockName, +} ) => { + const { postType, inherit } = query; + const { replaceInnerBlocks } = useDispatch( blockEditorStore ); + const { postTypesSelectOptions } = usePostTypes(); + const updateQuery = ( newQuery ) => + setAttributes( { query: { ...query, ...newQuery } } ); + const onFinish = ( innerBlocks ) => { + if ( innerBlocks ) { + replaceInnerBlocks( + clientId, + createBlocksFromInnerBlocksTemplate( innerBlocks ), + false + ); + } + }; + const onVariationSelect = ( nextVariation ) => { + if ( nextVariation.attributes ) { + setAttributes( nextVariation.attributes ); + } + if ( nextVariation.innerBlocks ) { + onFinish( nextVariation.innerBlocks ); + } + }; + const onBlockPatternSelect = ( blocks ) => { + onFinish( [ [ 'core/query-loop', {}, blocks ] ] ); + }; + const inheritToggleHelp = !! inherit + ? _x( + 'Inherit the global query depending on the URL.', + 'Query block `inherit` option helping text' + ) + : _x( + 'Customize the query arguments.', + 'Query block `inherit` option helping text' + ); + return ( + +
+ + updateQuery( { inherit: !! value } ) + } + help={ inheritToggleHelp } + /> + { ! inherit && ( + + updateQuery( { postType: newValue } ) + } + /> + ) } +
+
+ ); +}; + +export default QueryBlockSetup; diff --git a/packages/block-library/src/query/edit/query-inspector-controls.js b/packages/block-library/src/query/edit/query-inspector-controls.js index 188f5fdc9161f..1b982e6d971f6 100644 --- a/packages/block-library/src/query/edit/query-inspector-controls.js +++ b/packages/block-library/src/query/edit/query-inspector-controls.js @@ -25,7 +25,6 @@ import { useEffect, useState, useCallback, - useMemo, createInterpolateElement, } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; @@ -33,7 +32,7 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import { getTermsInfo } from '../utils'; +import { getTermsInfo, usePostTypes } from '../utils'; import { MAX_FETCHED_TERMS } from '../constants'; const stickyOptions = [ @@ -73,43 +72,24 @@ export default function QueryInspectorControls( { const [ showCategories, setShowCategories ] = useState( true ); const [ showTags, setShowTags ] = useState( true ); const [ showSticky, setShowSticky ] = useState( postType === 'post' ); - const { authorList, categories, tags, postTypes } = useSelect( - ( select ) => { - const { getEntityRecords, getPostTypes } = select( coreStore ); - const termsQuery = { per_page: MAX_FETCHED_TERMS }; - const _categories = getEntityRecords( - 'taxonomy', - 'category', - termsQuery - ); - const _tags = getEntityRecords( - 'taxonomy', - 'post_tag', - termsQuery - ); - const excludedPostTypes = [ 'attachment' ]; - const filteredPostTypes = getPostTypes( { per_page: -1 } )?.filter( - ( { viewable, slug } ) => - viewable && ! excludedPostTypes.includes( slug ) - ); - return { - categories: getTermsInfo( _categories ), - tags: getTermsInfo( _tags ), - authorList: getEntityRecords( 'root', 'user', { - per_page: -1, - } ), - postTypes: filteredPostTypes, - }; - }, - [] - ); - const postTypesTaxonomiesMap = useMemo( () => { - if ( ! postTypes?.length ) return; - return postTypes.reduce( ( accumulator, type ) => { - accumulator[ type.slug ] = type.taxonomies; - return accumulator; - }, {} ); - }, [ postTypes ] ); + const { postTypesTaxonomiesMap, postTypesSelectOptions } = usePostTypes(); + const { authorList, categories, tags } = useSelect( ( select ) => { + const { getEntityRecords } = select( coreStore ); + const termsQuery = { per_page: MAX_FETCHED_TERMS }; + const _categories = getEntityRecords( + 'taxonomy', + 'category', + termsQuery + ); + const _tags = getEntityRecords( 'taxonomy', 'post_tag', termsQuery ); + return { + categories: getTermsInfo( _categories ), + tags: getTermsInfo( _tags ), + authorList: getEntityRecords( 'root', 'user', { + per_page: -1, + } ), + }; + }, [] ); useEffect( () => { if ( ! postTypesTaxonomiesMap ) return; const postTypeTaxonomies = postTypesTaxonomiesMap[ postType ]; @@ -119,14 +99,6 @@ export default function QueryInspectorControls( { useEffect( () => { setShowSticky( postType === 'post' ); }, [ postType ] ); - const postTypesSelectOptions = useMemo( - () => - ( postTypes || [] ).map( ( { labels, slug } ) => ( { - label: labels.singular_name, - value: slug, - } ) ), - [ postTypes ] - ); const onPostTypeChange = ( newValue ) => { const updateQuery = { postType: newValue }; if ( ! postTypesTaxonomiesMap[ newValue ].includes( 'category' ) ) { diff --git a/packages/block-library/src/query/editor.scss b/packages/block-library/src/query/editor.scss index e28e6de564324..3803d1b1e9fac 100644 --- a/packages/block-library/src/query/editor.scss +++ b/packages/block-library/src/query/editor.scss @@ -9,3 +9,121 @@ .wp-block-query__create-new-link { padding: 0 $grid-unit-20 $grid-unit-20 56px; } + +.wp-block-query { + .components-placeholder { + .block-setup-navigation { + padding: $grid-unit-15 0 0; + } + + .block-attributes-setup-container { + padding-top: $grid-unit-30; + + .components-base-control__help { + margin: $grid-unit-15 auto; + } + } + } +} + +.block-setup-block-layout-list__container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + + .block-setup-block-layout-list__list-item { + cursor: pointer; + margin: 0 $grid-unit-15 $grid-unit-15 0; + width: 200px; + text-align: center; + display: flex; + flex-direction: column; + + &.is-block-variation { + width: 90px; + + button { + display: inline-flex; + margin-right: 0; + height: auto; + } + } + + .block-setup-block-layout-list__item-title { + padding: $grid-unit-05 0; + font-size: $helptext-font-size; + } + + .block-setup-block-layout-list__item { + height: 100%; + display: flex; + flex-direction: column; + padding: 2px; + transition: all 0.05s ease-in-out; + @include reduce-motion("transition"); + border-radius: $radius-block-ui; + border: $border-width solid $gray-300; + + &:hover { + border: $border-width solid var(--wp-admin-theme-color); + } + + &:focus { + box-shadow: inset 0 0 0 1px $white, 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + } + + .block-editor-block-preview__container { + margin: auto 0; + cursor: pointer; // This is for the BlockPreview. + } + + .block-setup-block-layout-list__item-variation-icon { + color: var(--wp-admin-theme-color); + padding: $grid-unit-05 * 1.5; + border-radius: $radius-block-ui; + box-shadow: inset 0 0 0 1px var(--wp-admin-theme-color); + display: inline-flex; + margin: $grid-unit-15 auto auto auto; + + svg { + fill: currentColor; + outline: none; + } + } + } + } + + // Size consecutive block variation icons the same. + .block-setup-block-layout-list__list-item.is-block-variation .block-setup-block-layout-list__item { + height: 90px; + } + + // Size the block variation icon same as the thumbnail on step 1. + .block-setup-block-layout-list__list-item:not(.is-block-variation) + .block-setup-block-layout-list__list-item.is-block-variation .block-setup-block-layout-list__item { + height: 100%; + } +} + +.components-button.block-setup-block-layout-back-button.is-tertiary.has-icon { + color: inherit; + padding-left: 0; + display: inline-flex; + margin-right: auto; + margin-top: -$grid-unit-15; + + &:hover:not(:disabled) { + box-shadow: none; + color: var(--wp-admin-theme-color); + } + + &:active:not(:disabled) { + background: transparent; + color: $gray-300; + } + + svg { + margin-right: 0; + } +} diff --git a/packages/block-library/src/query/utils.js b/packages/block-library/src/query/utils.js index 2e55a6b9365ce..d77961346ad15 100644 --- a/packages/block-library/src/query/utils.js +++ b/packages/block-library/src/query/utils.js @@ -1,3 +1,10 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; + /** * WordPress term object from REST API. * Categories ref: https://developer.wordpress.org/rest-api/reference/categories/ @@ -45,3 +52,40 @@ export const getTermsInfo = ( terms ) => ( { { mapById: {}, mapByName: {}, names: [] } ), } ); + +/** + * Returns a helper object that contains: + * 1. An `options` object from the available post types, to be passed to a `SelectControl`. + * 2. A helper map with available taxonomies per post type. + * + * @return {Object} The helper object related to post types. + */ +export const usePostTypes = () => { + const { postTypes } = useSelect( ( select ) => { + const { getPostTypes } = select( coreStore ); + const excludedPostTypes = [ 'attachment' ]; + const filteredPostTypes = getPostTypes( { per_page: -1 } )?.filter( + ( { viewable, slug } ) => + viewable && ! excludedPostTypes.includes( slug ) + ); + return { + postTypes: filteredPostTypes, + }; + }, [] ); + const postTypesTaxonomiesMap = useMemo( () => { + if ( ! postTypes?.length ) return; + return postTypes.reduce( ( accumulator, type ) => { + accumulator[ type.slug ] = type.taxonomies; + return accumulator; + }, {} ); + }, [ postTypes ] ); + const postTypesSelectOptions = useMemo( + () => + ( postTypes || [] ).map( ( { labels, slug } ) => ( { + label: labels.singular_name, + value: slug, + } ) ), + [ postTypes ] + ); + return { postTypesTaxonomiesMap, postTypesSelectOptions }; +};