From 893181e9e9c16b52d85a2d4e8be902cafdb73d6f Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Tue, 17 Sep 2024 13:22:31 -0700 Subject: [PATCH] Block bindings: Bring bindings UI in Site Editor (#64072) * Initial commit. Add meta field to post types. * Add post meta * Add todos * Add fields in all postType * WIP: Add first version to link templates and entities * Revert "WIP: Add first version to link templates and entities" This reverts commit a43e39194f25d39e69426b15a2b9036022f301d3. * Only expose public fields * Add subtype to meta properties * Render the appropriate fields depending on the postType in templates * Use context postType when available * Fetch the data on render, preventing one click needed * Yoda conditions.. * Try: Expose registered meta fields in schema * Try: Create a resolver to get registered post meta * Use rest namespace * Move actions and selectors to private. * Add unlocking and import * Merge useSelect * Fix duplicated * Add object_subtype to schema * Update docs to object_subtype * Add explanatory comment * Block Bindings: Use default values in connected custom fields in templates (#65128) * Abstract `getMetadata` and use it in `getValues` * Adapt e2e tests * Update e2e --------- Co-authored-by: SantosGuillamot Co-authored-by: cbravobernal Co-authored-by: gziolo Co-authored-by: mtias * Try removing all object subtype * Fix e2e * Update code * Fix `useSelect` warning * Remove old comment * Remove support for generic templates * Revert changes to e2e tests --------- Co-authored-by: mtias Co-authored-by: cbravobernal Co-authored-by: SantosGuillamot Co-authored-by: Mamaduka Co-authored-by: gziolo Co-authored-by: youknowriad Co-authored-by: tyxla Co-authored-by: TimothyBJacobs Co-authored-by: artemiomorales Co-authored-by: spacedmonkey --- .../block-editor/src/hooks/block-bindings.js | 101 ++++++++++-------- packages/core-data/src/index.js | 2 + packages/core-data/src/private-actions.js | 16 +++ packages/core-data/src/private-selectors.ts | 12 +++ packages/core-data/src/reducer.js | 20 ++++ packages/core-data/src/resolvers.js | 26 +++++ packages/core-data/src/selectors.ts | 1 + packages/e2e-tests/plugins/block-bindings.php | 2 +- packages/editor/src/bindings/post-meta.js | 46 +++++--- .../editor/various/block-bindings.spec.js | 34 +++--- 10 files changed, 184 insertions(+), 76 deletions(-) create mode 100644 packages/core-data/src/private-actions.js diff --git a/packages/block-editor/src/hooks/block-bindings.js b/packages/block-editor/src/hooks/block-bindings.js index be632cd4db3a2..a0bd8820d36c5 100644 --- a/packages/block-editor/src/hooks/block-bindings.js +++ b/packages/block-editor/src/hooks/block-bindings.js @@ -31,6 +31,8 @@ import { store as blockEditorStore } from '../store'; const { DropdownMenuV2 } = unlock( componentsPrivateApis ); +const EMPTY_OBJECT = {}; + const useToolsPanelDropdownMenuProps = () => { const isMobile = useViewportMatch( 'medium', '<' ); return ! isMobile @@ -182,11 +184,66 @@ function EditableBlockBindingsPanelItems( { export const BlockBindingsPanel = ( { name: blockName, metadata } ) => { const registry = useRegistry(); const blockContext = useContext( BlockContext ); - const { bindings } = metadata || {}; const { removeAllBlockBindings } = useBlockBindingsUtils(); const bindableAttributes = getBindableAttributes( blockName ); const dropdownMenuProps = useToolsPanelDropdownMenuProps(); + // `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 `registry.select`. + const _fieldsList = {}; + const { fieldsList, canUpdateBlockBindings } = useSelect( + ( select ) => { + if ( ! bindableAttributes || bindableAttributes.length === 0 ) { + return EMPTY_OBJECT; + } + const { getBlockBindingsSources } = unlock( blocksPrivateApis ); + 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 sourceList = getFieldsList( { + registry, + context, + } ); + // Only add source if the list is not empty. + if ( sourceList ) { + _fieldsList[ sourceName ] = { ...sourceList }; + } + } + } + ); + return { + fieldsList: + Object.values( _fieldsList ).length > 0 + ? _fieldsList + : EMPTY_OBJECT, + canUpdateBlockBindings: + select( blockEditorStore ).getSettings() + .canUpdateBlockBindings, + }; + }, + [ blockContext, bindableAttributes, registry ] + ); + // Return early if there are no bindable attributes. + if ( ! bindableAttributes || bindableAttributes.length === 0 ) { + return null; + } + // Remove empty sources from the list of fields. + Object.entries( fieldsList ).forEach( ( [ key, value ] ) => { + if ( ! Object.keys( value ).length ) { + delete fieldsList[ key ]; + } + } ); + // Filter bindings to only show bindable attributes and remove pattern overrides. + const { bindings } = metadata || {}; const filteredBindings = { ...bindings }; Object.keys( filteredBindings ).forEach( ( key ) => { if ( @@ -197,48 +254,6 @@ export const BlockBindingsPanel = ( { name: blockName, metadata } ) => { } } ); - const { canUpdateBlockBindings } = useSelect( ( select ) => { - return { - canUpdateBlockBindings: - select( blockEditorStore ).getSettings().canUpdateBlockBindings, - }; - }, [] ); - - if ( ! bindableAttributes || bindableAttributes.length === 0 ) { - return null; - } - - const fieldsList = {}; - const { getBlockBindingsSources } = unlock( blocksPrivateApis ); - 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 sourceList = getFieldsList( { - registry, - context, - } ); - // Only add source if the list is not empty. - if ( sourceList ) { - fieldsList[ sourceName ] = { ...sourceList }; - } - } - } - ); - // Remove empty sources. - Object.entries( fieldsList ).forEach( ( [ key, value ] ) => { - if ( ! Object.keys( value ).length ) { - delete fieldsList[ 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; diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index ad6adec0203c5..99507a914f377 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -10,6 +10,7 @@ import reducer from './reducer'; import * as selectors from './selectors'; import * as privateSelectors from './private-selectors'; import * as actions from './actions'; +import * as privateActions from './private-actions'; import * as resolvers from './resolvers'; import createLocksActions from './locks/actions'; import { @@ -79,6 +80,7 @@ const storeConfig = () => ( { */ export const store = createReduxStore( STORE_NAME, storeConfig() ); unlock( store ).registerPrivateSelectors( privateSelectors ); +unlock( store ).registerPrivateActions( privateActions ); register( store ); // Register store after unlocking private selectors to allow resolvers to use them. export { default as EntityProvider } from './entity-provider'; diff --git a/packages/core-data/src/private-actions.js b/packages/core-data/src/private-actions.js new file mode 100644 index 0000000000000..df76d2693e54f --- /dev/null +++ b/packages/core-data/src/private-actions.js @@ -0,0 +1,16 @@ +/** + * Returns an action object used in signalling that the registered post meta + * fields for a post type have been received. + * + * @param {string} postType Post type slug. + * @param {Object} registeredPostMeta Registered post meta. + * + * @return {Object} Action object. + */ +export function receiveRegisteredPostMeta( postType, registeredPostMeta ) { + return { + type: 'RECEIVE_REGISTERED_POST_META', + postType, + registeredPostMeta, + }; +} diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts index 841f4ee2ef460..b2f6fa7def985 100644 --- a/packages/core-data/src/private-selectors.ts +++ b/packages/core-data/src/private-selectors.ts @@ -93,3 +93,15 @@ export function getEntityRecordPermissions( ) { return getEntityRecordsPermissions( state, kind, name, id )[ 0 ]; } + +/** + * Returns the registered post meta fields for a given post type. + * + * @param state Data state. + * @param postType Post type. + * + * @return Registered post meta fields. + */ +export function getRegisteredPostMeta( state: State, postType: string ) { + return state.registeredPostMeta?.[ postType ] ?? {}; +} diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 97a8cc5904153..9748355fc5caf 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -628,6 +628,25 @@ export function defaultTemplates( state = {}, action ) { return state; } +/** + * Reducer returning an object of registered post meta. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function registeredPostMeta( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_REGISTERED_POST_META': + return { + ...state, + [ action.postType ]: action.registeredPostMeta, + }; + } + return state; +} + export default combineReducers( { terms, users, @@ -649,4 +668,5 @@ export default combineReducers( { userPatternCategories, navigationFallbackId, defaultTemplates, + registeredPostMeta, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 9229673903623..ce8c2db7a53b4 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -984,3 +984,29 @@ export const getRevision = dispatch.receiveRevisions( kind, name, recordKey, record, query ); } }; + +/** + * Requests a specific post type options from the REST API. + * + * @param {string} postType Post type slug. + */ +export const getRegisteredPostMeta = + ( postType ) => + async ( { dispatch, resolveSelect } ) => { + try { + const { + rest_namespace: restNamespace = 'wp/v2', + rest_base: restBase, + } = ( await resolveSelect.getPostType( postType ) ) || {}; + const options = await apiFetch( { + path: `${ restNamespace }/${ restBase }/?context=edit`, + method: 'OPTIONS', + } ); + dispatch.receiveRegisteredPostMeta( + postType, + options?.schema?.properties?.meta?.properties + ); + } catch { + dispatch.receiveRegisteredPostMeta( postType, false ); + } + }; diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index aeec14782ce4f..ba22723f951f4 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -47,6 +47,7 @@ export interface State { navigationFallbackId: EntityRecordKey; userPatternCategories: Array< UserPatternCategory >; defaultTemplates: Record< string, string >; + registeredPostMeta: Record< string, { postType: string } >; } type EntityRecordKey = string | number; diff --git a/packages/e2e-tests/plugins/block-bindings.php b/packages/e2e-tests/plugins/block-bindings.php index 143feb240ac2e..8951255d516bf 100644 --- a/packages/e2e-tests/plugins/block-bindings.php +++ b/packages/e2e-tests/plugins/block-bindings.php @@ -28,7 +28,7 @@ function gutenberg_test_block_bindings_registration() { 'show_in_rest' => true, 'type' => 'string', 'single' => true, - 'default' => 'Value of the text_custom_field', + 'default' => 'Value of the text custom field', ) ); register_meta( diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js index 7618ba6c36023..0562c1f7adf07 100644 --- a/packages/editor/src/bindings/post-meta.js +++ b/packages/editor/src/bindings/post-meta.js @@ -7,22 +7,43 @@ import { store as coreDataStore } from '@wordpress/core-data'; * Internal dependencies */ import { store as editorStore } from '../store'; +import { unlock } from '../lock-unlock'; + +function getMetadata( registry, context ) { + let metaFields = {}; + const { type } = registry.select( editorStore ).getCurrentPost(); + const { getEditedEntityRecord } = registry.select( coreDataStore ); + const { getRegisteredPostMeta } = unlock( + registry.select( coreDataStore ) + ); + + if ( type === 'wp_template' ) { + const fields = getRegisteredPostMeta( context?.postType ); + // Populate the `metaFields` object with the default values. + Object.entries( fields || {} ).forEach( ( [ key, props ] ) => { + metaFields[ key ] = props.default; + } ); + } else { + metaFields = getEditedEntityRecord( + 'postType', + context?.postType, + context?.postId + ).meta; + } + + return metaFields; +} export default { name: 'core/post-meta', getValues( { registry, context, bindings } ) { - const meta = registry - .select( coreDataStore ) - .getEditedEntityRecord( - 'postType', - context?.postType, - context?.postId - )?.meta; + const metaFields = getMetadata( registry, context ); + const newValues = {}; for ( const [ attributeName, source ] of Object.entries( bindings ) ) { // Use the key if the value is not set. newValues[ attributeName ] = - meta?.[ source.args.key ] ?? source.args.key; + metaFields?.[ source.args.key ] ?? source.args.key; } return newValues; }, @@ -82,19 +103,14 @@ export default { return true; }, getFieldsList( { registry, context } ) { - const metaFields = registry - .select( coreDataStore ) - .getEditedEntityRecord( - 'postType', - context?.postType, - context?.postId - ).meta; + const metaFields = getMetadata( registry, context ); if ( ! metaFields || ! Object.keys( metaFields ).length ) { return null; } // Remove footnotes or private keys from the list of fields. + // TODO: Remove this once we retrieve the fields from 'types' endpoint in post or page editor. return Object.fromEntries( Object.entries( metaFields ).filter( ( [ key ] ) => key !== 'footnotes' && key.charAt( 0 ) !== '_' diff --git a/test/e2e/specs/editor/various/block-bindings.spec.js b/test/e2e/specs/editor/various/block-bindings.spec.js index 6e36b6ad5dd33..c556c469698eb 100644 --- a/test/e2e/specs/editor/various/block-bindings.spec.js +++ b/test/e2e/specs/editor/various/block-bindings.spec.js @@ -1231,14 +1231,14 @@ test.describe( 'Block bindings', () => { name: 'Block: Paragraph', } ); await expect( paragraphBlock ).toHaveText( - 'Value of the text_custom_field' + 'Value of the text custom field' ); // Check the frontend shows the value of the custom field. const previewPage = await editor.openPreviewPage(); await expect( previewPage.locator( '#paragraph-binding' ) - ).toHaveText( 'Value of the text_custom_field' ); + ).toHaveText( 'Value of the text custom field' ); } ); test( "should show the value of the key when custom field doesn't exist", async ( { @@ -1400,7 +1400,7 @@ test.describe( 'Block bindings', () => { .locator( '[data-type="core/paragraph"]' ) .all(); await expect( initialParagraph ).toHaveText( - 'Value of the text_custom_field' + 'Value of the text custom field' ); await expect( newEmptyParagraph ).toHaveText( '' ); await expect( newEmptyParagraph ).toBeEditable(); @@ -1510,7 +1510,7 @@ test.describe( 'Block bindings', () => { name: 'Block: Paragraph', } ); await expect( paragraphBlock ).toHaveText( - 'Value of the text_custom_field' + 'Value of the text custom field' ); } ); } ); @@ -1538,14 +1538,14 @@ test.describe( 'Block bindings', () => { name: 'Block: Heading', } ); await expect( headingBlock ).toHaveText( - 'Value of the text_custom_field' + 'Value of the text custom field' ); // Check the frontend shows the value of the custom field. const previewPage = await editor.openPreviewPage(); await expect( previewPage.locator( '#heading-binding' ) - ).toHaveText( 'Value of the text_custom_field' ); + ).toHaveText( 'Value of the text custom field' ); } ); test( 'should add empty paragraph block when pressing enter', async ( { @@ -1584,7 +1584,7 @@ test.describe( 'Block bindings', () => { 'core/heading' ); await expect( initialHeading ).toHaveText( - 'Value of the text_custom_field' + 'Value of the text custom field' ); // Second block should be an empty paragraph block. await expect( newEmptyParagraph ).toHaveAttribute( @@ -1642,7 +1642,7 @@ test.describe( 'Block bindings', () => { .getByRole( 'textbox' ); await buttonBlock.click(); await expect( buttonBlock ).toHaveText( - 'Value of the text_custom_field' + 'Value of the text custom field' ); // Check the frontend shows the value of the custom field. @@ -1651,7 +1651,7 @@ test.describe( 'Block bindings', () => { '#button-text-binding a' ); await expect( buttonDom ).toHaveText( - 'Value of the text_custom_field' + 'Value of the text custom field' ); await expect( buttonDom ).toHaveAttribute( 'href', @@ -1731,7 +1731,7 @@ test.describe( 'Block bindings', () => { '#button-multiple-bindings a' ); await expect( buttonDom ).toHaveText( - 'Value of the text_custom_field' + 'Value of the text custom field' ); await expect( buttonDom ).toHaveAttribute( 'href', @@ -1778,7 +1778,7 @@ test.describe( 'Block bindings', () => { .all(); // First block should be the original block. await expect( initialButton ).toHaveText( - 'Value of the text_custom_field' + 'Value of the text custom field' ); // Second block should be an empty paragraph block. await expect( newEmptyButton ).toHaveText( '' ); @@ -1943,7 +1943,7 @@ test.describe( 'Block bindings', () => { .getByRole( 'tabpanel', { name: 'Settings' } ) .getByLabel( 'Alternative text' ) .inputValue(); - expect( altValue ).toBe( 'Value of the text_custom_field' ); + expect( altValue ).toBe( 'Value of the text custom field' ); // Check the frontend uses the value of the custom field. const previewPage = await editor.openPreviewPage(); @@ -1956,7 +1956,7 @@ test.describe( 'Block bindings', () => { ); await expect( imageDom ).toHaveAttribute( 'alt', - 'Value of the text_custom_field' + 'Value of the text custom field' ); await expect( imageDom ).toHaveAttribute( 'title', @@ -2013,7 +2013,7 @@ test.describe( 'Block bindings', () => { .getByRole( 'tabpanel', { name: 'Settings' } ) .getByLabel( 'Title attribute' ) .inputValue(); - expect( titleValue ).toBe( 'Value of the text_custom_field' ); + expect( titleValue ).toBe( 'Value of the text custom field' ); // Check the frontend uses the value of the custom field. const previewPage = await editor.openPreviewPage(); @@ -2030,7 +2030,7 @@ test.describe( 'Block bindings', () => { ); await expect( imageDom ).toHaveAttribute( 'title', - 'Value of the text_custom_field' + 'Value of the text custom field' ); } ); @@ -2077,7 +2077,7 @@ test.describe( 'Block bindings', () => { .getByRole( 'tabpanel', { name: 'Settings' } ) .getByLabel( 'Alternative text' ) .inputValue(); - expect( altValue ).toBe( 'Value of the text_custom_field' ); + expect( altValue ).toBe( 'Value of the text custom field' ); // Title input should have the original value. const advancedButton = page @@ -2107,7 +2107,7 @@ test.describe( 'Block bindings', () => { ); await expect( imageDom ).toHaveAttribute( 'alt', - 'Value of the text_custom_field' + 'Value of the text custom field' ); await expect( imageDom ).toHaveAttribute( 'title',