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(
+ '',
+ $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,
};