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][Product Block Editor]: introduce custom Block Binding API #44524

Draft
wants to merge 26 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ee65691
introduce custom block binding handler
retrofox Feb 9, 2024
25fb563
introduce woo/product-entity source handler
retrofox Feb 9, 2024
d78de5d
add register fallback fn
retrofox Feb 9, 2024
687354e
use binding
retrofox Feb 9, 2024
cfd6450
register `metadata` block attr
retrofox Feb 9, 2024
87014dc
clean console.log
retrofox Feb 9, 2024
9e43a2c
register metadata attr for all blocks
retrofox Feb 9, 2024
a71bda1
clean
retrofox Feb 9, 2024
df72a1c
polish binding-process
retrofox Feb 12, 2024
e8e6a6b
improve both-direction-data-propagation
retrofox Feb 13, 2024
5df9a0e
do not lock name block
retrofox Feb 16, 2024
217375c
use proper source name when binding
retrofox Feb 16, 2024
5367e1b
text-field: introduce `value` attr
retrofox Feb 16, 2024
b630608
move source into their own folder
retrofox Feb 17, 2024
b529829
introduce BlockBindingBridge component
retrofox Feb 17, 2024
5b70f6d
only bind product name
retrofox Feb 19, 2024
60ea250
introduce getBlockBoundSourePropsList() helper
retrofox Feb 19, 2024
74b74a6
minor change
retrofox Feb 19, 2024
926b7fc
TextFieldBlockEdit supports binding API
retrofox Feb 19, 2024
e64d023
text-field property is not needed anymore
retrofox Feb 19, 2024
4f31627
bind product-external-url with external_link
retrofox Feb 19, 2024
10e74ed
bind product-button-text with button_text
retrofox Feb 19, 2024
128ea0e
pass `source` to Connect component
retrofox Feb 20, 2024
9404b7d
rename woocommerce/entity/product source handler
retrofox Feb 20, 2024
b2198c2
update import path
retrofox Feb 20, 2024
eb0d061
update source handler name
retrofox Feb 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* External dependencies
*/
import { useEntityProp } from '@wordpress/core-data';
import { __ } from '@wordpress/i18n';
import { useCallback } from '@wordpress/element';

/**
* Internal dependencies
*/
import type {
AttributeBindingProps,
BindingSourceHandlerProps,
BindingUseSourceProps,
BlockProps,
BoundBlockAttributes,
} from '../../../bindings/types';
import { WooEntitySourceArgs } from './types';

/**
* Giving the block attributes,
* return the list of source props
*
* @param {BoundBlockAttributes} attributes - The block attributes.
* @return {string[]} The list of source props.
*/
export function getBlockBoundSourePropsList(
attributes: BoundBlockAttributes
): string[] {
const bindings = attributes?.metadata?.bindings;
if ( ! bindings ) {
return [];
}

return Object.values( bindings ).map(
( binding: AttributeBindingProps ) => binding.args?.prop
);
}

/**
* React custom hook to bind a source to a block.
*
* @param {BlockProps} blockProps - The block props.
* @param {WooEntitySourceArgs} sourceArgs - The source args.
* @return {BindingUseSourceProps} The source value and setter.
*/
const useSource = (
blockProps: BlockProps,
sourceArgs: WooEntitySourceArgs
): BindingUseSourceProps => {
if ( typeof sourceArgs === 'undefined' ) {
throw new Error( 'The "args" argument is required.' );
}

if ( ! sourceArgs?.prop ) {
throw new Error( 'The "prop" argument is required.' );
}

const { prop, id } = sourceArgs;

const [ value, updateValue ] = useEntityProp(
'postType',
'product',
prop,
id
);

const updateValueHandler = useCallback(
( nextEntityPropValue: string ) => {
updateValue( nextEntityPropValue );
},
[ updateValue ]
);

return {
placeholder: null,
value,
updateValue: updateValueHandler,
};
};

