Skip to content

DataViews: Fix mismatch between local state and nested data in DataForm modal panel#71384

Closed
straku wants to merge 5 commits intoWordPress:trunkfrom
straku:fix-dataform-modal-panel-with-nested-data
Closed

DataViews: Fix mismatch between local state and nested data in DataForm modal panel#71384
straku wants to merge 5 commits intoWordPress:trunkfrom
straku:fix-dataform-modal-panel-with-nested-data

Conversation

@straku
Copy link
Contributor

@straku straku commented Aug 28, 2025

What?

openAs with the dropdown and modal options was introduced in #71212. Unfortunately, we've discovered a problem with this functionality when used with nested data and getValue. The local state within the modal that holds "pending" changes (they get propagated through the onChange prop only when you click the "Apply" button in the modal) was storing the flattened data structure for each field (the one you get when getValue is called with the data object) instead of the nested one that DataForm expects.

Why?

Let's look at an example with pseudocode:

// DataForm setup
const data = { { user: { name: 'John Doe' } } };
const fields = [{
  id: 'user/name',
  type: 'text',
  getValue: ({ item }) => item.user.name;
}];

// setup local state within modal
const localChangesTrackedByModal = {};

const dataToDisplay = { ...data, ...localChangesTrackedByModal } // { user: { name: 'John Doe' } }

render(<DataForm
  data={dataToDisplay} 
  fields={fields}
  form={{
    layout: {
      type: 'panel',
      openAs: 'modal',
    },
    ...
  }}
/>);

// "user/name" field clicked and edited through the modal

console.log(localChangesTrackedByModal); // { 'user/name': 'John Doe edited' }
console.log(dataToDisplay); // { user: { name: 'John Doe' }, 'user/name': 'John Doe edited' }

As you can see above, dataToDisplay is a mix of nested and shallow data. DataForm displays the one that's still not edited.

How?

I've tried two approaches to fix it:

  • Flatten the data structure before passing it to DataForm (this PR implements it)
  • Provide a setValue function in the field API that will be required alongside getValue specifically for modals. It works, and implementation is cleaner, but:
    • We're changing the API to fix something internal within the implementation, there's nothing for the users to gain from this addition (unless we decide to use setValue to process what we report back in onChange too)
    • It's needed only for panel layout that opens in modal, we can model this requirement in types, but we would also need to have some runtime warnings. Also, what to do if it's not provided? Fallback to dropdown, runtime error, etc. ?

Testing Instructions

I've added unit tests, but I didn't add a specific story within Storybook for this case, as it wouldn't add value apart from the possibility to test it easier here. You can add it locally for testing purposes only.

  1. Open packages/dataviews/src/components/dataform/stories/index.story.tsx in your editor
  2. Paste the story implementation attached below this steps
  3. Run storybook (npm run storybook:dev)
  4. Open http://localhost:50240/?path=/story/dataviews-dataform--nested-data
  5. Click on each of the fields and verify that the modal is shown for each
  6. Do some edits within the modal inputs, but instead of clicking "Apply" in the modal, close it with "Cancel" or click outside
  7. Verify that the edits were not applied
  8. Repeat edits, but this time around click "Apply" in the modal
  9. Verify that the edits were applied
Story to copy & paste
const NestedDataComponent = () => {
	type NestedDataItem = {
		user: {
			profile: {
				name: string;
				email: string;
			};
			preferences: {
				theme: string;
				notifications: boolean;
			};
		};
		metadata: {
			createdAt: string;
			tags: string[];
		};
	};

	const [ data, setData ] = useState< NestedDataItem >( {
		user: {
			profile: {
				name: 'John Doe',
				email: 'john@example.com',
			},
			preferences: {
				theme: 'dark',
				notifications: true,
			},
		},
		metadata: {
			createdAt: '2023-01-01T12:00:00',
			tags: [ 'admin', 'vip' ],
		},
	} );

	const nestedFields: Field< NestedDataItem >[] = [
		{
			id: 'userName',
			label: 'User Name',
			type: 'text',
			getValue: ( { item } ) => item.user.profile.name,
		},
		{
			id: 'userEmail',
			label: 'User Email',
			type: 'email',
			getValue: ( { item } ) => item.user.profile.email,
		},
		{
			id: 'userTheme',
			label: 'Theme Preference',
			type: 'text',
			Edit: 'toggleGroup',
			elements: [
				{ value: 'light', label: 'Light' },
				{ value: 'dark', label: 'Dark' },
				{ value: 'auto', label: 'Auto' },
			],
			getValue: ( { item } ) => item.user.preferences.theme,
		},
		{
			id: 'notifications',
			label: 'Enable Notifications',
			type: 'boolean',
			getValue: ( { item } ) => item.user.preferences.notifications,
		},
		{
			id: 'createdAt',
			label: 'Created At',
			type: 'datetime',
			getValue: ( { item } ) => item.metadata.createdAt,
		},
		{
			id: 'tags',
			label: 'Tags',
			type: 'array',
			elements: [
				{ value: 'admin', label: 'Administrator' },
				{ value: 'user', label: 'Regular User' },
				{ value: 'vip', label: 'VIP' },
				{ value: 'guest', label: 'Guest' },
			],
			getValue: ( { item } ) => item.metadata.tags,
		},
	];

	const form: Form = {
		layout: { type: 'panel', labelPosition: 'top', openAs: 'modal' },
		fields: [
			{
				id: 'userProfile',
				label: 'User Profile',
				children: [ 'userName', 'userEmail' ],
			},
			{
				id: 'userPreferences',
				label: 'Preferences',
				children: [ 'userTheme', 'notifications' ],
			},
			{
				id: 'metadata',
				label: 'Metadata',
				children: [ 'createdAt', 'tags' ],
			},
		],
	};

	const handleChange = useCallback( ( edits: Record< string, any > ) => {
		setData( ( prev ) => {
			const newData = { ...prev };

			// Handle nested updates based on field mappings
			if ( 'userName' in edits ) {
				newData.user = {
					...newData.user,
					profile: { ...newData.user.profile, name: edits.userName },
				};
			}
			if ( 'userEmail' in edits ) {
				newData.user = {
					...newData.user,
					profile: {
						...newData.user.profile,
						email: edits.userEmail,
					},
				};
			}
			if ( 'userTheme' in edits ) {
				newData.user = {
					...newData.user,
					preferences: {
						...newData.user.preferences,
						theme: edits.userTheme,
					},
				};
			}
			if ( 'notifications' in edits ) {
				newData.user = {
					...newData.user,
					preferences: {
						...newData.user.preferences,
						notifications: edits.notifications,
					},
				};
			}
			if ( 'createdAt' in edits ) {
				newData.metadata = {
					...newData.metadata,
					createdAt: edits.createdAt,
				};
			}
			if ( 'tags' in edits ) {
				newData.metadata = { ...newData.metadata, tags: edits.tags };
			}

			return newData;
		} );
	}, [] );

	return (
		<DataForm< NestedDataItem >
			data={ data }
			fields={ nestedFields }
			form={ form }
			onChange={ handleChange }
		/>
	);
};

