diff --git a/packages/blocks/README.md b/packages/blocks/README.md index 40861862585739..d724f986b0ca81 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -462,6 +462,10 @@ _Returns_ - `Array|string`: A list of blocks or a string, depending on `handlerMode`. +### privateApis + +Undocumented declaration. + ### rawHandler Converts an HTML string to known blocks. diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js index aa72979818c9c7..803467cb2187e2 100644 --- a/packages/blocks/src/api/index.js +++ b/packages/blocks/src/api/index.js @@ -1,3 +1,14 @@ +/** + * Internal dependencies + */ +import { lock } from '../lock-unlock'; +import { + registerBlockBindingsSource, + unregisterBlockBindingsSource, + getBlockBindingsSource, + getBlockBindingsSources, +} from './registration'; + // The blocktype is the most important concept within the block API. It defines // all aspects of the block configuration and its interfaces, including `edit` // and `save`. The transforms specification allows converting one blocktype to @@ -164,3 +175,11 @@ export { __EXPERIMENTAL_ELEMENTS, __EXPERIMENTAL_PATHS_WITH_OVERRIDE, } from './constants'; + +export const privateApis = {}; +lock( privateApis, { + registerBlockBindingsSource, + unregisterBlockBindingsSource, + getBlockBindingsSource, + getBlockBindingsSources, +} ); diff --git a/packages/blocks/src/api/registration.js b/packages/blocks/src/api/registration.js index fb21b7083b0c54..b80178f8357aa6 100644 --- a/packages/blocks/src/api/registration.js +++ b/packages/blocks/src/api/registration.js @@ -758,3 +758,178 @@ export const registerBlockVariation = ( blockName, variation ) => { export const unregisterBlockVariation = ( blockName, variationName ) => { dispatch( blocksStore ).removeBlockVariations( blockName, variationName ); }; + +/** + * Registers a new block bindings source with an object defining its + * behavior. Once registered, the source is available to be connected + * to the supported block attributes. + * + * @param {Object} source Properties of the source to be registered. + * @param {string} source.name The unique and machine-readable name. + * @param {string} source.label Human-readable label. + * @param {Function} [source.getValue] Function to get the value of the source. + * @param {Function} [source.setValue] Function to update the value of the source. + * @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. + * + * @example + * ```js + * import { _x } from '@wordpress/i18n'; + * import { registerBlockBindingsSource } from '@wordpress/blocks' + * + * registerBlockBindingsSource( { + * name: 'plugin/my-custom-source', + * label: _x( 'My Custom Source', 'block bindings source' ), + * getValue: () => 'Value to place in the block attribute', + * setValue: () => updateMyCustomValue(), + * setValues: () => updateMyCustomValuesInBatch(), + * getPlaceholder: () => 'Placeholder text when the value is undefined', + * canUserEditValue: () => true, + * } ); + * ``` + */ +export const registerBlockBindingsSource = ( source ) => { + const { + name, + label, + getValue, + setValue, + setValues, + getPlaceholder, + canUserEditValue, + } = source; + + // Check if the source is already registered. + const existingSource = unlock( + select( blocksStore ) + ).getBlockBindingsSource( name ); + if ( existingSource ) { + console.error( + 'Block bindings source "' + name + '" is already registered.' + ); + return; + } + + // Check the `name` property is correct. + if ( ! name ) { + console.error( 'Block bindings source must contain a name.' ); + return; + } + + if ( typeof name !== 'string' ) { + console.error( 'Block bindings source name must be a string.' ); + return; + } + + if ( /[A-Z]+/.test( name ) ) { + console.error( + 'Block bindings source name must not contain uppercase characters.' + ); + return; + } + + if ( ! /^[a-z0-9/-]+$/.test( name ) ) { + console.error( + 'Block bindings source name must contain only valid characters: lowercase characters, hyphens, or digits. Example: my-plugin/my-custom-source.' + ); + return; + } + + if ( ! /^[a-z0-9-]+\/[a-z0-9-]+$/.test( name ) ) { + console.error( + 'Block bindings source name must contain a namespace and valid characters. Example: my-plugin/my-custom-source.' + ); + return; + } + + // Check the `label` property is correct. + if ( ! label ) { + console.error( 'Block bindings source must contain a label.' ); + return; + } + + if ( typeof label !== 'string' ) { + console.error( 'Block bindings source label must be a string.' ); + return; + } + + // Check the `getValue` property is correct. + if ( getValue && typeof getValue !== 'function' ) { + console.error( 'Block bindings source getValue must be a function.' ); + return; + } + + // Check the `setValue` property is correct. + if ( setValue && typeof setValue !== 'function' ) { + console.error( 'Block bindings source setValue must be a function.' ); + return; + } + + // Check the `setValues` property is correct. + if ( setValues && typeof setValues !== 'function' ) { + console.error( 'Block bindings source setValues must be a function.' ); + return; + } + + // Check the `getPlaceholder` property is correct. + if ( getPlaceholder && typeof getPlaceholder !== 'function' ) { + console.error( + 'Block bindings source getPlaceholder must be a function.' + ); + return; + } + + // Check the `getPlaceholder` property is correct. + if ( canUserEditValue && typeof canUserEditValue !== 'function' ) { + console.error( + 'Block bindings source canUserEditValue must be a function.' + ); + return; + } + + return unlock( dispatch( blocksStore ) ).addBlockBindingsSource( source ); +}; + +/** + * Unregisters a block bindings source + * + * @param {string} name The name of the block bindings source to unregister. + * + * @example + * ```js + * import { unregisterBlockBindingsSource } from '@wordpress/blocks'; + * + * unregisterBlockBindingsSource( 'plugin/my-custom-source' ); + * ``` + */ +export function unregisterBlockBindingsSource( name ) { + const oldSource = getBlockBindingsSource( name ); + if ( ! oldSource ) { + console.error( + 'Block bindings source "' + name + '" is not registered.' + ); + return; + } + unlock( dispatch( blocksStore ) ).removeBlockBindingsSource( name ); +} + +/** + * Returns a registered block bindings source. + * + * @param {string} name Block bindings source name. + * + * @return {?Object} Block bindings source. + */ +export function getBlockBindingsSource( name ) { + return unlock( select( blocksStore ) ).getBlockBindingsSource( name ); +} + +/** + * Returns all registered block bindings sources. + * + * @return {Array} Block bindings sources. + */ +export function getBlockBindingsSources() { + return unlock( select( blocksStore ) ).getAllBlockBindingsSources(); +} diff --git a/packages/blocks/src/api/test/registration.js b/packages/blocks/src/api/test/registration.js index 81f45f1999803e..d86d5d54d6ca99 100644 --- a/packages/blocks/src/api/test/registration.js +++ b/packages/blocks/src/api/test/registration.js @@ -14,8 +14,10 @@ import { registerBlockType, registerBlockCollection, registerBlockVariation, + registerBlockBindingsSource, unregisterBlockCollection, unregisterBlockType, + unregisterBlockBindingsSource, setFreeformContentHandlerName, getFreeformContentHandlerName, setUnregisteredTypeHandlerName, @@ -28,6 +30,7 @@ import { getBlockTypes, getBlockSupport, getBlockVariations, + getBlockBindingsSource, hasBlockSupport, isReusableBlock, unstable__bootstrapServerSideBlockDefinitions, // eslint-disable-line camelcase @@ -1432,6 +1435,213 @@ describe( 'blocks', () => { ] ); } ); } ); + + describe( 'registerBlockBindingsSource', () => { + // Check the name is correct. + it( 'should contain name property', () => { + const source = registerBlockBindingsSource( {} ); + expect( console ).toHaveErroredWith( + 'Block bindings source must contain a name.' + ); + expect( source ).toBeUndefined(); + } ); + + it( 'should reject numbers', () => { + const source = registerBlockBindingsSource( { name: 1 } ); + expect( console ).toHaveErroredWith( + 'Block bindings source name must be a string.' + ); + expect( source ).toBeUndefined(); + } ); + + it( 'should reject names with uppercase characters', () => { + registerBlockBindingsSource( { + name: 'Core/WrongName', + } ); + expect( console ).toHaveErroredWith( + 'Block bindings source name must not contain uppercase characters.' + ); + expect( + getBlockBindingsSource( 'Core/WrongName' ) + ).toBeUndefined(); + } ); + + it( 'should reject names with invalid characters', () => { + registerBlockBindingsSource( { + name: 'core/_wrong_name', + } ); + expect( console ).toHaveErroredWith( + 'Block bindings source name must contain only valid characters: lowercase characters, hyphens, or digits. Example: my-plugin/my-custom-source.' + ); + expect( + getBlockBindingsSource( 'core/_wrong_name' ) + ).toBeUndefined(); + } ); + + it( 'should reject invalid names without namespace', () => { + registerBlockBindingsSource( { + name: 'wrong-name', + } ); + expect( console ).toHaveErroredWith( + 'Block bindings source name must contain a namespace and valid characters. Example: my-plugin/my-custom-source.' + ); + expect( getBlockBindingsSource( 'wrong-name' ) ).toBeUndefined(); + } ); + + // Check the label is correct. + it( 'should contain label property', () => { + registerBlockBindingsSource( { + name: 'core/testing', + } ); + expect( console ).toHaveErroredWith( + 'Block bindings source must contain a label.' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + it( 'should reject invalid label', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 1, + } ); + expect( console ).toHaveErroredWith( + 'Block bindings source label must be a string.' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + // Check the `getValue` callback is correct. + it( 'should reject invalid getValue callback', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + getValue: 'should be a function', + } ); + expect( console ).toHaveErroredWith( + 'Block bindings source getValue must be a function.' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + // Check the `setValue` callback is correct. + it( 'should reject invalid setValue callback', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + setValue: 'should be a function', + } ); + expect( console ).toHaveErroredWith( + 'Block bindings source setValue must be a function.' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + // Check the `setValues` callback is correct. + it( 'should reject invalid setValues callback', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + setValues: 'should be a function', + } ); + expect( console ).toHaveErroredWith( + 'Block bindings source setValues must be a function.' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + // Check the `getPlaceholder` callback is correct. + it( 'should reject invalid getPlaceholder callback', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + getPlaceholder: 'should be a function', + } ); + expect( console ).toHaveErroredWith( + 'Block bindings source getPlaceholder must be a function.' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + // Check the `canUserEditValue` callback is correct. + it( 'should reject invalid canUserEditValue callback', () => { + registerBlockBindingsSource( { + name: 'core/testing', + label: 'testing', + canUserEditValue: 'should be a function', + } ); + expect( console ).toHaveErroredWith( + 'Block bindings source canUserEditValue must be a function.' + ); + expect( getBlockBindingsSource( 'core/testing' ) ).toBeUndefined(); + } ); + + // Check correct sources are registered as expected. + it( 'should register a valid source', () => { + const sourceProperties = { + label: 'Valid Source', + getValue: () => 'value', + setValue: () => 'new value', + setValues: () => 'new values', + getPlaceholder: () => 'placeholder', + canUserEditValue: () => true, + }; + registerBlockBindingsSource( { + name: 'core/valid-source', + ...sourceProperties, + } ); + expect( getBlockBindingsSource( 'core/valid-source' ) ).toEqual( + sourceProperties + ); + unregisterBlockBindingsSource( 'core/valid-source' ); + } ); + + it( 'should register a source with default values', () => { + registerBlockBindingsSource( { + name: 'core/valid-source', + label: 'Valid Source', + } ); + const source = getBlockBindingsSource( 'core/valid-source' ); + expect( source.getValue ).toBeUndefined(); + expect( source.setValue ).toBeUndefined(); + expect( source.setValues ).toBeUndefined(); + expect( source.getPlaceholder ).toBeUndefined(); + expect( source.canUserEditValue() ).toBe( false ); + unregisterBlockBindingsSource( 'core/valid-source' ); + } ); + + it( 'should reject registering the same source twice', () => { + const source = { + name: 'core/test-source', + label: 'Test Source', + }; + registerBlockBindingsSource( source ); + registerBlockBindingsSource( source ); + unregisterBlockBindingsSource( 'core/test-source' ); + expect( console ).toHaveErroredWith( + 'Block bindings source "core/test-source" is already registered.' + ); + } ); + } ); + + describe( 'unregisterBlockBindingsSource', () => { + it( 'should remove an existing block bindings source', () => { + registerBlockBindingsSource( { + name: 'core/test-source', + label: 'Test Source', + } ); + unregisterBlockBindingsSource( 'core/test-source' ); + expect( + getBlockBindingsSource( 'core/test-source' ) + ).toBeUndefined(); + } ); + + it( 'should reject removing a source that does not exist', () => { + unregisterBlockBindingsSource( 'core/non-existing-source' ); + expect( console ).toHaveErroredWith( + 'Block bindings source "core/non-existing-source" is not registered.' + ); + } ); + } ); } ); /* eslint-enable react/forbid-elements */ diff --git a/packages/blocks/src/store/private-actions.js b/packages/blocks/src/store/private-actions.js index dd6650338d9d1a..a59ed157e98693 100644 --- a/packages/blocks/src/store/private-actions.js +++ b/packages/blocks/src/store/private-actions.js @@ -42,15 +42,15 @@ export function addUnprocessedBlockType( name, blockType ) { } /** - * Register new block bindings source. + * Adds new block bindings source. * * @param {string} source Name of the source to register. */ -export function registerBlockBindingsSource( source ) { +export function addBlockBindingsSource( source ) { return { - type: 'REGISTER_BLOCK_BINDINGS_SOURCE', - sourceName: source.name, - sourceLabel: source.label, + type: 'ADD_BLOCK_BINDINGS_SOURCE', + name: source.name, + label: source.label, getValue: source.getValue, setValue: source.setValue, setValues: source.setValues, @@ -58,3 +58,15 @@ export function registerBlockBindingsSource( source ) { canUserEditValue: source.canUserEditValue, }; } + +/** + * Removes existing block bindings source. + * + * @param {string} name Name of the source to remove. + */ +export function removeBlockBindingsSource( name ) { + return { + type: 'REMOVE_BLOCK_BINDINGS_SOURCE', + name, + }; +} diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index c00810c534d55d..1b6d348fd9dddc 100644 --- a/packages/blocks/src/store/reducer.js +++ b/packages/blocks/src/store/reducer.js @@ -372,18 +372,22 @@ export function collections( state = {}, action ) { } export function blockBindingsSources( state = {}, action ) { - if ( action.type === 'REGISTER_BLOCK_BINDINGS_SOURCE' ) { - return { - ...state, - [ action.sourceName ]: { - label: action.sourceLabel, - getValue: action.getValue, - setValue: action.setValue, - setValues: action.setValues, - getPlaceholder: action.getPlaceholder, - canUserEditValue: action.canUserEditValue || ( () => false ), - }, - }; + switch ( action.type ) { + case 'ADD_BLOCK_BINDINGS_SOURCE': + return { + ...state, + [ action.name ]: { + label: action.label, + getValue: action.getValue, + setValue: action.setValue, + setValues: action.setValues, + getPlaceholder: action.getPlaceholder, + canUserEditValue: + action.canUserEditValue || ( () => false ), + }, + }; + case 'REMOVE_BLOCK_BINDINGS_SOURCE': + return omit( state, action.name ); } return state; } diff --git a/packages/edit-post/src/index.js b/packages/edit-post/src/index.js index 0b4cf883d07809..2cbf1958719c68 100644 --- a/packages/edit-post/src/index.js +++ b/packages/edit-post/src/index.js @@ -28,6 +28,7 @@ import { unlock } from './lock-unlock'; const { BackButton: __experimentalMainDashboardButton, registerDefaultActions, + registerCoreBlockBindingsSources, } = unlock( editorPrivateApis ); /** @@ -86,6 +87,7 @@ export function initializeEditor( } registerCoreBlocks(); + registerCoreBlockBindingsSources(); registerLegacyWidgetBlock( { inserter: false } ); registerWidgetGroupBlock( { inserter: false } ); if ( globalThis.IS_GUTENBERG_PLUGIN ) { diff --git a/packages/edit-site/src/index.js b/packages/edit-site/src/index.js index 14f5bb6be650c5..02d974e037c045 100644 --- a/packages/edit-site/src/index.js +++ b/packages/edit-site/src/index.js @@ -28,7 +28,8 @@ import { store as editSiteStore } from './store'; import { unlock } from './lock-unlock'; import App from './components/app'; -const { registerDefaultActions } = unlock( editorPrivateApis ); +const { registerDefaultActions, registerCoreBlockBindingsSources } = + unlock( editorPrivateApis ); /** * Initializes the site editor screen. @@ -45,6 +46,7 @@ export function initializeEditor( id, settings ) { ( { name } ) => name !== 'core/freeform' ); registerCoreBlocks( coreBlocks ); + registerCoreBlockBindingsSources(); dispatch( blocksStore ).setFreeformFallbackBlockName( 'core/html' ); registerLegacyWidgetBlock( { inserter: false } ); registerWidgetGroupBlock( { inserter: false } ); diff --git a/packages/editor/src/bindings/api.js b/packages/editor/src/bindings/api.js new file mode 100644 index 00000000000000..0037f3334215b8 --- /dev/null +++ b/packages/editor/src/bindings/api.js @@ -0,0 +1,27 @@ +/** + * WordPress dependencies + */ +import { privateApis as blocksPrivateApis } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import patternOverrides from './pattern-overrides'; +import postMeta from './post-meta'; +import { unlock } from '../lock-unlock'; + +/** + * Function to register core block bindings sources provided by the editor. + * + * @example + * ```js + * import { registerCoreBlockBindingsSources } from '@wordpress/editor'; + * + * registerCoreBlockBindingsSources(); + * ``` + */ +export function registerCoreBlockBindingsSources() { + const { registerBlockBindingsSource } = unlock( blocksPrivateApis ); + registerBlockBindingsSource( patternOverrides ); + registerBlockBindingsSource( postMeta ); +} diff --git a/packages/editor/src/bindings/index.js b/packages/editor/src/bindings/index.js deleted file mode 100644 index 182edc621453ed..00000000000000 --- a/packages/editor/src/bindings/index.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * WordPress dependencies - */ -import { store as blocksStore } from '@wordpress/blocks'; -import { dispatch } from '@wordpress/data'; -/** - * Internal dependencies - */ -import { unlock } from '../lock-unlock'; -import patternOverrides from './pattern-overrides'; -import postMeta from './post-meta'; - -const { registerBlockBindingsSource } = unlock( dispatch( blocksStore ) ); -registerBlockBindingsSource( postMeta ); -registerBlockBindingsSource( patternOverrides ); diff --git a/packages/editor/src/index.js b/packages/editor/src/index.js index 1f7bb7699c7040..8d31a0b4ed4c4c 100644 --- a/packages/editor/src/index.js +++ b/packages/editor/src/index.js @@ -1,7 +1,6 @@ /** * Internal dependencies */ -import './bindings'; import './hooks'; export { storeConfig, store } from './store'; diff --git a/packages/editor/src/private-apis.js b/packages/editor/src/private-apis.js index 8eeb97375268c3..58688a9099e879 100644 --- a/packages/editor/src/private-apis.js +++ b/packages/editor/src/private-apis.js @@ -24,6 +24,7 @@ import { GlobalStylesProvider, } from './components/global-styles-provider'; import registerDefaultActions from './dataviews/actions'; +import { registerCoreBlockBindingsSources } from './bindings/api'; const { store: interfaceStore, ...remainingInterfaceApis } = interfaceApis; @@ -43,6 +44,7 @@ lock( privateApis, { ViewMoreMenuGroup, ResizableEditor, registerDefaultActions, + registerCoreBlockBindingsSources, // This is a temporary private API while we're updating the site editor to use EditorProvider. useBlockEditorSettings,