Skip to content

Fix: Support setValue in fields to handle nested data updates in DataViews filters and forms #70989

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 81 additions & 69 deletions packages/dataviews/src/components/dataviews-filters/input-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,81 +17,93 @@ import type { View, NormalizedFilter, NormalizedField } from '../../types';
import { getCurrentValue } from './utils';

interface UserInputWidgetProps {
view: View;
filter: NormalizedFilter;
onChangeView: ( view: View ) => void;
fields: NormalizedField< any >[];
view: View;
filter: NormalizedFilter;
onChangeView: ( view: View ) => void;
fields: NormalizedField< any >[];
}

export default function InputWidget( {
filter,
view,
onChangeView,
fields,
filter,
view,
onChangeView,
fields,
}: UserInputWidgetProps ) {
const currentFilter = view.filters?.find(
( f ) => f.field === filter.field
);
const currentFilter = view.filters?.find(
( f ) => f.field === filter.field
);

const field = fields.find( ( f ) => f.id === filter.field );
const currentValue = getCurrentValue( filter, currentFilter );
const data = useMemo( () => {
return ( view.filters ?? [] ).reduce(
( acc, f ) => {
acc[ f.field ] = f.value;
return acc;
},
{} as Record< string, any >
);
}, [ view.filters ] );
const field = fields.find( ( f ) => f.id === filter.field );
const currentValue = getCurrentValue( filter, currentFilter );

const handleChange = useEvent( ( updatedData: Record< string, any > ) => {
if ( ! field || ! currentFilter ) {
return;
}
const nextValue = updatedData[ field.id ];
if ( fastDeepEqual( nextValue, currentValue ) ) {
return;
}
// Build a fake item using setValue if available, otherwise fallback to {[field.id]: value}
const data = useMemo( () => {
let item = {};
for ( const f of view.filters ?? [] ) {
const fieldDef = fields.find( fld => fld.id === f.field );
if (fieldDef && typeof fieldDef.setValue === 'function') {
item = fieldDef.setValue({ item, value: f.value });
} else {
item = { ...item, [f.field]: f.value };
}
}
return item;
}, [ view.filters, fields ] );

onChangeView( {
...view,
filters: ( view.filters ?? [] ).map( ( _filter ) =>
_filter.field === filter.field
? {
..._filter,
operator:
currentFilter.operator || filter.operators[ 0 ],
// Consider empty strings as undefined:
//
// - undefined as value means the filter is unset: the filter widget displays no value and the search returns all records
// - empty string as value means "search empty string": returns only the records that have an empty string as value
//
// In practice, this means the filter will not be able to find an empty string as the value.
value: nextValue === '' ? undefined : nextValue,
}
: _filter
),
} );
} );
const handleChange = useEvent( ( updatedData: Record< string, any > ) => {
if ( !field || !currentFilter ) {
return;
}
// Use setValue to extract the new value for this field
let nextValue;
Copy link
Member

Choose a reason for hiding this comment

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

At this point, all fields are normalized, and so they'll have a proper setValue. Additionally, if we handle this at the controls-level (see comment below), updatedData will be fine already. The only thing we'd need to do is making sure nextValue is calculated from getValue.

if (typeof field.setValue === 'function') {
// Simulate updating only this field in the item
const fakeItem = field.setValue({ item: data, value: updatedData[field.id] });
nextValue = field.getValue({ item: fakeItem });
} else {
nextValue = updatedData[field.id];
}
if ( fastDeepEqual( nextValue, currentValue ) ) {
return;
}
onChangeView( {
...view,
filters: ( view.filters ?? [] ).map( ( _filter ) =>
_filter.field === filter.field
? {
..._filter,
operator:
currentFilter.operator || filter.operators[ 0 ],
// Consider empty strings as undefined:
//
// - undefined as value means the filter is unset: the filter widget displays no value and the search returns all records
// - empty string as value means "search empty string": returns only the records that have an empty string as value
//
// In practice, this means the filter will not be able to find an empty string as the value.
value: nextValue === '' ? undefined : nextValue,
}
: _filter
),
} );
});

if ( ! field || ! field.Edit || ! currentFilter ) {
return null;
}
if ( ! field || ! field.Edit || ! currentFilter ) {
return null;
}

return (
<Flex
className="dataviews-filters__user-input-widget"
gap={ 2.5 }
direction="column"
>
<field.Edit
hideLabelFromVision
data={ data }
field={ field }
operator={ currentFilter.operator }
onChange={ handleChange }
/>
</Flex>
);
}
return (
<Flex
className="dataviews-filters__user-input-widget"
gap={ 2.5 }
direction="column"
>
<field.Edit
hideLabelFromVision
data={ data }
field={ field }
operator={ currentFilter.operator }
onChange={ handleChange }
/>
</Flex>
);
}
18 changes: 15 additions & 3 deletions packages/dataviews/src/dataforms-layouts/panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ export default function FormPanelField< Item >( {
: fieldDefinition?.label;

if ( labelPosition === 'top' ) {
const handleFieldChange = (value: Record<string, any>) => {
const updatedData = fieldDefinition.setValue({ item: data, value: value[fieldDefinition.id] });
onChange(updatedData);
};
return (
<VStack className="dataforms-layouts-panel__field" spacing={ 0 }>
<div
Expand All @@ -233,7 +237,7 @@ export default function FormPanelField< Item >( {
popoverAnchor={ popoverAnchor }
fieldDefinition={ fieldDefinition }
data={ data }
onChange={ onChange }
onChange={ handleFieldChange }
labelPosition={ labelPosition }
/>
</div>
Expand All @@ -242,21 +246,29 @@ export default function FormPanelField< Item >( {
}

if ( labelPosition === 'none' ) {
const handleFieldChange = (value: Record<string, any>) => {
const updatedData = fieldDefinition.setValue({ item: data, value: value[fieldDefinition.id] });
onChange(updatedData);
};
return (
<div className="dataforms-layouts-panel__field">
<PanelDropdown
field={ field }
popoverAnchor={ popoverAnchor }
fieldDefinition={ fieldDefinition }
data={ data }
onChange={ onChange }
onChange={ handleFieldChange }
labelPosition={ labelPosition }
/>
</div>
);
}

// Defaults to label position side.
const handleFieldChange = (value: Record<string, any>) => {
const updatedData = fieldDefinition.setValue({ item: data, value: value[fieldDefinition.id] });
onChange(updatedData);
};
return (
<HStack
ref={ setPopoverAnchor }
Expand All @@ -269,7 +281,7 @@ export default function FormPanelField< Item >( {
popoverAnchor={ popoverAnchor }
fieldDefinition={ fieldDefinition }
data={ data }
onChange={ onChange }
onChange={ handleFieldChange }
labelPosition={ labelPosition }
/>
</div>
Expand Down
13 changes: 11 additions & 2 deletions packages/dataviews/src/dataforms-layouts/regular/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ export default function FormRegularField< Item >( {
return null;
}
if ( labelPosition === 'side' ) {
const handleFieldChange = (value: Record<string, any>) => {
// Use setValue to update the item for this field
const updatedData = fieldDefinition.setValue({ item: data, value: value[fieldDefinition.id] });
onChange(updatedData);
};
return (
<HStack className="dataforms-layouts-regular__field">
<div
Expand All @@ -109,7 +114,7 @@ export default function FormRegularField< Item >( {
key={ fieldDefinition.id }
data={ data }
field={ fieldDefinition }
onChange={ onChange }
onChange={ handleFieldChange }
hideLabelFromVision
/>
) }
Expand All @@ -118,6 +123,10 @@ export default function FormRegularField< Item >( {
);
}

const handleFieldChange = (value: Record<string, any>) => {
const updatedData = fieldDefinition.setValue({ item: data, value: value[fieldDefinition.id] });
onChange(updatedData);
};
return (
<div className="dataforms-layouts-regular__field">
{ fieldDefinition.readOnly === true ? (
Expand All @@ -138,7 +147,7 @@ export default function FormRegularField< Item >( {
<fieldDefinition.Edit
data={ data }
field={ fieldDefinition }
onChange={ onChange }
onChange={ handleFieldChange }
Copy link
Member

@oandregal oandregal Aug 1, 2025

Choose a reason for hiding this comment

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

Instead of handling this at the layout-level, it should be handled at the Edit (controls) level. For example, in this control. Fields can provide a custom Edit function (control) and when they do so it's their responsibility to implement this.

hideLabelFromVision={
labelPosition === 'none' ? true : hideLabelFromVision
}
Expand Down
8 changes: 8 additions & 0 deletions packages/dataviews/src/normalize-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ export function normalizeFields< Item >(
);
const getValue = field.getValue || getValueFromId( field.id );

// Default setValue: shallow merge { ...item, [field.id]: value }
const defaultSetValue = ({ item, value }: { item: Item; value: any }) => ({
...item,
[field.id]: value,
});
const setValue = field.setValue || defaultSetValue;

const sort =
field.sort ??
function sort( a, b, direction ) {
Expand Down Expand Up @@ -166,6 +173,7 @@ export function normalizeFields< Item >(
label: field.label || field.id,
header: field.header || field.label || field.id,
getValue,
setValue,
render,
sort,
isValid,
Expand Down
20 changes: 20 additions & 0 deletions packages/dataviews/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import type { useFocusOnMount } from '@wordpress/compose';

export type SortDirection = 'asc' | 'desc';

/**
Copy link
Member

@oandregal oandregal Aug 1, 2025

Choose a reason for hiding this comment

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

What is this comment for?

* Generic option type.
*/

/**
* Generic option type.
*/
Expand Down Expand Up @@ -108,6 +112,11 @@ export type FieldTypeDefinition< Item > = {
* Callback used to sort the field.
*/
sort: ( a: Item, b: Item, direction: SortDirection ) => number;
/**
* Callback used to set the value of the field in the item.
* Defaults to `{ ...item, [field.id]: value }`.
*/
setValue?: ( args: { item: Item; value: any } ) => Item;
Copy link
Member

Choose a reason for hiding this comment

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

It'd be nice to rename value to newValue (same in all other lines).


/**
* Callback used to validate the field.
Expand Down Expand Up @@ -242,12 +251,23 @@ export type Field< Item > = {
* Defaults to `item[ field.id ]`.
*/
getValue?: ( args: { item: Item } ) => any;

/**
* Callback used to set the value of the field in the item.
* Defaults to `{ ...item, [field.id]: value }`.
*/
setValue?: ( args: { item: Item; value: any } ) => Item;
};

export type NormalizedField< Item > = Omit< Field< Item >, 'Edit' > & {
label: string;
header: string | ReactElement;
getValue: ( args: { item: Item } ) => any;
/**
* Callback used to set the value of the field in the item.
* Defaults to `{ ...item, [field.id]: value }`.
*/
setValue: ( args: { item: Item; value: any } ) => Item;
render: ComponentType< DataViewRenderFieldProps< Item > >;
Edit: ComponentType< DataFormControlProps< Item > > | null;
sort: ( a: Item, b: Item, direction: SortDirection ) => number;
Expand Down
Loading