diff --git a/package-lock.json b/package-lock.json index e4acb2114bb3f1..4af8ec42ac437d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21921,6 +21921,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -50406,6 +50407,7 @@ "clsx": "^2.1.1", "colord": "^2.7.0", "date-fns": "^4.1.0", + "deepmerge": "4.3.1", "fast-deep-equal": "^3.1.3", "remove-accents": "^0.5.0" }, diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 14dbd6cb635a35..ecd4a919beb5ad 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -9,6 +9,7 @@ ### Features +- Field API: introduce `setValue` to fix DataViews filters and DataForm panel layout (modal) when working with nested data. [#71604](https://github.com/WordPress/gutenberg/pull/71604) - Introduce a new `DataViewsPicker` component. [#70971](https://github.com/WordPress/gutenberg/pull/70971) - Dataform: Add new `telephone` field type and field control. [#71498](https://github.com/WordPress/gutenberg/pull/71498) - DataForm: introduce a new `row` layout, check the README for details. [#71124](https://github.com/WordPress/gutenberg/pull/71124) diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md index f371d5d60b6ab9..77cda7a7f05b45 100644 --- a/packages/dataviews/README.md +++ b/packages/dataviews/README.md @@ -989,24 +989,119 @@ Example: } ``` -### `getValue` +### `getValue` and `setValue` -React component that returns the value of a field. This value is used to sort or filter the fields. +These functions control how field values are read from and written to your data structure. -- Type: React component. +Both functions are optional and automatically generated from the field's `id` when not provided. The `id` is treated as a dot-notation path (e.g., `"user.profile.name"` accesses `item.user.profile.name`). + +#### `getValue` + +Function that extracts the field value from an item. This value is used to sort, filter, and display the field. + +- Type: `function`. - Optional. -- Defaults to `item[ id ]`. -- Props: - - `item` value to be processed. -- Returns a value that represents the field. +- Args: + - `item`: the data item to be processed. +- Returns the field's value. -Example: +#### `setValue` + +Function that creates a partial item object with updated field values. This is used by DataForm for editing operations and determines the structure of data passed to the `onChange` callback. + +- Type: `function`. +- Optional. +- Args: + - `item`: the current item being edited. + - `value`: the new value to be set for the field. +- Returns a partial item object with the changes to be applied. + +#### Simple field access + +For basic field access, you only need to specify the field `id`. Both `getValue` and `setValue` are automatically generated: ```js +// Data structure +const item = { + title: 'Hello World', + author: 'John Doe' +}; + +// Field definition { - getValue: ( { item } ) => { - /* The field's value. */ - }; + id: 'title', + label: 'Title' + // getValue: automatically becomes ( { item } ) => item.title + // setValue: automatically becomes ( { value } ) => ( { title: value } ) +} +``` + +#### Nested data access + +Use dot notation in the field `id` to access nested properties: + +```js +// Data structure +const item = { + user: { + profile: { + name: 'John Doe', + email: 'john@example.com' + } + } +}; + +// Field definition - using dot notation (automatic) +{ + id: 'user.profile.name', + label: 'User Name' + // getValue: automatically becomes ( { item } ) => item.user.profile.name + // setValue: automatically becomes ( { value } ) => ( { user: { profile: { name: value } } } ) +} + +// Alternative - using simple ID with custom functions +{ + id: 'userName', + label: 'User Name', + getValue: ( { item } ) => item.user.profile.name, + setValue: ( { value } ) => ( { + user: { + profile: { name: value } + } + } ) +} +``` + +#### Custom data transformation + +Provide custom `getValue` and `setValue` functions when you need to transform data between the storage format and display format: + +```js +// Data structure +const item = { + user: { + preferences: { + notifications: true + } + } +}; + +// Field definition - transform boolean to string options +{ + id: 'notifications', + label: 'Notifications', + Edit: 'radio', + elements: [ + { label: 'Enabled', value: 'enabled' }, + { label: 'Disabled', value: 'disabled' } + ], + getValue: ( { item } ) => + item.user.preferences.notifications === true ? 'enabled' : 'disabled', + setValue: ( { value } ) => ( { + user: { + preferences: { notifications: value === 'enabled' } + } + } ) } ``` diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index 0765c09ff26cf4..2bd132569701fb 100644 --- a/packages/dataviews/package.json +++ b/packages/dataviews/package.json @@ -62,6 +62,7 @@ "clsx": "^2.1.1", "colord": "^2.7.0", "date-fns": "^4.1.0", + "deepmerge": "4.3.1", "fast-deep-equal": "^3.1.3", "remove-accents": "^0.5.0" }, diff --git a/packages/dataviews/src/components/dataform/stories/index.story.tsx b/packages/dataviews/src/components/dataform/stories/index.story.tsx index 4f687ed10bb984..755a0d0990ce48 100644 --- a/packages/dataviews/src/components/dataform/stories/index.story.tsx +++ b/packages/dataviews/src/components/dataform/stories/index.story.tsx @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import deepMerge from 'deepmerge'; + /** * WordPress dependencies */ @@ -87,6 +92,9 @@ const fields: Field< SamplePost >[] = [ { value: 3, label: 'Alice' }, { value: 4, label: 'Bob' }, ], + setValue: ( { value } ) => ( { + author: Number( value ), + } ), }, { id: 'reviewer', @@ -472,15 +480,13 @@ function CustomEditControl< Item >( { onChange, hideLabelFromVision, }: DataFormControlProps< Item > ) { - const { id, label, placeholder, description } = field; - const value = field.getValue( { item: data } ); + const { label, placeholder, description, getValue, setValue } = field; + const value = getValue( { item: data } ); const onChangeControl = useCallback( ( newValue: string ) => - onChange( { - [ id ]: newValue, - } ), - [ id, onChange ] + onChange( setValue( { item: data, value: newValue } ) ), + [ data, onChange, setValue ] ); return ( @@ -546,6 +552,7 @@ const ValidationComponent = ( { return null; }; + const customSelectRule = ( value: ValidatedItem ) => { if ( value.select !== 'option1' ) { return 'Value must be Option 1.'; @@ -553,6 +560,7 @@ const ValidationComponent = ( { return null; }; + const customTextareaRule = ( value: ValidatedItem ) => { if ( ! /^[a-zA-Z ]+$/.test( value.textarea ) ) { return 'Value must only contain letters and spaces.'; @@ -1450,3 +1458,211 @@ export const Validation = { export const Visibility = { render: VisibilityComponent, }; + +const DataAdapterComponent = () => { + type DataAdapterItem = { + user: { + profile: { + name: string; + email: string; + }; + preferences: { + notifications: boolean; + }; + }; + revenue: { + total: number; + units: number; + pricePerUnit: number; + }; + }; + + const [ data, setData ] = useState< DataAdapterItem >( { + user: { + profile: { + name: 'John Doe', + email: 'john@example.com', + }, + preferences: { + notifications: true, + }, + }, + revenue: { + total: 30, + units: 10, + pricePerUnit: 3, + }, + } ); + + const nestedFields: Field< DataAdapterItem >[] = [ + // Examples of autogenerated getValue/setValue methods + // for nested data based on the field id. + { + id: 'user.profile.name', + label: 'User Name', + type: 'text', + }, + { + id: 'user.profile.email', + label: 'User Email', + type: 'email', + }, + // Example of adapting a data value to a control value + // by providing getValue/setValue methods. + { + id: 'user.preferences.notifications', + label: 'Notifications', + type: 'boolean', + Edit: 'radio', + elements: [ + { label: 'Enabled', value: 'enabled' }, + { label: 'Disabled', value: 'disabled' }, + ], + getValue: ( { item } ) => + item.user.preferences.notifications === true + ? 'enabled' + : 'disabled', + setValue: ( { value } ) => ( { + user: { + preferences: { notifications: value === 'enabled' }, + }, + } ), + }, + // Example of deriving data by leveraging setValue method. + { + id: 'revenue.total', + label: 'Total Revenue', + type: 'integer', + readOnly: true, + }, + { + id: 'revenue.pricePerUnit', + label: 'Price Per Unit', + type: 'integer', + setValue: ( { item, value } ) => ( { + revenue: { + total: value * item.revenue.units, + pricePerUnit: value, + }, + } ), + }, + { + id: 'revenue.units', + label: 'Units', + type: 'integer', + setValue: ( { item, value } ) => ( { + revenue: { + total: item.revenue.pricePerUnit * value, + units: value, + }, + } ), + }, + ]; + + const handleChange = useCallback( ( edits: any ) => { + // Edits will respect the shape of the data + // because fields provide the proper information + // (via field.id or via field.setValue). + setData( ( prev ) => deepMerge( prev, edits ) ); + }, [] ); + + return ( + <> +

