diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index 42663e127870c9..16248651d1c14a 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -88,7 +88,7 @@ function wp_enqueue_block_view_script( $block_name, $args ) { ) ) { require_once __DIR__ . '/block-bindings/index.php'; - // Allowed blocks that support block bindings. + // Allowed blocks that support block bindings. // TODO: Look for a mechanism to opt-in for this. Maybe adding a property to block attributes? global $block_bindings_allowed_blocks; $block_bindings_allowed_blocks = array( diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 5917ac235505cb..d0870237419533 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -812,6 +812,18 @@ _Properties_ Ensures that the text selection keeps the same vertical distance from the viewport during keyboard events within this component. The vertical distance can vary. It is the last clicked or scrolled to position. +### updateBlockBindingsAttribute + +Helper to update the bindings attribute used by the Block Bindings API. + +_Parameters_ + +- _blockAttributes_ `Object`: - The original block attributes. +- _setAttributes_ `Function`: - setAttributes function to modify the bindings property. +- _attributeName_ `string`: - The attribute in the bindings object to update. +- _sourceName_ `string`: - The source name added to the bindings property. +- _sourceAttributes_ `string`: - The source attributes added to the bindings property. + ### URLInput _Related_ diff --git a/packages/block-editor/src/utils/index.js b/packages/block-editor/src/utils/index.js index ee3b2692b369a8..21f33acfc274db 100644 --- a/packages/block-editor/src/utils/index.js +++ b/packages/block-editor/src/utils/index.js @@ -1,3 +1,4 @@ export { default as transformStyles } from './transform-styles'; export * from './block-variation-transforms'; export { default as getPxFromCssUnit } from './get-px-from-css-unit'; +export * from './update-block-bindings'; diff --git a/packages/block-editor/src/utils/update-block-bindings.js b/packages/block-editor/src/utils/update-block-bindings.js new file mode 100644 index 00000000000000..c34aa8b46c6e36 --- /dev/null +++ b/packages/block-editor/src/utils/update-block-bindings.js @@ -0,0 +1,68 @@ +/** + * Helper to update the bindings attribute used by the Block Bindings API. + * + * @param {Object} blockAttributes - The original block attributes. + * @param {Function} setAttributes - setAttributes function to modify the bindings property. + * @param {string} attributeName - The attribute in the bindings object to update. + * @param {string} sourceName - The source name added to the bindings property. + * @param {string} sourceAttributes - The source attributes added to the bindings property. + */ +export const updateBlockBindingsAttribute = ( + blockAttributes, + setAttributes, + attributeName, + sourceName, + sourceAttributes +) => { + // TODO: Review if we can create a React Hook for this. + + // Assuming the following format for the bindings property of the "metadata" attribute: + // + // "bindings": { + // "title": { + // "source": { + // "name": "metadata", + // "attributes": { "value": "text_custom_field" } + // } + // }, + // "url": { + // "source": { + // "name": "metadata", + // "attributes": { "value": "text_custom_field" } + // } + // } + // }, + // . + + let updatedBindings = {}; + // // If no sourceName is provided, remove the attribute from the bindings. + if ( sourceName === null ) { + if ( ! blockAttributes?.metadata.bindings ) { + return blockAttributes?.metadata; + } + + updatedBindings = { + ...blockAttributes?.metadata?.bindings, + [ attributeName ]: undefined, + }; + if ( Object.keys( updatedBindings ).length === 1 ) { + updatedBindings = undefined; + } + } else { + updatedBindings = { + ...blockAttributes?.metadata?.bindings, + [ attributeName ]: { + source: { name: sourceName, attributes: sourceAttributes }, + }, + }; + } + + setAttributes( { + metadata: { + ...blockAttributes.metadata, + bindings: updatedBindings, + }, + } ); + + return blockAttributes.metadata; +}; diff --git a/packages/editor/package.json b/packages/editor/package.json index 63656899e587c0..e138089b2b9169 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -27,7 +27,7 @@ "sideEffects": [ "build-style/**", "src/**/*.scss", - "{src,build,build-module}/{index.js,store/index.js,hooks/**}" + "{src,build,build-module}/{index.js,store/index.js,hooks/**,hooks/block-bindings-sources/**}" ], "dependencies": { "@babel/runtime": "^7.16.0", diff --git a/packages/editor/src/components/block-bindings/bindings-ui.js b/packages/editor/src/components/block-bindings/bindings-ui.js new file mode 100644 index 00000000000000..bd6ecf6cd3b5b8 --- /dev/null +++ b/packages/editor/src/components/block-bindings/bindings-ui.js @@ -0,0 +1,279 @@ +/** + * WordPress dependencies + */ +import { useState, cloneElement, Fragment } from '@wordpress/element'; +import { + BlockControls, + updateBlockBindingsAttribute, +} from '@wordpress/block-editor'; +import { + Button, + createSlotFill, + MenuItem, + MenuGroup, + Popover, +} from '@wordpress/components'; +import { + plugins as pluginsIcon, + chevronDown, + chevronUp, +} from '@wordpress/icons'; +import { addFilter } from '@wordpress/hooks'; +/** + * Internal dependencies + */ +import { BLOCK_BINDINGS_ALLOWED_BLOCKS } from '../../store/constants'; + +const { Slot, Fill } = createSlotFill( 'BlockBindingsUI' ); + +const BlockBindingsFill = ( { children, source, label } ) => { + return ( + + { ( props ) => { + return ( + <> + { cloneElement( children, { + source, + label, + ...props, + } ) } + + ); + } } + + ); +}; + +export default BlockBindingsFill; + +const BlockBindingsUI = ( props ) => { + const [ addingBinding, setAddingBinding ] = useState( false ); + const [ popoverAnchor, setPopoverAnchor ] = useState(); + return ( + <> + + + { addingBinding && ( + { + setAddingBinding( false ); + } } + onFocusOutside={ () => { + setAddingBinding( false ); + } } + placement="bottom" + shift + className="block-bindings-ui-popover" + { ...props } + > + + + ) } + + + ); +}; + +function AttributesLayer( props ) { + const [ activeAttribute, setIsActiveAttribute ] = useState( false ); + const [ activeSource, setIsActiveSource ] = useState( false ); + return ( + + { BLOCK_BINDINGS_ALLOWED_BLOCKS[ props.name ].map( + ( attribute ) => ( +
+ + setIsActiveAttribute( + activeAttribute === attribute + ? false + : attribute + ) + } + className="block-bindings-attribute-picker-button" + > + { attribute } + + { activeAttribute === attribute && ( + <> + + { /* Sources can fill this slot */ } + + { ( fills ) => { + if ( ! fills.length ) { + return null; + } + + return ( + <> + { fills.map( + ( fill, index ) => { + // TODO: Check better way to get the source and label. + const source = + fill[ 0 ].props + .children + .props + .source; + const sourceLabel = + fill[ 0 ].props + .children + .props + .label; + const isSourceSelected = + activeSource === + source; + + return ( + + + setIsActiveSource( + isSourceSelected + ? false + : source + ) + } + className="block-bindings-source-picker-button" + > + { + sourceLabel + } + + { isSourceSelected && + fill } + + ); + } + ) } + + ); + } } + + + + + ) } +
+ ) + ) } +
+ ); +} + +function RemoveBindingButton( props ) { + return ( + + ); +} + +if ( window.__experimentalBlockBindings ) { + addFilter( + 'blocks.registerBlockType', + 'core/block-bindings-ui', + ( settings, name ) => { + if ( ! ( name in BLOCK_BINDINGS_ALLOWED_BLOCKS ) ) { + return settings; + } + + // TODO: Review the implications of this and the code. + // Add the necessary context to the block. + const contextItems = [ 'postId', 'postType', 'queryId' ]; + const usesContextArray = settings.usesContext; + const oldUsesContextArray = new Set( usesContextArray ); + contextItems.forEach( ( item ) => { + if ( ! oldUsesContextArray.has( item ) ) { + usesContextArray.push( item ); + } + } ); + settings.usesContext = usesContextArray; + + // Add bindings button to the block toolbar. + const OriginalComponent = settings.edit; + settings.edit = ( props ) => { + return ( + <> + + + + ); + }; + + return settings; + } + ); +} + +// TODO: Add also some components to the sidebar. diff --git a/packages/editor/src/components/block-bindings/fields-list.js b/packages/editor/src/components/block-bindings/fields-list.js new file mode 100644 index 00000000000000..f681467126f5c0 --- /dev/null +++ b/packages/editor/src/components/block-bindings/fields-list.js @@ -0,0 +1,58 @@ +/** + * WordPress dependencies + */ +import { updateBlockBindingsAttribute } from '@wordpress/block-editor'; +import { MenuItem, MenuGroup } from '@wordpress/components'; + +export default function BlockBindingsFieldsList( props ) { + const { + attributes, + setAttributes, + setIsActiveAttribute, + currentAttribute, + fields, + source, + setAddingBinding, + } = props; + + // TODO: Try to abstract this function to be reused across all the sources. + function selectItem( item ) { + // Modify the attribute binded. + const newAttributes = {}; + newAttributes[ currentAttribute ] = item.value; + setAttributes( newAttributes ); + + // Update the bindings property. + updateBlockBindingsAttribute( + attributes, + setAttributes, + currentAttribute, + source, + { value: item.key } + ); + + setIsActiveAttribute( false ); + setAddingBinding( false ); + } + + return ( + + { fields.map( ( item ) => ( + selectItem( item ) } + className={ + attributes.metadata?.bindings?.[ currentAttribute ] + ?.source?.name === source && + attributes.metadata?.bindings?.[ currentAttribute ] + ?.source?.attributes?.value === item.key + ? 'selected-meta-field' + : '' + } + > + { item.label } + + ) ) } + + ); +} diff --git a/packages/editor/src/components/block-bindings/index.js b/packages/editor/src/components/block-bindings/index.js new file mode 100644 index 00000000000000..600523bc9bbf75 --- /dev/null +++ b/packages/editor/src/components/block-bindings/index.js @@ -0,0 +1,5 @@ +/** + * Internal dependencies + */ +export { default as BlockBindingsFill } from './bindings-ui'; +export { default as BlockBindingsFieldsList } from './fields-list'; diff --git a/packages/editor/src/components/block-bindings/style.scss b/packages/editor/src/components/block-bindings/style.scss new file mode 100644 index 00000000000000..738c7479884809 --- /dev/null +++ b/packages/editor/src/components/block-bindings/style.scss @@ -0,0 +1,30 @@ +// TODO: Change the styles. +.block-bindings-ui-popover { + margin-top: 12px; + width: 300px; + .components-popover__content { + width: 100%; + } + + .block-bindings-attribute-picker-container { + border-bottom: 1px solid #0002; + } + + .block-bindings-fields-list-ui { + padding: 12px; + li { + margin: 20px 8px; + cursor: pointer; + } + .selected-meta-field { + font-weight: bold; + } + .selected-meta-field::before { + content: "✔ "; + margin-left: -16px; + } + } + .block-bindings-remove-button { + color: var(--wp-admin-theme-color, #3858e9); + } +} diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index 33a18e6f9a6ad2..94cf5c50169d03 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -102,3 +102,6 @@ export { default as EditorProvider } from './provider'; export * from './deprecated'; export const VisualEditorGlobalKeyboardShortcuts = EditorKeyboardShortcuts; export const TextEditorGlobalKeyboardShortcuts = EditorKeyboardShortcuts; + +// Block Bindings Components. +export * from './block-bindings'; diff --git a/packages/editor/src/hooks/block-bindings-sources/post-meta.js b/packages/editor/src/hooks/block-bindings-sources/post-meta.js new file mode 100644 index 00000000000000..a02c71021e93ad --- /dev/null +++ b/packages/editor/src/hooks/block-bindings-sources/post-meta.js @@ -0,0 +1,96 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { addFilter } from '@wordpress/hooks'; +/** + * Internal dependencies + */ +import BlockBindingsFill from '../../components/block-bindings/bindings-ui'; +import BlockBindingsFieldsList from '../../components/block-bindings/fields-list'; +import { store as editorStore } from '../../store'; +import { BLOCK_BINDINGS_ALLOWED_BLOCKS } from '../../store/constants'; + +if ( window.__experimentalBlockBindings ) { + // External sources could do something similar. + + const withCoreSources = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const { name, isSelected, context } = props; + // If the block is not allowed, return the original BlockEdit. + if ( ! BLOCK_BINDINGS_ALLOWED_BLOCKS[ name ] ) { + return ; + } + const fields = []; + if ( isSelected ) { + const data = useSelect( + ( select ) => { + const postId = context.postId + ? context.postId + : select( editorStore ).getCurrentPostId(); + const postType = context.postType + ? context.postType + : select( editorStore ).getCurrentPostType(); + const { getEntityRecord } = select( coreStore ); + return getEntityRecord( 'postType', postType, postId ); + }, + [ context.postId, context.postType ] + ); + + if ( ! data || ! data.meta ) { + return ; + } + + // Adapt the data to the format expected by the fields list. + // Prettifying the name until we receive the label from the REST API endpoint. + const keyToLabel = ( key ) => { + return key + .split( '_' ) + .map( + ( word ) => + word.charAt( 0 ).toUpperCase() + word.slice( 1 ) + ) + .join( ' ' ); + }; + Object.entries( data.meta ).forEach( ( [ key, value ] ) => { + fields.push( { + key, + label: keyToLabel( key ), + value, + } ); + } ); + } + + return ( + <> + { isSelected && fields.length !== 0 && ( + <> + + + + + ) } + + + ); + }, + 'withToolbarControls' + ); + + // TODO: Review if there is a better filter for this. + // This runs for every block. + addFilter( + 'editor.BlockEdit', + 'core/block-bindings-ui/add-sources', + withCoreSources + ); +} diff --git a/packages/editor/src/hooks/index.js b/packages/editor/src/hooks/index.js index 5a48ec1bf49566..8d52e5a36e6631 100644 --- a/packages/editor/src/hooks/index.js +++ b/packages/editor/src/hooks/index.js @@ -4,3 +4,6 @@ import './custom-sources-backwards-compatibility'; import './default-autocompleters'; import './pattern-partial-syncing'; + +// Block bindings sources. +import './block-bindings-sources/post-meta'; diff --git a/packages/editor/src/store/constants.js b/packages/editor/src/store/constants.js index 7882ba53e64db3..c8d1c71f56abe9 100644 --- a/packages/editor/src/store/constants.js +++ b/packages/editor/src/store/constants.js @@ -18,3 +18,9 @@ export const TRASH_POST_NOTICE_ID = 'TRASH_POST_NOTICE_ID'; export const PERMALINK_POSTNAME_REGEX = /%(?:postname|pagename)%/; export const ONE_MINUTE_IN_MS = 60 * 1000; export const AUTOSAVE_PROPERTIES = [ 'title', 'excerpt', 'content' ]; +export const BLOCK_BINDINGS_ALLOWED_BLOCKS = { + 'core/paragraph': [ 'content' ], + 'core/heading': [ 'content' ], + 'core/image': [ 'url', 'title' ], + 'core/button': [ 'url', 'text' ], +}; diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index ff5a55a3881f99..78696d5f092de3 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -1,4 +1,5 @@ @import "./components/autocompleters/style.scss"; +@import "./components/block-bindings/style.scss"; @import "./components/document-bar/style.scss"; @import "./components/document-outline/style.scss"; @import "./components/document-tools/style.scss"; diff --git a/packages/patterns/src/components/partial-syncing-controls.js b/packages/patterns/src/components/partial-syncing-controls.js index f5ac19bc05f3d7..b7a50235cf5d3c 100644 --- a/packages/patterns/src/components/partial-syncing-controls.js +++ b/packages/patterns/src/components/partial-syncing-controls.js @@ -6,7 +6,10 @@ import { nanoid } from 'nanoid'; /** * WordPress dependencies */ -import { InspectorControls } from '@wordpress/block-editor'; +import { + InspectorControls, + updateBlockBindingsAttribute, +} from '@wordpress/block-editor'; import { BaseControl, CheckboxControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; @@ -31,59 +34,50 @@ function PartialSyncingControls( { name, attributes, setAttributes } ) { } function updateBindings( isChecked ) { - let updatedBindings = { - ...attributes?.metadata?.bindings, - }; - if ( ! isChecked ) { for ( const attributeName of Object.keys( syncedAttributes ) ) { - if ( - updatedBindings[ attributeName ]?.source?.name === - 'pattern_attributes' - ) { - delete updatedBindings[ attributeName ]; - } - } - if ( ! Object.keys( updatedBindings ).length ) { - updatedBindings = undefined; + updateBlockBindingsAttribute( + attributes, + setAttributes, + attributeName, + null, + null + ); } - setAttributes( { - metadata: { - ...attributes.metadata, - bindings: updatedBindings, - }, - } ); return; } - for ( const attributeName of Object.keys( syncedAttributes ) ) { - if ( ! updatedBindings[ attributeName ] ) { - updatedBindings[ attributeName ] = { - source: { - name: 'pattern_attributes', - }, - }; + if ( typeof attributes.metadata?.id === 'string' ) { + for ( const attributeName of Object.keys( syncedAttributes ) ) { + updateBlockBindingsAttribute( + attributes, + setAttributes, + attributeName, + 'pattern_attributes', + null + ); } + return; } - if ( typeof attributes.metadata?.id === 'string' ) { + const id = nanoid( 6 ); + for ( const attributeName of Object.keys( syncedAttributes ) ) { + const newMetadata = updateBlockBindingsAttribute( + attributes, + setAttributes, + attributeName, + 'pattern_attributes', + null + ); + setAttributes( { metadata: { - ...attributes.metadata, - bindings: updatedBindings, + ...newMetadata, + id, }, } ); return; } - - const id = nanoid( 6 ); - setAttributes( { - metadata: { - ...attributes.metadata, - id, - bindings: updatedBindings, - }, - } ); } return (