diff --git a/backport-changelog/6.9/9933.md b/backport-changelog/6.9/9933.md new file mode 100644 index 00000000000000..35411c74e63851 --- /dev/null +++ b/backport-changelog/6.9/9933.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/9933 + +* https://github.com/WordPress/gutenberg/pull/71542 diff --git a/lib/compat/wordpress-6.9/post-data-block-bindings.php b/lib/compat/wordpress-6.9/post-data-block-bindings.php index 7541b2f564eb17..4359b56c5e4b37 100644 --- a/lib/compat/wordpress-6.9/post-data-block-bindings.php +++ b/lib/compat/wordpress-6.9/post-data-block-bindings.php @@ -65,7 +65,7 @@ function gutenberg_register_block_bindings_post_data_source() { array( 'label' => _x( 'Post Data', 'block bindings source' ), 'get_value_callback' => 'gutenberg_block_bindings_post_data_get_value', - 'uses_context' => array( 'postId' ), + 'uses_context' => array( 'postId', 'postType' ), ) ); } diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index ca330dd4e71589..2a2ec59d3d0c87 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -2,6 +2,7 @@ * External dependencies */ import clsx from 'clsx'; +import fastDeepEqual from 'fast-deep-equal/es6'; /** * WordPress dependencies @@ -218,13 +219,19 @@ export function RichTextWrapper( const { getBlockAttributes } = select( blockEditorStore ); const blockAttributes = getBlockAttributes( clientId ); - const fieldsList = blockBindingsSource?.getFieldsList?.( { - select, - context: blockBindingsContext, - } ); + let clientSideFieldLabel = null; + if ( blockBindingsSource?.editorUI ) { + const editorUIResult = blockBindingsSource.editorUI( { + select, + context: blockBindingsContext, + } ); + clientSideFieldLabel = editorUIResult.data?.find( ( item ) => + fastDeepEqual( item.args, relatedBinding?.args ) + )?.label; + } + const bindingKey = - fieldsList?.[ relatedBinding?.args?.key ]?.label ?? - blockBindingsSource?.label; + clientSideFieldLabel ?? blockBindingsSource?.label; const _bindingsPlaceholder = _disableBoundBlock ? bindingKey diff --git a/packages/block-editor/src/hooks/block-bindings.js b/packages/block-editor/src/hooks/block-bindings.js index 2911aac1b256c1..87ab4e36321028 100644 --- a/packages/block-editor/src/hooks/block-bindings.js +++ b/packages/block-editor/src/hooks/block-bindings.js @@ -1,12 +1,13 @@ +/** + * External dependencies + */ +import fastDeepEqual from 'fast-deep-equal/es6'; + /** * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { - getBlockBindingsSource, - getBlockBindingsSources, - getBlockType, -} from '@wordpress/blocks'; +import { getBlockBindingsSources, getBlockType } from '@wordpress/blocks'; import { __experimentalItemGroup as ItemGroup, __experimentalItem as Item, @@ -14,10 +15,11 @@ import { __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem, __experimentalVStack as VStack, + Modal, privateApis as componentsPrivateApis, } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; -import { useContext, Fragment } from '@wordpress/element'; +import { useContext, useState } from '@wordpress/element'; import { useViewportMatch } from '@wordpress/compose'; /** @@ -47,72 +49,166 @@ const useToolsPanelDropdownMenuProps = () => { : {}; }; -function BlockBindingsPanelMenuContent( { fieldsList, attribute, binding } ) { +function BlockBindingsPanelMenuContent( { + attribute, + binding, + sources, + onOpenModal, +} ) { const { clientId } = useBlockEditContext(); - const registeredSources = getBlockBindingsSources(); const { updateBlockBindings } = useBlockBindingsUtils(); - const currentKey = binding?.args?.key; - const attributeType = useSelect( - ( select ) => { + const isMobile = useViewportMatch( 'medium', '<' ); + const blockContext = useContext( BlockContext ); + const { attributeType, select } = useSelect( + ( _select ) => { const { name: blockName } = - select( blockEditorStore ).getBlock( clientId ); + _select( blockEditorStore ).getBlock( clientId ); const _attributeType = getBlockType( blockName ).attributes?.[ attribute ]?.type; - return _attributeType === 'rich-text' ? 'string' : _attributeType; + return { + attributeType: + _attributeType === 'rich-text' ? 'string' : _attributeType, + select: _select, + }; }, [ clientId, attribute ] ); return ( - <> - { Object.entries( fieldsList ).map( ( [ name, fields ], i ) => ( - - - { Object.keys( fieldsList ).length > 1 && ( - - { registeredSources[ name ].label } - - ) } - { Object.entries( fields ) - .filter( - ( [ , args ] ) => args?.type === attributeType - ) - .map( ( [ key, args ] ) => ( - - updateBlockBindings( { - [ attribute ]: { - source: name, - args: { key }, - }, - } ) - } - name={ attribute + '-binding' } - value={ key } - checked={ key === currentKey } - > + + { Object.entries( sources ).map( ( [ sourceKey, source ] ) => { + // Only show sources that have compatible data for this specific attribute. + const sourceDataItems = source.data?.filter( + ( item ) => item?.type === attributeType + ); + + const noItemsAvailable = + ! sourceDataItems || sourceDataItems.length === 0; + + if ( source.mode === 'dropdown' ) { + return ( + + { noItemsAvailable ? ( + { source.label } + ) : ( + - { args?.label } + { source.label } - - { args?.value } - - - ) ) } - - { i !== Object.keys( fieldsList ).length - 1 && ( - - ) } - - ) ) } - + + ) } + + { ! noItemsAvailable && ( + + + { sourceDataItems.map( ( item ) => { + const itemBindings = { + source: sourceKey, + args: item?.args || { + key: item.key, + }, + }; + const values = source.getValues( { + select, + context: blockContext, + bindings: { + [ attribute ]: itemBindings, + }, + } ); + return ( + { + const isCurrentlySelected = + fastDeepEqual( + binding?.args, + item.args + ) ?? + // Deprecate key dependency in 7.0. + item.key === + binding?.args + ?.key; + + if ( + isCurrentlySelected + ) { + // Unset if the same item is selected again. + updateBlockBindings( + { + [ attribute ]: + undefined, + } + ); + } else { + updateBlockBindings( + { + [ attribute ]: + itemBindings, + } + ); + } + } } + name={ + attribute + '-binding' + } + value={ + values[ attribute ] + } + checked={ + fastDeepEqual( + binding?.args, + item.args + ) ?? + // Deprecate key dependency in 7.0. + item.key === + binding?.args?.key + } + > + + { item?.label } + + + { values[ attribute ] } + + + ); + } ) } + + + ) } + + ); + } + + if ( source.mode === 'modal' ) { + return ( + onOpenModal( { sourceKey } ) } + > + { source.label } + + ); + } + + return null; + } ) } + ); } -function BlockBindingsAttribute( { attribute, binding, fieldsList } ) { +function BlockBindingsAttribute( { attribute, binding, source } ) { const { source: sourceName, args } = binding || {}; - const sourceProps = getBlockBindingsSource( sourceName ); - const isSourceInvalid = ! sourceProps; + const isSourceInvalid = ! source; return ( { attribute } @@ -124,8 +220,10 @@ function BlockBindingsAttribute( { attribute, binding, fieldsList } ) { > { isSourceInvalid ? __( 'Invalid source' ) - : fieldsList?.[ sourceName ]?.[ args?.key ]?.label || - sourceProps?.label || + : source?.data?.find( ( item ) => + fastDeepEqual( item.args, args ) + )?.label || + source?.label || sourceName } ) } @@ -133,68 +231,61 @@ function BlockBindingsAttribute( { attribute, binding, fieldsList } ) { ); } -function ReadOnlyBlockBindingsPanelItems( { bindings, fieldsList } ) { +function ReadOnlyBlockBindingsPanelItem( { attribute, binding, source } ) { return ( - <> - { Object.entries( bindings ).map( ( [ attribute, binding ] ) => ( - - - - ) ) } - + !! binding } label={ attribute }> + + + + ); } -function EditableBlockBindingsPanelItems( { - attributes, - bindings, - fieldsList, +function EditableBlockBindingsPanelItem( { + attribute, + binding, + sources, + setModalState, } ) { const { updateBlockBindings } = useBlockBindingsUtils(); const isMobile = useViewportMatch( 'medium', '<' ); + + const handleOpenModal = ( { sourceKey } ) => { + setModalState( { attribute, sourceKey } ); + }; + return ( - <> - { attributes.map( ( attribute ) => { - const binding = bindings[ attribute ]; - return ( - !! binding } - label={ attribute } - onDeselect={ () => { - updateBlockBindings( { - [ attribute ]: undefined, - } ); - } } - > - - }> - - - - - - - - ); - } ) } - + !! binding } + label={ attribute } + onDeselect={ () => { + updateBlockBindings( { + [ attribute ]: undefined, + } ); + } } + > + + }> + + + + + + + ); } @@ -202,85 +293,169 @@ export const BlockBindingsPanel = ( { name: blockName, metadata } ) => { const blockContext = useContext( BlockContext ); const { removeAllBlockBindings } = useBlockBindingsUtils(); const dropdownMenuProps = useToolsPanelDropdownMenuProps(); + const [ modalState, setModalState ] = useState( null ); - // `useSelect` is used purposely here to ensure `getFieldsList` - // is updated whenever there are updates in block context. - // `source.getFieldsList` may also call a selector via `select`. - const _fieldsList = {}; - const { bindableAttributes, fieldsList, canUpdateBlockBindings } = - useSelect( - ( select ) => { - const { __experimentalBlockBindingsSupportedAttributes } = - select( blockEditorStore ).getSettings(); - const _bindableAttributes = - __experimentalBlockBindingsSupportedAttributes?.[ - blockName - ]; - if ( - ! _bindableAttributes || - _bindableAttributes.length === 0 - ) { - return EMPTY_OBJECT; - } - const registeredSources = getBlockBindingsSources(); - Object.entries( registeredSources ).forEach( - ( [ sourceName, { getFieldsList, usesContext } ] ) => { - if ( getFieldsList ) { - // Populate context. - const context = {}; - if ( usesContext?.length ) { - for ( const key of usesContext ) { - context[ key ] = blockContext[ key ]; - } + const handleCloseModal = () => { + setModalState( null ); + }; + + // Use useSelect to ensure sources are updated whenever there are updates in block context + // or when underlying data changes. + // Still needs a fix regarding _sources scope. + const _sources = {}; + const { sources, canUpdateBlockBindings, bindableAttributes } = useSelect( + ( select ) => { + const { __experimentalBlockBindingsSupportedAttributes } = + select( blockEditorStore ).getSettings(); + const _bindableAttributes = + __experimentalBlockBindingsSupportedAttributes?.[ blockName ]; + if ( ! _bindableAttributes || _bindableAttributes.length === 0 ) { + return EMPTY_OBJECT; + } + + const registeredSources = getBlockBindingsSources(); + Object.entries( registeredSources ).forEach( + ( [ + sourceName, + { editorUI, getFieldsList, usesContext, label, getValues }, + ] ) => { + if ( editorUI ) { + // Populate context. + const context = {}; + if ( usesContext?.length ) { + for ( const key of usesContext ) { + context[ key ] = blockContext[ key ]; } - const sourceList = getFieldsList( { - select, - context, - } ); - // Only add source if the list is not empty. - if ( Object.keys( sourceList || {} ).length ) { - _fieldsList[ sourceName ] = { ...sourceList }; + } + + const editorUIResult = editorUI( { + select, + context, + } ); + const hasCompatibleData = _bindableAttributes.some( + ( attribute ) => { + const _attributeType = + getBlockType( blockName ).attributes?.[ + attribute + ]?.type; + const attributeType = + _attributeType === 'rich-text' + ? 'string' + : _attributeType; + + return editorUIResult.data?.some( + ( item ) => item?.type === attributeType + ); } + ); + + if ( hasCompatibleData ) { + _sources[ sourceName ] = { + ...editorUIResult, + label, + getValues, + }; } + } else if ( getFieldsList ) { + // Backward compatibility: Convert getFieldsList to editorUI format + const context = {}; + if ( usesContext?.length ) { + for ( const key of usesContext ) { + context[ key ] = blockContext[ key ]; + } + } + + const fieldsListResult = getFieldsList( { + select, + context, + } ); + + if ( fieldsListResult ) { + // Convert getFieldsList format to editorUI format + const data = Object.entries( fieldsListResult ).map( + ( [ key, field ] ) => ( { + label: field.label || key, + type: field.type || 'string', + args: { key }, + } ) + ); + + const hasCompatibleData = _bindableAttributes.some( + ( attribute ) => { + const _attributeType = + getBlockType( blockName ).attributes?.[ + attribute + ]?.type; + const attributeType = + _attributeType === 'rich-text' + ? 'string' + : _attributeType; + + return data.some( + ( item ) => item?.type === attributeType + ); + } + ); + + if ( hasCompatibleData ) { + _sources[ sourceName ] = { + mode: 'dropdown', // Default mode for backward compatibility + data, + label, + getValues, + }; + } + } + } else { + /* + * Include sources without editorUI if they are introduced + * by other means (e.g. code editor). + */ + _sources[ sourceName ] = { + label, + getValues, + }; } - ); - return { - bindableAttributes: _bindableAttributes, - fieldsList: - Object.values( _fieldsList ).length > 0 - ? _fieldsList - : EMPTY_OBJECT, - canUpdateBlockBindings: - select( blockEditorStore ).getSettings() - .canUpdateBlockBindings, - }; - }, - [ blockContext ] - ); + } + ); + + return { + sources: + Object.values( _sources ).length > 0 + ? _sources + : EMPTY_OBJECT, + canUpdateBlockBindings: + select( blockEditorStore ).getSettings() + .canUpdateBlockBindings, + bindableAttributes: _bindableAttributes, + }; + }, + [ blockContext, blockName ] + ); // Return early if there are no bindable attributes. if ( ! bindableAttributes || bindableAttributes.length === 0 ) { return null; } - // Filter bindings to only show bindable attributes and remove pattern overrides. + // Filter bindings to only show bindable attributes. const { bindings } = metadata || {}; const filteredBindings = { ...bindings }; Object.keys( filteredBindings ).forEach( ( key ) => { - if ( - ! bindableAttributes.includes( key ) && - filteredBindings[ key ].source === 'core/pattern-overrides' - ) { + if ( ! bindableAttributes.includes( key ) ) { delete filteredBindings[ key ]; } } ); // Lock the UI when the user can't update bindings or there are no fields to connect to. const readOnly = - ! canUpdateBlockBindings || ! Object.keys( fieldsList ).length; + ! canUpdateBlockBindings || ! Object.keys( sources ).length; if ( readOnly && Object.keys( filteredBindings ).length === 0 ) { return null; } + const RenderModalContent = + sources[ modalState?.sourceKey ]?.renderModalContent; + return ( { className="block-editor-bindings__panel" > - { readOnly ? ( - - ) : ( - - ) } + { bindableAttributes.map( ( attribute ) => { + const binding = filteredBindings[ attribute ]; + const hasCompatibleData = Object.values( sources ).some( + ( source ) => source.data + ); + + return readOnly || ! hasCompatibleData ? ( + + ) : ( + + ); + } ) } { /* Use a div element to make the ToolsPanelHiddenInnerWrapper @@ -317,6 +503,14 @@ export const BlockBindingsPanel = ( { name: blockName, metadata } ) => {