Data adapter

+

+ This story is best looked at with the code on the side. It aims + to highlight how DataForm can wrangle data in scenarios such as + nested data, bridge data to/from UI controls, and derived data. +

+

+ Current data snapshot: +

+
{ JSON.stringify( data, null, 2 ) }
+

Nested data

+

+ The first example demonstrates how to signal nested data via{ ' ' } + field.id. +

+

+ By using { `{ id: 'user.profile.name' }` } as field + id, when users edit the name, the edits will come in this shape: + { `{ user: { profile: { name: 'John Doe' } } }` } +

+ + data={ data } + fields={ nestedFields } + form={ { + layout: { + type: 'panel', + labelPosition: 'top', + openAs: 'modal', + }, + fields: [ + { + id: 'userProfile', + label: 'User Profile', + children: [ + 'user.profile.name', + 'user.profile.email', + ], + }, + ], + } } + onChange={ handleChange } + /> +

Adapt data and UI control

+

+ Sometimes, we need to adapt the data type to and from the UI + control response. This example demonstrates how to adapt a + boolean to a text string (Enabled/Disabled). +

+ + data={ data } + fields={ nestedFields } + form={ { + layout: { + type: 'panel', + labelPosition: 'top', + openAs: 'modal', + }, + fields: [ 'user.preferences.notifications' ], + } } + onChange={ handleChange } + /> +

