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

Block Bindings: enhance block attribute binding to external sources #58895

Closed
wants to merge 74 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
e9a1ddf
replace use-binding-attributes with block-binding-support
retrofox Feb 12, 2024
8c20db3
minor enhancement
retrofox Feb 10, 2024
31c1bec
minor change
retrofox Feb 10, 2024
8a8a839
tweak
retrofox Feb 10, 2024
39b7a89
do not import use-binding-attributes
retrofox Feb 10, 2024
9f643e7
use isItPossibleToBindBlock() helper
retrofox Feb 10, 2024
f89fc68
introduce core/entity source handler
retrofox Feb 10, 2024
8ef5a5b
rename folder
retrofox Feb 11, 2024
b5a86ac
rename source name
retrofox Feb 12, 2024
71e716c
polish post-entity source handler
retrofox Feb 12, 2024
12d5cd4
make core/post-entity more consistent with core-data
retrofox Feb 12, 2024
ebb9bb2
make entity source hand;ler more generic
retrofox Feb 12, 2024
1470818
fix entity sour handl;er issues
retrofox Feb 12, 2024
64eedc2
remove uneeded useValue () hook (crossfingers)
retrofox Feb 12, 2024
a2d5cb2
minor jsdoc improvement
retrofox Feb 12, 2024
4deda60
clean
retrofox Feb 12, 2024
2510322
rename with updateValue()
retrofox Feb 12, 2024
fce8539
remove core/entity binding source handler
retrofox Feb 12, 2024
8d71311
move useSource to Connector cmp
retrofox Feb 12, 2024
411ede8
move the whole dryining logic to the Connect component
retrofox Feb 12, 2024
eb7e032
improve jsdoc
retrofox Feb 12, 2024
b8a9061
rename to blockProps
retrofox Feb 12, 2024
2ae62f0
minor jsdoc improvements
retrofox Feb 13, 2024
cc21a16
use a single effect to update attr and value
retrofox Feb 13, 2024
396dfb3
discard useValue. Return value and setValue instead
retrofox Feb 13, 2024
f25f9b8
check wheter updateValue function is defined
retrofox Feb 13, 2024
f3b3e05
check prop value is defined when updating attr
retrofox Feb 13, 2024
56ae182
handle `placerholder`
retrofox Feb 14, 2024
7de80c3
ensure to put attribute in sync when onmount
retrofox Feb 14, 2024
8866135
remove // eslint comment
retrofox Feb 14, 2024
42ed184
enable editing for bound with post-meta
retrofox Feb 14, 2024
60e73d4
move block bindiung processor to hooks/
retrofox Feb 14, 2024
3c9a341
ensure update bound attr once when mounting
retrofox Feb 15, 2024
7263d22
Update packages/block-editor/src/hooks/block-binding-support/index.js
retrofox Feb 15, 2024
d5b5e78
disable editing block attribute
retrofox Feb 15, 2024
7268a55
move changes to the use-binding-attributes file
retrofox Feb 15, 2024
10b7765
introduce BlockBindingBridge component
retrofox Feb 15, 2024
472d01e
update isItPossibleToBindBlock() import path
retrofox Feb 15, 2024
37c6ab9
introduce hasPossibleBlockBinding() helper
retrofox Feb 15, 2024
eab050b
use hooks API to extened blocks with bound attts
retrofox Feb 15, 2024
de04130
fix propagating attr value. jsdoc
retrofox Feb 15, 2024
28eb168
minor changes
retrofox Feb 15, 2024
fcc5b8a
minor code enhancement
retrofox Feb 19, 2024
cb9e380
not edit bound prop for now
retrofox Feb 19, 2024
bd9cf18
jsdoc
retrofox Feb 19, 2024
7ecfab9
revert using hooks API to extrend block
retrofox Feb 19, 2024
ed16267
jsdoc
retrofox Feb 19, 2024
dbf70e5
update internal path
retrofox Feb 19, 2024
8bb843d
rollback hook utils chnages
retrofox Feb 19, 2024
1abf420
tidy
retrofox Feb 20, 2024
ef40bd4
wrap Connector instances with a Fragment
retrofox Feb 20, 2024
ca54c3f
return original Edit instance when no bindings
retrofox Feb 20, 2024
bfde100
check whether useSource is defined
retrofox Feb 20, 2024
9aedf87
Use `useSelect` and move it out of the for loop
michalczaplinski Feb 20, 2024
dd77b7c
check attr value type
retrofox Feb 20, 2024
b17ce14
iterare when creating BindingConnector instances
retrofox Feb 21, 2024
74b32ad
rename helper functions
retrofox Feb 21, 2024
93430da
use useSelect to get binding sources
retrofox Feb 21, 2024
ecaf10a
Update packages/block-editor/src/hooks/use-bindings-attributes.js
retrofox Feb 21, 2024
2fd9911
Update packages/block-editor/src/hooks/use-bindings-attributes.js
retrofox Feb 21, 2024
5bd3a79
pass prev attr value to compare
retrofox Feb 22, 2024
1741497
improve binding allowed block attributes
retrofox Feb 22, 2024
e435a3d
sync derevied updates when updating bound attr
retrofox Feb 22, 2024
8f27c48
improve getting attr source
retrofox Feb 22, 2024
3d953f8
check properly bindings data
retrofox Feb 22, 2024
3d106ce
preserve the RichTextData for block attr
retrofox Feb 23, 2024
994bed1
comment line just for tesrting purposes
retrofox Feb 23, 2024
4998315
rebasing changes
retrofox Feb 23, 2024
9072e6a
rollback change foir testing purposes
retrofox Feb 23, 2024
2133393
change cmp prop name. improve jsdoc
retrofox Feb 27, 2024
91f64d6
simplify checking bindins value
retrofox Feb 27, 2024
987fb58
use attr name as key instance
retrofox Feb 27, 2024
db32d18
Refactor useMarkPersistent function
michalczaplinski Feb 26, 2024
e55f6bc
pick block data from straight props
retrofox Feb 27, 2024
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
6 changes: 3 additions & 3 deletions packages/block-editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -329,7 +329,7 @@ export function RichTextWrapper(
onChange,
} );

