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

(POC) Block Bindings: edit external prop value/block attribute #59537

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 173 additions & 128 deletions packages/block-editor/src/hooks/use-bindings-attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +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 {
useLayoutEffect,
useCallback,
useState,
useMemo,
useRef,
} from '@wordpress/element';
import { addFilter } from '@wordpress/hooks';
import { RichTextData } from '@wordpress/rich-text';

Expand Down Expand Up @@ -62,175 +68,214 @@ export function canBindAttribute( blockName, attributeName ) {
*
* @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.
* @param {Object} props.settings - The block bindings settings.
* @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;
}

onPropValueChange( { [ attrName ]: newAttrValue } );
},
[ attrName, onPropValueChange ]
);
const BindingConnector = ( { attrName, settings, onPropValueChange } ) => {
const { useSource } = settings;
const newPropValue = useSource();
const prevPropValue = useRef( newPropValue );

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 );
if ( prevPropValue.current === newPropValue ) {
return;
}
}, [
updateBoundAttibute,
propValue,
attrValue,
placeholder,
blockName,
attrName,
] );

// Propagate the source value to the block attribute.
onPropValueChange( { [ attrName ]: newPropValue } );
prevPropValue.current = newPropValue;
}, [ newPropValue, attrName, onPropValueChange ] );

return null;
};

/**
* BlockBindingBridge acts like a component wrapper
* that connects the bound attributes of a block
* to the source handlers.
* to the source helper.
* 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();

function BlockBindingBridge( { bindings, onPropValueChange } ) {
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 (
<BindingConnector
key={ attrName }
attrName={ attrName }
source={ source }
blockProps={ blockProps }
args={ boundAttribute.args }
onPropValueChange={ onPropValueChange }
/>
);
}
) }
{ Object.entries( bindings ).map( ( [ attrName, settings ] ) => {
return (
<BindingConnector
key={ attrName }
attrName={ attrName }
settings={ settings }
onPropValueChange={ onPropValueChange }
/>
);
} ) }
</>
);
}

const withBlockBindingSupport = createHigherOrderComponent(
( BlockEdit ) => ( props ) => {
const blockBindingsSources = unlock(
useSelect( blocksStore )
).getAllBlockBindingsSources();

/*
* Collect and update the bound attributes
* in a separate state.
* A trigger to refresh
* the bound attributesvalues
* when the source data changes.
* It is used to force the re-render of the BlockEdit component.
*/
const [ boundAttributes, setBoundAttributes ] = useState( {} );
const updateBoundAttributes = useCallback(
( newAttributes ) =>
setBoundAttributes( ( prev ) => ( {
...prev,
...newAttributes,
} ) ),
[]
const [ refreshTrigger, setTrigger ] = useState( 1 );
const refreshExternalSourceData = setTrigger.bind(
null,
refreshTrigger + 1
);

/*
* Create binding object filtering
* only the attributes that can be bound.
* Binding object:
* - filter out the bindings that are not allowed for the current block.
* - Map the bindings to the source handler.
*
* Object shape:
* {
* [attrName]: {
* args: Object,
* source: string,
* value: any,
* placeholder: any,
* useSource: Function,
* },
*/
const bindings = Object.fromEntries(
Object.entries( props.attributes.metadata?.bindings || {} ).filter(
( [ attrName ] ) => canBindAttribute( props.name, attrName )
)
const bindings = useMemo( () => {
if ( ! refreshTrigger ) {
return {};
}

return Object.fromEntries(
Object.entries( props.attributes.metadata?.bindings || {} )
.filter(
( [ attrName, binding ] ) =>
canBindAttribute( props.name, attrName ) &&
!! blockBindingsSources[ binding.source ]?.init
)
.map( ( [ attrName, binding ] ) => {
const settings = blockBindingsSources[
binding.source
].init( props, binding.args );

let value = settings.value;

/*
* If the original attribute value is a RichTextData,
* the bound value should be a RichTextData as well.
* To do: Probably we should have a better way to handle this.
*/
const originalAttrValue = props.attributes[ attrName ];

if ( typeof value !== 'undefined' ) {
value =
originalAttrValue instanceof RichTextData
? RichTextData.fromHTMLString(
settings.value
)
: settings.value;
} else if ( settings.placeholder ) {
/*
* Placeholder fallback.
* If the attribute is `src` or `href`,
* a placeholder can't be used because it is not a valid url.
* ToDo: Adding this workaround until
* attributes and metadata fields types are improved and include `url`.
*/
const htmlAttribute = getBlockType( props.name )
.attributes[ attrName ].attribute;

if (
htmlAttribute === 'src' ||
htmlAttribute === 'href'
) {
value = null;
} else {
value = settings.placeholder;
}
}

return [
attrName,
{
source: binding.source,
args: binding.args,
...settings,
value,
},
];
} )
);
}, [ blockBindingsSources, props, refreshTrigger ] );

// Pick bound attributes from the (memoized) bindings object.
const boundAttributes = Object.fromEntries(
Object.entries( bindings ).map( ( [ attrName, settings ] ) => {
const { value } = settings;
return [ attrName, value ];
} )
);

/**
* Helper function to update the block attributes,
* handling both bound and unbound attributes.
*
* For unbound attributes, it calls the BlockEdit `setAttributes` callback.
* For bound attributes, it calls the source `update` handler function.
*
* @param {Object} nextAttributes - The next attributes to update.
* @return {void}
*/
const updateAttributes = useCallback(
( nextAttributes ) => {
const unboundAttributes = {};
Object.entries( nextAttributes ).forEach(
( [ boundAttributeName, value ] ) => {
if ( ! ( boundAttributeName in bindings ) ) {
//Collect unbound attributes.
unboundAttributes[ boundAttributeName ] = value;
} else {
// Update bound attribute, one by one.
const settings = bindings[ boundAttributeName ];
settings.update( value );
}
}
);

// Update unbound attributes.
if ( Object.keys( unboundAttributes ).length ) {
props.setAttributes( unboundAttributes );
}
},
[ bindings, props ]
);

if ( Object.keys( bindings ).length <= 0 ) {
return <BlockEdit { ...props } />;
}

return (
<>
{ Object.keys( bindings ).length > 0 && (
<BlockBindingBridge
blockProps={ props }
bindings={ bindings }
onPropValueChange={ updateBoundAttributes }
/>
) }
<BlockBindingBridge
blockProps={ props }
bindings={ bindings }
onPropValueChange={ refreshExternalSourceData }
/>

<BlockEdit
{ ...props }
attributes={ { ...props.attributes, ...boundAttributes } }
setAttributes={ updateAttributes }
/>
</>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/blocks/src/store/private-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function registerBlockBindingsSource( source ) {
type: 'REGISTER_BLOCK_BINDINGS_SOURCE',
sourceName: source.name,
sourceLabel: source.label,
useSource: source.useSource,
init: source.init,
lockAttributesEditing: source.lockAttributesEditing,
};
}
2 changes: 1 addition & 1 deletion packages/blocks/src/store/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ export function blockBindingsSources( state = {}, action ) {
...state,
[ action.sourceName ]: {
label: action.sourceLabel,
useSource: action.useSource,
init: action.init,
lockAttributesEditing: action.lockAttributesEditing ?? true,
},
};
Expand Down
Loading
Loading