diff --git a/lib/compat/wordpress-7.0/php-only-blocks.php b/lib/compat/wordpress-7.0/php-only-blocks.php index 47b073f53fc095..43e862be3814bc 100644 --- a/lib/compat/wordpress-7.0/php-only-blocks.php +++ b/lib/compat/wordpress-7.0/php-only-blocks.php @@ -34,3 +34,49 @@ function gutenberg_register_auto_register_blocks() { } add_action( 'enqueue_block_editor_assets', 'gutenberg_register_auto_register_blocks', 5 ); + +/** + * Mark user-defined attributes for auto-generated inspector controls. + * + * This filter runs during block type registration, before the WP_Block_Type + * is instantiated. Block supports add their attributes AFTER the block type + * is created (via WP_Block_Supports::register_attributes()), so any attributes + * present at this stage are user-defined. + * + * The marker tells generateFieldsFromAttributes() which attributes should + * get auto-generated inspector controls. Attributes are excluded if they: + * - Have a 'source' (HTML-derived, edited inline not via inspector) + * - Have role 'local' (internal state, not user-configurable) + * - Were added by block supports (added after this filter runs) + * + * @param array $settings Array of block type arguments for registration. + * @return array Modified settings with marked attributes. + */ +function gutenberg_mark_auto_inspector_control_attributes( $settings ) { + if ( empty( $settings['attributes'] ) || ! is_array( $settings['attributes'] ) ) { + return $settings; + } + + // Only process blocks with auto_register flag. + $has_auto_register = ! empty( $settings['supports']['auto_register'] ); + if ( ! $has_auto_register ) { + return $settings; + } + + foreach ( $settings['attributes'] as $name => $def ) { + // Skip HTML-derived attributes (edited inline, not via inspector). + if ( ! empty( $def['source'] ) ) { + continue; + } + // Skip internal attributes (not user-configurable). + if ( isset( $def['role'] ) && 'local' === $def['role'] ) { + continue; + } + $settings['attributes'][ $name ]['__experimentalAutoInspectorControl'] = true; + } + + return $settings; +} + +// Priority 5 to mark original attributes before other filters (priority 10+) might add their own. +add_filter( 'register_block_type_args', 'gutenberg_mark_auto_inspector_control_attributes', 5 ); diff --git a/packages/block-editor/src/hooks/auto-inspector-controls.js b/packages/block-editor/src/hooks/auto-inspector-controls.js new file mode 100644 index 00000000000000..cf4e00156326b0 --- /dev/null +++ b/packages/block-editor/src/hooks/auto-inspector-controls.js @@ -0,0 +1,95 @@ +/** + * WordPress dependencies + */ +import { getBlockType } from '@wordpress/blocks'; +import { PanelBody } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import { DataForm } from '@wordpress/dataviews'; +import { useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import InspectorControls from '../components/inspector-controls'; +import { useBlockEditingMode } from '../components/block-editing-mode'; +import { store as blockEditorStore } from '../store'; +import { generateFieldsFromAttributes } from './generate-fields-from-attributes'; + +/** + * Checks if a block has any attributes marked for auto-generated inspector controls. + * + * @param {Object} blockTypeAttributes - The block type's attributes object. + * @return {boolean} True if any attribute has __experimentalAutoInspectorControl marker. + */ +function hasAutoInspectorControlAttributes( blockTypeAttributes ) { + if ( ! blockTypeAttributes ) { + return false; + } + return Object.values( blockTypeAttributes ).some( + ( attr ) => attr?.__experimentalAutoInspectorControl + ); +} + +/** + * Renders DataForm-based inspector controls for auto-registered PHP-only blocks. + * + * Fields are generated on-the-fly from attributes marked with `__experimentalAutoInspectorControl` + * during PHP registration. + * + * @param {Object} props Component props. + * @param {string} props.name Block name. + * @param {string} props.clientId Block client ID. + * @param {Function} props.setAttributes Function to update block attributes. + */ +function AutoRegisterControls( { name, clientId, setAttributes } ) { + const blockEditingMode = useBlockEditingMode(); + + const attributes = useSelect( + ( select ) => select( blockEditorStore ).getBlockAttributes( clientId ), + [ clientId ] + ); + + const blockType = getBlockType( name ); + + // Generate fields from user-defined attributes marked by PHP. + // The __experimentalAutoInspectorControl marker excludes block support attributes + // (which have their own UI) and internal state (role: 'local'). + // Memoized since blockType.attributes don't change after registration. + const { fields, form } = useMemo( () => { + if ( ! blockType?.attributes ) { + return { fields: [], form: { fields: [] } }; + } + return generateFieldsFromAttributes( blockType.attributes ); + }, [ blockType?.attributes ] ); + + if ( blockEditingMode !== 'default' ) { + return null; + } + + if ( ! fields || fields.length === 0 ) { + return null; + } + + return ( + + + + + + ); +} + +export default { + edit: AutoRegisterControls, + attributeKeys: [], + hasSupport( name ) { + const blockType = getBlockType( name ); + return hasAutoInspectorControlAttributes( blockType?.attributes ); + }, +}; diff --git a/packages/block-editor/src/hooks/generate-fields-from-attributes.js b/packages/block-editor/src/hooks/generate-fields-from-attributes.js new file mode 100644 index 00000000000000..30d892a9509168 --- /dev/null +++ b/packages/block-editor/src/hooks/generate-fields-from-attributes.js @@ -0,0 +1,65 @@ +/** + * Generates DataForm field definitions from block attributes. + * + * This utility enables PHP-only blocks to have auto-generated inspector controls + * by converting block attribute definitions into DataForm field definitions. + * + * @param {Object} attributes - Block type attributes from block registration + * @return {{ fields: Array, form: Object }} fieldsKey and formKey values + */ +export function generateFieldsFromAttributes( attributes ) { + const fields = []; + const fieldIds = []; + + Object.entries( attributes ).forEach( ( [ name, def ] ) => { + if ( ! def.__experimentalAutoInspectorControl ) { + return; + } + + const field = createFieldFromAttribute( name, def ); + if ( field ) { + fields.push( field ); + fieldIds.push( name ); + } + } ); + + return { + fields, + form: { fields: fieldIds }, + }; +} + +/** + * Creates a DataForm field definition from a block attribute definition. + * + * @param {string} name - The attribute name + * @param {Object} def - The attribute definition from block.json + * @return {Object|null} DataForm field definition or null if type not supported + */ +function createFieldFromAttribute( name, def ) { + const type = def.type; + + // Skip unsupported types (object, union types, etc.) + // Supported: stringβ†’text, number, integer, boolean (1:1 with DataForm) + if ( ! [ 'string', 'number', 'integer', 'boolean' ].includes( type ) ) { + return null; + } + + const field = { + id: name, + label: def.label || name, + // Only 'string' needs mapping to 'text'; others are 1:1 with DataForm types. + // This mapping will be unnecessary once #74105 lands. + type: type === 'string' ? 'text' : type, + }; + + // Add elements for enums (DataForm shows select UI when elements are present) + if ( def.enum && Array.isArray( def.enum ) ) { + field.elements = def.enum.map( ( value ) => ( { + value, + label: String( value ), + } ) ); + } + + return field; +} diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index 4234537efe6642..764d4d81af5720 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -36,6 +36,7 @@ import blockBindingsPanel from './block-bindings'; import listView from './list-view'; import './block-renaming'; import './grid-visualizer'; +import autoInspectorControls from './auto-inspector-controls'; createBlockEditFilter( [ @@ -54,6 +55,7 @@ createBlockEditFilter( childLayout, allowedBlocks, listView, + autoInspectorControls, ].filter( Boolean ) ); createBlockListBlockFilter( [ diff --git a/packages/block-editor/src/hooks/test/auto-inspector-controls.js b/packages/block-editor/src/hooks/test/auto-inspector-controls.js new file mode 100644 index 00000000000000..34b19ec936a9d8 --- /dev/null +++ b/packages/block-editor/src/hooks/test/auto-inspector-controls.js @@ -0,0 +1,115 @@ +/** + * WordPress dependencies + */ +import { + registerBlockType, + unregisterBlockType, + getBlockType, +} from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import autoInspectorControls from '../auto-inspector-controls'; + +describe( 'auto-inspector-controls', () => { + const blockName = 'test/auto-inspector-controls-block'; + + afterEach( () => { + if ( getBlockType( blockName ) ) { + unregisterBlockType( blockName ); + } + } ); + + describe( 'hasSupport()', () => { + it( 'should return false for blocks without __experimentalAutoInspectorControl markers', () => { + registerBlockType( blockName, { + title: 'Test Block', + category: 'text', + attributes: { + content: { + type: 'string', + }, + }, + edit: () => null, + save: () => null, + } ); + + expect( autoInspectorControls.hasSupport( blockName ) ).toBe( + false + ); + } ); + + it( 'should return true for blocks with __experimentalAutoInspectorControl markers', () => { + registerBlockType( blockName, { + title: 'Test Block', + category: 'text', + attributes: { + title: { + type: 'string', + __experimentalAutoInspectorControl: true, + }, + count: { + type: 'integer', + __experimentalAutoInspectorControl: true, + }, + }, + edit: () => null, + save: () => null, + } ); + + expect( autoInspectorControls.hasSupport( blockName ) ).toBe( + true + ); + } ); + + it( 'should return false for unregistered blocks', () => { + expect( + autoInspectorControls.hasSupport( 'non/existent-block' ) + ).toBe( false ); + } ); + + it( 'should return false for blocks with no attributes', () => { + registerBlockType( blockName, { + title: 'Test Block', + category: 'text', + edit: () => null, + save: () => null, + } ); + + expect( autoInspectorControls.hasSupport( blockName ) ).toBe( + false + ); + } ); + + it( 'should return true when at least one attribute has __experimentalAutoInspectorControl', () => { + registerBlockType( blockName, { + title: 'Test Block', + category: 'text', + attributes: { + // This one has the marker + title: { + type: 'string', + __experimentalAutoInspectorControl: true, + }, + // This one doesn't (e.g., added by block supports) + className: { + type: 'string', + }, + }, + edit: () => null, + save: () => null, + } ); + + expect( autoInspectorControls.hasSupport( blockName ) ).toBe( + true + ); + } ); + } ); + + describe( 'attributeKeys', () => { + it( 'should be an empty array', () => { + expect( autoInspectorControls.attributeKeys ).toEqual( [] ); + } ); + } ); +} ); diff --git a/packages/block-editor/src/hooks/test/generate-fields-from-attributes.js b/packages/block-editor/src/hooks/test/generate-fields-from-attributes.js new file mode 100644 index 00000000000000..7867272dfe2b40 --- /dev/null +++ b/packages/block-editor/src/hooks/test/generate-fields-from-attributes.js @@ -0,0 +1,216 @@ +/** + * Internal dependencies + */ +import { generateFieldsFromAttributes } from '../generate-fields-from-attributes'; + +/** + * Helper to mark attributes for auto-generated inspector controls. + * In production, this marker is added by PHP during block registration. + * + * @param {Object} attrs - Attributes object + * @return {Object} Attributes with __experimentalAutoInspectorControl marker + */ +function markForAutoInspectorControl( attrs ) { + const result = {}; + for ( const [ name, def ] of Object.entries( attrs ) ) { + result[ name ] = { ...def, __experimentalAutoInspectorControl: true }; + } + return result; +} + +describe( 'generateFieldsFromAttributes', () => { + it( 'should generate text field for string attribute', () => { + const attributes = markForAutoInspectorControl( { + message: { + type: 'string', + default: 'Hello', + }, + } ); + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields ).toHaveLength( 1 ); + expect( result.fields[ 0 ] ).toEqual( { + id: 'message', + label: 'message', + type: 'text', + } ); + expect( result.form.fields ).toContain( 'message' ); + } ); + + it( 'should generate number field for number attribute', () => { + const attributes = markForAutoInspectorControl( { + amount: { + type: 'number', + default: 10, + }, + } ); + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields ).toHaveLength( 1 ); + expect( result.fields[ 0 ] ).toEqual( { + id: 'amount', + label: 'amount', + type: 'number', + } ); + } ); + + it( 'should generate integer field for integer attribute', () => { + const attributes = markForAutoInspectorControl( { + count: { + type: 'integer', + default: 5, + }, + } ); + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields ).toHaveLength( 1 ); + expect( result.fields[ 0 ] ).toEqual( { + id: 'count', + label: 'count', + type: 'integer', + } ); + } ); + + it( 'should generate boolean field for boolean attribute', () => { + const attributes = markForAutoInspectorControl( { + enabled: { + type: 'boolean', + default: true, + }, + } ); + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields ).toHaveLength( 1 ); + expect( result.fields[ 0 ] ).toEqual( { + id: 'enabled', + label: 'enabled', + type: 'boolean', + } ); + } ); + + it( 'should generate text field with elements for enum attribute', () => { + const attributes = markForAutoInspectorControl( { + size: { + type: 'string', + enum: [ 'small', 'medium', 'large' ], + default: 'medium', + }, + } ); + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields ).toHaveLength( 1 ); + // DataForm automatically uses a select control when elements are present + expect( result.fields[ 0 ] ).toEqual( { + id: 'size', + label: 'size', + type: 'text', + elements: [ + { value: 'small', label: 'small' }, + { value: 'medium', label: 'medium' }, + { value: 'large', label: 'large' }, + ], + } ); + } ); + + it( 'should skip unsupported attribute types', () => { + const attributes = markForAutoInspectorControl( { + message: { + type: 'string', + default: 'Hello', + }, + items: { + type: 'array', + default: [], + }, + config: { + type: 'object', + default: {}, + }, + unionType: { + type: [ 'string', 'null' ], + default: null, + }, + } ); + + const result = generateFieldsFromAttributes( attributes ); + + // Only string attribute should generate a field + expect( result.fields ).toHaveLength( 1 ); + expect( result.fields[ 0 ].id ).toBe( 'message' ); + } ); + + it( 'should return empty fields array for empty attributes', () => { + const result = generateFieldsFromAttributes( {} ); + + expect( result.fields ).toHaveLength( 0 ); + expect( result.form.fields ).toHaveLength( 0 ); + } ); + + it( 'should use custom label when provided', () => { + const attributes = markForAutoInspectorControl( { + bgColor: { + type: 'string', + label: 'Background Color', + }, + } ); + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields[ 0 ].label ).toBe( 'Background Color' ); + } ); + + it( 'should skip attributes without __experimentalAutoInspectorControl marker', () => { + const attributes = { + userDefined: { + type: 'string', + __experimentalAutoInspectorControl: true, + }, + supportAdded: { + type: 'string', + // No marker = simulate attribute added by block supports + }, + }; + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields ).toHaveLength( 1 ); + expect( result.fields[ 0 ].id ).toBe( 'userDefined' ); + expect( result.form.fields ).not.toContain( 'supportAdded' ); + } ); + + it( 'should generate multiple fields for multiple attributes', () => { + const attributes = markForAutoInspectorControl( { + title: { + type: 'string', + default: '', + }, + count: { + type: 'integer', + default: 0, + }, + enabled: { + type: 'boolean', + default: false, + }, + size: { + type: 'string', + enum: [ 'small', 'large' ], + }, + } ); + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields ).toHaveLength( 4 ); + expect( result.form.fields ).toEqual( [ + 'title', + 'count', + 'enabled', + 'size', + ] ); + } ); +} ); diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 718e83a0bf8e8d..01376a4f129d8c 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -350,6 +350,7 @@ export const registerCoreBlocks = ( ...( ( bootstrappedBlockType?.apiVersion ?? 0 ) < 3 && { apiVersion: 3, } ), + // Inspector controls are rendered by the auto-register hook in block-editor edit: function Edit( { attributes } ) { const blockProps = useBlockProps(); const { content, status, error } = useServerSideRender( { diff --git a/packages/e2e-tests/plugins/server-side-rendered-block.php b/packages/e2e-tests/plugins/server-side-rendered-block.php index 7fcc6d9372cbf9..db00bda4c7439e 100644 --- a/packages/e2e-tests/plugins/server-side-rendered-block.php +++ b/packages/e2e-tests/plugins/server-side-rendered-block.php @@ -86,5 +86,85 @@ static function () { }, ) ); + + // PHP-only block with auto-generated controls from various attribute types + register_block_type( + 'test/auto-register-with-controls', + array( + 'title' => 'Auto Register With Controls', + 'icon' => 'admin-generic', + 'category' => 'widgets', + 'description' => 'A test block for auto-generated inspector controls', + 'keywords' => array( 'autoregister', 'controls', 'dataform' ), + // Labels are translatable via __() in real plugins. + 'attributes' => array( + 'title' => array( + 'type' => 'string', + 'default' => 'My Emoji Collection', + 'label' => 'Title', + ), + 'count' => array( + 'type' => 'integer', + 'default' => 5, + 'label' => 'Count', + ), + 'spacing' => array( + 'type' => 'number', + 'default' => 0.1, + 'label' => 'Spacing', + ), + 'showEmojis' => array( + 'type' => 'boolean', + 'default' => true, + 'label' => 'Show Emojis', + ), + 'emoji' => array( + 'type' => 'string', + 'enum' => array( '⭐', '❀️', 'πŸŽ‰', 'πŸš€', '🌈' ), + 'default' => '⭐', + 'label' => 'Emoji', + ), + // Should NOT get a control (has source - HTML-derived) + 'content' => array( + 'type' => 'string', + 'source' => 'html', + ), + // Should NOT get a control (role: local - internal state) + 'internalState' => array( + 'type' => 'string', + 'role' => 'local', + 'default' => 'internal', + ), + ), + 'render_callback' => static function ( $attributes ) { + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'style' => 'padding: 20px; text-align: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px;', + ) + ); + $title = esc_html( $attributes['title'] ); + $count = min( 20, max( 0, absint( $attributes['count'] ) ) ); + $spacing = floatval( $attributes['spacing'] ); + $show_emojis = $attributes['showEmojis']; + $emoji = $attributes['emoji']; + + $emoji_display = $show_emojis ? str_repeat( $emoji . ' ', $count ) : 'Emojis hidden'; + + return sprintf( + '
+

%s

+
%s
+
', + $wrapper_attributes, + $title, + $spacing, + $emoji_display + ); + }, + 'supports' => array( + 'auto_register' => true, + ), + ) + ); } ); diff --git a/phpunit/blocks/auto-register-blocks-test.php b/phpunit/blocks/auto-register-blocks-test.php new file mode 100644 index 00000000000000..a1722c50581a13 --- /dev/null +++ b/phpunit/blocks/auto-register-blocks-test.php @@ -0,0 +1,106 @@ + array( 'auto_register' => true ), + 'attributes' => array( + 'title' => array( 'type' => 'string' ), + 'count' => array( 'type' => 'integer' ), + ), + ); + + $result = gutenberg_mark_auto_inspector_control_attributes( $settings ); + + $this->assertTrue( $result['attributes']['title']['__experimentalAutoInspectorControl'] ); + $this->assertTrue( $result['attributes']['count']['__experimentalAutoInspectorControl'] ); + } + + /** + * Tests that attributes are not marked without auto_register flag. + */ + public function test_does_not_mark_attributes_without_auto_register() { + $settings = array( + 'attributes' => array( + 'title' => array( 'type' => 'string' ), + ), + ); + + $result = gutenberg_mark_auto_inspector_control_attributes( $settings ); + + $this->assertArrayNotHasKey( '__experimentalAutoInspectorControl', $result['attributes']['title'] ); + } + + /** + * Tests that attributes with source are excluded. + */ + public function test_excludes_attributes_with_source() { + $settings = array( + 'supports' => array( 'auto_register' => true ), + 'attributes' => array( + 'title' => array( 'type' => 'string' ), + 'content' => array( + 'type' => 'string', + 'source' => 'html', + ), + ), + ); + + $result = gutenberg_mark_auto_inspector_control_attributes( $settings ); + + $this->assertTrue( $result['attributes']['title']['__experimentalAutoInspectorControl'] ); + $this->assertArrayNotHasKey( '__experimentalAutoInspectorControl', $result['attributes']['content'] ); + } + + /** + * Tests that attributes with role: local are excluded. + * + * Example: The 'blob' attribute in media blocks (image, video, file, audio) + * stores a temporary blob URL during file upload. This is internal state + * that shouldn't be shown in the inspector or saved to the database. + */ + public function test_excludes_attributes_with_role_local() { + $settings = array( + 'supports' => array( 'auto_register' => true ), + 'attributes' => array( + 'title' => array( 'type' => 'string' ), + 'blob' => array( + 'type' => 'string', + 'role' => 'local', + ), + ), + ); + + $result = gutenberg_mark_auto_inspector_control_attributes( $settings ); + + $this->assertTrue( $result['attributes']['title']['__experimentalAutoInspectorControl'] ); + $this->assertArrayNotHasKey( '__experimentalAutoInspectorControl', $result['attributes']['blob'] ); + } + + /** + * Tests that empty attributes are handled gracefully. + */ + public function test_handles_empty_attributes() { + $settings = array( + 'supports' => array( 'auto_register' => true ), + ); + + $result = gutenberg_mark_auto_inspector_control_attributes( $settings ); + + $this->assertSame( $settings, $result ); + } +} diff --git a/test/e2e/specs/editor/plugins/server-side-rendered-block.spec.js b/test/e2e/specs/editor/plugins/server-side-rendered-block.spec.js index 148f57b0a0e556..7050614a270ada 100644 --- a/test/e2e/specs/editor/plugins/server-side-rendered-block.spec.js +++ b/test/e2e/specs/editor/plugins/server-side-rendered-block.spec.js @@ -232,4 +232,35 @@ test.describe( 'PHP-only auto-register blocks', () => { ); await expect( colorText ).toBeVisible(); } ); + + test( 'should generate inspector controls from block attributes', async ( { + editor, + page, + } ) => { + // Insert the block with auto-generated controls + await editor.insertBlock( { + name: 'test/auto-register-with-controls', + } ); + + // Open the document settings sidebar + await editor.openDocumentSettingsSidebar(); + + // Verify auto-generated controls are present + // String attribute β†’ text input + await expect( page.getByLabel( 'Title' ) ).toBeVisible(); + + // Integer attribute β†’ number input + await expect( page.getByLabel( 'Count' ) ).toBeVisible(); + + // Number attribute β†’ number control + await expect( page.getByLabel( 'Spacing' ) ).toBeVisible(); + + // Boolean attribute β†’ toggle/checkbox + await expect( page.getByLabel( 'Show Emojis' ) ).toBeVisible(); + + // Enum attribute β†’ select control + await expect( + page.getByLabel( 'Emoji', { exact: true } ) + ).toBeVisible(); + } ); } );