Skip to content

Field API: add support for object type#74409

Draft
oandregal wants to merge 2 commits intotrunkfrom
add/field-type-object
Draft

Field API: add support for object type#74409
oandregal wants to merge 2 commits intotrunkfrom
add/field-type-object

Conversation

@oandregal
Copy link
Member

@oandregal oandregal commented Jan 7, 2026

What?

This PR adds support for the object field type in the Field API.

Example of an object field type:

{
  id: 'address',
  type: 'object',
  label: 'Address',
  properties: {
    street: { id: 'street', type: 'text', label: 'Street' },
    city: { id: 'city', type: 'text', label: 'City' },
    country: { id: 'country', type: 'text', label: 'Country' },
  },
}

Why?

The work for content-only has surfaced this as a priority, see #73374 (comment)

How?

  • Create a object field type.
  • Create a object dataform control.

Testing Instructions

  • Open the local storybook (npm install && npm run storybook:dev).
  • Visit to "DataViews > Field Types > Object".

There are two objects in the story, link and address fields:

{
		id: 'link',
		type: 'object',
		label: 'Link',
		description: 'Object field with URL, rel, and target properties.',
		properties: {
			url: { id: 'url', type: 'url', label: 'URL' },
			rel: {
				id: 'rel',
				type: 'text',
				label: 'Rel attribute',
				elements: [
					// https://html.spec.whatwg.org/multipage/links.html#linkTypes
				],
			},
			target: {
				id: 'target',
				type: 'text',
				label: 'Target',
				elements: [
					// https://html.spec.whatwg.org/multipage/document-sequences.html#navigable-target-names
				],
			},
		},
		render: ( { item } ) => {
			const {
				link: { url, rel, target },
			} = item;
			return (
				<a href={ url } rel={ rel } target={ target }>
					{ url }
				</a>
			);
		},
	},
        {
		id: 'address',
		type: 'object',
		label: 'Address',
		description: 'Object field with street, city, and country properties.',
		properties: {
			street: { id: 'street', type: 'text', label: 'Street' },
			city: { id: 'city', type: 'text', label: 'City' },
			country: { id: 'country', type: 'text', label: 'Country' },
		},
	},
Screen.Recording.2026-01-07.at.19.42.46.mov

TODO

  • Validate the approach with some content-only attributes.
  • Validation story: update to reflect object types.

@oandregal oandregal self-assigned this Jan 7, 2026
@github-actions github-actions bot added the [Package] DataViews /packages/dataviews label Jan 7, 2026
@github-actions
Copy link

github-actions bot commented Jan 7, 2026

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: oandregal <oandregal@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@oandregal oandregal added [Type] Feature New feature to highlight in changelogs. [Feature] DataViews Work surrounding upgrading and evolving views in the site editor and beyond and removed [Package] DataViews /packages/dataviews labels Jan 7, 2026
@github-actions github-actions bot added the [Package] DataViews /packages/dataviews label Jan 7, 2026
@oandregal
Copy link
Member Author

@talldan @andrewserong what would be a good attribute that represents an object type? Is there any?

I looked at the current status for auto-generating forms from content-only blocks, specifically this link example. But then I realized link is not a block attribute, but rather a "virtual thing" that groups attributes. The DataForm way to do that is declaring form fields instead (see panel layout example, but all work the same):

Screen.Recording.2026-01-07.at.20.35.49.mov
With the following diff:
diff --git a/packages/block-library/src/image/index.js b/packages/block-library/src/image/index.js
index fc65703a23c..be43ffeac5f 100644
--- a/packages/block-library/src/image/index.js
+++ b/packages/block-library/src/image/index.js
@@ -84,15 +84,14 @@ if ( window.__experimentalContentOnlyInspectorFields ) {
                        },
                },
                {
-                       id: 'link',
-                       label: __( 'Link' ),
-                       type: 'link',
-                       mapping: {
-                               url: 'href',
-                               rel: 'rel',
-                               linkTarget: 'linkTarget',
-                               destination: 'linkDestination',
-                       },
+                       id: 'url',
+                       label: __( 'URL' ),
+                       type: 'url',
+               },
+               {
+                       id: 'linkTarget',
+                       label: __( 'Link target' ),
+                       type: 'boolean',
                },
                {
                        id: 'caption',
@@ -106,7 +105,15 @@ if ( window.__experimentalContentOnlyInspectorFields ) {
                },
        ];
        settings[ formKey ] = {
-               fields: [ 'image' ],
+               fields: [
+                       'image',
+                       {
+                               id: 'link',
+                               label: __( 'Link' ),
+                               layout: { type: 'panel', labelPosition: 'top' },
+                               children: [ 'url', 'linkTarget' ],
+                       },
+               ],
        };
 }

