From 0803b24214607ad217d8eaa0f60287a4ea1d4ccd Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 17 Dec 2025 21:57:07 +0100 Subject: [PATCH 01/14] Generate inspector controls from attributes --- packages/block-library/package.json | 1 + packages/block-library/src/index.js | 73 +++-- packages/blocks/README.md | 14 + .../api/generate-fields-from-attributes.js | 89 ++++++ packages/blocks/src/api/index.js | 2 + .../test/generate-fields-from-attributes.js | 253 ++++++++++++++++++ 6 files changed, 416 insertions(+), 16 deletions(-) create mode 100644 packages/blocks/src/api/generate-fields-from-attributes.js create mode 100644 packages/blocks/src/api/test/generate-fields-from-attributes.js diff --git a/packages/block-library/package.json b/packages/block-library/package.json index ea134397a9c1da..fc0875c920228f 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -105,6 +105,7 @@ "@wordpress/compose": "file:../compose", "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", + "@wordpress/dataviews": "file:../dataviews", "@wordpress/date": "file:../date", "@wordpress/deprecated": "file:../deprecated", "@wordpress/dom": "file:../dom", diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 718e83a0bf8e8d..2195b0b73eb651 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -8,11 +8,15 @@ import { setGroupingBlockName, registerBlockType, store as blocksStore, + generateFieldsFromAttributes, + privateApis as blocksPrivateApis, } from '@wordpress/blocks'; import { select } from '@wordpress/data'; -import { useBlockProps } from '@wordpress/block-editor'; +import { useBlockProps, InspectorControls } from '@wordpress/block-editor'; import { useServerSideRender } from '@wordpress/server-side-render'; import { __, sprintf } from '@wordpress/i18n'; +import { PanelBody } from '@wordpress/components'; +import { DataForm } from '@wordpress/dataviews'; /** * Internal dependencies @@ -145,6 +149,8 @@ import * as footnotes from './footnotes'; import isBlockMetadataExperimental from './utils/is-block-metadata-experimental'; import { unlock } from './lock-unlock'; +const { fieldsKey, formKey } = unlock( blocksPrivateApis ); + /** * Function to get all the block-library blocks in an array */ @@ -340,6 +346,14 @@ export const registerCoreBlocks = ( select( blocksStore ) ).getBootstrappedBlockType( blockName ); + // Generate DataForm fields from block attributes for auto-generated inspector controls + const { fields, form: formDefinition } = + bootstrappedBlockType?.attributes + ? generateFieldsFromAttributes( + bootstrappedBlockType.attributes + ) + : { fields: [], form: { fields: [] } }; + registerBlockType( blockName, { // Use all metadata from PHP registration, // but fall back title to block name if not provided, @@ -350,38 +364,65 @@ export const registerCoreBlocks = ( ...( ( bootstrappedBlockType?.apiVersion ?? 0 ) < 3 && { apiVersion: 3, } ), - edit: function Edit( { attributes } ) { + // Store auto-generated fields for DataForm-based inspector controls + [ fieldsKey ]: fields, + [ formKey ]: formDefinition, + edit: function Edit( { attributes, setAttributes } ) { const blockProps = useBlockProps(); const { content, status, error } = useServerSideRender( { block: blockName, attributes, } ); + const inspectorControls = fields.length > 0 && ( + + + + + + ); + if ( status === 'loading' ) { return ( -
{ __( 'Loading…' ) }
+ <> + { inspectorControls } +
+ { __( 'Loading…' ) } +
+ ); } if ( status === 'error' ) { return ( -
- { sprintf( - /* translators: %s: error message describing the problem */ - __( 'Error loading block: %s' ), - error - ) } -
+ <> + { inspectorControls } +
+ { sprintf( + /* translators: %s: error message describing the problem */ + __( 'Error loading block: %s' ), + error + ) } +
+ ); } return ( -
+ <> + { inspectorControls } +
+ ); }, save: () => null, diff --git a/packages/blocks/README.md b/packages/blocks/README.md index a13179dbcc34d8..36c3dfd3d21d0d 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -88,6 +88,20 @@ _Returns_ - `?Object`: Highest-priority transform candidate. +### generateFieldsFromAttributes + +Generates DataForm field definitions from block attributes. + +This utility enables PHP-only blocks to have auto-generated inspector controls by converting block.json attribute definitions into DataForm field definitions. + +_Parameters_ + +- _attributes_ `Object`: - Block type attributes from block.json + +_Returns_ + +- `{ fields: Array, form: Object }`: fieldsKey and formKey values + ### getBlockAttributes Returns the block attributes of a registered block node given its type. diff --git a/packages/blocks/src/api/generate-fields-from-attributes.js b/packages/blocks/src/api/generate-fields-from-attributes.js new file mode 100644 index 00000000000000..15900bd8796e12 --- /dev/null +++ b/packages/blocks/src/api/generate-fields-from-attributes.js @@ -0,0 +1,89 @@ +/** + * Generates DataForm field definitions from block attributes. + * + * This utility enables PHP-only blocks to have auto-generated inspector controls + * by converting block.json attribute definitions into DataForm field definitions. + * + * @param {Object} attributes - Block type attributes from block.json + * @return {{ fields: Array, form: Object }} fieldsKey and formKey values + */ +export function generateFieldsFromAttributes( attributes ) { + const fields = []; + const fieldIds = []; + + Object.entries( attributes ).forEach( ( [ name, def ] ) => { + // Skip HTML-derived attributes (edited inline, not via sidebar) + if ( def.source ) { + return; + } + // Skip internal attributes + if ( def.role === 'local' ) { + 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 ) { + // Handle union types (e.g., ["string", "null"]) by using the first type + const type = Array.isArray( def.type ) ? def.type[ 0 ] : def.type; + + // Skip unsupported types (array, object, 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: humanizeKey( name ), + // Only 'string' needs mapping to 'text'; others are 1:1 with DataForm types + 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: humanizeKey( String( value ) ), + } ) ); + } + + return field; +} + +/** + * Converts an attribute name to a human-readable label. + * + * @param {string} str - The attribute name (camelCase or snake_case) + * @return {string} Human-readable label + * + * @example + * humanizeKey('backgroundColor') // "Background Color" + * humanizeKey('show_title') // "Show Title" + * humanizeKey('itemCount') // "Item Count" + */ +function humanizeKey( str ) { + return str + .replace( /([A-Z])/g, ' $1' ) // Add space before capitals + .replace( /[_-]/g, ' ' ) // Replace underscores/hyphens with spaces + .trim() + .replace( /^\w/, ( c ) => c.toUpperCase() ); // Capitalize first letter +} diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js index 88ebf0036ced32..8fd8b3310aafb5 100644 --- a/packages/blocks/src/api/index.js +++ b/packages/blocks/src/api/index.js @@ -176,6 +176,8 @@ export { __EXPERIMENTAL_PATHS_WITH_OVERRIDE, } from './constants'; +export { generateFieldsFromAttributes } from './generate-fields-from-attributes'; + // Allows blocks to declare private keys (fields form) // that we can use to generate UI controls for them via DataForm. const fieldsKey = Symbol( 'fields' ); diff --git a/packages/blocks/src/api/test/generate-fields-from-attributes.js b/packages/blocks/src/api/test/generate-fields-from-attributes.js new file mode 100644 index 00000000000000..391ccb646d75e0 --- /dev/null +++ b/packages/blocks/src/api/test/generate-fields-from-attributes.js @@ -0,0 +1,253 @@ +/** + * Internal dependencies + */ +import { generateFieldsFromAttributes } from '../generate-fields-from-attributes'; + +describe( 'generateFieldsFromAttributes', () => { + it( 'should generate text field for string attribute', () => { + const attributes = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 exclude attributes with source property', () => { + const attributes = { + message: { + type: 'string', + default: 'Hello', + }, + content: { + type: 'string', + source: 'html', + }, + }; + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields ).toHaveLength( 1 ); + expect( result.fields[ 0 ].id ).toBe( 'message' ); + expect( result.form.fields ).not.toContain( 'content' ); + } ); + + it( 'should exclude attributes with role: local', () => { + const attributes = { + message: { + type: 'string', + default: 'Hello', + }, + internalState: { + type: 'string', + role: 'local', + }, + }; + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields ).toHaveLength( 1 ); + expect( result.fields[ 0 ].id ).toBe( 'message' ); + expect( result.form.fields ).not.toContain( 'internalState' ); + } ); + + it( 'should skip unsupported attribute types', () => { + const attributes = { + message: { + type: 'string', + default: 'Hello', + }, + items: { + type: 'array', + default: [], + }, + config: { + type: 'object', + default: {}, + }, + }; + + 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 handle union types by using the first type', () => { + const attributes = { + value: { + type: [ 'string', 'null' ], + default: null, + }, + }; + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields ).toHaveLength( 1 ); + expect( result.fields[ 0 ].type ).toBe( 'text' ); + } ); + + it( 'should humanize camelCase attribute names', () => { + const attributes = { + backgroundColor: { + type: 'string', + }, + showTitle: { + type: 'boolean', + }, + itemCount: { + type: 'integer', + }, + }; + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields[ 0 ].label ).toBe( 'Background Color' ); + expect( result.fields[ 1 ].label ).toBe( 'Show Title' ); + expect( result.fields[ 2 ].label ).toBe( 'Item Count' ); + } ); + + it( 'should humanize snake_case attribute names', () => { + const attributes = { + background_color: { + type: 'string', + }, + show_title: { + type: 'boolean', + }, + }; + + const result = generateFieldsFromAttributes( attributes ); + + expect( result.fields[ 0 ].label ).toBe( 'Background color' ); + expect( result.fields[ 1 ].label ).toBe( 'Show title' ); + } ); + + 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 generate multiple fields for multiple attributes', () => { + const attributes = { + 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', + ] ); + } ); +} ); From d44c2a9c6b39c78895905642f1cacd8340af13f0 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 17 Dec 2025 21:57:26 +0100 Subject: [PATCH 02/14] Add tests --- .../plugins/server-side-rendered-block.php | 74 +++++++++++++++++++ .../server-side-rendered-block.spec.js | 61 +++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/packages/e2e-tests/plugins/server-side-rendered-block.php b/packages/e2e-tests/plugins/server-side-rendered-block.php index 7fcc6d9372cbf9..56bc252f15abdd 100644 --- a/packages/e2e-tests/plugins/server-side-rendered-block.php +++ b/packages/e2e-tests/plugins/server-side-rendered-block.php @@ -86,5 +86,79 @@ 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' ), + 'attributes' => array( + 'title' => array( + 'type' => 'string', + 'default' => 'My Emoji Collection', + ), + 'count' => array( + 'type' => 'integer', + 'default' => 5, + ), + 'spacing' => array( + 'type' => 'number', + 'default' => 0.1, + ), + 'showEmojis' => array( + 'type' => 'boolean', + 'default' => true, + ), + 'emoji' => array( + 'type' => 'string', + 'enum' => array( '⭐', '❤️', '🎉', '🚀', '🌈' ), + 'default' => '⭐', + ), + // 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/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..2c1ce89d48a73f 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,65 @@ 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(); + + // Verify the block type has correct field definitions + const blockType = await page.evaluate( () => { + const bt = window.wp.blocks.getBlockType( + 'test/auto-register-with-controls' + ); + // Access private Symbol keys + const symbols = Object.getOwnPropertySymbols( bt ); + const fieldsSymbol = symbols.find( + ( s ) => s.description === 'fields' + ); + const formSymbol = symbols.find( + ( s ) => s.description === 'form' + ); + return { + fieldsCount: bt[ fieldsSymbol ]?.length ?? 0, + fieldIds: bt[ fieldsSymbol ]?.map( ( f ) => f.id ) ?? [], + formFields: bt[ formSymbol ]?.fields ?? [], + }; + } ); + + // Should have fields for title, count, spacing, showEmojis, emoji + // content and internalState should be excluded (source and role: local) + expect( blockType.fieldIds ).toContain( 'title' ); + expect( blockType.fieldIds ).toContain( 'count' ); + expect( blockType.fieldIds ).toContain( 'spacing' ); + expect( blockType.fieldIds ).toContain( 'showEmojis' ); + expect( blockType.fieldIds ).toContain( 'emoji' ); + expect( blockType.fieldIds ).not.toContain( 'content' ); + expect( blockType.fieldIds ).not.toContain( 'internalState' ); + } ); } ); From 4e9e83a80f21b1a3d300acd21fe686691ef35ed9 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:21:26 +0100 Subject: [PATCH 03/14] Avoid creating inspector controls for block supports --- lib/compat/wordpress-7.0/php-only-blocks.php | 35 +++++++++++++++++++ packages/blocks/README.md | 4 +-- .../api/generate-fields-from-attributes.js | 10 ++++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/compat/wordpress-7.0/php-only-blocks.php b/lib/compat/wordpress-7.0/php-only-blocks.php index 47b073f53fc095..89c2b738d53b4e 100644 --- a/lib/compat/wordpress-7.0/php-only-blocks.php +++ b/lib/compat/wordpress-7.0/php-only-blocks.php @@ -34,3 +34,38 @@ 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 DataForm fields. + * + * 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. + * + * This allows generateFieldsFromAttributes() to distinguish between + * user-defined attributes (which should get DataForm fields) and + * support-added attributes (which have their own UI controls). + * + * @param array $settings Array of block type arguments for registration. + * @return array Modified settings with marked attributes. + */ +function gutenberg_mark_auto_field_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 ( array_keys( $settings['attributes'] ) as $name ) { + $settings['attributes'][ $name ]['__experimentalAutoField'] = true; + } + + return $settings; +} + +add_filter( 'register_block_type_args', 'gutenberg_mark_auto_field_attributes', 5 ); diff --git a/packages/blocks/README.md b/packages/blocks/README.md index 36c3dfd3d21d0d..9117b9c84e888d 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -92,11 +92,11 @@ _Returns_ Generates DataForm field definitions from block attributes. -This utility enables PHP-only blocks to have auto-generated inspector controls by converting block.json attribute definitions into DataForm field definitions. +This utility enables PHP-only blocks to have auto-generated inspector controls by converting block attribute definitions into DataForm field definitions. _Parameters_ -- _attributes_ `Object`: - Block type attributes from block.json +- _attributes_ `Object`: - Block type attributes from block registration _Returns_ diff --git a/packages/blocks/src/api/generate-fields-from-attributes.js b/packages/blocks/src/api/generate-fields-from-attributes.js index 15900bd8796e12..459cbf85191cbd 100644 --- a/packages/blocks/src/api/generate-fields-from-attributes.js +++ b/packages/blocks/src/api/generate-fields-from-attributes.js @@ -2,9 +2,9 @@ * Generates DataForm field definitions from block attributes. * * This utility enables PHP-only blocks to have auto-generated inspector controls - * by converting block.json attribute definitions into DataForm field definitions. + * by converting block attribute definitions into DataForm field definitions. * - * @param {Object} attributes - Block type attributes from block.json + * @param {Object} attributes - Block type attributes from block registration * @return {{ fields: Array, form: Object }} fieldsKey and formKey values */ export function generateFieldsFromAttributes( attributes ) { @@ -12,6 +12,12 @@ export function generateFieldsFromAttributes( attributes ) { const fieldIds = []; Object.entries( attributes ).forEach( ( [ name, def ] ) => { + // Only process attributes marked for auto-field generation. + // This marker is added before block supports add their attributes, + // ensuring only user-defined attributes get DataForm fields. + if ( ! def.__experimentalAutoField ) { + return; + } // Skip HTML-derived attributes (edited inline, not via sidebar) if ( def.source ) { return; From 2a0b9538302c7c32ff916062106c5515b8eb621f Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 18 Dec 2025 12:46:55 +0100 Subject: [PATCH 04/14] Add unit test for the new marker and fix existing ones --- .../test/generate-fields-from-attributes.js | 78 ++++++++++++++----- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/packages/blocks/src/api/test/generate-fields-from-attributes.js b/packages/blocks/src/api/test/generate-fields-from-attributes.js index 391ccb646d75e0..246a0b89cf5765 100644 --- a/packages/blocks/src/api/test/generate-fields-from-attributes.js +++ b/packages/blocks/src/api/test/generate-fields-from-attributes.js @@ -3,14 +3,29 @@ */ import { generateFieldsFromAttributes } from '../generate-fields-from-attributes'; +/** + * Helper to mark attributes for auto-field generation. + * In production, this marker is added by PHP during block registration. + * + * @param {Object} attrs - Attributes object + * @return {Object} Attributes with __experimentalAutoField marker + */ +function markForAutoField( attrs ) { + const result = {}; + for ( const [ name, def ] of Object.entries( attrs ) ) { + result[ name ] = { ...def, __experimentalAutoField: true }; + } + return result; +} + describe( 'generateFieldsFromAttributes', () => { it( 'should generate text field for string attribute', () => { - const attributes = { + const attributes = markForAutoField( { message: { type: 'string', default: 'Hello', }, - }; + } ); const result = generateFieldsFromAttributes( attributes ); @@ -24,12 +39,12 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should generate number field for number attribute', () => { - const attributes = { + const attributes = markForAutoField( { amount: { type: 'number', default: 10, }, - }; + } ); const result = generateFieldsFromAttributes( attributes ); @@ -42,12 +57,12 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should generate integer field for integer attribute', () => { - const attributes = { + const attributes = markForAutoField( { count: { type: 'integer', default: 5, }, - }; + } ); const result = generateFieldsFromAttributes( attributes ); @@ -60,12 +75,12 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should generate boolean field for boolean attribute', () => { - const attributes = { + const attributes = markForAutoField( { enabled: { type: 'boolean', default: true, }, - }; + } ); const result = generateFieldsFromAttributes( attributes ); @@ -78,13 +93,13 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should generate text field with elements for enum attribute', () => { - const attributes = { + const attributes = markForAutoField( { size: { type: 'string', enum: [ 'small', 'medium', 'large' ], default: 'medium', }, - }; + } ); const result = generateFieldsFromAttributes( attributes ); @@ -107,10 +122,12 @@ describe( 'generateFieldsFromAttributes', () => { message: { type: 'string', default: 'Hello', + __experimentalAutoField: true, }, content: { type: 'string', source: 'html', + __experimentalAutoField: true, }, }; @@ -126,10 +143,12 @@ describe( 'generateFieldsFromAttributes', () => { message: { type: 'string', default: 'Hello', + __experimentalAutoField: true, }, internalState: { type: 'string', role: 'local', + __experimentalAutoField: true, }, }; @@ -141,7 +160,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should skip unsupported attribute types', () => { - const attributes = { + const attributes = markForAutoField( { message: { type: 'string', default: 'Hello', @@ -154,7 +173,7 @@ describe( 'generateFieldsFromAttributes', () => { type: 'object', default: {}, }, - }; + } ); const result = generateFieldsFromAttributes( attributes ); @@ -164,12 +183,12 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should handle union types by using the first type', () => { - const attributes = { + const attributes = markForAutoField( { value: { type: [ 'string', 'null' ], default: null, }, - }; + } ); const result = generateFieldsFromAttributes( attributes ); @@ -178,7 +197,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should humanize camelCase attribute names', () => { - const attributes = { + const attributes = markForAutoField( { backgroundColor: { type: 'string', }, @@ -188,7 +207,7 @@ describe( 'generateFieldsFromAttributes', () => { itemCount: { type: 'integer', }, - }; + } ); const result = generateFieldsFromAttributes( attributes ); @@ -198,14 +217,14 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should humanize snake_case attribute names', () => { - const attributes = { + const attributes = markForAutoField( { background_color: { type: 'string', }, show_title: { type: 'boolean', }, - }; + } ); const result = generateFieldsFromAttributes( attributes ); @@ -220,8 +239,27 @@ describe( 'generateFieldsFromAttributes', () => { expect( result.form.fields ).toHaveLength( 0 ); } ); - it( 'should generate multiple fields for multiple attributes', () => { + it( 'should skip attributes without __experimentalAutoField marker', () => { const attributes = { + userDefined: { + type: 'string', + __experimentalAutoField: 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 = markForAutoField( { title: { type: 'string', default: '', @@ -238,7 +276,7 @@ describe( 'generateFieldsFromAttributes', () => { type: 'string', enum: [ 'small', 'large' ], }, - }; + } ); const result = generateFieldsFromAttributes( attributes ); From 366d41f7967e03fd90ff8459101fe7d9d0fa189f Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:11:29 +0100 Subject: [PATCH 05/14] Consolidate exclusion logic in PHP, before marking attributes as needing inspector control --- lib/compat/wordpress-7.0/php-only-blocks.php | 18 ++- .../api/generate-fields-from-attributes.js | 11 -- .../test/generate-fields-from-attributes.js | 42 ------- phpunit/blocks/auto-register-blocks-test.php | 106 ++++++++++++++++++ 4 files changed, 120 insertions(+), 57 deletions(-) create mode 100644 phpunit/blocks/auto-register-blocks-test.php diff --git a/lib/compat/wordpress-7.0/php-only-blocks.php b/lib/compat/wordpress-7.0/php-only-blocks.php index 89c2b738d53b4e..9cff6e0b81f099 100644 --- a/lib/compat/wordpress-7.0/php-only-blocks.php +++ b/lib/compat/wordpress-7.0/php-only-blocks.php @@ -43,9 +43,11 @@ function gutenberg_register_auto_register_blocks() { * is created (via WP_Block_Supports::register_attributes()), so any attributes * present at this stage are user-defined. * - * This allows generateFieldsFromAttributes() to distinguish between - * user-defined attributes (which should get DataForm fields) and - * support-added attributes (which have their own UI controls). + * The marker tells generateFieldsFromAttributes() which attributes should + * get DataForm 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. @@ -61,7 +63,15 @@ function gutenberg_mark_auto_field_attributes( $settings ) { return $settings; } - foreach ( array_keys( $settings['attributes'] ) as $name ) { + 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 ]['__experimentalAutoField'] = true; } diff --git a/packages/blocks/src/api/generate-fields-from-attributes.js b/packages/blocks/src/api/generate-fields-from-attributes.js index 459cbf85191cbd..bd47223d626ec2 100644 --- a/packages/blocks/src/api/generate-fields-from-attributes.js +++ b/packages/blocks/src/api/generate-fields-from-attributes.js @@ -12,20 +12,9 @@ export function generateFieldsFromAttributes( attributes ) { const fieldIds = []; Object.entries( attributes ).forEach( ( [ name, def ] ) => { - // Only process attributes marked for auto-field generation. - // This marker is added before block supports add their attributes, - // ensuring only user-defined attributes get DataForm fields. if ( ! def.__experimentalAutoField ) { return; } - // Skip HTML-derived attributes (edited inline, not via sidebar) - if ( def.source ) { - return; - } - // Skip internal attributes - if ( def.role === 'local' ) { - return; - } const field = createFieldFromAttribute( name, def ); if ( field ) { diff --git a/packages/blocks/src/api/test/generate-fields-from-attributes.js b/packages/blocks/src/api/test/generate-fields-from-attributes.js index 246a0b89cf5765..f1fa2962159c9f 100644 --- a/packages/blocks/src/api/test/generate-fields-from-attributes.js +++ b/packages/blocks/src/api/test/generate-fields-from-attributes.js @@ -117,48 +117,6 @@ describe( 'generateFieldsFromAttributes', () => { } ); } ); - it( 'should exclude attributes with source property', () => { - const attributes = { - message: { - type: 'string', - default: 'Hello', - __experimentalAutoField: true, - }, - content: { - type: 'string', - source: 'html', - __experimentalAutoField: true, - }, - }; - - const result = generateFieldsFromAttributes( attributes ); - - expect( result.fields ).toHaveLength( 1 ); - expect( result.fields[ 0 ].id ).toBe( 'message' ); - expect( result.form.fields ).not.toContain( 'content' ); - } ); - - it( 'should exclude attributes with role: local', () => { - const attributes = { - message: { - type: 'string', - default: 'Hello', - __experimentalAutoField: true, - }, - internalState: { - type: 'string', - role: 'local', - __experimentalAutoField: true, - }, - }; - - const result = generateFieldsFromAttributes( attributes ); - - expect( result.fields ).toHaveLength( 1 ); - expect( result.fields[ 0 ].id ).toBe( 'message' ); - expect( result.form.fields ).not.toContain( 'internalState' ); - } ); - it( 'should skip unsupported attribute types', () => { const attributes = markForAutoField( { message: { diff --git a/phpunit/blocks/auto-register-blocks-test.php b/phpunit/blocks/auto-register-blocks-test.php new file mode 100644 index 00000000000000..af22033b896b44 --- /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_field_attributes( $settings ); + + $this->assertTrue( $result['attributes']['title']['__experimentalAutoField'] ); + $this->assertTrue( $result['attributes']['count']['__experimentalAutoField'] ); + } + + /** + * 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_field_attributes( $settings ); + + $this->assertArrayNotHasKey( '__experimentalAutoField', $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_field_attributes( $settings ); + + $this->assertTrue( $result['attributes']['title']['__experimentalAutoField'] ); + $this->assertArrayNotHasKey( '__experimentalAutoField', $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_field_attributes( $settings ); + + $this->assertTrue( $result['attributes']['title']['__experimentalAutoField'] ); + $this->assertArrayNotHasKey( '__experimentalAutoField', $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_field_attributes( $settings ); + + $this->assertSame( $settings, $result ); + } +} From 222d2f201691064223bd66668d6e000ea10136e9 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:14:00 +0100 Subject: [PATCH 06/14] Lint test file --- .../e2e-tests/plugins/server-side-rendered-block.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/e2e-tests/plugins/server-side-rendered-block.php b/packages/e2e-tests/plugins/server-side-rendered-block.php index 56bc252f15abdd..b4e836d5e4ed8d 100644 --- a/packages/e2e-tests/plugins/server-side-rendered-block.php +++ b/packages/e2e-tests/plugins/server-side-rendered-block.php @@ -97,29 +97,29 @@ static function () { 'description' => 'A test block for auto-generated inspector controls', 'keywords' => array( 'autoregister', 'controls', 'dataform' ), 'attributes' => array( - 'title' => array( + 'title' => array( 'type' => 'string', 'default' => 'My Emoji Collection', ), - 'count' => array( + 'count' => array( 'type' => 'integer', 'default' => 5, ), - 'spacing' => array( + 'spacing' => array( 'type' => 'number', 'default' => 0.1, ), - 'showEmojis' => array( + 'showEmojis' => array( 'type' => 'boolean', 'default' => true, ), - 'emoji' => array( + 'emoji' => array( 'type' => 'string', 'enum' => array( '⭐', '❤️', '🎉', '🚀', '🌈' ), 'default' => '⭐', ), // Should NOT get a control (has source - HTML-derived) - 'content' => array( + 'content' => array( 'type' => 'string', 'source' => 'html', ), From 77a975aea81a4f81caf9fc2c2b787f613aa0cb57 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:47:50 +0100 Subject: [PATCH 07/14] Add dataviews reference to block-library tsconfig --- packages/block-library/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json index b58dad52bda69f..6e7621f9a4a463 100644 --- a/packages/block-library/tsconfig.json +++ b/packages/block-library/tsconfig.json @@ -15,6 +15,7 @@ { "path": "../compose" }, { "path": "../core-data" }, { "path": "../data" }, + { "path": "../dataviews" }, { "path": "../date" }, { "path": "../deprecated" }, { "path": "../dom" }, From ae964dd7db40be922d8e03079f32b5a1db8f8e7e Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:37:42 +0100 Subject: [PATCH 08/14] Refactor: extract the inspector control generation to `block-editor` --- lib/compat/wordpress-7.0/php-only-blocks.php | 10 +- .../src/hooks/auto-inspector-controls.js | 94 ++++++++++++++ packages/block-editor/src/hooks/index.js | 2 + .../src/hooks/test/auto-inspector-controls.js | 115 ++++++++++++++++++ packages/block-library/package.json | 1 - packages/block-library/src/index.js | 74 +++-------- packages/block-library/tsconfig.json | 1 - .../api/generate-fields-from-attributes.js | 2 +- .../test/generate-fields-from-attributes.js | 32 ++--- phpunit/blocks/auto-register-blocks-test.php | 26 ++-- 10 files changed, 263 insertions(+), 94 deletions(-) create mode 100644 packages/block-editor/src/hooks/auto-inspector-controls.js create mode 100644 packages/block-editor/src/hooks/test/auto-inspector-controls.js diff --git a/lib/compat/wordpress-7.0/php-only-blocks.php b/lib/compat/wordpress-7.0/php-only-blocks.php index 9cff6e0b81f099..8f2a068ffa5323 100644 --- a/lib/compat/wordpress-7.0/php-only-blocks.php +++ b/lib/compat/wordpress-7.0/php-only-blocks.php @@ -36,7 +36,7 @@ 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 DataForm fields. + * 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 @@ -44,7 +44,7 @@ function gutenberg_register_auto_register_blocks() { * present at this stage are user-defined. * * The marker tells generateFieldsFromAttributes() which attributes should - * get DataForm inspector controls. Attributes are excluded if they: + * 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) @@ -52,7 +52,7 @@ function gutenberg_register_auto_register_blocks() { * @param array $settings Array of block type arguments for registration. * @return array Modified settings with marked attributes. */ -function gutenberg_mark_auto_field_attributes( $settings ) { +function gutenberg_mark_auto_inspector_control_attributes( $settings ) { if ( empty( $settings['attributes'] ) || ! is_array( $settings['attributes'] ) ) { return $settings; } @@ -72,10 +72,10 @@ function gutenberg_mark_auto_field_attributes( $settings ) { if ( isset( $def['role'] ) && 'local' === $def['role'] ) { continue; } - $settings['attributes'][ $name ]['__experimentalAutoField'] = true; + $settings['attributes'][ $name ]['__experimentalAutoInspectorControl'] = true; } return $settings; } -add_filter( 'register_block_type_args', 'gutenberg_mark_auto_field_attributes', 5 ); +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..aca3b57cc1907a --- /dev/null +++ b/packages/block-editor/src/hooks/auto-inspector-controls.js @@ -0,0 +1,94 @@ +/** + * WordPress dependencies + */ +import { getBlockType, generateFieldsFromAttributes } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { PanelBody } from '@wordpress/components'; +import { DataForm } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import InspectorControls from '../components/inspector-controls'; +import { useBlockEditingMode } from '../components/block-editing-mode'; +import { store as blockEditorStore } from '../store'; + +/** + * 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/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-library/package.json b/packages/block-library/package.json index fc0875c920228f..ea134397a9c1da 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -105,7 +105,6 @@ "@wordpress/compose": "file:../compose", "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", - "@wordpress/dataviews": "file:../dataviews", "@wordpress/date": "file:../date", "@wordpress/deprecated": "file:../deprecated", "@wordpress/dom": "file:../dom", diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 2195b0b73eb651..01376a4f129d8c 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -8,15 +8,11 @@ import { setGroupingBlockName, registerBlockType, store as blocksStore, - generateFieldsFromAttributes, - privateApis as blocksPrivateApis, } from '@wordpress/blocks'; import { select } from '@wordpress/data'; -import { useBlockProps, InspectorControls } from '@wordpress/block-editor'; +import { useBlockProps } from '@wordpress/block-editor'; import { useServerSideRender } from '@wordpress/server-side-render'; import { __, sprintf } from '@wordpress/i18n'; -import { PanelBody } from '@wordpress/components'; -import { DataForm } from '@wordpress/dataviews'; /** * Internal dependencies @@ -149,8 +145,6 @@ import * as footnotes from './footnotes'; import isBlockMetadataExperimental from './utils/is-block-metadata-experimental'; import { unlock } from './lock-unlock'; -const { fieldsKey, formKey } = unlock( blocksPrivateApis ); - /** * Function to get all the block-library blocks in an array */ @@ -346,14 +340,6 @@ export const registerCoreBlocks = ( select( blocksStore ) ).getBootstrappedBlockType( blockName ); - // Generate DataForm fields from block attributes for auto-generated inspector controls - const { fields, form: formDefinition } = - bootstrappedBlockType?.attributes - ? generateFieldsFromAttributes( - bootstrappedBlockType.attributes - ) - : { fields: [], form: { fields: [] } }; - registerBlockType( blockName, { // Use all metadata from PHP registration, // but fall back title to block name if not provided, @@ -364,65 +350,39 @@ export const registerCoreBlocks = ( ...( ( bootstrappedBlockType?.apiVersion ?? 0 ) < 3 && { apiVersion: 3, } ), - // Store auto-generated fields for DataForm-based inspector controls - [ fieldsKey ]: fields, - [ formKey ]: formDefinition, - edit: function Edit( { attributes, setAttributes } ) { + // Inspector controls are rendered by the auto-register hook in block-editor + edit: function Edit( { attributes } ) { const blockProps = useBlockProps(); const { content, status, error } = useServerSideRender( { block: blockName, attributes, } ); - const inspectorControls = fields.length > 0 && ( - - - - - - ); - if ( status === 'loading' ) { return ( - <> - { inspectorControls } -
- { __( 'Loading…' ) } -
- +
{ __( 'Loading…' ) }
); } if ( status === 'error' ) { return ( - <> - { inspectorControls } -
- { sprintf( - /* translators: %s: error message describing the problem */ - __( 'Error loading block: %s' ), - error - ) } -
- +
+ { sprintf( + /* translators: %s: error message describing the problem */ + __( 'Error loading block: %s' ), + error + ) } +
); } return ( - <> - { inspectorControls } -
- +
); }, save: () => null, diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json index 6e7621f9a4a463..b58dad52bda69f 100644 --- a/packages/block-library/tsconfig.json +++ b/packages/block-library/tsconfig.json @@ -15,7 +15,6 @@ { "path": "../compose" }, { "path": "../core-data" }, { "path": "../data" }, - { "path": "../dataviews" }, { "path": "../date" }, { "path": "../deprecated" }, { "path": "../dom" }, diff --git a/packages/blocks/src/api/generate-fields-from-attributes.js b/packages/blocks/src/api/generate-fields-from-attributes.js index bd47223d626ec2..0731e239d60340 100644 --- a/packages/blocks/src/api/generate-fields-from-attributes.js +++ b/packages/blocks/src/api/generate-fields-from-attributes.js @@ -12,7 +12,7 @@ export function generateFieldsFromAttributes( attributes ) { const fieldIds = []; Object.entries( attributes ).forEach( ( [ name, def ] ) => { - if ( ! def.__experimentalAutoField ) { + if ( ! def.__experimentalAutoInspectorControl ) { return; } diff --git a/packages/blocks/src/api/test/generate-fields-from-attributes.js b/packages/blocks/src/api/test/generate-fields-from-attributes.js index f1fa2962159c9f..7c9163e8fea8fa 100644 --- a/packages/blocks/src/api/test/generate-fields-from-attributes.js +++ b/packages/blocks/src/api/test/generate-fields-from-attributes.js @@ -4,23 +4,23 @@ import { generateFieldsFromAttributes } from '../generate-fields-from-attributes'; /** - * Helper to mark attributes for auto-field generation. + * 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 __experimentalAutoField marker + * @return {Object} Attributes with __experimentalAutoInspectorControl marker */ -function markForAutoField( attrs ) { +function markForAutoInspectorControl( attrs ) { const result = {}; for ( const [ name, def ] of Object.entries( attrs ) ) { - result[ name ] = { ...def, __experimentalAutoField: true }; + result[ name ] = { ...def, __experimentalAutoInspectorControl: true }; } return result; } describe( 'generateFieldsFromAttributes', () => { it( 'should generate text field for string attribute', () => { - const attributes = markForAutoField( { + const attributes = markForAutoInspectorControl( { message: { type: 'string', default: 'Hello', @@ -39,7 +39,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should generate number field for number attribute', () => { - const attributes = markForAutoField( { + const attributes = markForAutoInspectorControl( { amount: { type: 'number', default: 10, @@ -57,7 +57,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should generate integer field for integer attribute', () => { - const attributes = markForAutoField( { + const attributes = markForAutoInspectorControl( { count: { type: 'integer', default: 5, @@ -75,7 +75,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should generate boolean field for boolean attribute', () => { - const attributes = markForAutoField( { + const attributes = markForAutoInspectorControl( { enabled: { type: 'boolean', default: true, @@ -93,7 +93,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should generate text field with elements for enum attribute', () => { - const attributes = markForAutoField( { + const attributes = markForAutoInspectorControl( { size: { type: 'string', enum: [ 'small', 'medium', 'large' ], @@ -118,7 +118,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should skip unsupported attribute types', () => { - const attributes = markForAutoField( { + const attributes = markForAutoInspectorControl( { message: { type: 'string', default: 'Hello', @@ -141,7 +141,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should handle union types by using the first type', () => { - const attributes = markForAutoField( { + const attributes = markForAutoInspectorControl( { value: { type: [ 'string', 'null' ], default: null, @@ -155,7 +155,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should humanize camelCase attribute names', () => { - const attributes = markForAutoField( { + const attributes = markForAutoInspectorControl( { backgroundColor: { type: 'string', }, @@ -175,7 +175,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should humanize snake_case attribute names', () => { - const attributes = markForAutoField( { + const attributes = markForAutoInspectorControl( { background_color: { type: 'string', }, @@ -197,11 +197,11 @@ describe( 'generateFieldsFromAttributes', () => { expect( result.form.fields ).toHaveLength( 0 ); } ); - it( 'should skip attributes without __experimentalAutoField marker', () => { + it( 'should skip attributes without __experimentalAutoInspectorControl marker', () => { const attributes = { userDefined: { type: 'string', - __experimentalAutoField: true, + __experimentalAutoInspectorControl: true, }, supportAdded: { type: 'string', @@ -217,7 +217,7 @@ describe( 'generateFieldsFromAttributes', () => { } ); it( 'should generate multiple fields for multiple attributes', () => { - const attributes = markForAutoField( { + const attributes = markForAutoInspectorControl( { title: { type: 'string', default: '', diff --git a/phpunit/blocks/auto-register-blocks-test.php b/phpunit/blocks/auto-register-blocks-test.php index af22033b896b44..a1722c50581a13 100644 --- a/phpunit/blocks/auto-register-blocks-test.php +++ b/phpunit/blocks/auto-register-blocks-test.php @@ -6,7 +6,7 @@ */ /** - * Tests for gutenberg_mark_auto_field_attributes(). + * Tests for gutenberg_mark_auto_inspector_control_attributes(). * * @group blocks */ @@ -24,10 +24,10 @@ public function test_marks_attributes_with_auto_register_flag() { ), ); - $result = gutenberg_mark_auto_field_attributes( $settings ); + $result = gutenberg_mark_auto_inspector_control_attributes( $settings ); - $this->assertTrue( $result['attributes']['title']['__experimentalAutoField'] ); - $this->assertTrue( $result['attributes']['count']['__experimentalAutoField'] ); + $this->assertTrue( $result['attributes']['title']['__experimentalAutoInspectorControl'] ); + $this->assertTrue( $result['attributes']['count']['__experimentalAutoInspectorControl'] ); } /** @@ -40,9 +40,9 @@ public function test_does_not_mark_attributes_without_auto_register() { ), ); - $result = gutenberg_mark_auto_field_attributes( $settings ); + $result = gutenberg_mark_auto_inspector_control_attributes( $settings ); - $this->assertArrayNotHasKey( '__experimentalAutoField', $result['attributes']['title'] ); + $this->assertArrayNotHasKey( '__experimentalAutoInspectorControl', $result['attributes']['title'] ); } /** @@ -60,10 +60,10 @@ public function test_excludes_attributes_with_source() { ), ); - $result = gutenberg_mark_auto_field_attributes( $settings ); + $result = gutenberg_mark_auto_inspector_control_attributes( $settings ); - $this->assertTrue( $result['attributes']['title']['__experimentalAutoField'] ); - $this->assertArrayNotHasKey( '__experimentalAutoField', $result['attributes']['content'] ); + $this->assertTrue( $result['attributes']['title']['__experimentalAutoInspectorControl'] ); + $this->assertArrayNotHasKey( '__experimentalAutoInspectorControl', $result['attributes']['content'] ); } /** @@ -85,10 +85,10 @@ public function test_excludes_attributes_with_role_local() { ), ); - $result = gutenberg_mark_auto_field_attributes( $settings ); + $result = gutenberg_mark_auto_inspector_control_attributes( $settings ); - $this->assertTrue( $result['attributes']['title']['__experimentalAutoField'] ); - $this->assertArrayNotHasKey( '__experimentalAutoField', $result['attributes']['blob'] ); + $this->assertTrue( $result['attributes']['title']['__experimentalAutoInspectorControl'] ); + $this->assertArrayNotHasKey( '__experimentalAutoInspectorControl', $result['attributes']['blob'] ); } /** @@ -99,7 +99,7 @@ public function test_handles_empty_attributes() { 'supports' => array( 'auto_register' => true ), ); - $result = gutenberg_mark_auto_field_attributes( $settings ); + $result = gutenberg_mark_auto_inspector_control_attributes( $settings ); $this->assertSame( $settings, $result ); } From 74f9dcc7d5ab262c8723cf0023df365bac199d31 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:05:23 +0100 Subject: [PATCH 09/14] Refactor: move the field mapping to the `block-editor` package, too --- .../src/hooks/auto-inspector-controls.js | 7 ++++--- .../src/hooks}/generate-fields-from-attributes.js | 0 .../hooks}/test/generate-fields-from-attributes.js | 0 packages/blocks/README.md | 14 -------------- packages/blocks/src/api/index.js | 2 -- 5 files changed, 4 insertions(+), 19 deletions(-) rename packages/{blocks/src/api => block-editor/src/hooks}/generate-fields-from-attributes.js (100%) rename packages/{blocks/src/api => block-editor/src/hooks}/test/generate-fields-from-attributes.js (100%) diff --git a/packages/block-editor/src/hooks/auto-inspector-controls.js b/packages/block-editor/src/hooks/auto-inspector-controls.js index aca3b57cc1907a..cf4e00156326b0 100644 --- a/packages/block-editor/src/hooks/auto-inspector-controls.js +++ b/packages/block-editor/src/hooks/auto-inspector-controls.js @@ -1,12 +1,12 @@ /** * WordPress dependencies */ -import { getBlockType, generateFieldsFromAttributes } from '@wordpress/blocks'; +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'; -import { PanelBody } from '@wordpress/components'; -import { DataForm } from '@wordpress/dataviews'; /** * Internal dependencies @@ -14,6 +14,7 @@ import { DataForm } from '@wordpress/dataviews'; 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. diff --git a/packages/blocks/src/api/generate-fields-from-attributes.js b/packages/block-editor/src/hooks/generate-fields-from-attributes.js similarity index 100% rename from packages/blocks/src/api/generate-fields-from-attributes.js rename to packages/block-editor/src/hooks/generate-fields-from-attributes.js diff --git a/packages/blocks/src/api/test/generate-fields-from-attributes.js b/packages/block-editor/src/hooks/test/generate-fields-from-attributes.js similarity index 100% rename from packages/blocks/src/api/test/generate-fields-from-attributes.js rename to packages/block-editor/src/hooks/test/generate-fields-from-attributes.js diff --git a/packages/blocks/README.md b/packages/blocks/README.md index 9117b9c84e888d..a13179dbcc34d8 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -88,20 +88,6 @@ _Returns_ - `?Object`: Highest-priority transform candidate. -### generateFieldsFromAttributes - -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. - -_Parameters_ - -- _attributes_ `Object`: - Block type attributes from block registration - -_Returns_ - -- `{ fields: Array, form: Object }`: fieldsKey and formKey values - ### getBlockAttributes Returns the block attributes of a registered block node given its type. diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js index 8fd8b3310aafb5..88ebf0036ced32 100644 --- a/packages/blocks/src/api/index.js +++ b/packages/blocks/src/api/index.js @@ -176,8 +176,6 @@ export { __EXPERIMENTAL_PATHS_WITH_OVERRIDE, } from './constants'; -export { generateFieldsFromAttributes } from './generate-fields-from-attributes'; - // Allows blocks to declare private keys (fields form) // that we can use to generate UI controls for them via DataForm. const fieldsKey = Symbol( 'fields' ); From d9f91b51446f690c0b5092dbf2949ba308344735 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:54:57 +0100 Subject: [PATCH 10/14] Don't do special treatment for union types --- .../src/hooks/generate-fields-from-attributes.js | 5 ++--- .../test/generate-fields-from-attributes.js | 16 +++------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/block-editor/src/hooks/generate-fields-from-attributes.js b/packages/block-editor/src/hooks/generate-fields-from-attributes.js index 0731e239d60340..3c96545c0e2e4d 100644 --- a/packages/block-editor/src/hooks/generate-fields-from-attributes.js +++ b/packages/block-editor/src/hooks/generate-fields-from-attributes.js @@ -37,10 +37,9 @@ export function generateFieldsFromAttributes( attributes ) { * @return {Object|null} DataForm field definition or null if type not supported */ function createFieldFromAttribute( name, def ) { - // Handle union types (e.g., ["string", "null"]) by using the first type - const type = Array.isArray( def.type ) ? def.type[ 0 ] : def.type; + const type = def.type; - // Skip unsupported types (array, object, etc.) + // 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; 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 index 7c9163e8fea8fa..b24b62636f5028 100644 --- a/packages/block-editor/src/hooks/test/generate-fields-from-attributes.js +++ b/packages/block-editor/src/hooks/test/generate-fields-from-attributes.js @@ -131,18 +131,7 @@ describe( 'generateFieldsFromAttributes', () => { type: 'object', default: {}, }, - } ); - - 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 handle union types by using the first type', () => { - const attributes = markForAutoInspectorControl( { - value: { + unionType: { type: [ 'string', 'null' ], default: null, }, @@ -150,8 +139,9 @@ describe( 'generateFieldsFromAttributes', () => { const result = generateFieldsFromAttributes( attributes ); + // Only string attribute should generate a field expect( result.fields ).toHaveLength( 1 ); - expect( result.fields[ 0 ].type ).toBe( 'text' ); + expect( result.fields[ 0 ].id ).toBe( 'message' ); } ); it( 'should humanize camelCase attribute names', () => { From c56109d94c791a0840d122de8284ff363a4bb5e1 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Thu, 18 Dec 2025 21:59:10 +0100 Subject: [PATCH 11/14] Remove e2e assertions that became redundant after refactor --- .../server-side-rendered-block.spec.js | 30 ------------------- 1 file changed, 30 deletions(-) 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 2c1ce89d48a73f..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 @@ -262,35 +262,5 @@ test.describe( 'PHP-only auto-register blocks', () => { await expect( page.getByLabel( 'Emoji', { exact: true } ) ).toBeVisible(); - - // Verify the block type has correct field definitions - const blockType = await page.evaluate( () => { - const bt = window.wp.blocks.getBlockType( - 'test/auto-register-with-controls' - ); - // Access private Symbol keys - const symbols = Object.getOwnPropertySymbols( bt ); - const fieldsSymbol = symbols.find( - ( s ) => s.description === 'fields' - ); - const formSymbol = symbols.find( - ( s ) => s.description === 'form' - ); - return { - fieldsCount: bt[ fieldsSymbol ]?.length ?? 0, - fieldIds: bt[ fieldsSymbol ]?.map( ( f ) => f.id ) ?? [], - formFields: bt[ formSymbol ]?.fields ?? [], - }; - } ); - - // Should have fields for title, count, spacing, showEmojis, emoji - // content and internalState should be excluded (source and role: local) - expect( blockType.fieldIds ).toContain( 'title' ); - expect( blockType.fieldIds ).toContain( 'count' ); - expect( blockType.fieldIds ).toContain( 'spacing' ); - expect( blockType.fieldIds ).toContain( 'showEmojis' ); - expect( blockType.fieldIds ).toContain( 'emoji' ); - expect( blockType.fieldIds ).not.toContain( 'content' ); - expect( blockType.fieldIds ).not.toContain( 'internalState' ); } ); } ); From 3083ac5ca8fd28bcaa97c048ee8d261963c0919a Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:50:27 +0100 Subject: [PATCH 12/14] Remove label humanization --- .../hooks/generate-fields-from-attributes.js | 23 +------- .../test/generate-fields-from-attributes.js | 52 +++---------------- .../server-side-rendered-block.spec.js | 10 ++-- 3 files changed, 15 insertions(+), 70 deletions(-) diff --git a/packages/block-editor/src/hooks/generate-fields-from-attributes.js b/packages/block-editor/src/hooks/generate-fields-from-attributes.js index 3c96545c0e2e4d..878d00c279c2c0 100644 --- a/packages/block-editor/src/hooks/generate-fields-from-attributes.js +++ b/packages/block-editor/src/hooks/generate-fields-from-attributes.js @@ -47,7 +47,7 @@ function createFieldFromAttribute( name, def ) { const field = { id: name, - label: humanizeKey( name ), + label: name, // Only 'string' needs mapping to 'text'; others are 1:1 with DataForm types type: type === 'string' ? 'text' : type, }; @@ -56,28 +56,9 @@ function createFieldFromAttribute( name, def ) { if ( def.enum && Array.isArray( def.enum ) ) { field.elements = def.enum.map( ( value ) => ( { value, - label: humanizeKey( String( value ) ), + label: String( value ), } ) ); } return field; } - -/** - * Converts an attribute name to a human-readable label. - * - * @param {string} str - The attribute name (camelCase or snake_case) - * @return {string} Human-readable label - * - * @example - * humanizeKey('backgroundColor') // "Background Color" - * humanizeKey('show_title') // "Show Title" - * humanizeKey('itemCount') // "Item Count" - */ -function humanizeKey( str ) { - return str - .replace( /([A-Z])/g, ' $1' ) // Add space before capitals - .replace( /[_-]/g, ' ' ) // Replace underscores/hyphens with spaces - .trim() - .replace( /^\w/, ( c ) => c.toUpperCase() ); // Capitalize first letter -} 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 index b24b62636f5028..c87fff46d5f79d 100644 --- a/packages/block-editor/src/hooks/test/generate-fields-from-attributes.js +++ b/packages/block-editor/src/hooks/test/generate-fields-from-attributes.js @@ -32,7 +32,7 @@ describe( 'generateFieldsFromAttributes', () => { expect( result.fields ).toHaveLength( 1 ); expect( result.fields[ 0 ] ).toEqual( { id: 'message', - label: 'Message', + label: 'message', type: 'text', } ); expect( result.form.fields ).toContain( 'message' ); @@ -51,7 +51,7 @@ describe( 'generateFieldsFromAttributes', () => { expect( result.fields ).toHaveLength( 1 ); expect( result.fields[ 0 ] ).toEqual( { id: 'amount', - label: 'Amount', + label: 'amount', type: 'number', } ); } ); @@ -69,7 +69,7 @@ describe( 'generateFieldsFromAttributes', () => { expect( result.fields ).toHaveLength( 1 ); expect( result.fields[ 0 ] ).toEqual( { id: 'count', - label: 'Count', + label: 'count', type: 'integer', } ); } ); @@ -87,7 +87,7 @@ describe( 'generateFieldsFromAttributes', () => { expect( result.fields ).toHaveLength( 1 ); expect( result.fields[ 0 ] ).toEqual( { id: 'enabled', - label: 'Enabled', + label: 'enabled', type: 'boolean', } ); } ); @@ -107,12 +107,12 @@ describe( 'generateFieldsFromAttributes', () => { // DataForm automatically uses a select control when elements are present expect( result.fields[ 0 ] ).toEqual( { id: 'size', - label: 'Size', + label: 'size', type: 'text', elements: [ - { value: 'small', label: 'Small' }, - { value: 'medium', label: 'Medium' }, - { value: 'large', label: 'Large' }, + { value: 'small', label: 'small' }, + { value: 'medium', label: 'medium' }, + { value: 'large', label: 'large' }, ], } ); } ); @@ -144,42 +144,6 @@ describe( 'generateFieldsFromAttributes', () => { expect( result.fields[ 0 ].id ).toBe( 'message' ); } ); - it( 'should humanize camelCase attribute names', () => { - const attributes = markForAutoInspectorControl( { - backgroundColor: { - type: 'string', - }, - showTitle: { - type: 'boolean', - }, - itemCount: { - type: 'integer', - }, - } ); - - const result = generateFieldsFromAttributes( attributes ); - - expect( result.fields[ 0 ].label ).toBe( 'Background Color' ); - expect( result.fields[ 1 ].label ).toBe( 'Show Title' ); - expect( result.fields[ 2 ].label ).toBe( 'Item Count' ); - } ); - - it( 'should humanize snake_case attribute names', () => { - const attributes = markForAutoInspectorControl( { - background_color: { - type: 'string', - }, - show_title: { - type: 'boolean', - }, - } ); - - const result = generateFieldsFromAttributes( attributes ); - - expect( result.fields[ 0 ].label ).toBe( 'Background color' ); - expect( result.fields[ 1 ].label ).toBe( 'Show title' ); - } ); - it( 'should return empty fields array for empty attributes', () => { const result = generateFieldsFromAttributes( {} ); 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 7050614a270ada..37441231d726f9 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 @@ -247,20 +247,20 @@ test.describe( 'PHP-only auto-register blocks', () => { // Verify auto-generated controls are present // String attribute → text input - await expect( page.getByLabel( 'Title' ) ).toBeVisible(); + await expect( page.getByLabel( 'title' ) ).toBeVisible(); // Integer attribute → number input - await expect( page.getByLabel( 'Count' ) ).toBeVisible(); + await expect( page.getByLabel( 'count' ) ).toBeVisible(); // Number attribute → number control - await expect( page.getByLabel( 'Spacing' ) ).toBeVisible(); + await expect( page.getByLabel( 'spacing' ) ).toBeVisible(); // Boolean attribute → toggle/checkbox - await expect( page.getByLabel( 'Show Emojis' ) ).toBeVisible(); + await expect( page.getByLabel( 'showEmojis' ) ).toBeVisible(); // Enum attribute → select control await expect( - page.getByLabel( 'Emoji', { exact: true } ) + page.getByLabel( 'emoji', { exact: true } ) ).toBeVisible(); } ); } ); From 682ffb5aee38657072315ff06a36acd9e2d5c94e Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:57:19 +0100 Subject: [PATCH 13/14] Support custom labels --- .../src/hooks/generate-fields-from-attributes.js | 2 +- .../hooks/test/generate-fields-from-attributes.js | 13 +++++++++++++ .../plugins/server-side-rendered-block.php | 6 ++++++ .../plugins/server-side-rendered-block.spec.js | 10 +++++----- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/block-editor/src/hooks/generate-fields-from-attributes.js b/packages/block-editor/src/hooks/generate-fields-from-attributes.js index 878d00c279c2c0..8b5c357f574a08 100644 --- a/packages/block-editor/src/hooks/generate-fields-from-attributes.js +++ b/packages/block-editor/src/hooks/generate-fields-from-attributes.js @@ -47,7 +47,7 @@ function createFieldFromAttribute( name, def ) { const field = { id: name, - label: name, + label: def.label || name, // Only 'string' needs mapping to 'text'; others are 1:1 with DataForm types type: type === 'string' ? 'text' : type, }; 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 index c87fff46d5f79d..7867272dfe2b40 100644 --- a/packages/block-editor/src/hooks/test/generate-fields-from-attributes.js +++ b/packages/block-editor/src/hooks/test/generate-fields-from-attributes.js @@ -151,6 +151,19 @@ describe( 'generateFieldsFromAttributes', () => { 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: { diff --git a/packages/e2e-tests/plugins/server-side-rendered-block.php b/packages/e2e-tests/plugins/server-side-rendered-block.php index b4e836d5e4ed8d..db00bda4c7439e 100644 --- a/packages/e2e-tests/plugins/server-side-rendered-block.php +++ b/packages/e2e-tests/plugins/server-side-rendered-block.php @@ -96,27 +96,33 @@ static function () { '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( 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 37441231d726f9..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 @@ -247,20 +247,20 @@ test.describe( 'PHP-only auto-register blocks', () => { // Verify auto-generated controls are present // String attribute → text input - await expect( page.getByLabel( 'title' ) ).toBeVisible(); + await expect( page.getByLabel( 'Title' ) ).toBeVisible(); // Integer attribute → number input - await expect( page.getByLabel( 'count' ) ).toBeVisible(); + await expect( page.getByLabel( 'Count' ) ).toBeVisible(); // Number attribute → number control - await expect( page.getByLabel( 'spacing' ) ).toBeVisible(); + await expect( page.getByLabel( 'Spacing' ) ).toBeVisible(); // Boolean attribute → toggle/checkbox - await expect( page.getByLabel( 'showEmojis' ) ).toBeVisible(); + await expect( page.getByLabel( 'Show Emojis' ) ).toBeVisible(); // Enum attribute → select control await expect( - page.getByLabel( 'emoji', { exact: true } ) + page.getByLabel( 'Emoji', { exact: true } ) ).toBeVisible(); } ); } ); From 479431cd035fb68b801a605754f037fa17323229 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:53:39 +0100 Subject: [PATCH 14/14] Address feedback --- lib/compat/wordpress-7.0/php-only-blocks.php | 1 + .../block-editor/src/hooks/generate-fields-from-attributes.js | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/compat/wordpress-7.0/php-only-blocks.php b/lib/compat/wordpress-7.0/php-only-blocks.php index 8f2a068ffa5323..43e862be3814bc 100644 --- a/lib/compat/wordpress-7.0/php-only-blocks.php +++ b/lib/compat/wordpress-7.0/php-only-blocks.php @@ -78,4 +78,5 @@ function gutenberg_mark_auto_inspector_control_attributes( $settings ) { 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/generate-fields-from-attributes.js b/packages/block-editor/src/hooks/generate-fields-from-attributes.js index 8b5c357f574a08..30d892a9509168 100644 --- a/packages/block-editor/src/hooks/generate-fields-from-attributes.js +++ b/packages/block-editor/src/hooks/generate-fields-from-attributes.js @@ -48,7 +48,8 @@ function createFieldFromAttribute( name, def ) { const field = { id: name, label: def.label || name, - // Only 'string' needs mapping to 'text'; others are 1:1 with DataForm types + // 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, };