export const NestedData = {
	render: NestedDataComponent,
};

Testing Instructions for Keyboard

Screenshots or screencast

The example shown in the screencast is using nested data with getValue for each of its fields.

CleanShot.2025-08-28.at.11.53.30.mp4

@straku straku requested a review from oandregal as a code owner August 28, 2025 12:21
@github-actions
Copy link

Warning: Type of PR label mismatch

To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.

  • Type-related labels to choose from: [Type] Automated Testing, [Type] Breaking Change, [Type] Bug, [Type] Build Tooling, [Type] Code Quality, [Type] Copy, [Type] Developer Documentation, [Type] Enhancement, [Type] Experimental, [Type] Feature, [Type] New API, [Type] Task, [Type] Technical Prototype, [Type] Performance, [Type] Project Management, [Type] Regression, [Type] Security, [Type] WP Core Ticket, Backport from WordPress Core, Gutenberg Plugin, New Block.
  • Labels found: .

Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task.

@github-actions
Copy link

github-actions bot commented Aug 28, 2025

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: straku <lstraczynski@git.wordpress.org>
Co-authored-by: opr <opr18@git.wordpress.org>
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.

@github-actions
Copy link

👋 Thanks for your first Pull Request and for helping build the future of Gutenberg and WordPress, @straku! In case you missed it, we'd love to have you join us in our Slack community.

If you want to learn more about WordPress development in general, check out the Core Handbook full of helpful information.

@github-actions github-actions bot added the First-time Contributor Pull request opened by a first-time contributor to Gutenberg repository label Aug 28, 2025
@straku
Copy link
Contributor Author

straku commented Aug 28, 2025

Looking at tests now, they are passing locally for me 👀

EDIT: I didn't rebase with trunk, all fixed now.

@straku straku force-pushed the fix-dataform-modal-panel-with-nested-data branch from ac1cfad to 22072c5 Compare August 28, 2025 13:45
Copy link
Contributor

@opr opr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like having to flatten data is not ideal, but the other alternative of implementing asetValue does seem worse in comparison, putting additional friction on the user. Thanks for investigating them both.

I did leave one minor comment about type checking, but from my end I think this approach is fine and keeps the implementation details away from the user.

const { flattenedData, modifiedFields } = useMemo( () => {
// It will be a mix of original structure for fields without getValue,
// and a flattened object in shape of { [fieldId]: fieldValue }
const newFlattenedData: { [ key: string ]: any } = {};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any is fine here because getValue ultimately returns any?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, since getValue return value is typed as any I used it here too.

const newFlattenedData: { [ key: string ]: any } = {};

const newModifiedFields = fields.map( ( field ) => {
if ( field.getValue ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this check be tighter and check if it's a function too?

Suggested change
if ( field.getValue ) {
if ( field.getValue && typeof field.getValue === 'function' ) {

Copy link
Contributor Author

@straku straku Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would go in the opposite direction—I am not sure if we should gate it at all. In the fields normalization process, this function is always provided (even if not used by customers); see:

const getValue = field.getValue || getValueFromId( field.id );

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed that in f4957d2

@oandregal
Copy link
Member

oandregal commented Sep 2, 2025

Hi @straku thanks for taking the time, this is great work.

Provide a setValue function in the field API that will be required alongside getValue

There's another use case where nested data is problematic, see #70957 We need to fix this, it's worth looking into implementing setValue instead.

As I see it:

  • The field can define a setValue, but it doesn't need to. When normalizing the field, we use that function if present; otherwise, we create one that just returns item.id. In other words, getValue is optional in the Field type, but it's mandatory in the NormalizedField type.
  • We update the controls to use field.setValue in the onChange handlers, instead of updating directly item.id.
  • There is some existing logic that handles nested id for getValue. We need to consider this as well for setValue. The following two cases should be equivalent:
{
  id: 'name',
  getValue: ( { item } ) => item.user.name,
  setValue: ( { item, edits } ) => { /* handle setting the name in item.user.name */ }
}

{
  id: 'user.name'
  // Field doesn't need to provide getValue / setValue functions
  // that work with the nested value. We create them for the field in normalizeFields.
}

How does it sound for you?

@straku
Copy link
Contributor Author

straku commented Sep 3, 2025

@oandregal Sounds great! I will follow up with another PR in the next few days.

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

Labels

First-time Contributor Pull request opened by a first-time contributor to Gutenberg repository

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants