diff --git a/docs/concepts/block-bindings.md b/docs/concepts/block-bindings.md
index 61d18b437..dcf90418e 100644
--- a/docs/concepts/block-bindings.md
+++ b/docs/concepts/block-bindings.md
@@ -1,5 +1,32 @@
# Block bindings
-Remote Data Blocks takes advantage of the [block bindings API](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-bindings/). This core WordPress API allows you to “bind” dynamic data to the attributes of core blocks, which are then reflected in the final HTML markup. Generally, this avoids the need to write and maintain custom blocks.
+Remote Data Blocks takes advantage of the [block bindings API](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-bindings/). This core WordPress API allows you to "bind" dynamic data to the attributes of core blocks, which are then reflected in the final HTML markup. Generally, this avoids the need to write and maintain custom blocks.
For a quick overview of block bindings, the [announcement post](https://make.wordpress.org/core/2024/03/06/new-feature-the-block-bindings-api/) is very helpful; for a deeper dive, consult the [public documentation](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-bindings/). That said, an in-depth understanding of block bindings isn't necessary to use Remote Data Blocks: just know that the plugin is built on core, stable WordPress APIs.
+
+## Automatic Support for Block Bindings
+
+Remote Data Blocks automatically supports all blocks that WordPress core designates as supporting block bindings. This includes:
+
+- Core blocks like **Paragraph**, **Heading**, **Image**, **Button**, and **Details**
+- Dynamic blocks like **Post Title**, **Post Date**, **Post Excerpt**, **Post Featured Image**, and **Post Author**
+- Any custom blocks that register bindable attributes through WordPress core's mechanisms
+
+The plugin queries WordPress's `__experimentalBlockBindingsSupportedAttributes` setting to determine which blocks and attributes support bindings, ensuring compatibility with current and future WordPress versions without requiring manual updates.
+
+## Extending Block Bindings
+
+To add block binding support to your custom blocks, follow [WordPress's official documentation](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-bindings/#extending-supported-attributes) on extending supported attributes. Once registered with WordPress core, your blocks will automatically work with Remote Data Blocks.
+
+Example of making a custom block attribute bindable:
+
+```javascript
+registerBlockType( 'my-plugin/custom-block', {
+attributes: {
+customField: {
+type: 'string',
+__experimentalLabel: 'Custom Field', // Makes this attribute bindable
+},
+},
+} );
+```
diff --git a/src/block-editor/filters/addUsesContext.ts b/src/block-editor/filters/addUsesContext.ts
index 622e26dfe..5a1c0f66b 100644
--- a/src/block-editor/filters/addUsesContext.ts
+++ b/src/block-editor/filters/addUsesContext.ts
@@ -1,15 +1,35 @@
+import { store as blockEditorStore } from '@wordpress/block-editor';
import { BlockConfiguration } from '@wordpress/blocks';
+import { select } from '@wordpress/data';
-import {
- REMOTE_DATA_CONTEXT_KEY,
- SUPPORTED_CORE_BLOCKS,
-} from '@/blocks/remote-data-container/config/constants';
+import { REMOTE_DATA_CONTEXT_KEY } from '@/blocks/remote-data-container/config/constants';
+
+/**
+ * Get the list of blocks that support bindings from WordPress core.
+ *
+ * @returns Record of block names to their supported attributes
+ */
+function getSupportedBlockBindings(): Record< string, string[] > {
+ try {
+ // Get the block editor settings which contain the supported bindings
+ const editorSettings = select( blockEditorStore )?.getSettings?.();
+ // @ts-ignore - __experimentalBlockBindingsSupportedAttributes is not in types
+ return editorSettings?.__experimentalBlockBindingsSupportedAttributes ?? {};
+ } catch ( error ) {
+ // If the store isn't available yet (e.g., during initial load), return empty
+ return {};
+ }
+}
export function addUsesContext(
settings: BlockConfiguration< RemoteDataInnerBlockAttributes >,
name: string
) {
- if ( ! SUPPORTED_CORE_BLOCKS.includes( name ) ) {
+ // Check if this block supports bindings according to WordPress core
+ const supportedBindings = getSupportedBlockBindings();
+ const blockSupportsBindings = name in supportedBindings && supportedBindings[ name ].length > 0;
+
+ if ( ! blockSupportsBindings ) {
return settings;
}
diff --git a/src/blocks/remote-data-container/components/BlockBindingControls.tsx b/src/blocks/remote-data-container/components/BlockBindingControls.tsx
index 26821781c..f6cc9c84f 100644
--- a/src/blocks/remote-data-container/components/BlockBindingControls.tsx
+++ b/src/blocks/remote-data-container/components/BlockBindingControls.tsx
@@ -1,10 +1,16 @@
+import { store as blockEditorStore } from '@wordpress/block-editor';
import { CheckboxControl, SelectControl } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
+import { Fragment, useMemo } from '@wordpress/element';
import {
+ BUTTON_LINK_TARGET_FIELD_TYPES,
+ BUTTON_REL_FIELD_TYPES,
BUTTON_TEXT_FIELD_TYPES,
BUTTON_URL_FIELD_TYPES,
HTML_FIELD_TYPES,
IMAGE_ALT_FIELD_TYPES,
+ IMAGE_TITLE_FIELD_TYPES,
IMAGE_URL_FIELD_TYPES,
TEXT_FIELD_TYPES,
} from '@/blocks/remote-data-container/config/constants';
@@ -51,15 +57,99 @@ interface BlockBindingControlsProps {
updateBinding: ( target: string, args: Omit< RemoteDataBlockBindingArgs, 'block' > ) => void;
}
+/**
+ * Get appropriate field types for a given attribute name on a specific block.
+ * This provides sensible defaults based on common attribute names and block-specific overrides.
+ *
+ * @param blockName The name of the block
+ * @param attributeName The name of the attribute
+ * @returns Array of field types that should be available for this attribute
+ */
+function getFieldTypesForAttribute( blockName: string, attributeName: string ): string[] {
+ // Block-specific attribute mappings
+ if ( blockName === 'remote-data-blocks/remote-html' && attributeName === 'content' ) {
+ return HTML_FIELD_TYPES;
+ }
+
+ // Map attribute names to their appropriate field types
+ const attributeFieldTypeMap: Record< string, string[] > = {
+ // Text content attributes
+ content: TEXT_FIELD_TYPES,
+
+ // Image attributes
+ url: IMAGE_URL_FIELD_TYPES,
+ alt: IMAGE_ALT_FIELD_TYPES,
+ title: IMAGE_TITLE_FIELD_TYPES,
+
+ // Button attributes
+ text: BUTTON_TEXT_FIELD_TYPES,
+ linkTarget: BUTTON_LINK_TARGET_FIELD_TYPES,
+ rel: BUTTON_REL_FIELD_TYPES,
+
+ // HTML content
+ html: HTML_FIELD_TYPES,
+ };
+
+ // Return mapped types or default to TEXT_FIELD_TYPES for unknown attributes
+ return attributeFieldTypeMap[ attributeName ] ?? TEXT_FIELD_TYPES;
+}
+
+/**
+ * Get a human-readable label for an attribute name.
+ *
+ * @param attributeName The name of the attribute
+ * @returns A human-readable label
+ */
+function getAttributeLabel( attributeName: string ): string {
+ const labelMap: Record< string, string > = {
+ content: 'Content',
+ url: 'URL',
+ alt: 'Alt Text',
+ title: 'Title',
+ text: 'Text',
+ linkTarget: 'Link Target',
+ rel: 'Link Relationship',
+ id: 'ID',
+ caption: 'Caption',
+ };
+
+ // Return mapped label or capitalize the attribute name
+ return labelMap[ attributeName ] ?? attributeName.charAt( 0 ).toUpperCase() + attributeName.slice( 1 );
+}
+
export function BlockBindingControls( props: BlockBindingControlsProps ) {
const { attributes, availableBindings, blockName, remoteDataName, removeBinding, updateBinding } =
props;
- const contentArgs = attributes.metadata?.bindings?.content?.args;
- const contentField = contentArgs?.field ?? '';
- const imageAltField = attributes.metadata?.bindings?.alt?.args?.field ?? '';
- const imageUrlField = attributes.metadata?.bindings?.url?.args?.field ?? '';
- const buttonUrlField = attributes.metadata?.bindings?.url?.args?.field ?? '';
- const buttonTextField = attributes.metadata?.bindings?.text?.args?.field ?? '';
+
+ // Use useSelect to efficiently get supported bindings from WordPress
+ const supportedBindingsFromWP = useSelect(
+ ( select ) => {
+ try {
+ const editorSettings = select( blockEditorStore )?.getSettings?.();
+ // @ts-expect-error - __experimentalBlockBindingsSupportedAttributes is not in types yet
+ return editorSettings?.__experimentalBlockBindingsSupportedAttributes ?? {};
+ } catch ( error ) {
+ return {};
+ }
+ },
+ []
+ );
+
+ // Memoize the supported attributes for this block
+ const supportedAttributes = useMemo( () => {
+ // Check if this block has registered bindings in WordPress
+ if ( supportedBindingsFromWP[ blockName ] ) {
+ return supportedBindingsFromWP[ blockName ];
+ }
+
+ // Fallback for custom blocks like remote-data-blocks/remote-html
+ // that may not register with WordPress but still support bindings
+ if ( blockName === 'remote-data-blocks/remote-html' ) {
+ return [ 'content' ];
+ }
+
+ return [];
+ }, [ blockName, supportedBindingsFromWP ] );
function updateFieldBinding( target: string, field: string ): void {
if ( ! field ) {
@@ -83,102 +173,61 @@ export function BlockBindingControls( props: BlockBindingControlsProps ) {
} );
}
- function updateFieldLabel( showLabel: boolean ): void {
- if ( ! contentField ) {
- // Form input should be disabled in this state, but check anyway.
+ function updateFieldLabel( target: string, showLabel: boolean ): void {
+ const currentField = attributes.metadata?.bindings?.[ target ]?.args?.field;
+
+ if ( ! currentField ) {
return;
}
const label = showLabel
- ? Object.entries( availableBindings ).find( ( [ key ] ) => key === contentField )?.[ 1 ]?.name
+ ? Object.entries( availableBindings ).find( ( [ key ] ) => key === currentField )?.[ 1 ]?.name
: undefined;
- updateBinding( 'content', { ...contentArgs, field: contentField, label } );
+
+ const currentArgs = attributes.metadata?.bindings?.[ target ]?.args ?? {};
+ updateBinding( target, { ...currentArgs, field: currentField, label } );
sendTracksEvent( 'remote_data_container_actions', {
action: showLabel ? 'show_label' : 'hide_label',
data_source_type: getBlockDataSourceType( remoteDataName ),
} );
}
- switch ( blockName ) {
- case 'core/heading':
- case 'core/paragraph':
- return (
- <>
-
-
- >
- );
-
- case 'core/image':
- return (
- <>
-
-
- >
- );
- case 'core/button':
- return (
- <>
-
-
- >
- );
-
- case 'remote-data-blocks/remote-html':
- return (
- <>
-
- >
- );
+ // If no attributes support bindings, don't render anything
+ if ( supportedAttributes.length === 0 ) {
+ return null;
}
- return null;
+ // Dynamically generate controls for each supported attribute
+ return (
+ <>
+ { supportedAttributes.map( ( attributeName ) => {
+ const fieldValue = attributes.metadata?.bindings?.[ attributeName ]?.args?.field ?? '';
+ const fieldTypes = getFieldTypesForAttribute( blockName, attributeName );
+ const label = getAttributeLabel( attributeName );
+ const args = attributes.metadata?.bindings?.[ attributeName ]?.args;
+
+ return (
+
+
+ { attributeName === 'content' && fieldValue && (
+ updateFieldLabel( attributeName, showLabel ) }
+ />
+ ) }
+
+ );
+ } ) }
+ >
+ );
}
diff --git a/src/blocks/remote-data-container/config/constants.ts b/src/blocks/remote-data-container/config/constants.ts
index 8a50a21a9..b7c1d90ed 100644
--- a/src/blocks/remote-data-container/config/constants.ts
+++ b/src/blocks/remote-data-container/config/constants.ts
@@ -1,13 +1,6 @@
import { getRestUrl } from '@/utils/localized-block-data';
import { getClassName } from '@/utils/string';
-export const SUPPORTED_CORE_BLOCKS = [
- 'core/button',
- 'core/heading',
- 'core/image',
- 'core/paragraph',
-];
-
export const DISPLAY_QUERY_KEY = 'display';
export const REMOTE_DATA_CONTEXT_KEY = 'remote-data-blocks/remoteData';
export const REMOTE_DATA_REST_API_URL = getRestUrl();
@@ -24,9 +17,14 @@ export const SEARCH_INPUT_VARIABLE_TYPE = 'ui:search_input';
export const BUTTON_TEXT_FIELD_TYPES = [ 'button_text' ];
export const BUTTON_URL_FIELD_TYPES = [ 'button_url' ];
+// Include 'string' as fallback for newly supported attributes that may not have dedicated field types yet
+export const BUTTON_LINK_TARGET_FIELD_TYPES = [ 'button_link_target', 'string' ];
+export const BUTTON_REL_FIELD_TYPES = [ 'button_rel', 'string' ];
export const HTML_FIELD_TYPES = [ 'html' ];
export const ID_FIELD_TYPES = [ 'id', 'id:list' ];
export const IMAGE_ALT_FIELD_TYPES = [ 'image_alt' ];
+// Include 'string' as fallback for newly supported attributes that may not have dedicated field types yet
+export const IMAGE_TITLE_FIELD_TYPES = [ 'image_title', 'string' ];
export const IMAGE_URL_FIELD_TYPES = [ 'image_url' ];
export const TEXT_FIELD_TYPES = [
'currency_in_current_locale',
diff --git a/tests/src/block-editor/filters/addUsesContext.test.ts b/tests/src/block-editor/filters/addUsesContext.test.ts
new file mode 100644
index 000000000..e3c5dc8af
--- /dev/null
+++ b/tests/src/block-editor/filters/addUsesContext.test.ts
@@ -0,0 +1,203 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { BlockConfiguration } from '@wordpress/blocks';
+
+import { addUsesContext } from '@/block-editor/filters/addUsesContext';
+import { REMOTE_DATA_CONTEXT_KEY } from '@/blocks/remote-data-container/config/constants';
+
+// Mock the WordPress data store
+const mockGetSettings = vi.fn();
+const mockSelect = vi.fn( () => ( {
+ getSettings: mockGetSettings,
+} ) );
+
+vi.mock( '@wordpress/data', () => ( {
+ select: mockSelect,
+} ) );
+
+vi.mock( '@wordpress/block-editor', () => ( {
+ store: 'core/block-editor',
+} ) );
+
+describe( 'addUsesContext', () => {
+ beforeEach( () => {
+ // Reset mocks before each test
+ mockGetSettings.mockClear();
+ mockSelect.mockClear();
+ } );
+
+ it( 'should add context to blocks that support bindings according to WordPress', () => {
+ // Mock the block editor settings with supported bindings
+ mockGetSettings.mockReturnValue( {
+ __experimentalBlockBindingsSupportedAttributes: {
+ 'core/paragraph': [ 'content' ],
+ 'core/heading': [ 'content' ],
+ 'core/image': [ 'url', 'alt', 'title' ],
+ },
+ } );
+
+ const settings: BlockConfiguration< RemoteDataInnerBlockAttributes > = {
+ attributes: {},
+ category: 'text',
+ title: 'Test Block',
+ };
+
+ const result = addUsesContext( settings, 'core/paragraph' );
+
+ expect( result.usesContext ).toContain( REMOTE_DATA_CONTEXT_KEY );
+ } );
+
+ it( 'should not modify blocks that do not support bindings', () => {
+ // Mock the block editor settings without the block
+ mockGetSettings.mockReturnValue( {
+ __experimentalBlockBindingsSupportedAttributes: {
+ 'core/paragraph': [ 'content' ],
+ },
+ } );
+
+ const settings: BlockConfiguration< RemoteDataInnerBlockAttributes > = {
+ attributes: {},
+ category: 'text',
+ title: 'Test Block',
+ };
+
+ const result = addUsesContext( settings, 'core/unknown-block' );
+
+ expect( result ).toEqual( settings );
+ expect( result.usesContext ).toBeUndefined();
+ } );
+
+ it( 'should preserve existing usesContext values', () => {
+ mockGetSettings.mockReturnValue( {
+ __experimentalBlockBindingsSupportedAttributes: {
+ 'core/heading': [ 'content' ],
+ },
+ } );
+
+ const settings: BlockConfiguration< RemoteDataInnerBlockAttributes > = {
+ attributes: {},
+ category: 'text',
+ title: 'Test Block',
+ usesContext: [ 'existing/context' ],
+ };
+
+ const result = addUsesContext( settings, 'core/heading' );
+
+ expect( result.usesContext ).toContain( 'existing/context' );
+ expect( result.usesContext ).toContain( REMOTE_DATA_CONTEXT_KEY );
+ } );
+
+ it( 'should not duplicate context if already present', () => {
+ mockGetSettings.mockReturnValue( {
+ __experimentalBlockBindingsSupportedAttributes: {
+ 'core/button': [ 'url', 'text', 'linkTarget', 'rel' ],
+ },
+ } );
+
+ const settings: BlockConfiguration< RemoteDataInnerBlockAttributes > = {
+ attributes: {},
+ category: 'text',
+ title: 'Test Block',
+ usesContext: [ REMOTE_DATA_CONTEXT_KEY ],
+ };
+
+ const result = addUsesContext( settings, 'core/button' );
+
+ expect( result ).toEqual( settings );
+ const contextCount = result.usesContext?.filter( ctx => ctx === REMOTE_DATA_CONTEXT_KEY ).length;
+ expect( contextCount ).toBe( 1 );
+ } );
+
+ it( 'should handle blocks with multiple bindable attributes', () => {
+ mockGetSettings.mockReturnValue( {
+ __experimentalBlockBindingsSupportedAttributes: {
+ 'core/image': [ 'url', 'alt', 'title' ],
+ },
+ } );
+
+ const settings: BlockConfiguration< RemoteDataInnerBlockAttributes > = {
+ attributes: {},
+ category: 'media',
+ title: 'Image Block',
+ };
+
+ const result = addUsesContext( settings, 'core/image' );
+
+ expect( result.usesContext ).toContain( REMOTE_DATA_CONTEXT_KEY );
+ } );
+
+ it( 'should handle dynamic blocks like post blocks', () => {
+ mockGetSettings.mockReturnValue( {
+ __experimentalBlockBindingsSupportedAttributes: {
+ 'core/post-title': [ 'content' ],
+ 'core/post-featured-image': [ 'url', 'alt' ],
+ },
+ } );
+
+ const postTitleSettings: BlockConfiguration< RemoteDataInnerBlockAttributes > = {
+ attributes: {},
+ category: 'theme',
+ title: 'Post Title',
+ };
+
+ const postImageSettings: BlockConfiguration< RemoteDataInnerBlockAttributes > = {
+ attributes: {},
+ category: 'theme',
+ title: 'Post Featured Image',
+ };
+
+ const titleResult = addUsesContext( postTitleSettings, 'core/post-title' );
+ const imageResult = addUsesContext( postImageSettings, 'core/post-featured-image' );
+
+ expect( titleResult.usesContext ).toContain( REMOTE_DATA_CONTEXT_KEY );
+ expect( imageResult.usesContext ).toContain( REMOTE_DATA_CONTEXT_KEY );
+ } );
+
+ it( 'should return settings unchanged if block editor settings are not available', () => {
+ // Mock select to return undefined (store not ready yet)
+ mockSelect.mockReturnValue( undefined );
+
+ const settings: BlockConfiguration< RemoteDataInnerBlockAttributes > = {
+ attributes: {},
+ category: 'text',
+ title: 'Test Block',
+ };
+
+ const result = addUsesContext( settings, 'core/paragraph' );
+
+ expect( result ).toEqual( settings );
+ } );
+
+ it( 'should handle missing __experimentalBlockBindingsSupportedAttributes', () => {
+ // Mock settings without the experimental property
+ mockGetSettings.mockReturnValue( {} );
+
+ const settings: BlockConfiguration< RemoteDataInnerBlockAttributes > = {
+ attributes: {},
+ category: 'text',
+ title: 'Test Block',
+ };
+
+ const result = addUsesContext( settings, 'core/paragraph' );
+
+ expect( result ).toEqual( settings );
+ } );
+
+ it( 'should not add context for blocks with empty attribute arrays', () => {
+ mockGetSettings.mockReturnValue( {
+ __experimentalBlockBindingsSupportedAttributes: {
+ 'core/some-block': [], // Block registered but no attributes support bindings
+ },
+ } );
+
+ const settings: BlockConfiguration< RemoteDataInnerBlockAttributes > = {
+ attributes: {},
+ category: 'text',
+ title: 'Test Block',
+ };
+
+ const result = addUsesContext( settings, 'core/some-block' );
+
+ expect( result ).toEqual( settings );
+ expect( result.usesContext ).toBeUndefined();
+ } );
+} );