diff --git a/lib/block-supports/pattern.php b/lib/block-supports/pattern.php index a783135c793e3..f9dd1b4b44248 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-api/html-processing.php b/lib/experimental/block-bindings-api/html-processing.php new file mode 100644 index 0000000000000..9fb629d5e2821 --- /dev/null +++ b/lib/experimental/block-bindings-api/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-api/index.php b/lib/experimental/block-bindings-api/index.php new file mode 100644 index 0000000000000..8b3f4197017d7 --- /dev/null +++ b/lib/experimental/block-bindings-api/index.php @@ -0,0 +1,22 @@ +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' ), + 'apply' => $pattern_source_callback, + ) + ); +} diff --git a/lib/experimental/block-bindings-api/sources/post-data.php b/lib/experimental/block-bindings-api/sources/post-data.php new file mode 100644 index 0000000000000..f4f565781efc3 --- /dev/null +++ b/lib/experimental/block-bindings-api/sources/post-data.php @@ -0,0 +1,26 @@ +context['postId'] but it wasn't available in the image block. + $post_id = get_the_ID(); + } + return get_post( $post_id )->{$source_attrs['value']}; + }; + register_block_bindings_source( + 'post_data', + array( + 'label' => __( 'Post Data' ), + 'apply' => $post_data_source_callback, + ) + ); +} diff --git a/lib/experimental/block-bindings-api/sources/post-meta.php b/lib/experimental/block-bindings-api/sources/post-meta.php new file mode 100644 index 0000000000000..3220b3c6defb2 --- /dev/null +++ b/lib/experimental/block-bindings-api/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' ), + 'apply' => $post_meta_source_callback, + ) + ); +} diff --git a/lib/experimental/block-bindings-api/sources/site-data.php b/lib/experimental/block-bindings-api/sources/site-data.php new file mode 100644 index 0000000000000..5375a81b42385 --- /dev/null +++ b/lib/experimental/block-bindings-api/sources/site-data.php @@ -0,0 +1,19 @@ + __( 'Site Data' ), + 'apply' => $site_data_source_callback, + ) + ); +} diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index 88e46b478389d..106fd41d48d1b 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -78,123 +78,88 @@ 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-api/index.php'; + // List of allowed of blocks that support block bindings. + // We should 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; - } - - $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; - } - $tag_name = $tags->get_tag(); - $markup = "<$tag_name>$custom_value"; - $updated_tags = new WP_HTML_Tag_Processor( $markup ); - $updated_tags->next_tag(); + $source_callback = $block_bindings_sources[ $binding_source['source']['name'] ]['apply']; + // Get the value based on the source. + $source_value = $source_callback( $binding_source['source']['attributes'], $block_content, $block, $block_instance ); + // If the value is null, process next attribute. + if ( is_null( $source_value ) ) { + continue; + } - // 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 ) ); + // 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 ); } - - 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 ) { + 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 bf89ba177b6e9..0000000000000 --- 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 5f61684e8b134..729376cf030dd 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 b77a69b692ff1..282ec880f3176 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -127,14 +127,14 @@ function gutenberg_initialize_experiments_settings() { ); add_settings_field( - 'gutenberg-custom-fields', - __( 'Connections', 'gutenberg' ), + 'gutenberg-block-bindings', + __( '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 2d6a5627a52a4..3f21106ba9bf8 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -810,6 +810,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. +- _updatingAttribute_ `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 8ab816abc7352..0000000000000 --- a/packages/block-editor/src/hooks/custom-fields.js +++ /dev/null @@ -1,147 +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'; -import { createHigherOrderComponent } from '@wordpress/compose'; - -/** - * 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 CustomFieldsControl( props ) { - 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 ( props.name === 'core/paragraph' ) attributeName = 'content'; - if ( props.name === 'core/image' ) attributeName = 'url'; - - return ( - - - { - 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 - ), - } ); - } - } } - /> - - - ); -} - -/** - * Override the default edit UI to include a new block inspector control for - * assigning a connection to blocks that has support for connections. - * Currently, only the `core/paragraph` block is supported and there is only a relation - * between paragraph content and a custom field. - * - * @param {Component} BlockEdit Original component. - * - * @return {Component} Wrapped component. - */ -const withCustomFieldsControls = createHigherOrderComponent( ( BlockEdit ) => { - return ( props ) => { - const hasCustomFieldsSupport = hasBlockSupport( - props.name, - '__experimentalConnections', - false - ); - - // Check if the current block is a paragraph or image block. - // Currently, only these two blocks are supported. - if ( ! [ 'core/paragraph', 'core/image' ].includes( props.name ) ) { - return ; - } - - return ( - <> - - { hasCustomFieldsSupport && props.isSelected && ( - - ) } - - ); - }; -}, 'withCustomFieldsControls' ); - -if ( - window.__experimentalConnections || - window.__experimentalPatternPartialSyncing -) { - addFilter( - 'blocks.registerBlockType', - 'core/editor/connections/attribute', - addAttribute - ); -} -if ( window.__experimentalConnections ) { - addFilter( - 'editor.BlockEdit', - 'core/editor/connections/with-inspector-controls', - withCustomFieldsControls - ); -} diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index c088216c0645c..62f9dc4619e6c 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -19,7 +19,6 @@ import './position'; import './layout'; import './content-lock-ui'; import './metadata'; -import './custom-fields'; import './block-hooks'; import './block-renaming'; diff --git a/packages/block-editor/src/utils/index.js b/packages/block-editor/src/utils/index.js index af45111759699..972816ffeb758 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 './parse-css-unit-to-px'; +export * from './update-bindings'; diff --git a/packages/block-editor/src/utils/update-bindings.js b/packages/block-editor/src/utils/update-bindings.js new file mode 100644 index 0000000000000..a34902cab8d49 --- /dev/null +++ b/packages/block-editor/src/utils/update-bindings.js @@ -0,0 +1,73 @@ +/** + * 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} updatingAttribute - 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, + updatingAttribute, + sourceName, + sourceAttributes +) => { + // TODO: Review the bindings syntax. + // 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" } + // } + // } + // }, + // . + + // Only modify the bindings property of the metadata attribute + const metadataAttribute = blockAttributes.metadata + ? blockAttributes.metadata + : {}; + + // If no sourceName is provided, remove the attribute from the bindings. + if ( sourceName === null ) { + if ( ! metadataAttribute.bindings ) { + return metadataAttribute; + } + delete metadataAttribute.bindings[ updatingAttribute ]; + // If bindings is empty, remove the bindings property. + if ( Object.keys( metadataAttribute.bindings ).length === 0 ) { + delete metadataAttribute.bindings; + } + setAttributes( { + metadata: metadataAttribute, + } ); + return metadataAttribute; + } + + const bindingsProperty = metadataAttribute.bindings + ? metadataAttribute.bindings + : {}; + + bindingsProperty[ updatingAttribute ] = { + source: { name: sourceName, attributes: sourceAttributes }, + }; + + metadataAttribute.bindings = bindingsProperty; + // TODO: Decide if we want to include the setAttributes call here. + setAttributes( { + metadata: metadataAttribute, + } ); + + // TODO: Not sure if we need to return the updated attributes. + return metadataAttribute; +}; diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index e86ed9b59c62b..cf2cbe5f09693 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 85f56f4a838f5..809284b6f7927 100644 --- a/packages/block-library/src/paragraph/block.json +++ b/packages/block-library/src/paragraph/block.json @@ -42,7 +42,6 @@ "text": true } }, - "__experimentalConnections": true, "spacing": { "margin": true, "padding": true, 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 0000000000000..f0dafc80239dd --- /dev/null +++ b/packages/editor/src/components/block-bindings/bindings-ui.js @@ -0,0 +1,282 @@ +/** + * 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'; + +const blockBindingsAllowedBlocks = { + 'core/paragraph': [ 'content' ], + 'core/heading': [ 'content' ], + 'core/image': [ 'url', 'title' ], + 'core/button': [ 'url', 'text' ], +}; + +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 { setAttributes } = props; + + const [ addingBinding, setAddingBinding ] = useState( false ); + // TODO: Triage why it is reloading after selecting a binding. + function BindingsUI() { + return ( + { + 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 ( + + { blockBindingsAllowedBlocks[ 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 ( + + ); + } + + const [ popoverAnchor, setPopoverAnchor ] = useState(); + return ( + <> + + + { addingBinding && } + + + ); +}; + +if ( window.__experimentalBlockBindings ) { + addFilter( + 'blocks.registerBlockType', + 'core/block-bindings-ui', + ( settings, name ) => { + if ( ! ( name in blockBindingsAllowedBlocks ) ) { + 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 0000000000000..a70156e453ca2 --- /dev/null +++ b/packages/editor/src/components/block-bindings/fields-list.js @@ -0,0 +1,58 @@ +/** + * 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, + } = props; + + // TODO: Try to abstract this function to be reused across all the sources. + function selectItem( item ) { + // Modify the attribute we are binding. + // TODO: Not sure if we should do this. We might need to process the bindings attribute somehow in the editor to modify the content with context. + // TODO: Get the type from the block attribute definition and modify/validate the value returned by the source if needed. + const newAttributes = {}; + newAttributes[ currentAttribute ] = item.value; + setAttributes( newAttributes ); + + // Update the bindings property. + updateBlockBindingsAttribute( + attributes, + setAttributes, + currentAttribute, + source, + { value: item.key } + ); + + setIsActiveAttribute( 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 0000000000000..8bc50dcbc9f80 --- /dev/null +++ b/packages/editor/src/components/block-bindings/index.js @@ -0,0 +1,9 @@ +/** + * Internal dependencies + */ +export { default as BlockBindingsFill } from './bindings-ui'; +export { default as BlockBindingsFieldsList } from './fields-list'; +// TODO: Review where this files should go. +export { default as PostMeta } from './sources/post-meta'; +export { default as PostData } from './sources/post-data'; +export { default as SiteData } from './sources/site-data'; diff --git a/packages/editor/src/components/block-bindings/sources/post-data.js b/packages/editor/src/components/block-bindings/sources/post-data.js new file mode 100644 index 0000000000000..e9d76569904ef --- /dev/null +++ b/packages/editor/src/components/block-bindings/sources/post-data.js @@ -0,0 +1,91 @@ +/** + * 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 '../bindings-ui.js'; +import BlockBindingsFieldsList from '../fields-list.js'; + +const PostData = ( props ) => { + const { context } = props; + + // Fetching the REST API to get the post data. + // TODO: Explore if it makes sense to create a custom endpoint for this. + const data = useSelect( + ( select ) => { + const { getEntityRecord } = select( coreStore ); + return getEntityRecord( + 'postType', + context.postType, + context.postId + ); + }, + [ context.postType, context.postId ] + ); + + // Adapt the data to the format expected by the fields list. + // TODO: Ensure the key and label work with translations. + const fields = [ + { + key: 'post_title', + label: 'Post title', + value: data.title.rendered, + }, + { + key: 'post_date', + label: 'Post date', + value: data.date, + }, + { + key: 'guid', + label: 'Post link', + value: data.link, + }, + ]; + + return ( + + ); +}; + +if ( window.__experimentalBlockBindings ) { + // TODO: Read the context somehow to decide if we should add the source. + + const withCoreSources = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const { isSelected } = props; + + return ( + <> + { isSelected && ( + <> + + + + + ) } + + + ); + }, + 'withToolbarControls' + ); + + addFilter( + 'editor.BlockEdit', + 'core/block-bindings-ui/add-sources', + withCoreSources + ); +} diff --git a/packages/editor/src/components/block-bindings/sources/post-meta.js b/packages/editor/src/components/block-bindings/sources/post-meta.js new file mode 100644 index 0000000000000..fab1e352a5465 --- /dev/null +++ b/packages/editor/src/components/block-bindings/sources/post-meta.js @@ -0,0 +1,91 @@ +/** + * 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 '../bindings-ui.js'; +import BlockBindingsFieldsList from '../fields-list.js'; + +const PostMeta = ( props ) => { + const { context } = props; + + // Fetching the REST API to get the available custom fields. + // TODO: Review if it works with taxonomies. + // TODO: Explore how it should work in templates. + // TODO: Explore if it makes sense to create a custom endpoint for this. + const data = useSelect( + ( select ) => { + const { getEntityRecord } = select( coreStore ); + return getEntityRecord( + 'postType', + context.postType, + context.postId + ); + }, + [ context.postType, context.postId ] + ); + + // Adapt the data to the format expected by the fields list. + const fields = []; + // 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.meta ).forEach( ( [ key, value ] ) => { + fields.push( { + key, + label: keyToLabel( key ), + value, + } ); + } ); + + return ( + + ); +}; + +if ( window.__experimentalBlockBindings ) { + // TODO: Read the context somehow to decide if we should add the source. + // const data = useSelect( editorStore ); + + // External sources could do something similar. + const withCoreSources = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const { isSelected } = props; + return ( + <> + { isSelected && ( + <> + + + + + ) } + + + ); + }, + 'withToolbarControls' + ); + + addFilter( + 'editor.BlockEdit', + 'core/block-bindings-ui/add-sources', + withCoreSources + ); +} diff --git a/packages/editor/src/components/block-bindings/sources/site-data.js b/packages/editor/src/components/block-bindings/sources/site-data.js new file mode 100644 index 0000000000000..bb9eb7b01c5d4 --- /dev/null +++ b/packages/editor/src/components/block-bindings/sources/site-data.js @@ -0,0 +1,79 @@ +/** + * 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 '../bindings-ui.js'; +import BlockBindingsFieldsList from '../fields-list.js'; + +const SiteData = ( props ) => { + // TODO: Explore if it makes sense to create a custom endpoint for this. + const siteData = useSelect( ( select ) => { + const { getEntityRecord } = select( coreStore ); + return getEntityRecord( 'root', 'site' ); + }, [] ); + + // Adapt the data to the format expected by the fields list. + // TODO: Ensure the key and label work with translations. + const fields = [ + { + key: 'blogname', + label: 'Site title', + value: siteData.title, + }, + { + key: 'blogdescription', + label: 'Site description', + value: siteData.description, + }, + { + key: 'siteurl', + label: 'Site url', + value: siteData.description, + }, + ]; + + return ( + + ); +}; + +if ( window.__experimentalBlockBindings ) { + const withCoreSources = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const { isSelected } = props; + + return ( + <> + { isSelected && ( + <> + + + + + ) } + + + ); + }, + 'withToolbarControls' + ); + + addFilter( + 'editor.BlockEdit', + 'core/block-bindings-ui/add-sources', + withCoreSources + ); +} 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 0000000000000..738c747988480 --- /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 5fefc5506a02f..24c25c8a7451f 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -94,3 +94,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/pattern-partial-syncing.js b/packages/editor/src/hooks/pattern-partial-syncing.js index 40bd1e16dfc00..976efebb720f6 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/style.scss b/packages/editor/src/style.scss index 986cb645c271f..7c79577c05da5 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-outline/style.scss"; @import "./components/editor-notices/style.scss"; @import "./components/entities-saved-states/style.scss"; diff --git a/packages/patterns/src/components/partial-syncing-controls.js b/packages/patterns/src/components/partial-syncing-controls.js index 42c39ce69e87b..9f7fdf9863808 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,42 @@ 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, - } ); + // Update the bindings property. + 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 +75,15 @@ 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 ); + // TODO: REVIEW WHY THE CHECKED IS NOT UPDATED. + // The attributes are updated but the checkbox is not. + // It works fine when I switch from Visual Editor -> Code Editor -> Visual Editor. + updateBindings( attributeName, isChecked ); } } /> )