Derived data

+

+ Last, but not least, this example showcases how to work with + derived data by providing a custom setValue function. Note how, + changing UNITS or PRICE PER UNIT, updates the TOTAL value as + well. +

+ + data={ data } + fields={ nestedFields } + form={ { + layout: { + type: 'panel', + labelPosition: 'top', + openAs: 'modal', + }, + fields: [ + { + id: 'revenue', + label: 'Revenue', + children: [ + 'revenue.pricePerUnit', + 'revenue.units', + 'revenue.total', + ], + }, + ], + } } + onChange={ handleChange } + /> + + ); +}; + +export const DataAdapter = { + render: DataAdapterComponent, +}; diff --git a/packages/dataviews/src/components/dataviews-filters/input-widget.tsx b/packages/dataviews/src/components/dataviews-filters/input-widget.tsx index b48646135d7589..5d5aed07085599 100644 --- a/packages/dataviews/src/components/dataviews-filters/input-widget.tsx +++ b/packages/dataviews/src/components/dataviews-filters/input-widget.tsx @@ -32,13 +32,52 @@ export default function InputWidget( { const currentFilter = view.filters?.find( ( f ) => f.field === filter.field ); - - const field = fields.find( ( f ) => f.id === filter.field ); const currentValue = getCurrentValue( filter, currentFilter ); + + /* + * We are reusing the field.Edit component for filters. By doing so, + * we get for free a filter control specific to the field type + * and other aspects of the field API (Edit control configuration, etc.). + * + * This approach comes with an issue: the field.Edit controls work with getValue + * and setValue methods, which take an item (Item) as parameter. But, at this point, + * we don't have an item and we don't know how to create one, either. + * + * So, what we do is to prepare the data and the relevant field configuration + * as if Item was a plain object whose keys are the field ids: + * + * { + * [ fieldOne.id ]: value, + * [ fieldTwo.id ]: value, + * } + * + */ + const field = useMemo( () => { + const currentField = fields.find( ( f ) => f.id === filter.field ); + if ( currentField ) { + return { + ...currentField, + // Deactivate validation for filters. + isValid: { + required: false, + custom: () => null, + }, + // Configure getValue/setValue as if Item was a plain object. + getValue: ( { item }: { item: any } ) => + item[ currentField.id ], + setValue: ( { value }: { value: any } ) => ( { + [ currentField.id ]: value, + } ), + }; + } + return currentField; + }, [ fields, filter.field ] ); + const data = useMemo( () => { return ( view.filters ?? [] ).reduce( - ( acc, f ) => { - acc[ f.field ] = f.value; + ( acc, activeFilter ) => { + // We can now assume the field is stored as a Item prop. + acc[ activeFilter.field ] = activeFilter.value; return acc; }, {} as Record< string, any > @@ -49,7 +88,7 @@ export default function InputWidget( { if ( ! field || ! currentFilter ) { return; } - const nextValue = updatedData[ field.id ]; + const nextValue = field.getValue( { item: updatedData } ); if ( fastDeepEqual( nextValue, currentValue ) ) { return; } diff --git a/packages/dataviews/src/components/dataviews-picker/stories/index.story.tsx b/packages/dataviews/src/components/dataviews-picker/stories/index.story.tsx index cc399383b4366f..88a59047fa6589 100644 --- a/packages/dataviews/src/components/dataviews-picker/stories/index.story.tsx +++ b/packages/dataviews/src/components/dataviews-picker/stories/index.story.tsx @@ -86,7 +86,7 @@ export const Default = ( { .filter( ( item ) => selection?.includes( String( item.id ) ) ) - .map( ( item ) => item.title ) + .map( ( item ) => item.name.title ) .join( ', ' ); // eslint-disable-next-line no-alert window.alert( selectedItemNames ); diff --git a/packages/dataviews/src/components/dataviews/stories/fixtures.tsx b/packages/dataviews/src/components/dataviews/stories/fixtures.tsx index 4657ff23c60a15..f3cf6fc725ea3e 100644 --- a/packages/dataviews/src/components/dataviews/stories/fixtures.tsx +++ b/packages/dataviews/src/components/dataviews/stories/fixtures.tsx @@ -25,8 +25,10 @@ export type Theme = { export type SpaceObject = { id: number; - title: string; - description: string; + name: { + title: string; + description: string; + }; image: string; type: string; isPlanet: boolean; @@ -40,9 +42,11 @@ export type SpaceObject = { export const data: SpaceObject[] = [ { id: 1, - title: 'Moon', - description: - 'The Moon is Earth’s only natural satellite, orbiting at an average distance of 384,400 kilometers with a synchronous rotation that leads to fixed lunar phases as seen from Earth. Its cratered surface and subtle glow define night skies, inspiring exploration missions and influencing tides and biological rhythms worldwide.', + name: { + title: 'Moon', + description: + "The Moon is Earth's only natural satellite, orbiting at an average distance of 384,400 kilometers with a synchronous rotation that leads to fixed lunar phases as seen from Earth. Its cratered surface and subtle glow define night skies, inspiring exploration missions and influencing tides and biological rhythms worldwide.", + }, image: 'https://live.staticflickr.com/7398/9458193857_e1256123e3_z.jpg', type: 'Satellite', isPlanet: false, @@ -54,8 +58,10 @@ export const data: SpaceObject[] = [ }, { id: 2, - title: 'Io', - description: 'Moon of Jupiter', + name: { + title: 'Io', + description: 'Moon of Jupiter', + }, image: 'https://live.staticflickr.com/5482/9460973502_07e8ab81fe_z.jpg', type: 'Satellite', isPlanet: false, @@ -67,8 +73,10 @@ export const data: SpaceObject[] = [ }, { id: 3, - title: 'Europa', - description: 'Moon of Jupiter', + name: { + title: 'Europa', + description: 'Moon of Jupiter', + }, image: 'https://live.staticflickr.com/65535/31499273012_baf5f38cc1_z.jpg', type: 'Satellite', isPlanet: false, @@ -80,8 +88,10 @@ export const data: SpaceObject[] = [ }, { id: 4, - title: 'Ganymede', - description: 'Largest moon of Jupiter', + name: { + title: 'Ganymede', + description: 'Largest moon of Jupiter', + }, image: 'https://live.staticflickr.com/7816/33436473218_a836235935_k.jpg', type: 'Satellite', isPlanet: false, @@ -93,8 +103,10 @@ export const data: SpaceObject[] = [ }, { id: 5, - title: 'Callisto', - description: 'Outermost Galilean moon of Jupiter', + name: { + title: 'Callisto', + description: 'Outermost Galilean moon of Jupiter', + }, image: 'https://live.staticflickr.com/804/27604150528_4512448a9c_c.jpg', type: 'Satellite', isPlanet: false, @@ -106,8 +118,10 @@ export const data: SpaceObject[] = [ }, { id: 6, - title: 'Amalthea', - description: 'Small irregular moon of Jupiter', + name: { + title: 'Amalthea', + description: 'Small irregular moon of Jupiter', + }, image: 'https://upload.wikimedia.org/wikipedia/commons/6/62/Amalthea.gif', type: 'Satellite', isPlanet: false, @@ -119,8 +133,10 @@ export const data: SpaceObject[] = [ }, { id: 7, - title: 'Himalia', - description: 'Largest irregular moon of Jupiter', + name: { + title: 'Himalia', + description: 'Largest irregular moon of Jupiter', + }, image: 'https://upload.wikimedia.org/wikipedia/commons/c/c2/Cassini-Huygens_Image_of_Himalia.png', type: 'Satellite', isPlanet: false, @@ -132,8 +148,10 @@ export const data: SpaceObject[] = [ }, { id: 8, - title: 'Neptune', - description: 'Ice giant in the Solar system', + name: { + title: 'Neptune', + description: 'Ice giant in the Solar system', + }, image: 'https://live.staticflickr.com/65535/29523683990_000ff4720c_z.jpg', type: 'Ice giant', isPlanet: true, @@ -145,8 +163,10 @@ export const data: SpaceObject[] = [ }, { id: 9, - title: 'Triton', - description: 'Largest moon of Neptune', + name: { + title: 'Triton', + description: 'Largest moon of Neptune', + }, image: 'https://live.staticflickr.com/65535/50728384241_02c5126c30_h.jpg', type: 'Satellite', isPlanet: false, @@ -158,8 +178,10 @@ export const data: SpaceObject[] = [ }, { id: 10, - title: 'Nereid', - description: 'Irregular moon of Neptune', + name: { + title: 'Nereid', + description: 'Irregular moon of Neptune', + }, image: 'https://upload.wikimedia.org/wikipedia/commons/b/b0/Nereid-Voyager2.jpg', type: 'Satellite', isPlanet: false, @@ -171,8 +193,10 @@ export const data: SpaceObject[] = [ }, { id: 11, - title: 'Proteus', - description: 'Second-largest moon of Neptune', + name: { + title: 'Proteus', + description: 'Second-largest moon of Neptune', + }, image: 'https://live.staticflickr.com/65535/50727825808_bf427e007b_c.jpg', type: 'Satellite', isPlanet: false, @@ -184,8 +208,10 @@ export const data: SpaceObject[] = [ }, { id: 12, - title: 'Mercury', - description: 'Terrestrial planet in the Solar system', + name: { + title: 'Mercury', + description: 'Terrestrial planet in the Solar system', + }, image: 'https://live.staticflickr.com/813/40199101735_e5e92ffd11_z.jpg', type: 'Terrestrial', isPlanet: true, @@ -197,8 +223,10 @@ export const data: SpaceObject[] = [ }, { id: 13, - title: 'Venus', - description: 'La planète Vénus', + name: { + title: 'Venus', + description: 'La planète Vénus', + }, image: 'https://live.staticflickr.com/8025/7544560662_900e717727_z.jpg', type: 'Terrestrial', isPlanet: true, @@ -210,8 +238,10 @@ export const data: SpaceObject[] = [ }, { id: 14, - title: 'Earth', - description: 'Terrestrial planet in the Solar system', + name: { + title: 'Earth', + description: 'Terrestrial planet in the Solar system', + }, image: 'https://live.staticflickr.com/3762/9460163562_964fe6af07_z.jpg', type: 'Terrestrial', isPlanet: true, @@ -223,8 +253,10 @@ export const data: SpaceObject[] = [ }, { id: 15, - title: 'Mars', - description: 'Terrestrial planet in the Solar system', + name: { + title: 'Mars', + description: 'Terrestrial planet in the Solar system', + }, image: 'https://live.staticflickr.com/8151/7651156426_e047f4d219_z.jpg', type: 'Terrestrial', isPlanet: true, @@ -236,8 +268,10 @@ export const data: SpaceObject[] = [ }, { id: 16, - title: 'Jupiter', - description: 'Gas giant in the Solar system', + name: { + title: 'Jupiter', + description: 'Gas giant in the Solar system', + }, image: 'https://staging-jubilee.flickr.com/2853/9458010071_6e6fc41408_z.jpg', type: 'Gas giant', isPlanet: true, @@ -249,8 +283,10 @@ export const data: SpaceObject[] = [ }, { id: 17, - title: 'Saturn', - description: 'Gas giant in the Solar system', + name: { + title: 'Saturn', + description: 'Gas giant in the Solar system', + }, image: 'https://live.staticflickr.com/5524/9464658509_fc2d83dff5_z.jpg', type: 'Gas giant', isPlanet: true, @@ -262,8 +298,10 @@ export const data: SpaceObject[] = [ }, { id: 18, - title: 'Uranus', - description: 'Ice giant in the Solar system', + name: { + title: 'Uranus', + description: 'Ice giant in the Solar system', + }, image: 'https://live.staticflickr.com/65535/5553350875_3072df91e2_c.jpg', type: 'Ice giant', isPlanet: true, @@ -722,7 +760,7 @@ export const actions: Action< SpaceObject >[] = [ return ( - { `Are you sure you want to delete "${ items[ 0 ].title }"?` } + { `Are you sure you want to delete "${ items[ 0 ].name.title }"?` }