-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
Block Bindings: enhance block attribute binding to external sources #58895
Changes from all commits
e9a1ddf
8c20db3
31c1bec
8a8a839
39b7a89
9f643e7
f89fc68
8ef5a5b
b5a86ac
71e716c
12d5cd4
ebb9bb2
1470818
64eedc2
a2d5cb2
4deda60
2510322
fce8539
8d71311
411ede8
eb7e032
b8a9061
2ae62f0
cc21a16
396dfb3
f25f9b8
f3b3e05
56ae182
7de80c3
8866135
42ed184
60e73d4
3c9a341
7263d22
d5b5e78
7268a55
10b7765
472d01e
37c6ab9
eab050b
de04130
28eb168
fcc5b8a
cb9e380
bd9cf18
7ecfab9
ed16267
dbf70e5
8bb843d
1abf420
ef40bd4
ca54c3f
bfde100
9aedf87
dd77b7c
b17ce14
74b32ad
93430da
ecaf10a
2fd9911
5bd3a79
1741497
e435a3d
8f27c48
3d953f8
3d106ce
994bed1
4998315
9072e6a
2133393
91f64d6
987fb58
db32d18
e55f6bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,13 +3,15 @@ | |
*/ | ||
import { getBlockType, store as blocksStore } from '@wordpress/blocks'; | ||
import { createHigherOrderComponent } from '@wordpress/compose'; | ||
import { useSelect } from '@wordpress/data'; | ||
import { useSelect, useDispatch } from '@wordpress/data'; | ||
import { useEffect, useCallback } 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 +24,216 @@ 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. | ||
* @return {null} This is a data-handling component. Render nothing. | ||
*/ | ||
const BindingConnector = ( { args, attrName, blockProps, source } ) => { | ||
const { placeholder, value: propValue } = source.useSource( | ||
blockProps, | ||
args | ||
); | ||
|
||
const { setAttributes, name } = blockProps; | ||
const attrValue = blockProps.attributes[ attrName ]; | ||
|
||
const { syncDerivedUpdates } = unlock( useDispatch( blockEditorStore ) ); | ||
|
||
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 ); | ||
} | ||
|
||
return ( | ||
<BlockEdit | ||
key="edit" | ||
{ ...props } | ||
attributes={ updatedAttributes } | ||
/> | ||
); | ||
if ( prevAttrValue === newAttrValue ) { | ||
return; | ||
} | ||
|
||
syncDerivedUpdates( () => { | ||
setAttributes( { | ||
[ attrName ]: newAttrValue, | ||
} ); | ||
} ); | ||
}, | ||
'useBoundAttributes' | ||
[ attrName, setAttributes, syncDerivedUpdates ] | ||
); | ||
|
||
useEffect( () => { | ||
if ( typeof propValue !== 'undefined' ) { | ||
updateBoundAttibute( propValue, attrValue ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we not use an There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure how doable/easy/convenient it could be, especially considering that we'll have to deal with not only reading but updating the bound attr and external source prop in follow-ups There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we can avoid |
||
} 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( name ).attributes[ attrName ].attribute; | ||
|
||
if ( htmlAttribute === 'src' || htmlAttribute === 'href' ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What alternatives have you considered here? Isn't the list of exceptions based on the currently supported blocks and their attributes? How will it work with different block attributes that translate into HTML attributes? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't worked on it since I considered it outside the scope of PR. I've simply moved the current implementation here. I think it's better to continue in a follow-up. |
||
updateBoundAttibute( null ); | ||
return; | ||
} | ||
|
||
updateBoundAttibute( placeholder ); | ||
} | ||
}, [ | ||
updateBoundAttibute, | ||
propValue, | ||
attrValue, | ||
placeholder, | ||
name, | ||
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. | ||
* @return {null} This is a data-handling component. Render nothing. | ||
*/ | ||
function BlockBindingBridge( { blockProps, bindings } ) { | ||
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 ( | ||
<BindingConnector | ||
key={ attrName } | ||
attrName={ attrName } | ||
source={ source } | ||
blockProps={ blockProps } | ||
args={ boundAttribute.args } | ||
/> | ||
); | ||
} | ||
) } | ||
</> | ||
); | ||
} | ||
|
||
const withBlockBindingSupport = createHigherOrderComponent( | ||
( BlockEdit ) => ( props ) => { | ||
/* | ||
* 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 && ( | ||
<BlockBindingBridge | ||
blockProps={ props } | ||
bindings={ bindings } | ||
/> | ||
) } | ||
<BlockEdit { ...props } /> | ||
</> | ||
); | ||
}, | ||
'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( | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's not a workaround and necessary. Attribute values should be serializable and deserializable.
RichTextData
is the only special type of value AFAIK. However, we can implement some kind of universal contract for values to serialize/deserialize automatically though.For instance, when registering a new custom type, we need to specify both a serialize and a deserialize function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added this checking as a quick temporary solution. This is needed because when comparing initially the value provided by the external source (HTML) with the bound attribute( RichTextData instance), it will always be different.