Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the Block Bindings API #5888

Conversation

michalczaplinski
Copy link
Contributor

@michalczaplinski michalczaplinski commented Jan 17, 2024

More information about the Block Bindings API: WordPress/gutenberg#54536


Trac ticket: https://core.trac.wordpress.org/ticket/60282


This PR introduces the Block Bindings API for WordPress.

The API allows developers to connects block attributes to different sources. In this PR, two such sources are included: "post meta" and "pattern". Attributes connected to sources can have their HTML replaced by values coming from the source in a way defined by the binding.

How does it work?

On a technical level, the way the API works is a 3-step process:

  1. Create a binding between block attributes and a source - in the editor.
  2. Get the value from the source defined in the binding - at runtime.
  3. Update the HTML using the value obtained from the source - at runtime.

1. Create a binding

The bindings have the following structure:

An example binding for the content attribute of Paragraph block and the url attribute of the Image block.
Both using the post_meta source.

// These are block attributes
{
    "metadata": {
        "bindings": {
            "content": {
                "source": "post_meta",
                "args": {
                    "key": "text_custom_field"
                }
            },
            "url": {
                "source": "post_meta",
                "args": {
                    "key": "url_custom_field"
                }
            }
        }
    }
}

This PR does not include any UI for adding those metatada attributes to blocks at the moment. This is intentional as the API is meant as a building block and such UI will be developed later.

2. Get the value

This PR includes a mechanism for registering "sources" for the block bindings. A "source" defines where to get a value for the binding from. Two sources are included in this PR:

  • "post meta"
  • "pattern" - which is used by Partially Synced Patterns

Each source is responsible any logic required to obtain the value from a source.

3. Update the HTML

At runtime, the HTML API is used to update the HTML of the block with values from the block binding sources. At the moment, a limited and hardcoded number of blocks and attributes is supported in this PR:

$allowed_blocks = array(
    'core/paragraph' => array( 'content' ),
    'core/heading'   => array( 'content' ),
    'core/image'     => array( 'url', 'title', 'alt' ),
    'core/button'    => array( 'url', 'text' ),
);

Testing Instructions

Register a new custom field however you prefer. You can use a snippet similar to this:

register_meta(
	'post',
	'text_custom_field',
	array(
		'show_in_rest' => true,
		'single'       => true,
		'type'         => 'string',
		'default'	   => 'This is the content of the text custom field',
	)
);
register_meta(
	'post',
	'url_custom_field',
	array(
		'show_in_rest' => true,
		'single'       => true,
		'type'         => 'string',
		'default'	   => 'https://wpmovies.dev/wp-content/uploads/2023/04/goncharov-poster-original-1-682x1024.jpeg',
	)
);

In a page/post

Test paragraph

  1. Add a paragraph with the content connected to a custom field:
<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"core/post-meta","args":{"key":"text_custom_field"}}}}} -->
<p>Hello</p>
<!-- /wp:paragraph -->
  1. Add a paragraph without any bindings.
  2. Check that the paragraph with the bindings became non-editable.
  3. Check that the paragraph shows the content of the custom field.
  4. Check that the normal paragraph works as expected.
  5. Go to the frontend and check that the value of the custom field is shown there.

Test heading

Repeat the paragraph test but using a heading.

Test button

  1. Add a button with the text connected to a custom field.
  2. Add another button with the URL connected to a custom field.
  3. Add a button with the text and the URL connected to custom fields.
<!-- wp:buttons -->
<div class="wp-block-buttons"><!-- wp:button {"metadata":{"bindings":{"text":{"source":"core/post-meta","args":{"key":"text_custom_field"}}}}} -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button">TEXT</a></div>
<!-- /wp:button -->

<!-- wp:button {"metadata":{"bindings":{"url":{"source":"core/post-meta","args":{"key":"url_custom_field"}}}}} -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://wpmovies.dev/wp-content/uploads/2023/03/3bhkrj58Vtu7enYsRolD1fZdja1-683x1024.jpg">URL</a></div>
<!-- /wp:button -->