I've got this UI:

Screen.Recording.2026-01-07.at.21.35.33.mov

Would you be able to look at that direction to remove mapping from content-only?

@oandregal oandregal added the [Status] In Progress Tracking issues with work in progress label Jan 7, 2026
@oandregal oandregal marked this pull request as draft January 7, 2026 20:43
@talldan
Copy link
Contributor

talldan commented Jan 8, 2026

The DataForm way to do that is declaring form fields instead (see panel layout example, but all work the same):

Interesting point, thanks for mentioning that!

@talldan @andrewserong what would be a good attribute that represents an object type? Is there any?

Media is the best example. For example, when adding a media library image to an image block it'll usually set id, src and caption in one action (not via separate controls).

As part of this we also need to look at aligning the media field used for blocks with the one @ntsekouras has been working on (#74336).

@talldan
Copy link
Contributor

talldan commented Jan 8, 2026

@oandregal I've attempted to use the object type for block fields in this PR - Block fields: Add object type support for media and link controls.

It does work, but there's still some mapping required from the field keys to the block attribute keys. It'd be interesting to know if it's possible to remove the need for any mapping.

Block attributes also don't tend to be in a nested structure like this:

{ image: { id, url, caption } }

They're instead all at the root:

{ id, url, caption }

So in setValues/getValues the values have to be flattened.

@youknowriad
Copy link
Contributor

This PR makes me wonder why "properties" is not named "fields". I guess to stay close to JSON Schema. This is good I think. But I wonder we should rename the "fields" prop of DataViews and DataForm to "schema". It feels like a better name and maybe offer a deprecation for some time. Just food for thoughts.

@oandregal oandregal force-pushed the add/field-type-object branch from 122b8ab to 3742e56 Compare January 8, 2026 20:18
@oandregal
Copy link
Member Author

At 3742e56 I've pushed an additional field type: group. Like the object field type, it admits properties. Unlike the object field type it works with flat data.

{
		id: 'imageGroup',
		type: 'group',
		label: 'Image',
		description: 'Group field with flat data (not nested).',
		properties: {
			imageId: { id: 'imageId', type: 'text', label: 'Image ID' },
			imageUrl: { id: 'imageUrl', type: 'url', label: 'URL' },
			imageCaption: { id: 'imageCaption', type: 'text', label: 'Caption' },
		},
	},

I'd like to think more about this but wanted to push anyway for you to play with, so timezones work to our favor.

What I like about having both is that it's easy to reason about object and group field types:

  • object represents nested fields and mimics JSON Schema (comparison).
  • group represents a group of fields and mimics ACF (group field).

object and group are very similar, but that's something that can be said as well of text, email, password, and url. Code-wise, the implementation can be improved, but, my main interest right now is to surface if this is enough API-wise, or we're missing anything. @talldan @andrewserong Would you give it a try with content-only and share your feedback?

@talldan
Copy link
Contributor

talldan commented Jan 9, 2026

Thanks for making that change @oandregal.

I think it works. Is the idea that the group Edit component shouldn't use setValue / getValue internally, or is that something you're looking to tidy up?

I think it'd be great if we can find a way to support something like setValue / getValue to be able to support different data structures from a single control.

So for example, the image block stores media like this - { id: 25, url: 'picture.jpg' }, but media-text stores it like this { mediaId: 25, mediaUrl: 'picture.jpg' }.

The Edit component will always receive the raw inconsistent data, it'd be great to be able to something like this to remove the inconsistencies of the external data:

const { id, url } = field.getValue( { item: { mediaId: 25, mediaUrl: 'picture.jpg' } } );
field.setValue( { item: data, value: { id: 26, url: 'test.jpg' } } ); // { mediaId: 26, mediaUrl: 'test.jpg' }

I thought it may be possible to achieve it with a field definition like this (the property key is the internal key the Edit component uses and the id is the key that exists in the data):

{
  id: 'media',
  type: 'group', // could also be 'object',
  properties: {
    id: { id: 'mediaId', type: 'number', label: __( 'Id' ) },
    url: { id: 'mediaUrl', type: 'url', label: __( 'Url' ) }
  }
}

Something else that comes to mind is whether property ids should have parity with regular field ids, and support the dot notation. That might be useful with a group, as it'd allow grouping values that are not colocated into a single field:

{
  id: 'media',
  type: 'group',
  properties: {
    id: { id: 'deeply.nested.media.id', type: 'number', label: __( 'Id' ) },
    url: { id: 'shallowUrl', type: 'url', label: __( 'Url' ) }
  }
}

However, one outcome of this is that it means group can completely implement object:

{
  id: 'media',
  type: 'group',
  properties: {
    id: { id: 'media.id', type: 'number', label: __( 'Id' ) },
    url: { id: 'media.url', type: 'url', label: __( 'Url' ) }
  }
}

So perhaps that hints at not needing two separate types for object and group.

@ntsekouras
Copy link
Contributor

Media is the best example. For example, when adding a media library image to an image block it'll usually set id, src and caption in one action (not via separate controls).

I guess this could be achieved by the field's setValue.

As part of this we also need to look at aligning the media field used for blocks with the one @ntsekouras has been working on (#74336).

@jameskoster right now the designs for MediaEdit have no concept of handling an external image. Has this been considered?

@youknowriad
Copy link
Contributor

I don't like the "group" type personally.

The schema/fields should be about defining the shape of the data (not defining something for the form to render). Which means if a property is top level, it shouldn't use the "object" type (or group), it should just be defined as a "top" level field.

The "group" field breaks that, and IMO this is a concern of the DataForm form property and not something the schema/fields needs to be concerned about.

@jameskoster
Copy link
Contributor

right now the designs for MediaEdit have no concept of handling an external image. Has this been considered?

It has not. Do you think it should be part of the same control? My initial reaction is that selecting from (and uploading to) the Media Library feels like a separate consideration to inserting an external image.

@ntsekouras
Copy link
Contributor

Do you think it should be part of the same control? My initial reaction is that selecting from (and uploading to) the Media Library feels like a separate consideration to inserting an external image.

Not sure yet. I agree that it feels like a separate consideration. I'll see to explore though in that area.

@talldan
Copy link
Contributor

talldan commented Jan 12, 2026

The schema/fields should be about defining the shape of the data

I don't really see it as all that different to the way the id already supports dot notation for accessing properties. In that fields already supports data that's a different structure to the field structure itself. Maybe the critique also applies to that.

edit: Also just to mention I'm happy enough to go with just object support for now. That alone would allow me to improve quite a lot of the BlockFields code, and I can work around any issues by using setValues/getValues.

Do you think it should be part of the same control? My initial reaction is that selecting from (and uploading to) the Media Library feels like a separate consideration to inserting an external image.

If it's two separate controls how might it work on a block like image? In just about every existing case for blocks, media controls are presented as a single control:
Screenshot 2026-01-12 at 10 52 07 am

I think even for the placeholder, the placeholder itself represents the item that will be replaced and it has subcontrols for how a user replaces that single item.

@youknowriad
Copy link
Contributor

I don't really see it as all that different to the way the id already supports dot notation for accessing properties. In that fields already supports data that's a different structure to the field structure itself. Maybe the critique also applies to that.

Yes it does :)

@oandregal
Copy link
Member Author

I've prepared #74575 to remove mapping and args from the content-only code with the existing DataForm API. We don't need anything new to do that.

I'd like to land that PR and then revisit the conversation on this one. By landing 74575 first, we have more clarity about the problem to solve in this PR.

@mcsf
Copy link
Contributor

mcsf commented Jan 15, 2026

I don't like the "group" type personally.

The schema/fields should be about defining the shape of the data (not defining something for the form to render). Which means if a property is top level, it shouldn't use the "object" type (or group), it should just be defined as a "top" level field.

The "group" field breaks that, and IMO this is a concern of the DataForm form property and not something the schema/fields needs to be concerned about.

I understand wanting to separate things and arguing that it's form's responsibility to recombine data. However, we are not always in full control of our data sources, and it may convenience us greatly to be able to declare the semantics of our data rather than their real "shape".

(A scenario that just occurred to me is the ability to define these fields dynamically depending on the backend version, or depending on the block schema version, etc. The form can be defined more semantically, while the field definitions can absorb imperfections or transitions in the data sources.)

Perhaps what "group" means is "if I had full control — or had no backcompat to deal with — this would have been an 'object' from the start". 🤔

As always, it's a double-edged sword. The downside of "group" is that it could be seen as an indirection. But on the other hand, without it, we risk delegating too much to the form API and muddy its semantics. For example, FormField already has properties layout and children; layout sounds similar to type and Edit, but means something else.


As an aside, it occurred to me while talking to André that the group type could also be named mapping. This would reduce the confusion if we choose to have both object and group mapping.

@youknowriad
Copy link
Contributor

I understand wanting to separate things and arguing that it's form's responsibility to recombine data. However, we are not always in full control of our data sources, and it may convenience us greatly to be able to declare the semantics of our data rather than their real "shape".

In that case, it's the responsibility of the "form" property to render the form in the way we want. The "fields"/"schema" should be just about the shape of the data IMO

@youknowriad
Copy link
Contributor

Perhaps what "group" means is "if I had full control — or had no backcompat to deal with — this would have been an 'object' from the start". 🤔

Exactly, in other words, it's the responsibility of the blocks to migrate to a new version potentially (or accept some temporary migration code in the framework), but this shouldn't force us to break the DataForm and Fields API abstractions.

@youknowriad
Copy link
Contributor

I actually don't mind some kind of "mapping" to exist but that should be in the "form" object.

@oandregal
Copy link
Member Author

In that case, it's the responsibility of the "form" property to render the form in the way we want. The "fields"/"schema" should be just about the shape of the data IMO

Yeah, I also suggested this approach. However, this is not enough to support custom Edit interactions like the ones we already support in the inspector. Form grouping only enables us to either display all controls up-front (regular layout) or behind a modal (panel layout) that we open via a generic button provided by the framework.

This is the link as a regular layout (click to see diff):
diff --git a/packages/block-library/src/image/index.js b/packages/block-library/src/image/index.js
index ec3ab836244..d45baacf393 100644
--- a/packages/block-library/src/image/index.js
+++ b/packages/block-library/src/image/index.js
@@ -90,22 +90,22 @@ if ( window.__experimentalContentOnlyInspectorFields ) {
 				caption: value.caption,
 			} ),
 		},