/*
* Create the product-entity
* block binding source handler.
*
* source ID: `woocommerce/entity/product`
* args:
* - prop: The name of the entity property to bind.
*
* In the example below,
* the `content` attribute is bound to the `short_description` property.
* `product` entity and `postType` kind are defined by the context.
*
* ```
* metadata: {
* bindings: {
* content: {
* source: 'woocommerce/entity/product',
* args: {
* prop: 'short_description',
* },
* },
* },
* ```
*/
export default {
name: 'woocommerce/entity/product',
label: __( 'Product Entity', 'woocommerce' ),
useSource,
lockAttributesEditing: true,
} as BindingSourceHandlerProps< WooEntitySourceArgs >;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export type WooEntitySourceArgs = {
/*
* The kind of entity to bind.
* Default is `postType`.
*/
kind?: string;

/*
* The name of the entity to bind.
*/
name?: string;

/*
* The name of the entity property to bind.
*/
prop: string;

/*
* The ID of the entity to bind.
*/
id?: string;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* External dependencies
*/
import { createHigherOrderComponent } from '@wordpress/compose';
import {
createElement,
Fragment,
useEffect,
useCallback,
useRef,
} from '@wordpress/element';
import { getBlockType } from '@wordpress/blocks';

/**
* Internal dependencies
*/
import type {
BindingSourceHandlerProps,
BoundBlockEditComponent,
BoundBlockEditInstance,
MetadataBindingsProps,
} from '../types';
import { getBlockBindingsSource, hasPossibleBlockBinding } from '..';

type BlockBindingConnectorProp = {
/*
* The name of the attribute to bind.
*/
attrName: string;

/*
* The value of the attribute
* it can be any type.
*/
attrValue: any; // eslint-disable-line @typescript-eslint/no-explicit-any

/*
* The block props with bound attribute.
*/
blockProps: BoundBlockEditInstance;

/*
* The source handler.
*/
source: BindingSourceHandlerProps< any >; // eslint-disable-line @typescript-eslint/no-explicit-any

/*
* The source args.
*/
args: any; // eslint-disable-line @typescript-eslint/no-explicit-any

placeholder?: string | null;
};

/**
* This component is responsible detecting and
* propagating data changes between block attribute and
* the block-binding source property.
*
* The app creates an instance of this component for each
* pair of block-attribute/source-property.
*
* @param {BlockBindingConnectorProp} props - The component props.
* @return {null} The component.
*/
const BlockBindingConnector = ( {
args,
attrName,
attrValue,
blockProps,
source,
}: BlockBindingConnectorProp ): null => {
const {
placeholder,
value: propValue,
updateValue: updatePropValue,
} = source.useSource( blockProps, args );

const blockName = blockProps.name;

const setAttributes = blockProps.setAttributes;

const updateBoundAttibute = useCallback(
( newAttrValue ) => {
setAttributes( {
[ attrName ]: newAttrValue,
} );
},
[ attrName, setAttributes ]
);

// Store a reference to the last value and attribute value.
const lastPropValue = useRef( propValue );
const lastAttrValue = useRef( attrValue );

/*
* Initially sync (first render / onMount ) attribute
* value with the source prop value.
*/
useEffect( () => {
updateBoundAttibute( propValue );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ updateBoundAttibute ] );

/*
* Sync data.
* This effect will run every time
* the attribute value or the prop value changes.
* It will sync them in both directions.
*/
useEffect( () => {
/*
* Source Prop => Block Attribute
*
* Detect changes in source prop value,
* and update the attribute value accordingly.
*/
if ( typeof propValue !== 'undefined' ) {
if ( propValue !== lastPropValue.current ) {
lastPropValue.current = propValue;
updateBoundAttibute( propValue );
return;
}
} else if ( placeholder ) {
/*
* 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 ] as {
attribute: string;
}
).attribute;

if ( htmlAttribute === 'src' || htmlAttribute === 'href' ) {
updateBoundAttibute( null );
return;
}

updateBoundAttibute( placeholder );
}

/*
* Block Attribute => Source Prop
*
* Detect changes in block attribute value,
* and update the source prop value accordingly.
*/
if ( attrValue !== lastAttrValue.current && updatePropValue ) {
lastAttrValue.current = attrValue;
updatePropValue( attrValue );
}
}, [
updateBoundAttibute,
propValue,
attrValue,
updatePropValue,
placeholder,
blockName,
attrName,
] );

return null;
};

function BlockBindingBridge( {
bindings,
props,
}: {
bindings: MetadataBindingsProps;
props: BoundBlockEditInstance;
} ) {
if ( ! bindings ) {
return null;
}

const { name, attributes } = props;

// Collect all the binding connectors.
const BindingConnectorInstances: JSX.Element[] = [];

Object.entries( bindings ).forEach(
( [ attrName, boundAttrSettings ], i ) => {
// Check if the block attribute can be bound.
if ( ! hasPossibleBlockBinding( name, attrName ) ) {
return;
}

// Bail early if the block doesn't have a valid source handler.
const source = getBlockBindingsSource( boundAttrSettings.source );
if ( ! source ) {
return;
}

BindingConnectorInstances.push(
<BlockBindingConnector
key={ `${ boundAttrSettings.source }-${ name }-${ attrName }-${ i }` }
attrName={ attrName }
attrValue={ attributes[ attrName ] }
source={ source }
blockProps={ props }
args={ boundAttrSettings.args }
/>
);
}
);

return <>{ BindingConnectorInstances }</>;
}

const withBlockBindingSupport =
createHigherOrderComponent< BoundBlockEditComponent >(
( BlockEdit: BoundBlockEditComponent ) =>
( props: BoundBlockEditInstance ) => {
const { attributes } = props;

// Bail early if the block doesn't have bindings.
const bindings = attributes?.metadata?.bindings;
if ( ! bindings || Object.keys( bindings ).length === 0 ) {
return null;
}

return (
<>
<BlockBindingBridge
bindings={ bindings }
props={ props }
/>
<BlockEdit { ...props } />
</>
);
},
'withBlockBindingSupport'
);

export default withBlockBindingSupport;