From fd0d8e3eba374ecf5b6199914a21b8a18af8a4ae Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Thu, 30 Nov 2023 10:42:06 +0800 Subject: [PATCH] Automatically assign block ids to pattern blocks for use in partial synching of pattern instances (#56495) * Automatically assign block ids * Downgrade package to fix tooling * Move to the editor package and allow core/button * Fix test resolver --- lib/experimental/blocks.php | 28 ++++-- lib/experimental/connection-sources/index.php | 5 +- package-lock.json | 6 +- .../block-editor/src/hooks/custom-fields.js | 90 ++++++----------- packages/block-library/src/block/edit.js | 36 +++---- packages/block-library/src/button/block.json | 3 +- packages/editor/src/hooks/index.js | 1 + .../src/hooks/pattern-partial-syncing.js | 73 ++++++++++++++ packages/patterns/package.json | 3 +- .../components/partial-syncing-controls.js | 98 +++++++++++++++++++ packages/patterns/src/constants.js | 11 +++ packages/patterns/src/private-apis.js | 4 + test/unit/scripts/resolver.js | 3 +- 13 files changed, 270 insertions(+), 91 deletions(-) create mode 100644 packages/editor/src/hooks/pattern-partial-syncing.js create mode 100644 packages/patterns/src/components/partial-syncing-controls.js diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index 73c23999cc3c4..323ae54d27338 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -97,9 +97,11 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst // Allowlist of blocks that support block connections. // Currently, we only allow the following blocks and attributes: // - Paragraph: content. + // - Button: text. // - Image: url. $blocks_attributes_allowlist = array( 'core/paragraph' => array( 'content' ), + 'core/button' => array( 'text' ), 'core/image' => array( 'url' ), ); @@ -142,17 +144,25 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst continue; } - // If the attribute does not specify the name of the custom field, skip it. - if ( ! isset( $attribute_value['value'] ) ) { - continue; + if ( 'pattern_attributes' === $attribute_value['source'] ) { + if ( ! _wp_array_get( $block_instance->attributes, array( 'metadata', 'id' ), false ) ) { + continue; + } + + $custom_value = $connection_sources[ $attribute_value['source'] ]( $block_instance ); + } else { + // If the attribute does not specify the name of the custom field, skip it. + if ( ! isset( $attribute_value['value'] ) ) { + continue; + } + + // Get the content from the connection source. + $custom_value = $connection_sources[ $attribute_value['source'] ]( + $block_instance, + $attribute_value['value'] + ); } - // Get the content from the connection source. - $custom_value = $connection_sources[ $attribute_value['source'] ]( - $block_instance, - $attribute_value['value'] - ); - if ( false === $custom_value ) { continue; } diff --git a/lib/experimental/connection-sources/index.php b/lib/experimental/connection-sources/index.php index 4bfc9f89d9adf..435f97142f31a 100644 --- a/lib/experimental/connection-sources/index.php +++ b/lib/experimental/connection-sources/index.php @@ -12,7 +12,8 @@ // if it doesn't, `get_post_meta()` will just return an empty string. return get_post_meta( $block_instance->context['postId'], $meta_field, true ); }, - 'pattern_attributes' => function ( $block_instance, $meta_field ) { - return _wp_array_get( $block_instance->context, array( 'overrides', $meta_field ), false ); + 'pattern_attributes' => function ( $block_instance ) { + $block_id = $block_instance->attributes['metadata']['id']; + return _wp_array_get( $block_instance->context, array( 'overrides', $block_id ), false ); }, ); diff --git a/package-lock.json b/package-lock.json index b2deb50ef559f..7c3539dd39fbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56046,7 +56046,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" }, "engines": { "node": ">=16.0.0" @@ -70916,7 +70917,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" } }, "@wordpress/plugins": { diff --git a/packages/block-editor/src/hooks/custom-fields.js b/packages/block-editor/src/hooks/custom-fields.js index 60c924f40c940..adb9df15824a7 100644 --- a/packages/block-editor/src/hooks/custom-fields.js +++ b/packages/block-editor/src/hooks/custom-fields.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { addFilter } from '@wordpress/hooks'; -import { PanelBody, TextControl, SelectControl } from '@wordpress/components'; +import { PanelBody, TextControl } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { hasBlockSupport } from '@wordpress/blocks'; import { createHigherOrderComponent } from '@wordpress/compose'; @@ -47,71 +47,45 @@ function CustomFieldsControl( props ) { if ( props.name === 'core/paragraph' ) attributeName = 'content'; if ( props.name === 'core/image' ) attributeName = 'url'; - const connectionSource = - props.attributes?.connections?.attributes?.[ attributeName ]?.source || - ''; - const connectionValue = - props.attributes?.connections?.attributes?.[ attributeName ]?.value || - ''; - - function updateConnections( source, value ) { - if ( value === '' ) { - props.setAttributes( { - connections: undefined, - placeholder: undefined, - } ); - } else { - props.setAttributes( { - connections: { - attributes: { - // The attributeName will be either `content` or `url`. - [ attributeName ]: { - // Source will be variable, could be post_meta, user_meta, term_meta, etc. - // Could even be a custom source like a social media attribute. - source, - value, - }, - }, - }, - placeholder: sprintf( - 'This content will be replaced on the frontend by the value of "%s" custom field.', - value - ), - } ); - } - } - return ( - { - updateConnections( nextSource, connectionValue ); - } } - /> { - updateConnections( connectionSource, nextValue ); + if ( nextValue === '' ) { + props.setAttributes( { + connections: undefined, + [ attributeName ]: undefined, + placeholder: undefined, + } ); + } else { + props.setAttributes( { + connections: { + attributes: { + // The attributeName will be either `content` or `url`. + [ attributeName ]: { + // Source will be variable, could be post_meta, user_meta, term_meta, etc. + // Could even be a custom source like a social media attribute. + source: 'meta_fields', + value: nextValue, + }, + }, + }, + [ attributeName ]: undefined, + placeholder: sprintf( + 'This content will be replaced on the frontend by the value of "%s" custom field.', + nextValue + ), + } ); + } } } /> diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 5a8e153eb6033..e86ed9b59c62b 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -46,14 +46,11 @@ function isPartiallySynced( block ) { ); } function getPartiallySyncedAttributes( block ) { - const attributes = {}; - for ( const [ attribute, connection ] of Object.entries( - block.attributes.connections.attributes - ) ) { - if ( connection.source !== 'pattern_attributes' ) continue; - attributes[ attribute ] = connection.value; - } - return attributes; + return Object.entries( block.attributes.connections.attributes ) + .filter( + ( [ , connection ] ) => connection.source === 'pattern_attributes' + ) + .map( ( [ attributeKey ] ) => attributeKey ); } const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; @@ -94,13 +91,15 @@ function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { overrides, defaultValues ); - if ( ! isPartiallySynced( block ) ) return { ...block, innerBlocks }; + const blockId = block.attributes.metadata?.id; + if ( ! isPartiallySynced( block ) || ! blockId ) + return { ...block, innerBlocks }; const attributes = getPartiallySyncedAttributes( block ); const newAttributes = { ...block.attributes }; - for ( const [ attributeKey, id ] of Object.entries( attributes ) ) { - defaultValues[ id ] = block.attributes[ attributeKey ]; - if ( overrides[ id ] ) { - newAttributes[ attributeKey ] = overrides[ id ]; + for ( const attributeKey of attributes ) { + defaultValues[ blockId ] = block.attributes[ attributeKey ]; + if ( overrides[ blockId ] ) { + newAttributes[ attributeKey ] = overrides[ blockId ]; } } return { @@ -119,11 +118,14 @@ function getOverridesFromBlocks( blocks, defaultValues ) { overrides, getOverridesFromBlocks( block.innerBlocks, defaultValues ) ); - if ( ! isPartiallySynced( block ) ) continue; + const blockId = block.attributes.metadata?.id; + if ( ! isPartiallySynced( block ) || ! blockId ) continue; const attributes = getPartiallySyncedAttributes( block ); - for ( const [ attributeKey, id ] of Object.entries( attributes ) ) { - if ( block.attributes[ attributeKey ] !== defaultValues[ id ] ) { - overrides[ id ] = block.attributes[ attributeKey ]; + for ( const attributeKey of attributes ) { + if ( + block.attributes[ attributeKey ] !== defaultValues[ blockId ] + ) { + overrides[ blockId ] = block.attributes[ attributeKey ]; } } } diff --git a/packages/block-library/src/button/block.json b/packages/block-library/src/button/block.json index eec327b4ca48e..4286f200fda4b 100644 --- a/packages/block-library/src/button/block.json +++ b/packages/block-library/src/button/block.json @@ -118,7 +118,8 @@ "width": true } }, - "__experimentalSelector": ".wp-block-button .wp-block-button__link" + "__experimentalSelector": ".wp-block-button .wp-block-button__link", + "__experimentalConnections": true }, "styles": [ { "name": "fill", "label": "Fill", "isDefault": true }, diff --git a/packages/editor/src/hooks/index.js b/packages/editor/src/hooks/index.js index 6e0934d63c0cf..5a48ec1bf4956 100644 --- a/packages/editor/src/hooks/index.js +++ b/packages/editor/src/hooks/index.js @@ -3,3 +3,4 @@ */ import './custom-sources-backwards-compatibility'; import './default-autocompleters'; +import './pattern-partial-syncing'; diff --git a/packages/editor/src/hooks/pattern-partial-syncing.js b/packages/editor/src/hooks/pattern-partial-syncing.js new file mode 100644 index 0000000000000..75a969435f829 --- /dev/null +++ b/packages/editor/src/hooks/pattern-partial-syncing.js @@ -0,0 +1,73 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { privateApis } from '@wordpress/patterns'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useBlockEditingMode } from '@wordpress/block-editor'; +import { hasBlockSupport } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../store'; +import { unlock } from '../lock-unlock'; + +const { + PartialSyncingControls, + PATTERN_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, +} = unlock( privateApis ); + +/** + * Override the default edit UI to include a new block inspector control for + * assigning a partial syncing controls to supported blocks in the pattern editor. + * Currently, only the `core/paragraph` block is supported. + * + * @param {Component} BlockEdit Original component. + * + * @return {Component} Wrapped component. + */ +const withPartialSyncingControls = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const blockEditingMode = useBlockEditingMode(); + const hasCustomFieldsSupport = hasBlockSupport( + props.name, + '__experimentalConnections', + false + ); + const isEditingPattern = useSelect( + ( select ) => + select( editorStore ).getCurrentPostType() === + PATTERN_TYPES.user, + [] + ); + + const shouldShowPartialSyncingControls = + hasCustomFieldsSupport && + props.isSelected && + isEditingPattern && + blockEditingMode === 'default' && + Object.keys( PARTIAL_SYNCING_SUPPORTED_BLOCKS ).includes( + props.name + ); + + return ( + <> + + { shouldShowPartialSyncingControls && ( + + ) } + + ); + } +); + +if ( window.__experimentalConnections ) { + addFilter( + 'editor.BlockEdit', + 'core/editor/with-partial-syncing-controls', + withPartialSyncingControls + ); +} diff --git a/packages/patterns/package.json b/packages/patterns/package.json index bab11059bf92c..783193ad2a686 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -44,7 +44,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/patterns/src/components/partial-syncing-controls.js b/packages/patterns/src/components/partial-syncing-controls.js new file mode 100644 index 0000000000000..42c39ce69e87b --- /dev/null +++ b/packages/patterns/src/components/partial-syncing-controls.js @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { nanoid } from 'nanoid'; + +/** + * WordPress dependencies + */ +import { InspectorControls } from '@wordpress/block-editor'; +import { BaseControl, CheckboxControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { PARTIAL_SYNCING_SUPPORTED_BLOCKS } from '../constants'; + +function PartialSyncingControls( { name, attributes, setAttributes } ) { + const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ]; + + function updateConnections( attributeName, isChecked ) { + if ( ! isChecked ) { + let updatedConnections = { + ...attributes.connections, + attributes: { + ...attributes.connections?.attributes, + [ attributeName ]: undefined, + }, + }; + if ( Object.keys( updatedConnections.attributes ).length === 1 ) { + updatedConnections.attributes = undefined; + } + if ( + Object.keys( updatedConnections ).length === 1 && + updateConnections.attributes === undefined + ) { + updatedConnections = undefined; + } + setAttributes( { + connections: updatedConnections, + } ); + return; + } + + const updatedConnections = { + ...attributes.connections, + attributes: { + ...attributes.connections?.attributes, + [ attributeName ]: { + source: 'pattern_attributes', + }, + }, + }; + + if ( typeof attributes.metadata?.id === 'string' ) { + setAttributes( { connections: updatedConnections } ); + return; + } + + const id = nanoid( 6 ); + setAttributes( { + connections: updatedConnections, + metadata: { + ...attributes.metadata, + id, + }, + } ); + } + + return ( + + + + { __( 'Synced attributes' ) } + + { Object.entries( syncedAttributes ).map( + ( [ attributeName, label ] ) => ( + { + updateConnections( attributeName, isChecked ); + } } + /> + ) + ) } + + + ); +} + +export default PartialSyncingControls; diff --git a/packages/patterns/src/constants.js b/packages/patterns/src/constants.js index 465970b17b7aa..c61150874c562 100644 --- a/packages/patterns/src/constants.js +++ b/packages/patterns/src/constants.js @@ -1,3 +1,8 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + export const PATTERN_TYPES = { theme: 'pattern', user: 'wp_block', @@ -14,3 +19,9 @@ export const PATTERN_SYNC_TYPES = { full: 'fully', unsynced: 'unsynced', }; + +// TODO: This should not be hardcoded. Maybe there should be a config and/or an UI. +export const PARTIAL_SYNCING_SUPPORTED_BLOCKS = { + 'core/paragraph': { content: __( 'Content' ) }, + 'core/button': { text: __( 'Text' ) }, +}; diff --git a/packages/patterns/src/private-apis.js b/packages/patterns/src/private-apis.js index 770a78fd4fa9d..b357efb1bc107 100644 --- a/packages/patterns/src/private-apis.js +++ b/packages/patterns/src/private-apis.js @@ -7,12 +7,14 @@ import DuplicatePatternModal from './components/duplicate-pattern-modal'; import RenamePatternModal from './components/rename-pattern-modal'; import PatternsMenuItems from './components'; import RenamePatternCategoryModal from './components/rename-pattern-category-modal'; +import PartialSyncingControls from './components/partial-syncing-controls'; import { PATTERN_TYPES, PATTERN_DEFAULT_CATEGORY, PATTERN_USER_CATEGORY, EXCLUDED_PATTERN_SOURCES, PATTERN_SYNC_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, } from './constants'; export const privateApis = {}; @@ -22,9 +24,11 @@ lock( privateApis, { RenamePatternModal, PatternsMenuItems, RenamePatternCategoryModal, + PartialSyncingControls, PATTERN_TYPES, PATTERN_DEFAULT_CATEGORY, PATTERN_USER_CATEGORY, EXCLUDED_PATTERN_SOURCES, PATTERN_SYNC_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, } ); diff --git a/test/unit/scripts/resolver.js b/test/unit/scripts/resolver.js index 2c359145f0b3e..7672bb723e124 100644 --- a/test/unit/scripts/resolver.js +++ b/test/unit/scripts/resolver.js @@ -24,7 +24,8 @@ module.exports = ( path, options ) => { pkg.name === 'uuid' || pkg.name === 'react-colorful' || pkg.name === '@eslint/eslintrc' || - pkg.name === 'expect' + pkg.name === 'expect' || + pkg.name === 'nanoid' ) { delete pkg.exports; delete pkg.module;