-		{
-			id: 'link',
-			label: __( 'Link' ),
-			type: 'url',
-			Edit: 'link', // TODO: replace with custom component
-			getValue: ( { item } ) => ( {
-				url: item.href,
-				rel: item.rel,
-				linkTarget: item.linkTarget,
-			} ),
-			setValue: ( { value } ) => ( {
-				href: value.url,
-				rel: value.rel,
-				linkTarget: value.linkTarget,
-			} ),
-		},
+		// {
+		// 	id: 'link',
+		// 	label: __( 'Link' ),
+		// 	type: 'url',
+		// 	Edit: 'link', // TODO: replace with custom component
+		// 	getValue: ( { item } ) => ( {
+		// 		url: item.href,
+		// 		rel: item.rel,
+		// 		linkTarget: item.linkTarget,
+		// 	} ),
+		// 	setValue: ( { value } ) => ( {
+		// 		href: value.url,
+		// 		rel: value.rel,
+		// 		linkTarget: value.linkTarget,
+		// 	} ),
+		// },
 		{
 			id: 'caption',
 			label: __( 'Caption' ),
@@ -117,9 +117,24 @@ if ( window.__experimentalContentOnlyInspectorFields ) {
 			label: __( 'Alt text' ),
 			type: 'text',
 		},
+		{
+			id: 'href',
+			label: __( 'Link' ),
+			type: 'url',
+		},
+		{
+			id: 'linkTarget',
+			label: __( 'Open in new tab' ),
+			type: 'boolean',
+		},
 	];
 	settings[ formKey ] = {
-		fields: [ 'image', 'link', 'caption', 'alt' ],
+		fields: [
+			'image',
+			{ id: 'link', children: [ 'href', 'linkTarget' ] },
+			'caption',
+			'alt',
+		],
 	};
 }