useMarkPersistent( { html: adjustedValue, value } );
useMarkPersistent( value );

const keyboardShortcuts = useRef( new Set() );
const inputEvents = useRef( new Set() );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useDispatch } from '@wordpress/data';
*/
import { store as blockEditorStore } from '../../store';

export function useMarkPersistent( { html, value } ) {
export function useMarkPersistent( value ) {
const previousText = useRef();
const hasActiveFormats = !! value.activeFormats?.length;
const { __unstableMarkLastChangeAsPersistent } =
Expand All @@ -36,5 +36,5 @@ export function useMarkPersistent( { html, value } ) {
}

__unstableMarkLastChangeAsPersistent();
}, [ html, hasActiveFormats ] );
}, [ value.text, hasActiveFormats ] );
}
257 changes: 194 additions & 63 deletions packages/block-editor/src/hooks/use-bindings-attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 ) {
Copy link
Member

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.

Copy link
Contributor Author

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.

// 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 );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we not use an useEffect to derive states in this case? See https://react.dev/learn/you-might-not-need-an-effect.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can avoid useEffect() because we're not just calling
useState() but also calling syncDerivedUpdates() which is a redux action and setAttributes().

} 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' ) {
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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(
Expand Down
5 changes: 4 additions & 1 deletion packages/editor/src/bindings/post-meta.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default {
const postType = context.postType
? context.postType
: getCurrentPostType();

const [ meta, setMeta ] = useEntityProp(
'postType',
context.postType,
Expand All @@ -33,9 +34,11 @@ export default {
const updateMetaValue = ( newValue ) => {
setMeta( { ...meta, [ metaKey ]: newValue } );
};

return {
placeholder: metaKey,
useValue: [ metaValue, updateMetaValue ],
value: metaValue,
updateValue: updateMetaValue,
};
},
};
Loading