diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index aa870dee07397..3f896702d4d17 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -47,7 +47,7 @@ import { getAllowedFormats } from './utils'; import { Content } from './content'; import { withDeprecations } from './with-deprecations'; import { unlock } from '../../lock-unlock'; -import { BLOCK_BINDINGS_ALLOWED_BLOCKS } from '../../hooks/use-bindings-attributes'; +import { canBindBlock } from '../../hooks/use-bindings-attributes'; export const keyboardShortcutContext = createContext(); export const inputEventContext = createContext(); @@ -161,7 +161,7 @@ export function RichTextWrapper( ( select ) => { // Disable Rich Text editing if block bindings specify that. let _disableBoundBlocks = false; - if ( blockBindings && blockName in BLOCK_BINDINGS_ALLOWED_BLOCKS ) { + if ( blockBindings && canBindBlock( blockName ) ) { const blockTypeAttributes = getBlockType( blockName ).attributes; const { getBlockBindingsSource } = unlock( diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js index 0e5b6614f07cb..5cd8cb46b3b7e 100644 --- a/packages/block-editor/src/hooks/use-bindings-attributes.js +++ b/packages/block-editor/src/hooks/use-bindings-attributes.js @@ -4,12 +4,13 @@ import { getBlockType, store as blocksStore } from '@wordpress/blocks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; +import { useLayoutEffect, useCallback, useState } from '@wordpress/element'; import { addFilter } from '@wordpress/hooks'; +import { RichTextData } from '@wordpress/rich-text'; + /** * 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 */ @@ -22,87 +23,238 @@ import { unlock } from '../lock-unlock'; * @return {WPHigherOrderComponent} Higher-order component. */ -export const BLOCK_BINDINGS_ALLOWED_BLOCKS = { +const BLOCK_BINDINGS_ALLOWED_BLOCKS = { 'core/paragraph': [ 'content' ], 'core/heading': [ 'content' ], 'core/image': [ 'url', 'title', 'alt' ], 'core/button': [ 'url', 'text', 'linkTarget' ], }; -const createEditFunctionWithBindingsAttribute = () => - createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - const { clientId, name: blockName } = useBlockEditContext(); - const blockBindingsSources = unlock( - useSelect( blocksStore ) - ).getAllBlockBindingsSources(); - const { getBlockAttributes } = useSelect( blockEditorStore ); - - const updatedAttributes = getBlockAttributes( clientId ); - if ( updatedAttributes?.metadata?.bindings ) { - Object.entries( updatedAttributes.metadata.bindings ).forEach( - ( [ attributeName, settings ] ) => { - const source = blockBindingsSources[ settings.source ]; - - if ( source && source.useSource ) { - // Second argument (`updateMetaValue`) will be used to update the value in the future. - const { - placeholder, - useValue: [ metaValue = null ] = [], - } = source.useSource( props, settings.args ); - - if ( placeholder && ! metaValue ) { - // If the attribute is `src` or `href`, a placeholder can't be used because it is not a valid url. - // Adding this workaround until attributes and metadata fields types are improved and include `url`. - const htmlAttribute = - getBlockType( blockName ).attributes[ - attributeName - ].attribute; - if ( - htmlAttribute === 'src' || - htmlAttribute === 'href' - ) { - updatedAttributes[ attributeName ] = null; - } else { - updatedAttributes[ attributeName ] = - placeholder; - } - } - - if ( metaValue ) { - updatedAttributes[ attributeName ] = metaValue; - } - } - } - ); +/** + * Based on the given block name, + * check if it is possible to bind the block. + * + * @param {string} blockName - The block name. + * @return {boolean} Whether it is possible to bind the block to sources. + */ +export function canBindBlock( blockName ) { + return blockName in BLOCK_BINDINGS_ALLOWED_BLOCKS; +} + +/** + * Based on the given block name and attribute name, + * check if it is possible to bind the block attribute. + * + * @param {string} blockName - The block name. + * @param {string} attributeName - The attribute name. + * @return {boolean} Whether it is possible to bind the block attribute. + */ +export function canBindAttribute( blockName, attributeName ) { + return ( + canBindBlock( blockName ) && + BLOCK_BINDINGS_ALLOWED_BLOCKS[ blockName ].includes( attributeName ) + ); +} + +/** + * This component is responsible for detecting and + * propagating data changes from the source to the block. + * + * @param {Object} props - The component props. + * @param {string} props.attrName - The attribute name. + * @param {Object} props.blockProps - The block props with bound attribute. + * @param {Object} props.source - Source handler. + * @param {Object} props.args - The arguments to pass to the source. + * @param {Function} props.onPropValueChange - The function to call when the attribute value changes. + * @return {null} Data-handling component. Render nothing. + */ +const BindingConnector = ( { + args, + attrName, + blockProps, + source, + onPropValueChange, +} ) => { + const { placeholder, value: propValue } = source.useSource( + blockProps, + args + ); + + const { name: blockName } = blockProps; + const attrValue = blockProps.attributes[ attrName ]; + + const updateBoundAttibute = useCallback( + ( newAttrValue, prevAttrValue ) => { + /* + * If the attribute is a RichTextData instance, + * (core/paragraph, core/heading, core/button, etc.) + * compare its HTML representation with the new value. + * + * To do: it looks like a workaround. + * Consider improving the attribute and metadata fields types. + */ + if ( prevAttrValue instanceof RichTextData ) { + // Bail early if the Rich Text value is the same. + if ( prevAttrValue.toHTMLString() === newAttrValue ) { + return; + } + + /* + * To preserve the value type, + * convert the new value to a RichTextData instance. + */ + newAttrValue = RichTextData.fromHTMLString( newAttrValue ); + } + + if ( prevAttrValue === newAttrValue ) { + return; } - return ( + onPropValueChange( { [ attrName ]: newAttrValue } ); + }, + [ attrName, onPropValueChange ] + ); + + useLayoutEffect( () => { + if ( typeof propValue !== 'undefined' ) { + updateBoundAttibute( propValue, attrValue ); + } else if ( placeholder ) { + /* + * Placeholder fallback. + * If the attribute is `src` or `href`, + * a placeholder can't be used because it is not a valid url. + * Adding this workaround until + * attributes and metadata fields types are improved and include `url`. + */ + const htmlAttribute = + getBlockType( blockName ).attributes[ attrName ].attribute; + + if ( htmlAttribute === 'src' || htmlAttribute === 'href' ) { + updateBoundAttibute( null ); + return; + } + + updateBoundAttibute( placeholder ); + } + }, [ + updateBoundAttibute, + propValue, + attrValue, + placeholder, + blockName, + attrName, + ] ); + + return null; +}; + +/** + * BlockBindingBridge acts like a component wrapper + * that connects the bound attributes of a block + * to the source handlers. + * For this, it creates a BindingConnector for each bound attribute. + * + * @param {Object} props - The component props. + * @param {Object} props.blockProps - The BlockEdit props object. + * @param {Object} props.bindings - The block bindings settings. + * @param {Function} props.onPropValueChange - The function to call when the attribute value changes. + * @return {null} Data-handling component. Render nothing. + */ +function BlockBindingBridge( { blockProps, bindings, onPropValueChange } ) { + const blockBindingsSources = unlock( + useSelect( blocksStore ) + ).getAllBlockBindingsSources(); + + return ( + <> + { Object.entries( bindings ).map( + ( [ attrName, boundAttribute ] ) => { + // Bail early if the block doesn't have a valid source handler. + const source = + blockBindingsSources[ boundAttribute.source ]; + if ( ! source?.useSource ) { + return null; + } + + return ( + + ); + } + ) } + + ); +} + +const withBlockBindingSupport = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + /* + * Collect and update the bound attributes + * in a separate state. + */ + const [ boundAttributes, setBoundAttributes ] = useState( {} ); + const updateBoundAttributes = useCallback( + ( newAttributes ) => + setBoundAttributes( ( prev ) => ( { + ...prev, + ...newAttributes, + } ) ), + [] + ); + + /* + * Create binding object filtering + * only the attributes that can be bound. + */ + const bindings = Object.fromEntries( + Object.entries( props.attributes.metadata?.bindings || {} ).filter( + ( [ attrName ] ) => canBindAttribute( props.name, attrName ) + ) + ); + + return ( + <> + { Object.keys( bindings ).length > 0 && ( + + ) } + - ); - }, - 'useBoundAttributes' - ); + + ); + }, + 'withBlockBindingSupport' +); /** * Filters a registered block's settings to enhance a block's `edit` component * to upgrade bound attributes. * - * @param {WPBlockSettings} settings Registered block settings. - * + * @param {WPBlockSettings} settings - Registered block settings. + * @param {string} name - Block name. * @return {WPBlockSettings} Filtered block settings. */ -function shimAttributeSource( settings ) { - if ( ! ( settings.name in BLOCK_BINDINGS_ALLOWED_BLOCKS ) ) { +function shimAttributeSource( settings, name ) { + if ( ! canBindBlock( name ) ) { return settings; } - settings.edit = createEditFunctionWithBindingsAttribute()( settings.edit ); - return settings; + return { + ...settings, + edit: withBlockBindingSupport( settings.edit ), + }; } addFilter( diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js index a9a00599b6803..0d0c737d0eaf7 100644 --- a/packages/editor/src/bindings/post-meta.js +++ b/packages/editor/src/bindings/post-meta.js @@ -19,6 +19,7 @@ export default { const postType = context.postType ? context.postType : getCurrentPostType(); + const [ meta, setMeta ] = useEntityProp( 'postType', context.postType, @@ -33,9 +34,11 @@ export default { const updateMetaValue = ( newValue ) => { setMeta( { ...meta, [ metaKey ]: newValue } ); }; + return { placeholder: metaKey, - useValue: [ metaValue, updateMetaValue ], + value: metaValue, + updateValue: updateMetaValue, }; }, }; diff --git a/test/e2e/specs/editor/various/block-bindings.spec.js b/test/e2e/specs/editor/various/block-bindings.spec.js index 419a70faeaf9b..ca19c06beff01 100644 --- a/test/e2e/specs/editor/various/block-bindings.spec.js +++ b/test/e2e/specs/editor/various/block-bindings.spec.js @@ -1245,7 +1245,7 @@ test.describe( 'Block bindings', () => { const postId = await editor.publishPost(); await page.goto( `/?p=${ postId }` ); await expect( page.locator( '#paragraph-binding' ) ).toHaveText( - 'non_existing_custom_field' + 'fallback value' ); } ); @@ -1276,7 +1276,7 @@ test.describe( 'Block bindings', () => { const postId = await editor.publishPost(); await page.goto( `/?p=${ postId }` ); await expect( page.locator( '#paragraph-binding' ) ).toHaveText( - '_protected_field' + 'fallback value' ); } ); @@ -1309,7 +1309,7 @@ test.describe( 'Block bindings', () => { const postId = await editor.publishPost(); await page.goto( `/?p=${ postId }` ); await expect( page.locator( '#paragraph-binding' ) ).toHaveText( - 'show_in_rest_false_field' + 'fallback value' ); } ); } );