Screen.Recording.2026-01-15.at.18.22.57.mov
This is the link as a panel layout (click to see diff):
diff --git a/packages/block-library/src/image/index.js b/packages/block-library/src/image/index.js
index ec3ab836244..1b2fcdd9785 100644
--- a/packages/block-library/src/image/index.js
+++ b/packages/block-library/src/image/index.js
@@ -90,22 +90,22 @@ if ( window.__experimentalContentOnlyInspectorFields ) {
 				caption: value.caption,
 			} ),
 		},
-		{
-			id: 'link',
-			label: __( 'Link' ),
-			type: 'url',
-			Edit: 'link', // TODO: replace with custom component
-			getValue: ( { item } ) => ( {
-				url: item.href,
-				rel: item.rel,
-				linkTarget: item.linkTarget,
-			} ),
-			setValue: ( { value } ) => ( {
-				href: value.url,
-				rel: value.rel,
-				linkTarget: value.linkTarget,
-			} ),
-		},
+		// {
+		// 	id: 'link',
+		// 	label: __( 'Link' ),
+		// 	type: 'url',
+		// 	Edit: 'link', // TODO: replace with custom component
+		// 	getValue: ( { item } ) => ( {
+		// 		url: item.href,
+		// 		rel: item.rel,
+		// 		linkTarget: item.linkTarget,
+		// 	} ),
+		// 	setValue: ( { value } ) => ( {
+		// 		href: value.url,
+		// 		rel: value.rel,
+		// 		linkTarget: value.linkTarget,
+		// 	} ),
+		// },
 		{
 			id: 'caption',
 			label: __( 'Caption' ),
@@ -117,9 +117,29 @@ if ( window.__experimentalContentOnlyInspectorFields ) {
 			label: __( 'Alt text' ),
 			type: 'text',
 		},
+		{
+			id: 'href',
+			label: __( 'Link' ),
+			type: 'url',
+		},
+		{
+			id: 'linkTarget',
+			label: __( 'Open in new tab' ),
+			type: 'boolean',
+		},
 	];
 	settings[ formKey ] = {
-		fields: [ 'image', 'link', 'caption', 'alt' ],
+		fields: [
+			'image',
+			{
+				id: 'link',
+				label: 'Link',
+				layout: { type: 'panel', labelPosition: 'top' },
+				children: [ 'href', 'linkTarget' ],
+			},
+			'caption',
+			'alt',
+		],
 	};
 }
