diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 09ac4b6990d919..065264fd124e8c 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -34,6 +34,9 @@ function gutenberg_enable_experiments() { if ( $gutenberg_experiments && array_key_exists( 'gutenberg-quick-edit-dataviews', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalQuickEditDataViews = true', 'before' ); } + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-block-bindings-ui', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalBlockBindingsUI = true', 'before' ); + } } add_action( 'admin_init', 'gutenberg_enable_experiments' ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 7cc4198c14ef91..acb095b47fde30 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -163,6 +163,18 @@ function gutenberg_initialize_experiments_settings() { ) ); + add_settings_field( + 'gutenberg-block-bindings-ui', + __( 'UI to create block bindings', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Add UI to create and update block bindings in block inspector controls.', 'gutenberg' ), + 'id' => 'gutenberg-block-bindings-ui', + ) + ); + register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/packages/block-editor/src/hooks/block-bindings.js b/packages/block-editor/src/hooks/block-bindings.js index c61f586575a530..ce3fdd2f327c40 100644 --- a/packages/block-editor/src/hooks/block-bindings.js +++ b/packages/block-editor/src/hooks/block-bindings.js @@ -2,42 +2,199 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { store as blocksStore } from '@wordpress/blocks'; +import { privateApis as blocksPrivateApis } from '@wordpress/blocks'; import { - BaseControl, - PanelBody, - __experimentalHStack as HStack, __experimentalItemGroup as ItemGroup, __experimentalItem as Item, + __experimentalText as Text, + __experimentalToolsPanel as ToolsPanel, + __experimentalToolsPanelItem as ToolsPanelItem, + __experimentalTruncate as Truncate, + __experimentalVStack as VStack, + privateApis as componentsPrivateApis, } from '@wordpress/components'; -import { useSelect } from '@wordpress/data'; +import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; +import { useContext, Fragment } from '@wordpress/element'; +import { useViewportMatch } from '@wordpress/compose'; /** * Internal dependencies */ -import { canBindAttribute } from '../hooks/use-bindings-attributes'; +import { + canBindAttribute, + getBindableAttributes, +} from '../hooks/use-bindings-attributes'; +import { store as blockEditorStore } from '../store'; import { unlock } from '../lock-unlock'; import InspectorControls from '../components/inspector-controls'; +import BlockContext from '../components/block-context'; + +const { + DropdownMenuV2: DropdownMenu, + DropdownMenuGroupV2: DropdownMenuGroup, + DropdownMenuRadioItemV2: DropdownMenuRadioItem, + DropdownMenuItemLabelV2: DropdownMenuItemLabel, + DropdownMenuItemHelpTextV2: DropdownMenuItemHelpText, + DropdownMenuSeparatorV2: DropdownMenuSeparator, +} = unlock( componentsPrivateApis ); + +const useToolsPanelDropdownMenuProps = () => { + const isMobile = useViewportMatch( 'medium', '<' ); + return ! isMobile + ? { + popoverProps: { + placement: 'left-start', + // For non-mobile, inner sidebar width (248px) - button width (24px) - border (1px) + padding (16px) + spacing (20px) + offset: 259, + }, + } + : {}; +}; + +function BlockBindingsPanelDropdown( { + fieldsList, + addConnection, + attribute, + binding, +} ) { + const currentKey = binding?.args?.key; + return ( + <> + { Object.entries( fieldsList ).map( ( [ label, fields ], i ) => ( + + + { Object.keys( fieldsList ).length > 1 && ( + + { label } + + ) } + { Object.entries( fields ).map( ( [ key, value ] ) => ( + + addConnection( key, attribute ) + } + name={ attribute + '-binding' } + value={ key } + checked={ key === currentKey } + > + + { key } + + + { value } + + + ) ) } + + { i !== Object.keys( fieldsList ).length - 1 && ( + + ) } + + ) ) } + + ); +} + +function BlockBindingsAttribute( { attribute, binding } ) { + const { source: sourceName, args } = binding || {}; + const sourceProps = + unlock( blocksPrivateApis ).getBlockBindingsSource( sourceName ); + return ( + + { attribute } + { !! binding && ( + + + { args?.key || sourceProps?.label || sourceName } + + + ) } + + ); +} + +function ReadOnlyBlockBindingsPanelItems( { bindings } ) { + return ( + <> + { Object.entries( bindings ).map( ( [ attribute, binding ] ) => ( + + + + ) ) } + + ); +} + +function EditableBlockBindingsPanelItems( { + attributes, + bindings, + fieldsList, + addConnection, + removeConnection, +} ) { + const isMobile = useViewportMatch( 'medium', '<' ); + return ( + <> + { attributes.map( ( attribute ) => { + const binding = bindings[ attribute ]; + return ( + !! binding } + label={ attribute } + onDeselect={ () => { + removeConnection( attribute ); + } } + > + + + + } + > + + + + ); + } ) } + + ); +} export const BlockBindingsPanel = ( { name, metadata } ) => { + const registry = useRegistry(); + const blockContext = useContext( BlockContext ); const { bindings } = metadata || {}; - const { sources } = useSelect( ( select ) => { - const _sources = unlock( - select( blocksStore ) - ).getAllBlockBindingsSources(); - return { - sources: _sources, - }; - }, [] ); - - if ( ! bindings ) { - return null; - } + const bindableAttributes = getBindableAttributes( name ); + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); - // Don't show not allowed attributes. - // Don't show the bindings connected to pattern overrides in the inspectors panel. - // TODO: Explore if this should be abstracted to let other sources decide. const filteredBindings = { ...bindings }; Object.keys( filteredBindings ).forEach( ( key ) => { if ( @@ -48,43 +205,137 @@ export const BlockBindingsPanel = ( { name, metadata } ) => { } } ); - if ( Object.keys( filteredBindings ).length === 0 ) { + const { updateBlockAttributes } = useDispatch( blockEditorStore ); + + const { _id } = useSelect( ( select ) => { + const { getSelectedBlockClientId } = select( blockEditorStore ); + + return { + _id: getSelectedBlockClientId(), + }; + }, [] ); + + if ( ! bindableAttributes || bindableAttributes.length === 0 ) { + return null; + } + + const removeAllConnections = () => { + const newMetadata = { ...metadata }; + delete newMetadata.bindings; + updateBlockAttributes( _id, { + metadata: + Object.keys( newMetadata ).length === 0 + ? undefined + : newMetadata, + } ); + }; + + const addConnection = ( value, attribute ) => { + // Assuming the block expects a flat structure for its metadata attribute + const newMetadata = { + ...metadata, + // Adjust this according to the actual structure expected by your block + bindings: { + ...metadata?.bindings, + [ attribute ]: { + source: 'core/post-meta', + args: { key: value }, + }, + }, + }; + // Update the block's attributes with the new metadata + updateBlockAttributes( _id, { + metadata: newMetadata, + } ); + }; + + const removeConnection = ( key ) => { + const newMetadata = { ...metadata }; + if ( ! newMetadata.bindings ) { + return; + } + + delete newMetadata.bindings[ key ]; + if ( Object.keys( newMetadata.bindings ).length === 0 ) { + delete newMetadata.bindings; + } + updateBlockAttributes( _id, { + metadata: + Object.keys( newMetadata ).length === 0 + ? undefined + : newMetadata, + } ); + }; + + const fieldsList = {}; + const { getBlockBindingsSources } = unlock( blocksPrivateApis ); + const registeredSources = getBlockBindingsSources(); + Object.values( registeredSources ).forEach( + ( { getFieldsList, label, usesContext } ) => { + if ( getFieldsList ) { + // Populate context. + const context = {}; + if ( usesContext?.length ) { + for ( const key of usesContext ) { + context[ key ] = blockContext[ key ]; + } + } + const sourceList = getFieldsList( { + registry, + context, + } ); + // Only add source if the list is not empty. + if ( sourceList ) { + fieldsList[ label ] = { ...sourceList }; + } + } + } + ); + // Remove empty sources. + Object.entries( fieldsList ).forEach( ( [ key, value ] ) => { + if ( ! Object.keys( value ).length ) { + delete fieldsList[ key ]; + } + } ); + + // Lock the UI when the experiment is not enabled or there are no fields to connect to. + const readOnly = + ! window.__experimentalBlockBindingsUI || + ! Object.keys( fieldsList ).length; + + if ( readOnly && Object.keys( filteredBindings ).length === 0 ) { return null; } return ( - { + removeAllConnections(); + } } + dropdownMenuProps={ dropdownMenuProps } + className="block-editor-bindings__panel" > - - - { Object.keys( filteredBindings ).map( ( key ) => { - return ( - - - { key } - - { sources[ - filteredBindings[ key ].source - ] - ? sources[ - filteredBindings[ key ] - .source - ].label - : filteredBindings[ key ] - .source } - - - - ); - } ) } - - - + + { readOnly ? ( + + ) : ( + + ) } + + + { __( 'Attributes connected to various sources.' ) } + + ); }; diff --git a/packages/block-editor/src/hooks/block-bindings.scss b/packages/block-editor/src/hooks/block-bindings.scss index fd46674ad11426..73e7c490160d3e 100644 --- a/packages/block-editor/src/hooks/block-bindings.scss +++ b/packages/block-editor/src/hooks/block-bindings.scss @@ -1,3 +1,14 @@ -.components-panel__block-bindings-panel .components-item__block-bindings-source { - color: $gray-700; +div.block-editor-bindings__panel { + grid-template-columns: auto; + button:hover .block-editor-bindings__item-explanation { + color: inherit; + } +} + +.block-editor-bindings__popover { + // This won't be needed if `DropdownMenuGroup` component handles the label. + .block-editor-bindings__source-label { + grid-column: 2; + margin: $grid-unit-10 0; + } } diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index b58ede2e9d389d..2f74640ef4f633 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -37,7 +37,6 @@ import './grid-visualizer'; createBlockEditFilter( [ - blockBindingsPanel, align, textAlign, anchor, @@ -48,6 +47,7 @@ createBlockEditFilter( layout, contentLockUI, blockHooks, + blockBindingsPanel, childLayout, ].filter( Boolean ) ); diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js index 54b5425bfc44a5..4cac29a8bc5e91 100644 --- a/packages/block-editor/src/hooks/use-bindings-attributes.js +++ b/packages/block-editor/src/hooks/use-bindings-attributes.js @@ -91,6 +91,10 @@ export function canBindAttribute( blockName, attributeName ) { ); } +export function getBindableAttributes( blockName ) { + return BLOCK_BINDINGS_ALLOWED_BLOCKS[ blockName ]; +} + export const withBlockBindingSupport = createHigherOrderComponent( ( BlockEdit ) => ( props ) => { const registry = useRegistry(); diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss index e7e4b75d0a30f0..47b87bb50918df 100644 --- a/packages/block-editor/src/style.scss +++ b/packages/block-editor/src/style.scss @@ -46,8 +46,8 @@ @import "./components/tool-selector/style.scss"; @import "./components/url-input/style.scss"; @import "./components/url-popover/style.scss"; -@import "./hooks/block-bindings.scss"; @import "./hooks/block-hooks.scss"; +@import "./hooks/block-bindings.scss"; @import "./hooks/border.scss"; @import "./hooks/color.scss"; @import "./hooks/dimensions.scss"; diff --git a/packages/blocks/src/api/registration.js b/packages/blocks/src/api/registration.js index 7cce959c78cc80..e109a7e562f00e 100644 --- a/packages/blocks/src/api/registration.js +++ b/packages/blocks/src/api/registration.js @@ -775,6 +775,7 @@ export const unregisterBlockVariation = ( blockName, variationName ) => { * @param {Function} [source.setValues] Function to update multiple values connected to the source. * @param {Function} [source.getPlaceholder] Function to get the placeholder when the value is undefined. * @param {Function} [source.canUserEditValue] Function to determine if the user can edit the value. + * @param {Function} [source.getFieldsList] Function to get the lists of fields to expose in the connections panel. * * @example * ```js @@ -800,6 +801,7 @@ export const registerBlockBindingsSource = ( source ) => { setValues, getPlaceholder, canUserEditValue, + getFieldsList, } = source; const existingSource = unlock( @@ -899,6 +901,13 @@ export const registerBlockBindingsSource = ( source ) => { return; } + // Check the `getFieldsList` property is correct. + if ( getFieldsList && typeof getFieldsList !== 'function' ) { + // eslint-disable-next-line no-console + warning( 'Block bindings source getFieldsList must be a function.' ); + return; + } + return unlock( dispatch( blocksStore ) ).addBlockBindingsSource( source ); }; diff --git a/packages/blocks/src/api/test/registration.js b/packages/blocks/src/api/test/registration.js index cc339e59d27053..874d664f139047 100644 --- a/packages/blocks/src/api/test/registration.js +++ b/packages/blocks/src/api/test/registration.js @@ -1656,6 +1656,19 @@ describe( 'blocks', () => { expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); } ); + // Check the `getFieldsList` callback is correct. + it( 'should reject invalid getFieldsList callback', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + getFieldsList: 'should be a function', + } ); + expect( console ).toHaveWarnedWith( + 'Block bindings source getFieldsList must be a function.' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + // Check correct sources are registered as expected. it( 'should register a valid source', () => { const sourceProperties = { @@ -1665,6 +1678,9 @@ describe( 'blocks', () => { setValues: () => 'new values', getPlaceholder: () => 'placeholder', canUserEditValue: () => true, + getFieldsList: () => { + return { field: 'value' }; + }, }; registerBlockBindingsSource( { name: 'core/valid-source', @@ -1687,6 +1703,7 @@ describe( 'blocks', () => { expect( source.setValues ).toBeUndefined(); expect( source.getPlaceholder ).toBeUndefined(); expect( source.canUserEditValue ).toBeUndefined(); + expect( source.getFieldsList ).toBeUndefined(); unregisterBlockBindingsSource( 'core/valid-source' ); } ); diff --git a/packages/blocks/src/store/private-actions.js b/packages/blocks/src/store/private-actions.js index 977270cf1d0c97..6d2f7598ef7838 100644 --- a/packages/blocks/src/store/private-actions.js +++ b/packages/blocks/src/store/private-actions.js @@ -56,6 +56,7 @@ export function addBlockBindingsSource( source ) { setValues: source.setValues, getPlaceholder: source.getPlaceholder, canUserEditValue: source.canUserEditValue, + getFieldsList: source.getFieldsList, }; } diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index 2f141fb0cf9927..df6b33314fb492 100644 --- a/packages/blocks/src/store/reducer.js +++ b/packages/blocks/src/store/reducer.js @@ -393,6 +393,7 @@ export function blockBindingsSources( state = {}, action ) { setValues: action.setValues, getPlaceholder: action.getPlaceholder, canUserEditValue: action.canUserEditValue, + getFieldsList: action.getFieldsList, }, }; case 'ADD_BOOTSTRAPPED_BLOCK_BINDINGS_SOURCE': diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js index aafc784a21bd4a..298ec4d8ba1535 100644 --- a/packages/editor/src/bindings/post-meta.js +++ b/packages/editor/src/bindings/post-meta.js @@ -75,4 +75,24 @@ export default { return true; }, + getFieldsList( { registry, context } ) { + const metaFields = registry + .select( coreDataStore ) + .getEditedEntityRecord( + 'postType', + context?.postType, + context?.postId + ).meta; + + if ( ! metaFields || ! Object.keys( metaFields ).length ) { + return null; + } + + // Remove footnotes from the list of fields + return Object.fromEntries( + Object.entries( metaFields ).filter( + ( [ key ] ) => key !== 'footnotes' + ) + ); + }, }; diff --git a/test/e2e/specs/editor/various/block-bindings.spec.js b/test/e2e/specs/editor/various/block-bindings.spec.js index 1499e255482c63..d0e1f079ce8439 100644 --- a/test/e2e/specs/editor/various/block-bindings.spec.js +++ b/test/e2e/specs/editor/various/block-bindings.spec.js @@ -1390,6 +1390,70 @@ test.describe( 'Block bindings', () => { 'false' ); } ); + test( 'should show a selector for content', async ( { + editor, + page, + } ) => { + // Activate the block bindings UI experiment. + await page.evaluate( () => { + window.__experimentalBlockBindingsUI = true; + } ); + + await editor.insertBlock( { + name: 'core/paragraph', + } ); + await page + .getByRole( 'tabpanel', { + name: 'Settings', + } ) + .getByLabel( 'Attributes options' ) + .click(); + const contentAttribute = page.getByRole( 'menuitemcheckbox', { + name: 'Show content', + } ); + await expect( contentAttribute ).toBeVisible(); + } ); + test( 'should use a selector to update the content', async ( { + editor, + page, + } ) => { + // Activate the block bindings UI experiment. + await page.evaluate( () => { + window.__experimentalBlockBindingsUI = true; + } ); + + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'fallback value', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { key: 'undefined_field' }, + }, + }, + }, + }, + } ); + await page + .getByRole( 'tabpanel', { + name: 'Settings', + } ) + .getByRole( 'button', { name: 'content' } ) + .click(); + + await page + .getByRole( 'menuitemradio' ) + .filter( { hasText: 'text_custom_field' } ) + .click(); + const paragraphBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + await expect( paragraphBlock ).toHaveText( + 'Value of the text_custom_field' + ); + } ); } ); test.describe( 'Heading', () => { @@ -1471,6 +1535,29 @@ test.describe( 'Block bindings', () => { await expect( newEmptyParagraph ).toHaveText( '' ); await expect( newEmptyParagraph ).toBeEditable(); } ); + test( 'should show a selector for content', async ( { + editor, + page, + } ) => { + // Activate the block bindings UI experiment. + await page.evaluate( () => { + window.__experimentalBlockBindingsUI = true; + } ); + + await editor.insertBlock( { + name: 'core/heading', + } ); + await page + .getByRole( 'tabpanel', { + name: 'Settings', + } ) + .getByLabel( 'Attributes options' ) + .click(); + const contentAttribute = page.getByRole( 'menuitemcheckbox', { + name: 'Show content', + } ); + await expect( contentAttribute ).toBeVisible(); + } ); } ); test.describe( 'Button', () => { @@ -1648,6 +1735,56 @@ test.describe( 'Block bindings', () => { await expect( newEmptyButton ).toHaveText( '' ); await expect( newEmptyButton ).toBeEditable(); } ); + test( 'should show a selector for url, text, linkTarget and rel', async ( { + editor, + page, + } ) => { + // Activate the block bindings UI experiment. + await page.evaluate( () => { + window.__experimentalBlockBindingsUI = true; + } ); + + await editor.insertBlock( { + name: 'core/buttons', + innerBlocks: [ + { + name: 'core/button', + }, + ], + } ); + await editor.canvas + .getByRole( 'document', { + name: 'Block: Button', + exact: true, + } ) + .getByRole( 'textbox' ) + .click(); + await page + .getByRole( 'tabpanel', { + name: 'Settings', + } ) + .getByLabel( 'Attributes options' ) + .click(); + const urlAttribute = page.getByRole( 'menuitemcheckbox', { + name: 'Show url', + } ); + await expect( urlAttribute ).toBeVisible(); + const textAttribute = page.getByRole( 'menuitemcheckbox', { + name: 'Show text', + } ); + await expect( textAttribute ).toBeVisible(); + const linkTargetAttribute = page.getByRole( + 'menuitemcheckbox', + { + name: 'Show linkTarget', + } + ); + await expect( linkTargetAttribute ).toBeVisible(); + const relAttribute = page.getByRole( 'menuitemcheckbox', { + name: 'Show rel', + } ); + await expect( relAttribute ).toBeVisible(); + } ); } ); test.describe( 'Image', () => { @@ -1933,6 +2070,41 @@ test.describe( 'Block bindings', () => { 'default title value' ); } ); + test( 'should show a selector for url, id, title and alt', async ( { + editor, + page, + } ) => { + // Activate the block bindings UI experiment. + await page.evaluate( () => { + window.__experimentalBlockBindingsUI = true; + } ); + + await editor.insertBlock( { + name: 'core/image', + } ); + await page + .getByRole( 'tabpanel', { + name: 'Settings', + } ) + .getByLabel( 'Attributes options' ) + .click(); + const urlAttribute = page.getByRole( 'menuitemcheckbox', { + name: 'Show url', + } ); + await expect( urlAttribute ).toBeVisible(); + const idAttribute = page.getByRole( 'menuitemcheckbox', { + name: 'Show id', + } ); + await expect( idAttribute ).toBeVisible(); + const titleAttribute = page.getByRole( 'menuitemcheckbox', { + name: 'Show title', + } ); + await expect( titleAttribute ).toBeVisible(); + const altAttribute = page.getByRole( 'menuitemcheckbox', { + name: 'Show alt', + } ); + await expect( altAttribute ).toBeVisible(); + } ); } ); test.describe( 'Edit custom fields', () => { @@ -2195,10 +2367,12 @@ test.describe( 'Block bindings', () => { }, } ); - const bindingLabel = page.locator( - '.components-item__block-bindings-source' - ); - await expect( bindingLabel ).toHaveText( 'Server Source' ); + const bindingsPanel = page + .getByRole( 'tabpanel', { + name: 'Settings', + } ) + .locator( '.block-editor-bindings__panel' ); + await expect( bindingsPanel ).toContainText( 'Server Source' ); } ); } ); } );