<!-- wp:button {"metadata":{"bindings":{"text":{"source":"core/post-meta","args":{"key":"text_custom_field"}},"url":{"source":"core/post-meta","args":{"key":"url_custom_field"}}}}} -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://wpmovies.dev/wp-content/uploads/2023/03/3bhkrj58Vtu7enYsRolD1fZdja1-683x1024.jpg">TEXT</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons -->
  1. Check that for the buttons with the text connected, they are not editable and they show the content of the custom field.
  2. Check that for the buttons with the URL connected, the buttons to change the URL disappear from the UI.
  3. Check that everything works in the frontend.

Test image

  1. Add an image with the URL connected to a custom field.
  2. Add an image with the alt attribute connected to a custom field.
  3. Add an image with the title attribute connected to a custom field.
  4. Add an image with more than one connection.
<!-- wp:heading {"level":3} -->
<h3 class="wp-block-heading">Image url and alt connected</h3>
<!-- /wp:heading -->

<!-- wp:image {"id":134,"sizeSlug":"large","linkDestination":"none","metadata":{"bindings":{"url":{"source":"core/post-meta","args":{"key":"page_url_custom_field"}},"alt":{"source":"core/post-meta","args":{"key":"page_text_custom_field"}}}}} -->
<figure class="wp-block-image size-large"><img src="https://wpmovies.dev/wp-content/uploads/2023/03/3bhkrj58Vtu7enYsRolD1fZdja1-683x1024.jpg" alt="Content of the page_text_custom_field" class="wp-image-134" title="Content of the PAGE text custom field"/></figure>
<!-- /wp:image -->

<!-- wp:heading {"level":3} -->
<h3 class="wp-block-heading">Alt connected</h3>
<!-- /wp:heading -->

<!-- wp:image {"sizeSlug":"large","linkDestination":"none","metadata":{"bindings":{"alt":{"source":"core/post-meta","args":{"key":"page_text_custom_field"}}}}} -->
<figure class="wp-block-image size-large"><img src="https://wpmovies.dev/wp-content/uploads/2023/04/q6y0Go1tsGEsmtFryDOJo3dEmqu-683x1024.jpg" alt="Content of the page_text_custom_field" title=""/></figure>
<!-- /wp:image -->

<!-- wp:heading {"level":3} -->
<h3 class="wp-block-heading">Nothing connected</h3>
<!-- /wp:heading -->

<!-- wp:image {"sizeSlug":"large","linkDestination":"none"} -->
<figure class="wp-block-image size-large"><img src="https://wpmovies.dev/wp-content/uploads/2023/04/q6y0Go1tsGEsmtFryDOJo3dEmqu-683x1024.jpg" alt=""/></figure>
<!-- /wp:image -->
  1. For the images with the URL connected, check that the related buttons to link an image have disappeared and the image shows the URL from the custom field.
  2. For the images with the alt/title connected, check that, in the right sidebar, they are disabled. Showing the value of the custom field instead and a message saying it is connected to custom fields.

Copy link

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • The Plugin and Theme Directories cannot be accessed within Playground.
  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@talldan
Copy link
Contributor

talldan commented Jan 19, 2024

The API allows developers to connects block attributes to different sources. In this PR, two such sources are included: "post meta" and "pattern".

An option could be to consider a separate backport PR for the 'pattern' source, as I think there's also a little bit of extra code needed to set up the block context for pattern overrides. Let me know what you think.

Actually, this changed recently, there are now no backports for pattern overrides other than the registration of the binding source.

Comment on lines 9 to 17
// Use the postId attribute if available
if ( isset( $source_attrs['postId'] ) ) {
$post_id = $source_attrs['postId'];
} else {
// $block_instance->context['postId'] is not available in the Image block.
$post_id = get_the_ID();
}

return get_post_meta( $post_id, $source_attrs['value'], true );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at this here I would love it if the function in core could already take care of looking up the post_id in the current context.

I cannot think of any instance where I wouldn't want to get the ID of the current queried post within a query loop for example. So this code that is shown here will be needed in 100% of the cases where this API is used and feels error prone. If we could make it so that the callback is already getting passed the current post id :)

Copy link

@lgladdy lgladdy Jan 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also pass the result through a filter here, passing through the post ID, and source_attrs['value'].

This could be useful for ACF to hydrate a raw post meta value into it's formatted value which users would expect.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should pass this through filters, this is its own abstractions if a third-party plugin wants its own kind of values, they can implement their own custom source.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fabiankaegy I'm not sure I understand the comment, can you clarify a bit?

Copy link
Member

@fabiankaegy fabiankaegy Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@youknowriad What I mean is that I would love it if the wp_block_bindings_register_source function already passed in the current post ID as a parameter of the callback function.

Having to manually do the check to see if $block_instance->context['postId'] is available and if so using it vs. accessing the id via the get_the_ID function is something that every single block binding source will need to do if it should work correctly in all context such as inside query loops.

I would like it if the callback could be simplified to:

 /**
  * Add the post_meta source to the Block Bindings API.
  *
  * @since 6.5.0
  * @package WordPress
  */
 function post_meta_source_callback( $source_attrs, $post_id ) {
 	// Override the current post id when an attribute called `postId` exists 
 	if ( isset( $source_attrs['postId'] ) ) {
 		$post_id = $source_attrs['postId'];
 	}

 	return get_post_meta( $post_id, $source_attrs['value'], true );
}

If I'm not mistaken there even is a bug in this implementation here. Because it doesn't use the context value at all. It should instead check for the availability of the context and if so use that over get_the_ID. And because of that very reason I think ideally core would already handle that check for context and pass the correct post id as a parameter to the callback.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally it doesn't make sense to me. The sources can be anything, from posts to a random database table, to a call to some remote API... I don't think the signature should accommodate a specific source over any other.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But don't sources always need to know which context they are rendered within?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m still not sold on the name source, because it implies that the value is sourced from something external. In practice, the value used for replacement could be for example :

  • current date
  • translation
  • hardcoded value

Overall, we are opening an API to dynamically compute the value of the attribute on the server so people don't need to default to custom and dynamic blocks.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That context actually is helpful! In my mind, I was really mostly concerned with dynamic attributes on the post object / external API connections that need to be aware of the current context which is why I was so focussed on the Post ID.

In that case an unrelated side note, I would love it if simply all blocks anywhere had the usesContext: [ 'postId' ] specified because the whole accessing the correct ID is an error-prone thing that often gets overlooked and the a feature doesn't work correctly in query loops etc

