diff --git a/lib/block-supports/pattern.php b/lib/block-supports/pattern.php index a783135c793e3f..f9dd1b4b44248a 100644 --- a/lib/block-supports/pattern.php +++ b/lib/block-supports/pattern.php @@ -13,7 +13,7 @@ * @param WP_Block_Type $block_type Block Type. */ function gutenberg_register_pattern_support( $block_type ) { - $pattern_support = property_exists( $block_type, 'supports' ) ? _wp_array_get( $block_type->supports, array( '__experimentalConnections' ), false ) : false; + $pattern_support = property_exists( $block_type, 'supports' ) ? _wp_array_get( $block_type->supports, array( '__experimentalBlockBindings' ), false ) : false; if ( $pattern_support ) { if ( ! $block_type->uses_context ) { diff --git a/lib/experimental/block-bindings/html-processing.php b/lib/experimental/block-bindings/html-processing.php new file mode 100644 index 00000000000000..9fb629d5e28218 --- /dev/null +++ b/lib/experimental/block-bindings/html-processing.php @@ -0,0 +1,112 @@ +get_registered( $block_name ); + if ( null === $block_type ) { + return; + } + + // Depending on the attribute source, the processing will be different. + // TODO: Get the type from the block attribute definition and modify/validate the value returned by the source if needed. + switch ( $block_type->attributes[ $block_attr ]['source'] ) { + case 'html': + case 'rich-text': + $p = new WP_HTML_Tag_Processor( $block_content ); + + // TODO: Support for CSS selectors whenever they are ready in the HTML API. + // In the meantime, support comma-separated selectors by exploding them into an array. + $selectors = explode( ',', $block_type->attributes[ $block_attr ]['selector'] ); + // Add a bookmark to the first tag to be able to iterate over the selectors. + $p->next_tag(); + $p->set_bookmark( 'iterate-selectors' ); + + // TODO: This shouldn't be needed when the `set_inner_html` function is ready. + // Store the parent tag and its attributes to be able to restore them later in the button. + // The button block has a wrapper while the paragraph and heading blocks don't. + if ( 'core/button' === $block_name ) { + $parent_tag = $p->get_tag(); + $parent_tag_names = $p->get_attribute_names_with_prefix( '' ); + $parent_tag_attrs = array(); + foreach ( $parent_tag_names as $name ) { + $parent_tag_attrs[ $name ] = $p->get_attribute( $name ); + } + } + + foreach ( $selectors as $selector ) { + // If the parent tag, or any of its children, matches the selector, replace the HTML. + if ( strcasecmp( $p->get_tag( $selector ), $selector ) === 0 || $p->next_tag( + array( + 'tag_name' => $selector, + ) + ) ) { + $p->release_bookmark( 'iterate-selectors' ); + + // TODO: Use `set_inner_html` method whenever it's ready in the HTML API. + // Until then, it is hardcoded for the paragraph, heading, and button blocks. + // Store the tag and its attributes to be able to restore them later. + $selector_tag_names = $p->get_attribute_names_with_prefix( '' ); + $selector_tag_attrs = array(); + foreach ( $selector_tag_names as $name ) { + $selector_tag_attrs[ $name ] = $p->get_attribute( $name ); + } + $selector_markup = "<$selector>" . esc_html( $source_value ) . ""; + $p2 = new WP_HTML_Tag_Processor( $selector_markup ); + $p2->next_tag(); + foreach ( $selector_tag_attrs as $attribute_key => $attribute_value ) { + $p2->set_attribute( $attribute_key, $attribute_value ); + } + $selector_updated_html = $p2->get_updated_html(); + if ( 'core/paragraph' === $block_name || 'core/heading' === $block_name ) { + return $selector_updated_html; + } + if ( 'core/button' === $block_name ) { + $markup = "<$parent_tag>$selector_updated_html"; + $p3 = new WP_HTML_Tag_Processor( $markup ); + $p3->next_tag(); + foreach ( $parent_tag_attrs as $attribute_key => $attribute_value ) { + $p3->set_attribute( $attribute_key, $attribute_value ); + } + return $p3->get_updated_html(); + } + } else { + $p->seek( 'iterate-selectors' ); + } + } + $p->release_bookmark( 'iterate-selectors' ); + return $block_content; + + case 'attribute': + $p = new WP_HTML_Tag_Processor( $block_content ); + if ( ! $p->next_tag( + array( + // TODO: build the query from CSS selector. + 'tag_name' => $block_type->attributes[ $block_attr ]['selector'], + ) + ) ) { + return $block_content; + } + $p->set_attribute( $block_type->attributes[ $block_attr ]['attribute'], esc_attr( $source_value ) ); + return $p->get_updated_html(); + break; + + default: + return $block_content; + break; + } + return; + } +} diff --git a/lib/experimental/block-bindings/index.php b/lib/experimental/block-bindings/index.php new file mode 100644 index 00000000000000..cca857e93702f3 --- /dev/null +++ b/lib/experimental/block-bindings/index.php @@ -0,0 +1,20 @@ +attributes, array( 'metadata', 'id' ), false ) ) { + return; + } + $block_id = $block_instance->attributes['metadata']['id']; + return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id ), false ); + }; + register_block_bindings_source( + 'pattern_attributes', + array( + 'label' => __( 'Pattern Attributes', 'gutenberg' ), + 'apply' => $pattern_source_callback, + ) + ); +} diff --git a/lib/experimental/block-bindings/sources/post-meta.php b/lib/experimental/block-bindings/sources/post-meta.php new file mode 100644 index 00000000000000..9e2bf22aa8b734 --- /dev/null +++ b/lib/experimental/block-bindings/sources/post-meta.php @@ -0,0 +1,27 @@ +context['postId'] but it wasn't available in the image block. + $post_id = get_the_ID(); + } + + return get_post_meta( $post_id, $source_attrs['value'], true ); + }; + register_block_bindings_source( + 'post_meta', + array( + 'label' => __( 'Post Meta', 'gutenberg' ), + 'apply' => $post_meta_source_callback, + ) + ); +} diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index 88e46b478389d2..922774ba4ca978 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -83,118 +83,101 @@ function wp_enqueue_block_view_script( $block_name, $args ) { $gutenberg_experiments = get_option( 'gutenberg-experiments' ); if ( $gutenberg_experiments && ( - array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) || + array_key_exists( 'gutenberg-block-bindings', $gutenberg_experiments ) || array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) ) ) { - /** - * Renders the block meta attributes. - * - * @param string $block_content Block Content. - * @param array $block Block attributes. - * @param WP_Block $block_instance The block instance. - */ - function gutenberg_render_block_connections( $block_content, $block, $block_instance ) { - $connection_sources = require __DIR__ . '/connection-sources/index.php'; - $block_type = $block_instance->block_type; - - // Allowlist of blocks that support block connections. - // Currently, we only allow the following blocks and attributes: - // - Paragraph: content. - // - Image: url. - $blocks_attributes_allowlist = array( - 'core/paragraph' => array( 'content' ), - 'core/image' => array( 'url' ), - ); - - // Whitelist of the block types that support block connections. - // Currently, we only allow the Paragraph and Image blocks to use block connections. - if ( ! in_array( $block['blockName'], array_keys( $blocks_attributes_allowlist ), true ) ) { - return $block_content; - } - - // If for some reason, the block type is not found, skip it. - if ( null === $block_type ) { - return $block_content; - } - - // If the block does not have support for block connections, skip it. - if ( ! block_has_support( $block_type, array( '__experimentalConnections' ), false ) ) { - return $block_content; - } - - // Get all the attributes that have a connection. - $connected_attributes = $block['attrs']['connections']['attributes'] ?? false; - if ( ! $connected_attributes ) { - return $block_content; - } - - foreach ( $connected_attributes as $attribute_name => $attribute_value ) { - - // If the attribute is not in the allowlist, skip it. - if ( ! in_array( $attribute_name, $blocks_attributes_allowlist[ $block['blockName'] ], true ) ) { - continue; - } - - // Skip if the source value is not "meta_fields" or "pattern_attributes". - if ( 'meta_fields' !== $attribute_value['source'] && 'pattern_attributes' !== $attribute_value['source'] ) { - continue; - } - // If the attribute does not have a source, skip it. - if ( ! isset( $block_type->attributes[ $attribute_name ]['source'] ) ) { - continue; + require_once __DIR__ . '/block-bindings/index.php'; + // Allowed blocks that support block bindings. + // TODO: Look for a mechanism to opt-in for this. Maybe adding a property to block attributes? + global $block_bindings_allowed_blocks; + $block_bindings_allowed_blocks = array( + 'core/paragraph' => array( 'content' ), + 'core/heading' => array( 'content' ), + 'core/image' => array( 'url', 'title' ), + 'core/button' => array( 'url', 'text' ), + ); + if ( ! function_exists( 'process_block_bindings' ) ) { + /** + * Process the block bindings attribute. + * + * @param string $block_content Block Content. + * @param array $block Block attributes. + * @param WP_Block $block_instance The block instance. + */ + function process_block_bindings( $block_content, $block, $block_instance ) { + // If the block doesn't have the bindings property, return. + if ( ! isset( $block['attrs']['metadata']['bindings'] ) ) { + return $block_content; } - if ( 'pattern_attributes' === $attribute_value['source'] ) { - if ( ! _wp_array_get( $block_instance->attributes, array( 'metadata', 'id' ), false ) ) { + // TODO: Review the bindings syntax. + // Assuming the following format for the bindings property of the "metadata" attribute: + // + // "bindings": { + // "title": { + // "source": { + // "name": "post_meta", + // "attributes": { "value": "text_custom_field" } + // } + // }, + // "url": { + // "source": { + // "name": "post_meta", + // "attributes": { "value": "text_custom_field" } + // } + // } + // }, + // . + global $block_bindings_allowed_blocks; + global $block_bindings_sources; + $modified_block_content = $block_content; + foreach ( $block['attrs']['metadata']['bindings'] as $binding_attribute => $binding_source ) { + // If the block is not in the list, stop processing. + if ( ! isset( $block_bindings_allowed_blocks[ $block['blockName'] ] ) ) { + return $block_content; + } + // If the attribute is not in the list, process next attribute. + if ( ! in_array( $binding_attribute, $block_bindings_allowed_blocks[ $block['blockName'] ], true ) ) { 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'] ) ) { + // If no source is provided, or that source is not registered, process next attribute. + if ( ! isset( $binding_source['source'] ) || ! isset( $binding_source['source']['name'] ) || ! isset( $block_bindings_sources[ $binding_source['source']['name'] ] ) ) { continue; } - // Get the content from the connection source. - $custom_value = $connection_sources[ $attribute_value['source'] ]( - $block_instance, - $attribute_value['value'] - ); - } - - if ( false === $custom_value ) { - continue; - } + $source_callback = $block_bindings_sources[ $binding_source['source']['name'] ]['apply']; + // Get the value based on the source. + if ( ! isset( $binding_source['source']['attributes'] ) ) { + $source_args = array(); + } else { + $source_args = $binding_source['source']['attributes']; + } + $source_value = $source_callback( $source_args, $block_content, $block, $block_instance ); + // If the value is null, process next attribute. + if ( is_null( $source_value ) ) { + continue; + } - $tags = new WP_HTML_Tag_Processor( $block_content ); - $found = $tags->next_tag( - array( - // TODO: In the future, when blocks other than Paragraph and Image are - // supported, we should build the full query from CSS selector. - 'tag_name' => $block_type->attributes[ $attribute_name ]['selector'], - ) - ); - if ( ! $found ) { - return $block_content; + // Process the HTML based on the block and the attribute. + $modified_block_content = block_bindings_replace_html( $modified_block_content, $block['blockName'], $binding_attribute, $source_value ); } - $tag_name = $tags->get_tag(); - $markup = "<$tag_name>$custom_value"; - $updated_tags = new WP_HTML_Tag_Processor( $markup ); - $updated_tags->next_tag(); - - // Get all the attributes from the original block and add them to the new markup. - $names = $tags->get_attribute_names_with_prefix( '' ); - foreach ( $names as $name ) { - $updated_tags->set_attribute( $name, $tags->get_attribute( $name ) ); - } - - return $updated_tags->get_updated_html(); + return $modified_block_content; } - return $block_content; + // Add filter only to the blocks in the list. + foreach ( $block_bindings_allowed_blocks as $block_name => $attributes ) { + // if ( ! function_exists( 'process_block_bindings' ) ) { + // function filter_metadata_registration( $metadata ) { + // var_dump( $metadata ); + // die; + // $metadata['usesContext'] = array( 'postId', 'postType', 'queryId' ); + // return $metadata; + // } + // add_filter( 'block_type_metadata', 'filter_metadata_registration' ); + // } + + add_filter( 'render_block_' . $block_name, 'process_block_bindings', 20, 3 ); + } } - - add_filter( 'render_block', 'gutenberg_render_block_connections', 10, 3 ); } diff --git a/lib/experimental/connection-sources/index.php b/lib/experimental/connection-sources/index.php deleted file mode 100644 index bf89ba177b6e94..00000000000000 --- a/lib/experimental/connection-sources/index.php +++ /dev/null @@ -1,19 +0,0 @@ - 'meta', - 'meta_fields' => function ( $block_instance, $meta_field ) { - // We should probably also check if the meta field exists but for now it's okay because - // 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 ) { - $block_id = $block_instance->attributes['metadata']['id']; - return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id ), false ); - }, -); diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 5f61684e8b1342..729376cf030dd9 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -26,8 +26,8 @@ function gutenberg_enable_experiments() { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableGroupGridVariation = true', 'before' ); } - if ( $gutenberg_experiments && array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) ) { - wp_add_inline_script( 'wp-block-editor', 'window.__experimentalConnections = true', 'before' ); + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-block-bindings', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalBlockBindings = true', 'before' ); } if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) { diff --git a/lib/experiments-page.php b/lib/experiments-page.php index b77a69b692ff1f..c407e106a34173 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -128,13 +128,13 @@ function gutenberg_initialize_experiments_settings() { add_settings_field( 'gutenberg-custom-fields', - __( 'Connections', 'gutenberg' ), + __( 'Block Bindings & Custom Fields', 'gutenberg' ), 'gutenberg_display_experiment_field', 'gutenberg-experiments', 'gutenberg_experiments_section', array( - 'label' => __( 'Test Connections', 'gutenberg' ), - 'id' => 'gutenberg-connections', + 'label' => __( 'Test connecting block attributes to different sources like custom fields', 'gutenberg' ), + 'id' => 'gutenberg-block-bindings', ) ); diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 5917ac235505cb..d0870237419533 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -812,6 +812,18 @@ _Properties_ Ensures that the text selection keeps the same vertical distance from the viewport during keyboard events within this component. The vertical distance can vary. It is the last clicked or scrolled to position. +### updateBlockBindingsAttribute + +Helper to update the bindings attribute used by the Block Bindings API. + +_Parameters_ + +- _blockAttributes_ `Object`: - The original block attributes. +- _setAttributes_ `Function`: - setAttributes function to modify the bindings property. +- _attributeName_ `string`: - The attribute in the bindings object to update. +- _sourceName_ `string`: - The source name added to the bindings property. +- _sourceAttributes_ `string`: - The source attributes added to the bindings property. + ### URLInput _Related_ diff --git a/packages/block-editor/src/hooks/custom-fields.js b/packages/block-editor/src/hooks/custom-fields.js deleted file mode 100644 index 9b677933adc138..00000000000000 --- a/packages/block-editor/src/hooks/custom-fields.js +++ /dev/null @@ -1,115 +0,0 @@ -/** - * WordPress dependencies - */ -import { addFilter } from '@wordpress/hooks'; -import { PanelBody, TextControl } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; -import { hasBlockSupport } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { InspectorControls } from '../components'; -import { useBlockEditingMode } from '../components/block-editing-mode'; - -/** - * Filters registered block settings, extending attributes to include `connections`. - * - * @param {Object} settings Original block settings. - * - * @return {Object} Filtered block settings. - */ -function addAttribute( settings ) { - if ( hasBlockSupport( settings, '__experimentalConnections', true ) ) { - // Gracefully handle if settings.attributes.connections is undefined. - settings.attributes = { - ...settings.attributes, - connections: { - type: 'object', - }, - }; - } - - return settings; -} - -function CustomFieldsControlPure( { name, connections, setAttributes } ) { - const blockEditingMode = useBlockEditingMode(); - if ( blockEditingMode !== 'default' ) { - return null; - } - - // If the block is a paragraph or image block, we need to know which - // attribute to use for the connection. Only the `content` attribute - // of the paragraph block and the `url` attribute of the image block are supported. - let attributeName; - if ( name === 'core/paragraph' ) attributeName = 'content'; - if ( name === 'core/image' ) attributeName = 'url'; - - return ( - - - { - if ( nextValue === '' ) { - setAttributes( { - connections: undefined, - [ attributeName ]: undefined, - placeholder: undefined, - } ); - } else { - 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 - ), - } ); - } - } } - /> - - - ); -} - -export default { - edit: CustomFieldsControlPure, - attributeKeys: [ 'connections' ], - hasSupport( name ) { - return ( - hasBlockSupport( name, '__experimentalConnections', false ) && - // Check if the current block is a paragraph or image block. - // Currently, only these two blocks are supported. - [ 'core/paragraph', 'core/image' ].includes( name ) - ); - }, -}; - -if ( - window.__experimentalConnections || - window.__experimentalPatternPartialSyncing -) { - addFilter( - 'blocks.registerBlockType', - 'core/editor/connections/attribute', - addAttribute - ); -} diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index 385b9fe6b1511e..f17c0a22166e4e 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -25,7 +25,6 @@ import layout from './layout'; import childLayout from './layout-child'; import contentLockUI from './content-lock-ui'; import './metadata'; -import customFields from './custom-fields'; import blockHooks from './block-hooks'; import blockRenaming from './block-renaming'; @@ -39,7 +38,6 @@ createBlockEditFilter( position, layout, contentLockUI, - window.__experimentalConnections ? customFields : null, blockHooks, blockRenaming, ].filter( Boolean ) diff --git a/packages/block-editor/src/utils/index.js b/packages/block-editor/src/utils/index.js index ee3b2692b369a8..21f33acfc274db 100644 --- a/packages/block-editor/src/utils/index.js +++ b/packages/block-editor/src/utils/index.js @@ -1,3 +1,4 @@ export { default as transformStyles } from './transform-styles'; export * from './block-variation-transforms'; export { default as getPxFromCssUnit } from './get-px-from-css-unit'; +export * from './update-block-bindings'; diff --git a/packages/block-editor/src/utils/update-block-bindings.js b/packages/block-editor/src/utils/update-block-bindings.js new file mode 100644 index 00000000000000..c34aa8b46c6e36 --- /dev/null +++ b/packages/block-editor/src/utils/update-block-bindings.js @@ -0,0 +1,68 @@ +/** + * Helper to update the bindings attribute used by the Block Bindings API. + * + * @param {Object} blockAttributes - The original block attributes. + * @param {Function} setAttributes - setAttributes function to modify the bindings property. + * @param {string} attributeName - The attribute in the bindings object to update. + * @param {string} sourceName - The source name added to the bindings property. + * @param {string} sourceAttributes - The source attributes added to the bindings property. + */ +export const updateBlockBindingsAttribute = ( + blockAttributes, + setAttributes, + attributeName, + sourceName, + sourceAttributes +) => { + // TODO: Review if we can create a React Hook for this. + + // Assuming the following format for the bindings property of the "metadata" attribute: + // + // "bindings": { + // "title": { + // "source": { + // "name": "metadata", + // "attributes": { "value": "text_custom_field" } + // } + // }, + // "url": { + // "source": { + // "name": "metadata", + // "attributes": { "value": "text_custom_field" } + // } + // } + // }, + // . + + let updatedBindings = {}; + // // If no sourceName is provided, remove the attribute from the bindings. + if ( sourceName === null ) { + if ( ! blockAttributes?.metadata.bindings ) { + return blockAttributes?.metadata; + } + + updatedBindings = { + ...blockAttributes?.metadata?.bindings, + [ attributeName ]: undefined, + }; + if ( Object.keys( updatedBindings ).length === 1 ) { + updatedBindings = undefined; + } + } else { + updatedBindings = { + ...blockAttributes?.metadata?.bindings, + [ attributeName ]: { + source: { name: sourceName, attributes: sourceAttributes }, + }, + }; + } + + setAttributes( { + metadata: { + ...blockAttributes.metadata, + bindings: updatedBindings, + }, + } ); + + return blockAttributes.metadata; +}; diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index fbfef0b4cf1778..c8290e5e30090f 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -38,17 +38,21 @@ const { useLayoutClasses } = unlock( blockEditorPrivateApis ); function isPartiallySynced( block ) { return ( - !! getBlockSupport( block.name, '__experimentalConnections', false ) && - !! block.attributes.connections?.attributes && - Object.values( block.attributes.connections.attributes ).some( - ( connection ) => connection.source === 'pattern_attributes' + !! getBlockSupport( + block.name, + '__experimentalBlockBindings', + false + ) && + !! block.attributes.metadata?.bindings && + Object.values( block.attributes.metadata.bindings ).some( + ( binding ) => binding.source.name === 'pattern_attributes' ) ); } function getPartiallySyncedAttributes( block ) { - return Object.entries( block.attributes.connections.attributes ) + return Object.entries( block.attributes.metadata.bindings ) .filter( - ( [ , connection ] ) => connection.source === 'pattern_attributes' + ( [ , binding ] ) => binding.source.name === 'pattern_attributes' ) .map( ( [ attributeKey ] ) => attributeKey ); } diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json index 3fe4fbb34e1029..70f43d3793bcaa 100644 --- a/packages/block-library/src/paragraph/block.json +++ b/packages/block-library/src/paragraph/block.json @@ -41,7 +41,6 @@ "text": true } }, - "__experimentalConnections": true, "spacing": { "margin": true, "padding": true, @@ -64,6 +63,7 @@ "fontSize": true } }, + "__experimentalBlockBindings": true, "__experimentalSelector": "p", "__unstablePasteTextInline": true }, diff --git a/packages/editor/package.json b/packages/editor/package.json index 63656899e587c0..e138089b2b9169 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -27,7 +27,7 @@ "sideEffects": [ "build-style/**", "src/**/*.scss", - "{src,build,build-module}/{index.js,store/index.js,hooks/**}" + "{src,build,build-module}/{index.js,store/index.js,hooks/**,hooks/block-bindings-sources/**}" ], "dependencies": { "@babel/runtime": "^7.16.0", diff --git a/packages/editor/src/components/block-bindings/bindings-ui.js b/packages/editor/src/components/block-bindings/bindings-ui.js new file mode 100644 index 00000000000000..7f384bfc7c7ca4 --- /dev/null +++ b/packages/editor/src/components/block-bindings/bindings-ui.js @@ -0,0 +1,280 @@ +/** + * WordPress dependencies + */ +import { useState, cloneElement, Fragment } from '@wordpress/element'; +import { + BlockControls, + updateBlockBindingsAttribute, +} from '@wordpress/block-editor'; +import { + Button, + createSlotFill, + MenuItem, + MenuGroup, + Popover, +} from '@wordpress/components'; +import { + plugins as pluginsIcon, + chevronDown, + chevronUp, +} from '@wordpress/icons'; +import { addFilter } from '@wordpress/hooks'; +/** + * Internal dependencies + */ +import { BLOCK_BINDINGS_ALLOWED_BLOCKS } from '../../store/constants'; + +const { Slot, Fill } = createSlotFill( 'BlockBindingsUI' ); + +const BlockBindingsFill = ( { children, source, label } ) => { + return ( + + { ( props ) => { + return ( + <> + { cloneElement( children, { + source, + label, + ...props, + } ) } + + ); + } } + + ); +}; + +export default BlockBindingsFill; + +const BlockBindingsUI = ( props ) => { + const [ addingBinding, setAddingBinding ] = useState( false ); + const [ popoverAnchor, setPopoverAnchor ] = useState(); + return ( + <> + + + { addingBinding && ( + { + setAddingBinding( false ); + } } + onFocusOutside={ () => { + setAddingBinding( false ); + } } + placement="bottom" + shift + className="block-bindings-ui-popover" + { ...props } + > + + + ) } + + + ); +}; + +function AttributesLayer( props ) { + const [ activeAttribute, setIsActiveAttribute ] = useState( false ); + const [ activeSource, setIsActiveSource ] = useState( false ); + return ( + + { BLOCK_BINDINGS_ALLOWED_BLOCKS[ props.name ].map( + ( attribute ) => ( +
+ + setIsActiveAttribute( + activeAttribute === attribute + ? false + : attribute + ) + } + className="block-bindings-attribute-picker-button" + > + { attribute } + + { activeAttribute === attribute && ( + <> + + { /* Sources can fill this slot */ } + + { ( fills ) => { + if ( ! fills.length ) { + return null; + } + + return ( + <> + { fills.map( + ( fill, index ) => { + // TODO: Check better way to get the source and label. + const source = + fill[ 0 ].props + .children + .props + .source; + const sourceLabel = + fill[ 0 ].props + .children + .props + .label; + const isSourceSelected = + activeSource === + source; + + return ( + + + setIsActiveSource( + isSourceSelected + ? false + : source + ) + } + className="block-bindings-source-picker-button" + > + { + sourceLabel + } + + { isSourceSelected && + fill } + + ); + } + ) } + + ); + } } + + + + + ) } +
+ ) + ) } +
+ ); +} + +function RemoveBindingButton( props ) { + return ( + + ); +} + +if ( window.__experimentalBlockBindings ) { + addFilter( + 'blocks.registerBlockType', + 'core/block-bindings-ui', + ( settings, name ) => { + if ( ! ( name in BLOCK_BINDINGS_ALLOWED_BLOCKS ) ) { + return settings; + } + + // TODO: Review the implications of this and the code. + // Add the necessary context to the block. + const contextItems = [ 'postId', 'postType', 'queryId' ]; + const usesContextArray = settings.usesContext; + const oldUsesContextArray = new Set( usesContextArray ); + contextItems.forEach( ( item ) => { + if ( ! oldUsesContextArray.has( item ) ) { + usesContextArray.push( item ); + } + } ); + settings.usesContext = usesContextArray; + + // Add bindings button to the block toolbar. + const OriginalComponent = settings.edit; + settings.edit = ( props ) => { + return ( + <> + + + + ); + }; + + return settings; + } + ); +} + +// TODO: Add also some components to the sidebar. diff --git a/packages/editor/src/components/block-bindings/fields-list.js b/packages/editor/src/components/block-bindings/fields-list.js new file mode 100644 index 00000000000000..3c83cdb825fb17 --- /dev/null +++ b/packages/editor/src/components/block-bindings/fields-list.js @@ -0,0 +1,62 @@ +/** + * WordPress dependencies + */ +import { updateBlockBindingsAttribute } from '@wordpress/block-editor'; +import { MenuItem, MenuGroup } from '@wordpress/components'; + +export default function BlockBindingsFieldsList( props ) { + const { + attributes, + setAttributes, + setIsActiveAttribute, + currentAttribute, + fields, + source, + setAddingBinding, + } = props; + + // TODO: Try to abstract this function to be reused across all the sources. + function selectItem( item ) { + // Modify the attribute we are binding. + // TODO: Inherit the value from the bindings instead of setting it manually. + const newAttributes = {}; + newAttributes[ currentAttribute ] = item.value ? item.value : ''; + // TODO: Improve the way we manage placeholders. + if ( item.placeholder && ! item.value ) + newAttributes.placeholder = '{ ' + item.placeholder + ' }'; + setAttributes( newAttributes ); + + // Update the bindings property. + updateBlockBindingsAttribute( + attributes, + setAttributes, + currentAttribute, + source, + { value: item.key } + ); + + setIsActiveAttribute( false ); + setAddingBinding( false ); + } + + return ( + + { fields.map( ( item ) => ( + selectItem( item ) } + className={ + attributes.metadata?.bindings?.[ currentAttribute ] + ?.source?.name === source && + attributes.metadata?.bindings?.[ currentAttribute ] + ?.source?.attributes?.value === item.key + ? 'selected-meta-field' + : '' + } + > + { item.label } + + ) ) } + + ); +} diff --git a/packages/editor/src/components/block-bindings/index.js b/packages/editor/src/components/block-bindings/index.js new file mode 100644 index 00000000000000..600523bc9bbf75 --- /dev/null +++ b/packages/editor/src/components/block-bindings/index.js @@ -0,0 +1,5 @@ +/** + * Internal dependencies + */ +export { default as BlockBindingsFill } from './bindings-ui'; +export { default as BlockBindingsFieldsList } from './fields-list'; diff --git a/packages/editor/src/components/block-bindings/style.scss b/packages/editor/src/components/block-bindings/style.scss new file mode 100644 index 00000000000000..738c7479884809 --- /dev/null +++ b/packages/editor/src/components/block-bindings/style.scss @@ -0,0 +1,30 @@ +// TODO: Change the styles. +.block-bindings-ui-popover { + margin-top: 12px; + width: 300px; + .components-popover__content { + width: 100%; + } + + .block-bindings-attribute-picker-container { + border-bottom: 1px solid #0002; + } + + .block-bindings-fields-list-ui { + padding: 12px; + li { + margin: 20px 8px; + cursor: pointer; + } + .selected-meta-field { + font-weight: bold; + } + .selected-meta-field::before { + content: "✔ "; + margin-left: -16px; + } + } + .block-bindings-remove-button { + color: var(--wp-admin-theme-color, #3858e9); + } +} diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index 33a18e6f9a6ad2..94cf5c50169d03 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -102,3 +102,6 @@ export { default as EditorProvider } from './provider'; export * from './deprecated'; export const VisualEditorGlobalKeyboardShortcuts = EditorKeyboardShortcuts; export const TextEditorGlobalKeyboardShortcuts = EditorKeyboardShortcuts; + +// Block Bindings Components. +export * from './block-bindings'; diff --git a/packages/editor/src/hooks/block-bindings-sources/post-meta.js b/packages/editor/src/hooks/block-bindings-sources/post-meta.js new file mode 100644 index 00000000000000..bfd76262d0696c --- /dev/null +++ b/packages/editor/src/hooks/block-bindings-sources/post-meta.js @@ -0,0 +1,211 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { addFilter } from '@wordpress/hooks'; +/** + * Internal dependencies + */ +import BlockBindingsFill from '../../components/block-bindings/bindings-ui'; +import BlockBindingsFieldsList from '../../components/block-bindings/fields-list'; +import { store as editorStore } from '../../store'; +import { BLOCK_BINDINGS_ALLOWED_BLOCKS } from '../../store/constants'; + +if ( window.__experimentalBlockBindings ) { + // External sources could do something similar. + + const withCoreSources = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const { name, isSelected, context } = props; + // If the block is not allowed, return the original BlockEdit. + if ( ! BLOCK_BINDINGS_ALLOWED_BLOCKS[ name ] ) { + return ; + } + const fields = []; + if ( isSelected ) { + const data = useSelect( + ( select ) => { + const postId = context.postId + ? context.postId + : select( editorStore ).getCurrentPostId(); + const postType = context.postType + ? context.postType + : select( editorStore ).getCurrentPostType(); + const { getEntityRecord, getEntityRecords } = + select( coreStore ); + + let isTemplate = false; + // If it is a template, get example data if it is a post/page/cpt. + if ( postType === 'wp_template' ) { + isTemplate = true; + const { slug: templateSlug } = getEntityRecord( + 'postType', + 'wp_template', + postId + ); + + // Get the post type from the template slug. + + // Match "page-{slug}". + const pagePattern = /^page(?:-(.+))?$/; + // Match "single-{postType}-{slug}". + const postPattern = /^single-([^-]+)(?:-(.+))?$/; + // Match "wp-custom-template-{slug}". + const customTemplatePattern = + /^wp-custom-template-(.+)$/; + // If it doesn't match any of the accepted patterns, return. + if ( + ! templateSlug !== 'index' && + ! templateSlug !== 'page' && + ! pagePattern.test( templateSlug ) && + ! postPattern.test( templateSlug ) && + ! customTemplatePattern.test( templateSlug ) + ) { + return null; + } + + let records = []; + // If it is an index or a generic page template, return any page. + if ( + templateSlug === 'index' || + templateSlug === 'page' + ) { + records = getEntityRecords( + 'postType', + 'page', + { + per_page: 1, + } + ); + } + + // If it is specific page template, return that one. + if ( pagePattern.test( templateSlug ) ) { + records = getEntityRecords( + 'postType', + 'page', + { + slug: templateSlug.match( + pagePattern + )[ 1 ], + } + ); + } + + // If it is post/cpt template. + if ( postPattern.test( templateSlug ) ) { + const [ , entityPostType, entitySlug ] = + templateSlug.match( postPattern ); + + // If it is a specific post. + if ( entitySlug ) { + records = getEntityRecords( + 'postType', + entityPostType, + { + slug: entitySlug, + } + ); + } else { + // If it is a generic template, return any post. + records = getEntityRecords( + 'postType', + entityPostType, + { + per_page: 1, + } + ); + } + } + + // If it is a custom template, get the fields from any page. + if ( + customTemplatePattern.test( templateSlug ) || + ! records + ) { + records = getEntityRecords( + 'postType', + 'page', + { + per_page: 1, + } + ); + } + + return { isTemplate, fields: records?.[ 0 ] }; + } + + return { + isTemplate, + fields: getEntityRecord( + 'postType', + postType, + postId + ), + }; + }, + [ context.postId, context.postType ] + ); + + if ( ! data || ! data?.fields?.meta ) { + return ; + } + + // Adapt the data to the format expected by the fields list. + // Prettifying the name until we receive the label from the REST API endpoint. + const keyToLabel = ( key ) => { + return key + .split( '_' ) + .map( + ( word ) => + word.charAt( 0 ).toUpperCase() + word.slice( 1 ) + ) + .join( ' ' ); + }; + Object.entries( data.fields.meta ).forEach( + ( [ key, value ] ) => { + fields.push( { + key, + label: keyToLabel( key ), + value: data.isTemplate ? null : value, + placeholder: data.isTemplate + ? keyToLabel( key ) + : null, + } ); + } + ); + } + + return ( + <> + { isSelected && fields.length !== 0 && ( + <> + + + + + ) } + + + ); + }, + 'withToolbarControls' + ); + + // TODO: Review if there is a better filter for this. + // This runs for every block. + addFilter( + 'editor.BlockEdit', + 'core/block-bindings-ui/add-sources', + withCoreSources + ); +} diff --git a/packages/editor/src/hooks/index.js b/packages/editor/src/hooks/index.js index 5a48ec1bf49566..8d52e5a36e6631 100644 --- a/packages/editor/src/hooks/index.js +++ b/packages/editor/src/hooks/index.js @@ -4,3 +4,6 @@ import './custom-sources-backwards-compatibility'; import './default-autocompleters'; import './pattern-partial-syncing'; + +// Block bindings sources. +import './block-bindings-sources/post-meta'; diff --git a/packages/editor/src/hooks/pattern-partial-syncing.js b/packages/editor/src/hooks/pattern-partial-syncing.js index 40bd1e16dfc00d..976efebb720f6e 100644 --- a/packages/editor/src/hooks/pattern-partial-syncing.js +++ b/packages/editor/src/hooks/pattern-partial-syncing.js @@ -34,7 +34,7 @@ const withPartialSyncingControls = createHigherOrderComponent( const blockEditingMode = useBlockEditingMode(); const hasCustomFieldsSupport = hasBlockSupport( props.name, - '__experimentalConnections', + '__experimentalBlockBindings', false ); const isEditingPattern = useSelect( diff --git a/packages/editor/src/store/constants.js b/packages/editor/src/store/constants.js index 7882ba53e64db3..c8d1c71f56abe9 100644 --- a/packages/editor/src/store/constants.js +++ b/packages/editor/src/store/constants.js @@ -18,3 +18,9 @@ export const TRASH_POST_NOTICE_ID = 'TRASH_POST_NOTICE_ID'; export const PERMALINK_POSTNAME_REGEX = /%(?:postname|pagename)%/; export const ONE_MINUTE_IN_MS = 60 * 1000; export const AUTOSAVE_PROPERTIES = [ 'title', 'excerpt', 'content' ]; +export const BLOCK_BINDINGS_ALLOWED_BLOCKS = { + 'core/paragraph': [ 'content' ], + 'core/heading': [ 'content' ], + 'core/image': [ 'url', 'title' ], + 'core/button': [ 'url', 'text' ], +}; diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index 50359984af1628..4bec3804bb1e87 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -1,4 +1,5 @@ @import "./components/autocompleters/style.scss"; +@import "./components/block-bindings/style.scss"; @import "./components/document-bar/style.scss"; @import "./components/document-outline/style.scss"; @import "./components/editor-notices/style.scss"; diff --git a/packages/patterns/src/components/partial-syncing-controls.js b/packages/patterns/src/components/partial-syncing-controls.js index 42c39ce69e87bf..700a05b10c896c 100644 --- a/packages/patterns/src/components/partial-syncing-controls.js +++ b/packages/patterns/src/components/partial-syncing-controls.js @@ -6,7 +6,10 @@ import { nanoid } from 'nanoid'; /** * WordPress dependencies */ -import { InspectorControls } from '@wordpress/block-editor'; +import { + InspectorControls, + updateBlockBindingsAttribute, +} from '@wordpress/block-editor'; import { BaseControl, CheckboxControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; @@ -18,50 +21,41 @@ import { PARTIAL_SYNCING_SUPPORTED_BLOCKS } from '../constants'; function PartialSyncingControls( { name, attributes, setAttributes } ) { const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ]; - function updateConnections( attributeName, isChecked ) { + function updateBindings( 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, - } ); + updateBlockBindingsAttribute( + attributes, + setAttributes, + attributeName, + null, + null + ); return; } - const updatedConnections = { - ...attributes.connections, - attributes: { - ...attributes.connections?.attributes, - [ attributeName ]: { - source: 'pattern_attributes', - }, - }, - }; - if ( typeof attributes.metadata?.id === 'string' ) { - setAttributes( { connections: updatedConnections } ); + updateBlockBindingsAttribute( + attributes, + setAttributes, + attributeName, + 'pattern_attributes', + null + ); return; } const id = nanoid( 6 ); + const newMetadata = updateBlockBindingsAttribute( + attributes, + setAttributes, + attributeName, + 'pattern_attributes', + null + ); + setAttributes( { - connections: updatedConnections, metadata: { - ...attributes.metadata, + ...newMetadata, id, }, } ); @@ -80,12 +74,12 @@ function PartialSyncingControls( { name, attributes, setAttributes } ) { __nextHasNoMarginBottom label={ label } checked={ - attributes.connections?.attributes?.[ + attributes?.metadata?.bindings?.[ attributeName - ]?.source === 'pattern_attributes' + ]?.source?.name === 'pattern_attributes' } onChange={ ( isChecked ) => { - updateConnections( attributeName, isChecked ); + updateBindings( attributeName, isChecked ); } } /> )