Screen.Recording.2026-01-15.at.18.28.00.mov

Now, compare that to the existing interaction for links in the image block:

Screen.Recording.2026-01-15.at.17.55.22.mov
  • It works with three attributes (href, target, rel), but the component only displays two (href, target), rel is automatically filled.
  • The trigger button is a custom Edit (has an icon as prefix, etc.) that can't be replicated with the panel's button.
  • The component uses a progressive disclosure pattern: you enter only the href first, and then you're allowed to edit href and target. It's a very custom interaction.
  • It provides actions (copy, reset).

Or this other example with media for the cover block:

Screen.Recording.2026-01-15.at.17.56.50.mov

Supporting this level of interaction needs a custom Edit. We could make BlockField/ContentOnly work only with the object type if we migrate all the block attributes to be objects, that's true — but it's a lot of work. I'm convinced this same challenge will be faced by other consumers: DataForm will be deployed in many places where the consumer can't (or it's too costly to) change the shape of the data source.

Taking all of this into account, I feel we need to be data-shape agnostic (support both neatly grouped objects and flat structures).

@youknowriad
Copy link
Contributor

Taking all of this into account, I feel we need to be data-shape agnostic (support both neatly grouped objects and flat structures).

Yes, but we already are, the data is defined in "fields" and the agnostic part (adapting the data to the rendering) is the "form". I think we start mixing these concerns, we're going to break the abstractions, it becomes very unclear what should go into "fields" and what should go into "form". (we kind of already started with some of the configs, but we should probably no go far)

Would it be possible to actually update the "form" config (maybe new configs there) to overcome the limitations that you mention?

@oandregal
Copy link
Member Author

I'm exploring an alternative to remove getValue/setValue functions from the blocks at #74900

@ntsekouras
Copy link
Contributor

Isn't the object type something we want in general? Can we scope down this PR and land it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] DataViews Work surrounding upgrading and evolving views in the site editor and beyond [Package] DataViews /packages/dataviews [Status] In Progress Tracking issues with work in progress [Type] Feature New feature to highlight in changelogs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants