diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md
index 5917ac235505cb..d0870237419533 100644
--- a/packages/block-editor/README.md
+++ b/packages/block-editor/README.md
@@ -812,6 +812,18 @@ _Properties_
Ensures that the text selection keeps the same vertical distance from the viewport during keyboard events within this component. The vertical distance can vary. It is the last clicked or scrolled to position.
+### updateBlockBindingsAttribute
+
+Helper to update the bindings attribute used by the Block Bindings API.
+
+_Parameters_
+
+- _blockAttributes_ `Object`: - The original block attributes.
+- _setAttributes_ `Function`: - setAttributes function to modify the bindings property.
+- _attributeName_ `string`: - The attribute in the bindings object to update.
+- _sourceName_ `string`: - The source name added to the bindings property.
+- _sourceAttributes_ `string`: - The source attributes added to the bindings property.
+
### URLInput
_Related_
diff --git a/packages/block-editor/src/hooks/bindings.js b/packages/block-editor/src/hooks/bindings.js
new file mode 100644
index 00000000000000..3b01a6b3b91fb6
--- /dev/null
+++ b/packages/block-editor/src/hooks/bindings.js
@@ -0,0 +1,147 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import {
+ Button,
+ privateApis as componentsPrivateApis,
+} from '@wordpress/components';
+import { useContext } from '@wordpress/element';
+import { addFilter } from '@wordpress/hooks';
+import { plugins as pluginsIcon } from '@wordpress/icons';
+/**
+ * Internal dependencies
+ */
+import { BlockControls } from '../components';
+import { store as blockEditorStore } from '../store';
+import { unlock } from '../lock-unlock';
+import BlockContext from '../components/block-context';
+
+const {
+ DropdownMenuV2: DropdownMenu,
+ DropdownMenuItemV2: DropdownMenuItem,
+ DropdownMenuItemLabelV2: DropdownMenuItemLabel,
+} = unlock( componentsPrivateApis );
+
+const BLOCK_BINDINGS_ALLOWED_BLOCKS = {
+ 'core/paragraph': [ 'content' ],
+ 'core/heading': [ 'content' ],
+ 'core/image': [ 'url', 'title' ],
+ 'core/button': [ 'url', 'text' ],
+};
+
+function BlockBindingsUI( props ) {
+ const { name: blockName, clientId } = props;
+ const { attributes, sources } = useSelect(
+ ( select ) => {
+ return {
+ attributes:
+ select( blockEditorStore ).getBlockAttributes( clientId ),
+ sources: unlock(
+ select( blockEditorStore )
+ ).getAllBlockBindingsSources(),
+ };
+ },
+ [ clientId ]
+ );
+ const blockContext = useContext( BlockContext );
+ if ( ! ( blockName in BLOCK_BINDINGS_ALLOWED_BLOCKS ) ) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ }
+ >
+ { /* Iterate over block attributes */ }
+ { BLOCK_BINDINGS_ALLOWED_BLOCKS[ blockName ].map(
+ ( attribute ) => {
+ return (
+
+
+ { attribute }
+
+
+ }
+ key={ attribute }
+ >
+ { /* Iterate over sources */ }
+ { Object.entries( sources ).map(
+ ( [ sourceName, source ] ) => {
+ return (
+
+
+ { source.label }
+
+
+ }
+ key={ sourceName }
+ >
+ { source.component(
+ props,
+ blockContext,
+ attribute
+ ) }
+
+ );
+ }
+ ) }
+
+ );
+ }
+ ) }
+
+
+ >
+ );
+}
+
+export default {
+ edit: BlockBindingsUI,
+ attributeKeys: [ 'metadata' ],
+ hasSupport() {
+ return true;
+ },
+};
+
+if ( window.__experimentalBlockBindings ) {
+ addFilter(
+ 'blocks.registerBlockType',
+ 'core/block-bindings-ui',
+ ( settings, name ) => {
+ if ( ! ( name in BLOCK_BINDINGS_ALLOWED_BLOCKS ) ) {
+ return settings;
+ }
+ 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;
+ return settings;
+ }
+ );
+}
diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js
index f17c0a22166e4e..6a6383911b0c56 100644
--- a/packages/block-editor/src/hooks/index.js
+++ b/packages/block-editor/src/hooks/index.js
@@ -11,6 +11,7 @@ import align from './align';
import './lock';
import anchor from './anchor';
import ariaLabel from './aria-label';
+import bindings from './bindings';
import customClassName from './custom-class-name';
import './generated-class-name';
import style from './style';
@@ -27,11 +28,13 @@ import contentLockUI from './content-lock-ui';
import './metadata';
import blockHooks from './block-hooks';
import blockRenaming from './block-renaming';
+import './use-bindings-attributes';
createBlockEditFilter(
[
align,
anchor,
+ bindings,
customClassName,
style,
duotone,
diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js
new file mode 100644
index 00000000000000..f3a670cc1883e6
--- /dev/null
+++ b/packages/block-editor/src/hooks/use-bindings-attributes.js
@@ -0,0 +1,123 @@
+/**
+ * WordPress dependencies
+ */
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { useRegistry, useSelect } from '@wordpress/data';
+import { addFilter } from '@wordpress/hooks';
+/**
+ * Internal dependencies
+ */
+import { store as blockEditorStore } from '../store';
+import { useBlockEditContext } from '../components/block-edit/context';
+import { unlock } from '../lock-unlock';
+
+/** @typedef {import('@wordpress/compose').WPHigherOrderComponent} WPHigherOrderComponent */
+/** @typedef {import('@wordpress/blocks').WPBlockSettings} WPBlockSettings */
+
+/**
+ * Given a binding of block attributes, returns a higher order component that
+ * overrides its `attributes` and `setAttributes` props to sync any changes needed.
+ *
+ * @return {WPHigherOrderComponent} Higher-order component.
+ */
+
+const BLOCK_BINDINGS_ALLOWED_BLOCKS = {
+ 'core/paragraph': [ 'content' ],
+ 'core/heading': [ 'content' ],
+ 'core/image': [ 'url', 'title' ],
+ 'core/button': [ 'url', 'text' ],
+};
+
+const createEditFunctionWithBindingsAttribute = () =>
+ createHigherOrderComponent(
+ ( BlockEdit ) => ( props ) => {
+ const { clientId } = useBlockEditContext();
+
+ const {
+ getBlockBindingsSource,
+ getBlockAttributes,
+ updateBlockAttributes,
+ } = useSelect( ( select ) => {
+ return {
+ getBlockBindingsSource: unlock( select( blockEditorStore ) )
+ .getBlockBindingsSource,
+ getBlockAttributes:
+ select( blockEditorStore ).getBlockAttributes,
+ updateBlockAttributes:
+ select( blockEditorStore ).updateBlockAttributes,
+ };
+ }, [] );
+
+ const updatedAttributes = getBlockAttributes( clientId );
+ if ( updatedAttributes?.metadata?.bindings ) {
+ Object.entries( updatedAttributes.metadata.bindings ).forEach(
+ ( [ attributeName, settings ] ) => {
+ const source = getBlockBindingsSource(
+ settings.source.name
+ );
+
+ if ( source ) {
+ // Second argument (`updateMetaValue`) will be used to update the value in the future.
+ const {
+ placeholder,
+ useValue: [ metaValue = null ] = [],
+ } = source.useSource(
+ props,
+ settings.source.attributes
+ );
+
+ if ( placeholder ) {
+ updatedAttributes.placeholder = placeholder;
+ updatedAttributes[ attributeName ] = null;
+ }
+
+ if ( metaValue ) {
+ updatedAttributes[ attributeName ] = metaValue;
+ }
+ }
+ }
+ );
+ }
+
+ const registry = useRegistry();
+
+ return (
+ <>
+
+ registry.batch( () =>
+ updateBlockAttributes( blockId, newAttributes )
+ )
+ }
+ { ...props }
+ />
+ >
+ );
+ },
+ 'useBoundAttributes'
+ );
+
+/**
+ * Filters a registered block's settings to enhance a block's `edit` component
+ * to upgrade bound attributes.
+ *
+ * @param {WPBlockSettings} settings Registered block settings.
+ *
+ * @return {WPBlockSettings} Filtered block settings.
+ */
+function shimAttributeSource( settings ) {
+ if ( ! ( settings.name in BLOCK_BINDINGS_ALLOWED_BLOCKS ) ) {
+ return settings;
+ }
+ settings.edit = createEditFunctionWithBindingsAttribute()( settings.edit );
+
+ return settings;
+}
+
+addFilter(
+ 'blocks.registerBlockType',
+ 'core/editor/custom-sources-backwards-compatibility/shim-attribute-source',
+ shimAttributeSource
+);
diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js
index a31455a0b7e7b3..528b1dd4c31cba 100644
--- a/packages/block-editor/src/store/private-actions.js
+++ b/packages/block-editor/src/store/private-actions.js
@@ -240,6 +240,16 @@ export function clearBlockRemovalPrompt() {
};
}
+export function registerBlockBindingsSource( source ) {
+ return {
+ type: 'REGISTER_BLOCK_BINDINGS_SOURCE',
+ sourceName: source.name,
+ sourceLabel: source.label,
+ sourceComponent: source.component,
+ useSource: source.useSource,
+ };
+}
+
/**
* Returns an action object used to set up any rules that a block editor may
* provide in order to prevent a user from accidentally removing certain
diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js
index d31a710fd94fe3..253ec8d546064e 100644
--- a/packages/block-editor/src/store/private-selectors.js
+++ b/packages/block-editor/src/store/private-selectors.js
@@ -170,6 +170,14 @@ export function getStyleOverrides( state ) {
return state.styleOverrides;
}
+export function getAllBlockBindingsSources( state ) {
+ return state.blockBindingsSources;
+}
+
+export function getBlockBindingsSource( state, sourceName ) {
+ return state?.blockBindingsSources?.[ sourceName ];
+}
+
/** @typedef {import('./actions').InserterMediaCategory} InserterMediaCategory */
/**
* Returns the registered inserter media categories through the public API.
diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js
index fa6c8942e66add..c49307ee6234b0 100644
--- a/packages/block-editor/src/store/reducer.js
+++ b/packages/block-editor/src/store/reducer.js
@@ -1513,6 +1513,21 @@ function removalPromptData( state = false, action ) {
return state;
}
+function blockBindingsSources( state = {}, action ) {
+ if ( action.type === 'REGISTER_BLOCK_BINDINGS_SOURCE' ) {
+ return {
+ ...state,
+ [ action.sourceName ]: {
+ label: action.sourceLabel,
+ component: action.sourceComponent,
+ useSource: action.useSource,
+ },
+ };
+ }
+
+ return state;
+}
+
/**
* Reducer returning any rules that a block editor may provide in order to
* prevent a user from accidentally removing certain blocks. These rules are
@@ -2044,6 +2059,7 @@ const combinedReducers = combineReducers( {
blockEditingModes,
styleOverrides,
removalPromptData,
+ blockBindingsSources,
blockRemovalRules,
openedBlockSettingsMenu,
registeredInserterMediaCategories,
diff --git a/packages/block-editor/src/utils/index.js b/packages/block-editor/src/utils/index.js
index ee3b2692b369a8..488e27d12da52a 100644
--- a/packages/block-editor/src/utils/index.js
+++ b/packages/block-editor/src/utils/index.js
@@ -1,3 +1,4 @@
export { default as transformStyles } from './transform-styles';
export * from './block-variation-transforms';
export { default as getPxFromCssUnit } from './get-px-from-css-unit';
+export * from './update-bindings-attribute';
diff --git a/packages/block-editor/src/utils/update-bindings-attribute.js b/packages/block-editor/src/utils/update-bindings-attribute.js
new file mode 100644
index 00000000000000..a5ee0671848e43
--- /dev/null
+++ b/packages/block-editor/src/utils/update-bindings-attribute.js
@@ -0,0 +1,50 @@
+/**
+ * Helper to update the bindings attribute used by the Block Bindings API.
+ *
+ * @param {Object} blockAttributes - The original block attributes.
+ * @param {Function} setAttributes - setAttributes function to modify the bindings property.
+ * @param {string} attributeName - The attribute in the bindings object to update.
+ * @param {string} sourceName - The source name added to the bindings property.
+ * @param {string} sourceAttributes - The source attributes added to the bindings property.
+ */
+export const updateBlockBindingsAttribute = (
+ blockAttributes,
+ setAttributes,
+ attributeName,
+ sourceName,
+ sourceAttributes
+) => {
+ // TODO: Review if we can create a React Hook for this.
+
+ let updatedBindings = {};
+ // // If no sourceName is provided, remove the attribute from the bindings.
+ if ( sourceName === null ) {
+ if ( ! blockAttributes?.metadata.bindings ) {
+ return blockAttributes?.metadata;
+ }
+
+ updatedBindings = {
+ ...blockAttributes?.metadata?.bindings,
+ [ attributeName ]: undefined,
+ };
+ if ( Object.keys( updatedBindings ).length === 1 ) {
+ updatedBindings = undefined;
+ }
+ } else {
+ updatedBindings = {
+ ...blockAttributes?.metadata?.bindings,
+ [ attributeName ]: {
+ source: { name: sourceName, attributes: sourceAttributes },
+ },
+ };
+ }
+
+ setAttributes( {
+ metadata: {
+ ...blockAttributes.metadata,
+ bindings: updatedBindings,
+ },
+ } );
+
+ return blockAttributes.metadata;
+};
diff --git a/packages/editor/package.json b/packages/editor/package.json
index b974c7443851f1..63c81cbd5cc7f8 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -27,7 +27,7 @@
"sideEffects": [
"build-style/**",
"src/**/*.scss",
- "{src,build,build-module}/{index.js,store/index.js,hooks/**}"
+ "{src,build,build-module}/{index.js,store/index.js,hooks/**,bindings/**}"
],
"dependencies": {
"@babel/runtime": "^7.16.0",
diff --git a/packages/editor/src/bindings/index.js b/packages/editor/src/bindings/index.js
new file mode 100644
index 00000000000000..8a883e8904a71b
--- /dev/null
+++ b/packages/editor/src/bindings/index.js
@@ -0,0 +1,13 @@
+/**
+ * WordPress dependencies
+ */
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { dispatch } from '@wordpress/data';
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../lock-unlock';
+import postMeta from './post-meta';
+
+const { registerBlockBindingsSource } = unlock( dispatch( blockEditorStore ) );
+registerBlockBindingsSource( postMeta );
diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js
new file mode 100644
index 00000000000000..7b3338295ed617
--- /dev/null
+++ b/packages/editor/src/bindings/post-meta.js
@@ -0,0 +1,177 @@
+/**
+ * WordPress dependencies
+ */
+import { updateBlockBindingsAttribute } from '@wordpress/block-editor';
+import { privateApis as componentsPrivateApis } from '@wordpress/components';
+import { useEntityProp, store as coreStore } from '@wordpress/core-data';
+import { select } from '@wordpress/data';
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../lock-unlock';
+import { store as editorStore } from '../store';
+
+const { getCurrentPostId, getCurrentPostType } = select( editorStore );
+const { getEntityRecord, getEntityRecords } = select( coreStore );
+
+// Prettify the name until the label is available in the REST API endpoint.
+const keyToLabel = ( key ) => {
+ return key
+ .split( '_' )
+ .map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) )
+ .join( ' ' );
+};
+
+function PostMetaComponent( props, blockContext, selectedAttribute ) {
+ const {
+ DropdownMenuGroupV2: DropdownMenuGroup,
+ DropdownMenuItemLabelV2: DropdownMenuItemLabel,
+ DropdownMenuCheckboxItemV2: DropdownMenuCheckboxItem,
+ } = unlock( componentsPrivateApis );
+ const { metadata, setAttributes } = props;
+
+ // Get list of post_meta fields depending on the context.
+ const postId = blockContext.postId
+ ? blockContext.postId
+ : getCurrentPostId();
+ const postType = blockContext.postType
+ ? blockContext.postType
+ : getCurrentPostType();
+
+ let data = {};
+ if ( postType === 'wp_template' ) {
+ const { slug: templateSlug } = getEntityRecord(
+ 'postType',
+ 'wp_template',
+ postId
+ );
+
+ // Get the post type from the template slug.
+
+ // Match "page-{slug}".
+ const pagePattern = /^page(?:-(.+))?$/;
+ // Match "single-{postType}-{slug}".
+ const postPattern = /^single-([^-]+)(?:-(.+))?$/;
+ // Match "wp-custom-template-{slug}".
+ const customTemplatePattern = /^wp-custom-template-(.+)$/;
+ // If it doesn't match any of the accepted patterns, return.
+ if (
+ ! templateSlug !== 'index' &&
+ ! templateSlug !== 'page' &&
+ ! pagePattern.test( templateSlug ) &&
+ ! postPattern.test( templateSlug ) &&
+ ! customTemplatePattern.test( templateSlug )
+ ) {
+ data = null;
+ }
+
+ let records = [];
+ // If it is an index or a generic page template, return any page.
+ if ( templateSlug === 'index' || templateSlug === 'page' ) {
+ records = getEntityRecords( 'postType', 'page', {
+ per_page: 1,
+ } );
+ }
+
+ // If it is specific page template, return that one.
+ if ( pagePattern.test( templateSlug ) ) {
+ records = getEntityRecords( 'postType', 'page', {
+ slug: templateSlug.match( pagePattern )[ 1 ],
+ } );
+ }
+
+ // If it is post/cpt template.
+ if ( postPattern.test( templateSlug ) ) {
+ const [ , entityPostType, entitySlug ] =
+ templateSlug.match( postPattern );
+
+ // If it is a specific post.
+ if ( entitySlug ) {
+ records = getEntityRecords( 'postType', entityPostType, {
+ slug: entitySlug,
+ } );
+ } else {
+ // If it is a generic template, return any post.
+ records = getEntityRecords( 'postType', entityPostType, {
+ per_page: 1,
+ } );
+ }
+ }
+
+ // If it is a custom template, get the fields from any page.
+ if ( customTemplatePattern.test( templateSlug ) || ! records ) {
+ records = getEntityRecords( 'postType', 'page', {
+ per_page: 1,
+ } );
+ }
+
+ data = records?.[ 0 ];
+ } else {
+ data = getEntityRecord( 'postType', postType, postId );
+ }
+
+ if ( ! data || ! data?.meta ) {
+ return null;
+ }
+
+ return (
+
+ { Object.keys( data.meta ).map( ( key ) => {
+ return (
+ {
+ updateBlockBindingsAttribute(
+ { metadata },
+ setAttributes,
+ selectedAttribute,
+ 'post_meta',
+ { value: key }
+ );
+ } }
+ >
+
+ { keyToLabel( key ) }
+
+
+ );
+ } ) }
+
+ );
+}
+
+export default {
+ name: 'post_meta',
+ label: 'Post Meta',
+ component: PostMetaComponent,
+ useSource( props, sourceAttributes ) {
+ const { context } = props;
+ const { value: metaKey } = sourceAttributes;
+ const postType = context.postType
+ ? context.postType
+ : getCurrentPostType();
+ const [ meta, setMeta ] = useEntityProp(
+ 'postType',
+ context.postType,
+ 'meta',
+ context.postId
+ );
+
+ if ( postType === 'wp_template' ) {
+ return { placeholder: keyToLabel( metaKey ) };
+ }
+ const metaValue = meta[ metaKey ];
+ const updateMetaValue = ( newValue ) => {
+ setMeta( { ...meta, [ metaKey ]: newValue } );
+ };
+ return { useValue: [ metaValue, updateMetaValue ] };
+ },
+};
diff --git a/packages/editor/src/index.js b/packages/editor/src/index.js
index 05c04b8232907c..3f6d7a78d837c0 100644
--- a/packages/editor/src/index.js
+++ b/packages/editor/src/index.js
@@ -1,6 +1,7 @@
/**
* Internal dependencies
*/
+import './bindings';
import './hooks';
export { storeConfig, store } from './store';