function _process_block_bindings( $block_content, $block, $block_instance ) {

// Allowed blocks that support block bindings.
// TODO: Look for a mechanism to opt-in for this. Maybe adding a property to block attributes?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm totally fine with this static list right now, especially since it's a bit early for this API but isn't the presence of the "metadata.bindings" attribute a sufficient indicator here. in other words, maybe we can just remove this check in the future.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hope is that with HTML API we will be able to replace all attributes that are serialized in the saved HTML of the block.

Copy link
Contributor

@youknowriad youknowriad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my main feedback https://github.com/WordPress/wordpress-develop/pull/5888/files#r1462908142

Other than that, it's looking good overall.

src/wp-includes/blocks.php Outdated Show resolved Hide resolved
src/wp-includes/blocks.php Outdated Show resolved Hide resolved
Copy link
Member

@gziolo gziolo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summarizing my comments, we could further simplify what gets exposed as a public API for v1. We should consider moving two functions to WP_Block class as private methods:

  • wp_block_bindings_replace_html
  • _process_block_bindings

That leaves us with three public functions:

  • wp_block_bindings
  • wp_block_bindings_register_source
  • wp_block_bindings_get_sources

One change that @youknowriad and I agree on is that we should make the bindings registration open for extensions. Therefore, the signature should take that name of the bindings and the array of settings. Similar use cases existing in the codebase that can be used for reference:

function register_block_type( $block_type, $args = array() ) {

function register_block_style( $block_name, $style_properties ) {

function register_block_pattern_category( $category_name, $category_properties ) {

function register_block_pattern( $pattern_name, $pattern_properties ) {

Which leads me to the next observation, that we should follow the established pattern and rename the method to align:

function register_block_binding( $name, $properties )

If we follow other registration helpers, the class name would better fit as:

WP_Block_Bindings_Registry

Examples:

final class WP_Block_Pattern_Categories_Registry {

final class WP_Block_Patterns_Registry {

regarding methods, we could have register

public function register( $pattern_name, $pattern_properties ) {

and get_all_registered

public function get_all_registered() {

Comment on lines +13 to +14
// $block_instance->context['postId'] is not available in the Image block.
$post_id = get_the_ID();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there might be a bug here.

Instead of completely omitting the usage of the $block_instance->context['postId'] here, we should check if the value isset and if so use it over the ID we get from get_the_ID.

Something like this:

$post_id = isset( $block_instance->context['postId'] ) ? $block_instance->context['postId'] : get_the_ID();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also wonder if we should always use the context here. as if I'm not wrong the default context should be set as get_the_ID if there's no parent query block.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I tested, $block_instance->context is an empty array for the heading, button, and image block, so that's why we couldn't get the postId from there. Is that not expected?

We could definitely use the conditional suggested. However, I'd like to understand if just using $block_instance->context should work.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we have an issue somewhere but in the client the block context has some "default context values" like the post ID if I'm not wrong, and in the server you're saying that these are not present. IMO, the block context should be the same for the client and the server.

I think it's fine if we defer the potential fix to a dedicated issue but we might want to track it somewhere.

@michalczaplinski
Copy link
Contributor Author

The unit tests are failing because Tests_WP_Customize_Widgets::test_get_selective_refreshable_widgets_when_no_theme_supports test calls $this->do_customize_boot_actions(); which calls do_action( 'init' );.

Because of this, the init action is fired twice and this check fails.

If understand correctly, init could be fired multiple times per request on a site under some circumstances (which is what happens here). Block registration has a similar check.

I'm not sure what the right behavior here should be. I see these options:

  1. For the bindings that ship in core (post-meta & pattern), always unregister any bindings with the same name.
  2. Remove the _doing_it_wrong() altoghether.

What do you think @youknowriad @gziolo ?

@gziolo
Copy link
Member

gziolo commented Feb 1, 2024

The following diff should resolve the issue that exists only in the test env:

diff --git a/tests/phpunit/includes/functions.php b/tests/phpunit/includes/functions.php
index 81d4339db1..0fdff9c71a 100644
--- a/tests/phpunit/includes/functions.php
+++ b/tests/phpunit/includes/functions.php
@@ -339,10 +339,15 @@ tests_add_filter( 'send_auth_cookies', '__return_false' );
  * @since 5.0.0
  */
 function _unhook_block_registration() {
+	// Block types.
 	require __DIR__ . '/unregister-blocks-hooks.php';
 	remove_action( 'init', 'register_core_block_types_from_metadata' );
 	remove_action( 'init', 'register_block_core_legacy_widget' );
 	remove_action( 'init', 'register_block_core_widget_group' );
 	remove_action( 'init', 'register_core_block_types_from_metadata' );
+
+	// Block binding sources.
+	remove_action( 'init', '_register_block_bindings_pattern_overrides_source' );
+	remove_action( 'init', '_register_block_bindings_post_meta_source' );
 }
 tests_add_filter( 'init', '_unhook_block_registration', 1000 );

It will never be the case that init runs twice in the production code.

Copy link
Member

@gziolo gziolo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be good to land the next iteration. We still need more unit tests to cover registered block binding sources, but it can be done separately.

@youknowriad
Copy link
Contributor

Thanks all, commit https://core.trac.wordpress.org/changeset/57514

@youknowriad youknowriad closed this Feb 1, 2024
Copy link
Member

@mukeshpanchal27 mukeshpanchal27 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@youknowriad Left some nit-pick

src/wp-includes/class-wp-block.php Show resolved Hide resolved
src/wp-includes/class-wp-block.php Show resolved Hide resolved

foreach ( $selectors as $selector ) {
// If the parent tag, or any of its children, matches the selector, replace the HTML.
if ( strcasecmp( $block_reader->get_tag( $selector ), $selector ) === 0 || $block_reader->next_tag(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_tag() doesn't take any argument, and it could return NULL, which would throw an error when passed as the first argument of strcasecmp. I think we should guard against it first.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is currently an error being triggered in trunk:

Deprecated: strcasecmp(): Passing null to parameter #1 ($string1) of type string is deprecated in /var/www/html/wp-includes/class-wp-block.php on line 330

To reproduce:

  1. Create a new post
  2. Add a heading block and add the text 'default' to it
  3. Using the block settings menu, click 'Create pattern'.
  4. In the resulting modal, give the pattern a name and click 'Create'
  5. Click the 'Edit original' button that appears on the resulting pattern block
  6. Select the heading block and check the 'Allow instance overrides' option in the advanced inspector tools
  7. Save and click the 'Back' button from the topbar
  8. Now you're back in the post editor, change the heading text 'default' to 'override'

Preview the post
Expected: The preview should show the heading with the text 'override'
Actual: It still shows 'default' and the above error is shown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe having something like this should solve the issue:

$parent_tag = $block_reader->get_tag();
if ( ! is_null( $parent_tag ) || strcasecmp( $parent_tag, $selector ) === 0 || $block_reader->next_tag(
    array(
	'tag_name' => $selector,
    )
) )

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when using next_tag() we will always get a string for get_tag() unless no tag was found.

it would be better to check the result of next_tag() so we don't descend into this function as if a tag were matched.

if ( ! $block_reader->next_tag() ) {
	continue; // or whatever control flow is appropriate
}

return $amended_button->get_updated_html();
}
} else {
$block_reader->seek( 'iterate-selectors' );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seek() could return false which means we fail to seek the bookmark. This could happen when we reach the end the of input.

I think there's currently a bug in WP_HTML_Tag_Processor API that seek() after next_tag() won't correctly put the cursor at the right place.

$p = new WP_HTML_Tag_Processor( '<h2></h2>' );
$p->next_tag();
$p->set_bookmark( 'bookmark' );
$p->next_tag();
var_dump( $p->seek( 'bookmark' ) );
var_dump( $p->get_tag() );

For instance, I expect the above snippet to output true and H2, but instead it outputs false and NULL. @dmsnell might know better if it's a bug or not 🙇.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kevin940726, what was the user interaction, and what HTML was saved for the block that triggered the issue? It's a great opportunity to add a unit test that will help fix the issue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some further details on the comment above - #5888 (comment)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the find @kevin940726 - pushed out #6021 to fix it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kevin940726 fixed in trunk now 👍

pento pushed a commit that referenced this pull request Feb 8, 2024
…ag processor

Fix for the Block Bindings processing.
See #5888 (comment).

Props: czapla, dmsnell, gziolo.



git-svn-id: https://develop.svn.wordpress.org/trunk@57561 602fd350-edb4-49c9-b593-d223f7449a82
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Feb 8, 2024
…ag processor

Fix for the Block Bindings processing.
See WordPress/wordpress-develop#5888 (comment).

Props: czapla, dmsnell, gziolo.


Built from https://develop.svn.wordpress.org/trunk@57561


git-svn-id: http://core.svn.wordpress.org/trunk@57062 1a063a9b-81f0-0310-95a4-ce76da25c4cd
github-actions bot pushed a commit to gilzow/wordpress-performance that referenced this pull request Feb 8, 2024
…ag processor

Fix for the Block Bindings processing.
See WordPress/wordpress-develop#5888 (comment).

Props: czapla, dmsnell, gziolo.


Built from https://develop.svn.wordpress.org/trunk@57561


git-svn-id: https://core.svn.wordpress.org/trunk@57062 1a063a9b-81f0-0310-95a4-ce76da25c4cd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.