Skip to content
29 changes: 28 additions & 1 deletion docs/concepts/block-bindings.md
Original file line number Diff line number Diff line change
@@ -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
},
},
} );
```
30 changes: 25 additions & 5 deletions src/block-editor/filters/addUsesContext.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
231 changes: 140 additions & 91 deletions src/blocks/remote-data-container/components/BlockBindingControls.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 ) {
Expand All @@ -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 (
<>
<BlockBindingFieldControl
availableBindings={ availableBindings }
fieldTypes={ TEXT_FIELD_TYPES }
label="Content"
target="content"
updateFieldBinding={ updateFieldBinding }
value={ contentField }
/>
<CheckboxControl
checked={ Boolean( contentArgs?.label ) }
disabled={ ! contentField }
label="Show label"
name="show_label"
onChange={ updateFieldLabel }
/>
</>
);

case 'core/image':
return (
<>
<BlockBindingFieldControl
availableBindings={ availableBindings }
fieldTypes={ IMAGE_URL_FIELD_TYPES }
label="Image URL"
target="url"
updateFieldBinding={ updateFieldBinding }
value={ imageUrlField }
/>
<BlockBindingFieldControl
availableBindings={ availableBindings }
fieldTypes={ IMAGE_ALT_FIELD_TYPES }
label="Image alt text"
target="alt"
updateFieldBinding={ updateFieldBinding }
value={ imageAltField }
/>
</>
);
case 'core/button':
return (
<>
<BlockBindingFieldControl
availableBindings={ availableBindings }
fieldTypes={ BUTTON_URL_FIELD_TYPES }
label="Button URL"
target="url"
updateFieldBinding={ updateFieldBinding }
value={ buttonUrlField }
/>
<BlockBindingFieldControl
availableBindings={ availableBindings }
fieldTypes={ BUTTON_TEXT_FIELD_TYPES }
label="Button Text"
target="text"
updateFieldBinding={ updateFieldBinding }
value={ buttonTextField }
/>
</>
);

case 'remote-data-blocks/remote-html':
return (
<>
<BlockBindingFieldControl
availableBindings={ availableBindings }
fieldTypes={ HTML_FIELD_TYPES }
label="Raw HTML"
target="content"
updateFieldBinding={ updateFieldBinding }
value={ contentField }
/>
</>
);
// 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 (
<Fragment key={ attributeName }>
<BlockBindingFieldControl
availableBindings={ availableBindings }
fieldTypes={ fieldTypes }
label={ label }
target={ attributeName }
updateFieldBinding={ updateFieldBinding }
value={ fieldValue }
/>
{ attributeName === 'content' && fieldValue && (
<CheckboxControl
checked={ Boolean( args?.label ) }
disabled={ ! fieldValue }
label="Show label"
name={ `show_label_${ attributeName }` }
onChange={ ( showLabel ) => updateFieldLabel( attributeName, showLabel ) }
/>
) }
</Fragment>
);
} ) }
</>
);
}
12 changes: 5 additions & 7 deletions src/blocks/remote-data-container/config/constants.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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',
Expand Down
Loading