+ { RenderModalContent && ( + + + + ) }
); }; diff --git a/packages/blocks/src/api/registration.js b/packages/blocks/src/api/registration.js index 249e39e8779688..93e47280f39319 100644 --- a/packages/blocks/src/api/registration.js +++ b/packages/blocks/src/api/registration.js @@ -123,6 +123,28 @@ import { unlock } from '../lock-unlock'; * then no preview is shown. */ +/** + * An object describing data for a field in block bindings EditorUI. + * + * @typedef {Object} WPBlockBindingsEditorUIDataItem + * + * @property {string} key The unique identifier for the field. + * @property {string} [label] Human-readable label for the field. + * @property {string} [type] The data type of the field (e.g., 'string', 'number'). + * @property {Object} [args] Source-specific arguments used to retrieve the value from the source. + */ + +/** + * An object describing the EditorUI configuration for block bindings. + * + * @typedef {Object} WPBlockBindingsEditorUI + * + * @property {string} mode The UI mode - either 'dropdown' or 'modal'. + * @property {Array} [data] Array of field data items. + * @property {Function} [renderModalContent] Required for modal mode. Function that renders the modal content. + * Receives an object with the `attribute` that is being changed, and a `closeModal` function to close the modal. + */ + /** * An object describing a Block Bindings source. * @@ -134,6 +156,7 @@ import { unlock } from '../lock-unlock'; * @property {Function} [getValues] Optional function to get the values from the source. * @property {Function} [setValues] Optional function to update multiple values connected to the source. * @property {Function} [canUserEditValue] Optional function to determine if the user can edit the value. + * @property {Function} [editorUI] Optional function that returns WPBlockBindingsEditorUI configuration. */ function isObject( object ) { @@ -808,6 +831,7 @@ export const registerBlockBindingsSource = ( source ) => { setValues, canUserEditValue, getFieldsList, + editorUI, } = source; const existingSource = unlock( @@ -907,6 +931,109 @@ export const registerBlockBindingsSource = ( source ) => { return; } + if ( editorUI ) { + if ( typeof editorUI === 'function' ) { + // If it's a function, we can't validate the returned object at registration time + // since it may depend on runtime context, so we just validate it's a function + return unlock( dispatch( blocksStore ) ).addBlockBindingsSource( + source + ); + } + if ( typeof editorUI !== 'object' || Array.isArray( editorUI ) ) { + warning( 'EditorUI must be an object or a function' ); + return; + } + const { mode, data } = editorUI; + + if ( mode && ! [ 'dropdown', 'modal' ].includes( mode ) ) { + warning( 'EditorUI mode must be either "dropdown" or "modal"' ); + return; + } + + if ( mode === 'dropdown' ) { + if ( ! data || ! Array.isArray( data ) ) { + warning( + 'EditorUI data must be an array of field objects for dropdown mode' + ); + return; + } + + for ( const field of data ) { + if ( ! field || typeof field !== 'object' ) { + warning( 'Each field must be an object' ); + return; + } + + if ( ! field.label || ! field.args ) { + warning( + 'Each field must have "label" and "args" properties' + ); + return; + } + + if ( typeof field.label !== 'string' ) { + warning( 'Field "label" property must be a string' ); + return; + } + + // Validate type field if provided (follows WordPress block attributes type validation). + if ( field.type ) { + const validTypes = [ + 'null', + 'boolean', + 'object', + 'array', + 'string', + 'integer', + 'number', + ]; + if ( ! validTypes.includes( field.type ) ) { + warning( + 'Field "type" must be one of: ' + + validTypes.join( ', ' ) + ); + return; + } + + if ( field.format ) { + if ( field.type !== 'string' ) { + warning( + 'Field "format" can only be used with string type' + ); + return; + } + + const validFormats = [ + 'date-time', + 'uri', + 'email', + 'ip', + 'uuid', + 'hex-color', + ]; + if ( ! validFormats.includes( field.format ) ) { + warning( + 'Field "format" must be one of: ' + + validFormats.join( ', ' ) + ); + return; + } + } + } + } + } else if ( mode === 'modal' ) { + if ( ! editorUI.renderModalContent ) { + warning( 'Modal mode requires renderModalContent function' ); + return; + } + + if ( typeof editorUI.renderModalContent !== 'function' ) { + warning( 'renderModalContent 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 406aa7b241c32e..8a84e0c6b99630 100644 --- a/packages/blocks/src/api/test/registration.js +++ b/packages/blocks/src/api/test/registration.js @@ -1730,6 +1730,184 @@ describe( 'blocks', () => { expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); } ); + // Check EditorUI object validation + it( 'should reject EditorUI with invalid mode', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + editorUI: { + mode: 'invalid-mode', + }, + } ); + expect( console ).toHaveWarnedWith( + 'EditorUI mode must be either "dropdown" or "modal"' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + it( 'should reject dropdown mode without data array', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + editorUI: { + mode: 'dropdown', + data: 'not-an-array', + }, + } ); + expect( console ).toHaveWarnedWith( + 'EditorUI data must be an array of field objects for dropdown mode' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + it( 'should reject dropdown data with invalid field structure', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + editorUI: { + mode: 'dropdown', + data: [ + { label: 'Field 1' }, // missing 'args' + ], + }, + } ); + expect( console ).toHaveWarnedWith( + 'Each field must have "label" and "args" properties' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + it( 'should reject dropdown data with invalid type', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + editorUI: { + mode: 'dropdown', + data: [ + { + label: 'Field 1', + args: { testArg: 'test' }, + type: 'invalid-type', + }, + ], + }, + } ); + expect( console ).toHaveWarnedWith( + 'Field "type" must be one of: null, boolean, object, array, string, integer, number' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + it( 'should reject dropdown data with invalid field type', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + editorUI: { + mode: 'dropdown', + data: [ + { + label: 'Field 1', + args: { testArg: 'test' }, + type: 'invalid-type', + }, + ], + }, + } ); + expect( console ).toHaveWarnedWith( + 'Field "type" must be one of: null, boolean, object, array, string, integer, number' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + it( 'should accept valid dropdown data with type validation', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + editorUI: { + mode: 'dropdown', + data: [ + { + label: 'String Field', + args: { exampleArg: 'example' }, + type: 'string', + }, + { + label: 'Number Field', + args: { exampleArg: 'numberExample' }, + type: 'number', + }, + { + label: 'Boolean Field', + args: { exampleArg: 'boolExample' }, + type: 'boolean', + }, + ], + }, + } ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeDefined(); + unregisterBlockBindingsSource( 'core/testing' ); + } ); + + it( 'should reject modal mode without renderModalContent function', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + editorUI: { + mode: 'modal', + }, + } ); + expect( console ).toHaveWarnedWith( + 'Modal mode requires renderModalContent function' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + it( 'should reject modal mode with invalid renderModalContent', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + editorUI: { + mode: 'modal', + renderModalContent: 'not-a-function', + }, + } ); + expect( console ).toHaveWarnedWith( + 'renderModalContent must be a function' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + it( 'should successfully register valid dropdown mode source', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + editorUI: { + mode: 'dropdown', + data: [ + { label: 'Field 1', args: { testArg: 'abc' } }, + { label: 'Field 2', args: { testArg: 'def' } }, + ], + }, + } ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeDefined(); + unregisterBlockBindingsSource( 'core/testing' ); + } ); + + it( 'should successfully register valid modal mode source', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + editorUI: { + mode: 'modal', + renderModalContent: () => { + return 'mock-react-element'; + }, + }, + } ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeDefined(); + unregisterBlockBindingsSource( 'core/testing' ); + } ); + // Check correct sources are registered as expected. it( 'should register a valid source', () => { const sourceProperties = { @@ -1738,9 +1916,6 @@ describe( 'blocks', () => { getValues: () => 'value', setValues: () => 'new values', canUserEditValue: () => true, - getFieldsList: () => { - return { field: 'value' }; - }, }; registerBlockBindingsSource( { name: 'core/valid-source', diff --git a/packages/blocks/src/store/private-actions.js b/packages/blocks/src/store/private-actions.js index 33f29a93c34832..38dd13695967d9 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, canUserEditValue: source.canUserEditValue, getFieldsList: source.getFieldsList, + editorUI: source.editorUI, }; } diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index ac652b91890319..8acf27b7cf9014 100644 --- a/packages/blocks/src/store/reducer.js +++ b/packages/blocks/src/store/reducer.js @@ -414,6 +414,7 @@ export function blockBindingsSources( state = {}, action ) { canUserEditValue: action.setValues && action.canUserEditValue, getFieldsList, + editorUI: action.editorUI, }, }; case 'REMOVE_BLOCK_BINDINGS_SOURCE': diff --git a/packages/e2e-tests/plugins/block-bindings.php b/packages/e2e-tests/plugins/block-bindings.php index 1fd6d8468c77db..3a1db066dd0779 100644 --- a/packages/e2e-tests/plugins/block-bindings.php +++ b/packages/e2e-tests/plugins/block-bindings.php @@ -69,7 +69,9 @@ function gutenberg_test_block_bindings_registration() { if ( ! isset( $source_args['key'] ) || ! isset( $fields_list[ $source_args['key'] ] ) ) { return null; } - return $fields_list[ $source_args['key'] ]['value']; }, + return $fields_list[ $source_args['key'] ]['value']; + }, + 'uses_context' => array( 'postType', 'postId' ), ) ); register_block_bindings_source( diff --git a/packages/e2e-tests/plugins/block-bindings/index.js b/packages/e2e-tests/plugins/block-bindings/index.js index 63c463e197fa8a..5221cc805c9dd3 100644 --- a/packages/e2e-tests/plugins/block-bindings/index.js +++ b/packages/e2e-tests/plugins/block-bindings/index.js @@ -13,13 +13,20 @@ const getValues = ( { bindings } ) => { } return newValues; }; -const setValues = ( { dispatch, bindings } ) => { +const setValues = ( { dispatch, context, bindings } ) => { + const newMeta = {}; Object.values( bindings ).forEach( ( { args, newValue } ) => { - // Example of what could be done. - dispatch( 'core' ).editEntityRecord( 'postType', 'post', 1, { - meta: { [ args?.key ]: newValue }, - } ); + newMeta[ args.key ] = newValue; } ); + + dispatch( 'core' ).editEntityRecord( + 'postType', + context?.postType, + context?.postId, + { + meta: newMeta, + } + ); }; registerBlockBindingsSource( { @@ -27,7 +34,90 @@ registerBlockBindingsSource( { getValues, setValues, canUserEditValue: () => true, - getFieldsList: () => fieldsList, + editorUI() { + return { + mode: 'dropdown', + data: Object.entries( fieldsList || {} ).map( + ( [ key, field ] ) => ( { + label: field?.label || key, + type: field?.type || 'string', + args: { + key, + }, + } ) + ), + }; + }, +} ); + +const ModalButton = ( { fieldKey, fieldLabel, attribute, closeModal } ) => { + const { updateBlockBindings } = wp.blockEditor.useBlockBindingsUtils(); + + return el( + 'button', + { + onClick: () => { + updateBlockBindings( { + [ attribute ]: { + source: 'testing/modal-source', + args: { key: fieldKey }, + }, + } ); + closeModal(); + }, + style: { + display: 'block', + margin: '5px 0', + padding: '10px', + width: '100%', + }, + }, + fieldLabel + ); +}; + +registerBlockBindingsSource( { + name: 'testing/modal-source', + label: 'Modal Source', + getValues, + setValues, + canUserEditValue: () => true, + editorUI() { + return { + mode: 'modal', + data: Object.entries( fieldsList || {} ).map( + ( [ key, field ] ) => ( { + label: field?.label || key, + type: field?.type || 'string', + args: { + key, + }, + } ) + ), + renderModalContent( { attribute, closeModal } ) { + return el( + 'div', + { style: { padding: '20px' } }, + el( 'h3', null, 'Select a field from the modal' ), + el( + 'p', + null, + 'This is a modal interface for selecting fields.' + ), + Object.entries( fieldsList || {} ).map( + ( [ key, field ] ) => + el( ModalButton, { + key, + fieldKey: key, + fieldLabel: field?.label || key, + attribute, + closeModal, + } ) + ) + ); + }, + }; + }, } ); registerBlockBindingsSource( { diff --git a/packages/editor/src/bindings/post-data.js b/packages/editor/src/bindings/post-data.js index ca9d0088fc533a..2112680b657361 100644 --- a/packages/editor/src/bindings/post-data.js +++ b/packages/editor/src/bindings/post-data.js @@ -123,6 +123,29 @@ export default { return true; }, getFieldsList( { select, context } ) { + // Deprecated, will be removed after 6.9. return getPostDataFields( select, context ); }, + editorUI( { select, context } ) { + const selectedBlock = select( 'core/block-editor' ).getSelectedBlock(); + if ( selectedBlock?.name !== 'core/post-date' ) { + return {}; + } + const postDataFields = Object.entries( + getPostDataFields( select, context ) || {} + ).map( ( [ key, field ] ) => ( { + label: field.label, + args: { + key, + }, + type: field.type, + } ) ); + /* + * We need to define the data as [{ label: string, value: any, type: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#type-validation }] + */ + return { + mode: 'dropdown', + data: postDataFields, + }; + }, }; diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js index 6e5f271c373761..644ea36f06d376 100644 --- a/packages/editor/src/bindings/post-meta.js +++ b/packages/editor/src/bindings/post-meta.js @@ -142,6 +142,25 @@ export default { return true; }, getFieldsList( { select, context } ) { + // Deprecated, will be removed after 6.9. return getPostMetaFields( select, context ); }, + editorUI( { select, context } ) { + const metaFields = Object.entries( + getPostMetaFields( select, context ) || {} + ).map( ( [ key, field ] ) => ( { + label: field.label, + args: { + key, + }, + type: field.type, + } ) ); + /* + * We need to define the data as [{ label: string, value: any, type: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#type-validation }] + */ + return { + mode: 'dropdown', + data: metaFields, + }; + }, }; diff --git a/packages/editor/src/bindings/term-data.js b/packages/editor/src/bindings/term-data.js index 5d0c4e8d7358a4..3f5baef7a0eff9 100644 --- a/packages/editor/src/bindings/term-data.js +++ b/packages/editor/src/bindings/term-data.js @@ -154,6 +154,25 @@ export default { return false; }, getFieldsList( { select, context } ) { + // Deprecated, will be removed after 6.9. return getTermDataFields( select, context ); }, + editorUI( { select, context } ) { + const termDataFields = Object.entries( + getTermDataFields( select, context ) || {} + ).map( ( [ key, field ] ) => ( { + label: field.label, + type: field.type, + args: { + key, + }, + } ) ); + /* + * We need to define the data as [{ label: string, value: any, type: https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/#type-validation }] + */ + return { + mode: 'dropdown', + data: termDataFields, + }; + }, }; diff --git a/test/e2e/specs/editor/various/block-bindings/custom-sources.spec.js b/test/e2e/specs/editor/various/block-bindings/custom-sources.spec.js index c402ef8797a566..afdb7fcb2e68fc 100644 --- a/test/e2e/specs/editor/various/block-bindings/custom-sources.spec.js +++ b/test/e2e/specs/editor/various/block-bindings/custom-sources.spec.js @@ -763,7 +763,7 @@ test.describe( 'Registered sources', () => { } ); } ); - test.describe( 'getFieldsList', () => { + test.describe( 'UI Editor', () => { test( 'should be possible to update attribute value through bindings UI', async ( { editor, page, @@ -779,7 +779,12 @@ test.describe( 'Registered sources', () => { .click(); await page.getByRole( 'button', { name: 'content' } ).click(); await page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitem', { + name: 'Complete Source', + } ) + .click(); + await page + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'Text Field Label' } ) .click(); const paragraphBlock = editor.canvas.getByRole( 'document', { @@ -914,13 +919,18 @@ test.describe( 'Registered sources', () => { }, } ); await page.getByRole( 'button', { name: 'content' } ).click(); + await page + .getByRole( 'menuitem', { + name: 'Complete Source', + } ) + .click(); const textField = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'Text Field Label' } ); await expect( textField ).toBeVisible(); await expect( textField ).toBeChecked(); const urlField = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'URL Field Label' } ); await expect( urlField ).toBeVisible(); await expect( urlField ).not.toBeChecked(); @@ -1209,4 +1219,117 @@ test.describe( 'Registered sources', () => { } ); await expect( contentButton ).toContainText( 'Invalid source' ); } ); + + test.describe( 'Modal source', () => { + test( 'should open modal and allow field selection', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + } ); + + // Open the bindings panel + await page.getByLabel( 'Attributes options' ).click(); + await page + .getByRole( 'menuitemcheckbox', { + name: 'Show content', + } ) + .click(); + + // Click on the content binding button + await page + .getByRole( 'button', { + name: 'content', + } ) + .click(); + + // Click on the modal source + await page + .getByRole( 'menuitem', { + name: 'Modal Source', + } ) + .click(); + + // Modal should be visible + const modal = page.getByRole( 'dialog' ); + await expect( modal ).toBeVisible(); + + // Check modal content + await expect( modal ).toContainText( + 'Select a field from the modal' + ); + await expect( modal ).toContainText( + 'This is a modal interface for selecting fields.' + ); + + // Click on a field button in the modal + const fieldButton = modal.getByRole( 'button', { + name: 'Text Field Label', + } ); + await expect( fieldButton ).toBeVisible(); + await fieldButton.click(); + + // Modal should close and binding should be applied + await expect( modal ).toBeHidden(); + + // Check that the paragraph shows the bound value + const paragraphBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + await expect( paragraphBlock ).toHaveText( 'Text Field Value' ); + } ); + + test( 'should show modal source in attributes panel', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'fallback content', + metadata: { + bindings: { + content: { + source: 'testing/modal-source', + args: { + key: 'text_field', + }, + }, + }, + }, + }, + } ); + + // Check that the binding shows the modal source label + const contentButton = page.getByRole( 'button', { + name: 'content', + } ); + await expect( contentButton ).toContainText( 'Text Field Label' ); + } ); + } ); + + test.describe( 'Source compatibility filtering', () => { + test( 'should show compatible sources enabled and incompatible sources disabled', async ( { + editor, + page, + } ) => { + // Test string attribute - paragraph content + await editor.insertBlock( { name: 'core/image' } ); + await page.getByLabel( 'Attributes options' ).click(); + await page + .getByRole( 'menuitemcheckbox', { name: 'Show id' } ) + .click(); + await page.getByRole( 'button', { name: 'id' } ).click(); + + // String sources enabled, number source disabled for string attribute + await expect( + page.getByRole( 'menuitem', { name: 'Complete Source' } ) + ).toBeDisabled(); + + await expect( + page.getByRole( 'menuitem', { name: 'Modal Source' } ) + ).toBeEnabled(); + } ); + } ); } ); diff --git a/test/e2e/specs/editor/various/block-bindings/post-data.spec.js b/test/e2e/specs/editor/various/block-bindings/post-data.spec.js new file mode 100644 index 00000000000000..ac22338d0af5e3 --- /dev/null +++ b/test/e2e/specs/editor/various/block-bindings/post-data.spec.js @@ -0,0 +1,91 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Post Data source', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( + 'gutenberg-test-themes/block-bindings' + ); + await requestUtils.activatePlugin( 'gutenberg-test-block-bindings' ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost( { title: 'Test bindings' } ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + await requestUtils.deactivatePlugin( 'gutenberg-test-block-bindings' ); + } ); + test.describe( 'Post Data bindings UI.', () => { + test( 'should not include post data fields in UI to connect attributes on non date blocks', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'fallback content', + metadata: { + bindings: { + content: { + source: 'core/post-data', + args: { + key: 'modified', + }, + }, + }, + }, + }, + } ); + await page + .getByRole( 'button', { + name: 'content', + } ) + .click(); + const postDataMenuItem = page.getByRole( 'menuitem', { + name: 'Post Data', + } ); + await expect( postDataMenuItem ).toBeHidden(); + // Check the fields registered by other sources are there. + } ); + + test( 'should include post data fields in UI to connect attributes on date blocks', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/post-date', + attributes: { + metadata: { + bindings: { + datetime: { + source: 'core/post-data', + args: { key: 'date' }, + }, + }, + }, + }, + } ); + await page + .getByRole( 'button', { + name: 'datetime', + } ) + .click(); + const postDataMenuItem = page.getByRole( 'menuitem', { + name: 'Post Data', + } ); + await expect( postDataMenuItem ).toBeVisible(); + } ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js b/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js index 0794a8fe5d34fe..611f88bde77473 100644 --- a/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js +++ b/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js @@ -164,7 +164,12 @@ test.describe( 'Post Meta source', () => { } ); await contentBinding.click(); await page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitem', { + name: 'Post Meta', + } ) + .click(); + await page + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'Movie field label' } ) .click(); await expect( contentBinding ).toContainText( @@ -195,7 +200,12 @@ test.describe( 'Post Meta source', () => { } ); await contentBinding.click(); await page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitem', { + name: 'Post Meta', + } ) + .click(); + await page + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'field_without_label_or_default' } ) .click(); await expect( contentBinding ).toContainText( @@ -221,13 +231,18 @@ test.describe( 'Post Meta source', () => { name: 'content', } ) .click(); + await page + .getByRole( 'menuitem', { + name: 'Post Meta', + } ) + .click(); } ); test( 'should include movie fields in UI to connect attributes', async ( { page, } ) => { const movieField = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'Movie field label' } ); await expect( movieField ).toBeVisible(); } ); @@ -235,23 +250,23 @@ test.describe( 'Post Meta source', () => { page, } ) => { const globalField = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'text_custom_field' } ); await expect( globalField ).toBeVisible(); } ); test( 'should not include protected fields', async ( { page } ) => { // Ensure the fields have loaded by checking the field is visible. const globalField = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'text_custom_field' } ); await expect( globalField ).toBeVisible(); // Check the protected fields are not visible. const protectedField = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: '_protected_field' } ); await expect( protectedField ).toBeHidden(); const showInRestFalseField = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'show_in_rest_false_field' } ); await expect( showInRestFalseField ).toBeHidden(); } ); @@ -259,20 +274,22 @@ test.describe( 'Post Meta source', () => { page, } ) => { const fieldButton = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'Movie field label' } ); await expect( fieldButton ).toContainText( 'Movie field default value' ); } ); + // We need to discuss this approach. As now is showing the label, like post-meta getValues function on the editor. test( 'should not show anything if the default value is not defined', async ( { page, } ) => { const fieldButton = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'Field with only label' } ); - // Check it only contains the field label. - await expect( fieldButton ).toHaveText( + + // Check that the field contains the label text + await expect( fieldButton ).toContainText( 'Field with only label' ); } ); @@ -314,19 +331,25 @@ test.describe( 'Post Meta source', () => { name: 'content', } ) .click(); + await page + .getByRole( 'menuitem', { + name: 'Complete Source', + } ) + .click(); // Check the fields registered by other sources are there. const customSourceField = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'Text Field Label' } ); await expect( customSourceField ).toBeVisible(); // Check the post meta fields are not visible. const globalField = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'text_custom_field' } ); await expect( globalField ).toBeHidden(); - const movieField = page - .getByRole( 'menuitemradio' ) - .filter( { hasText: 'Movie field label' } ); + const movieField = page.getByRole( 'menuitem', { + name: 'Post Meta', + } ); + await expect( movieField ).toBeHidden(); } ); test( 'should show the key in attributes connected to post meta', async ( { @@ -583,8 +606,13 @@ test.describe( 'Post Meta source', () => { name: 'content', } ) .click(); + await page + .getByRole( 'menuitem', { + name: 'Post Meta', + } ) + .click(); const movieField = page - .getByRole( 'menuitemradio' ) + .getByRole( 'menuitemcheckbox' ) .filter( { hasText: 'Movie field label' } ); await expect( movieField ).toBeVisible(); } ); @@ -606,33 +634,38 @@ test.describe( 'Post Meta source', () => { name: 'content', } ) .click(); + await page + .getByRole( 'menuitem', { + name: 'Post Meta', + } ) + .click(); await expect( - page.getByRole( 'menuitemradio', { + page.getByRole( 'menuitemcheckbox', { name: 'String custom field', } ) ).toBeVisible(); await expect( - page.getByRole( 'menuitemradio', { + page.getByRole( 'menuitemcheckbox', { name: 'Number custom field', } ) ).toBeHidden(); await expect( - page.getByRole( 'menuitemradio', { + page.getByRole( 'menuitemcheckbox', { name: 'Integer custom field', } ) ).toBeHidden(); await expect( - page.getByRole( 'menuitemradio', { + page.getByRole( 'menuitemcheckbox', { name: 'Boolean custom field', } ) ).toBeHidden(); await expect( - page.getByRole( 'menuitemradio', { + page.getByRole( 'menuitemcheckbox', { name: 'Object custom field', } ) ).toBeHidden(); await expect( - page.getByRole( 'menuitemradio', { + page.getByRole( 'menuitemcheckbox', { name: 'Array custom field', } ) ).toBeHidden();