Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/dataviews/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### Code Quality

- DataViews: Remove extra wrapper for GridItem. [#73665](https://github.com/WordPress/gutenberg/pull/73665)
- Field API: move validation to the field type. [#73642](https://github.com/WordPress/gutenberg/pull/73642)
- Field API: move format logic to the field type. [#73922](https://github.com/WordPress/gutenberg/pull/73922)

### Bug Fixes

Expand All @@ -18,7 +20,6 @@

- DataViews table layout: remove row click-to-select behavior and hover styles. Selection is now only possible via checkboxes, or by ctrl/cmd clicking. [#73873](https://github.com/WordPress/gutenberg/pull/73873)
- Better labels for operators and deprecate the `isNotAll` operator. [#73671](https://github.com/WordPress/gutenberg/pull/73671)
- Field API: move validation to the field type. [#73642](https://github.com/WordPress/gutenberg/pull/73642)
- DataForm: add support for `min`/`max` and `minLength`/`maxLength` validation for relevant controls. [#73465](https://github.com/WordPress/gutenberg/pull/73465)
- Field API: display formats for `number` and `integer` types. [#73644](https://github.com/WordPress/gutenberg/pull/73644)
- Field API: add display format for `datetime` type. [#73924](https://github.com/WordPress/gutenberg/pull/73924)
Expand Down
59 changes: 58 additions & 1 deletion packages/dataviews/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1344,13 +1344,70 @@ const item = {
}
```

### `getValueFormatted`

Function that formats the field value for display by computing it from the field's `format` configuration. The formatted value is used for consistent value presentation across different contexts. For example, by the default `render` implementation provided by the field types and by the filter components that display values.

- Type: `function`.
- Optional.
- Each field `type` provides a default implementation that formats values appropriately (e.g., considers weekStartsOn for date, thousand separators for number, etc.).
- Args:
- `item`: the data item containing the value.
- `field`: the normalized field configuration.
- Returns the formatted value for display (typically a string).

Example of some custom `getValueFormatted` functions:

```js
// Format a number as currency
{
id: 'price',
type: 'number',
label: 'Price',
getValueFormatted: ( { item, field } ) => {
const value = field.getValue( { item } );
if ( value === null || value === undefined ) {
return '';
}

return `$${ value.toFixed( field.format.decimals ) }`;
}
}
```

```js
// Format a date with custom logic
{
id: 'publishDate',
type: 'date',
label: 'Published',
getValueFormatted: ( { item, field } ) => {
const value = field.getValue( { item } );
if ( ! value ) {
return 'Not published';
}

const date = new Date( value );
const now = new Date();
const diffDays = Math.floor( ( now - date ) / ( 1000 * 60 * 60 * 24 ) );
if ( diffDays === 0 ) {
return 'Today';
}
if ( diffDays === 1 ) {
return 'Yesterday';
}
return `${ diffDays } days ago`;
}
}
```

### `render`

React component that renders the field.

- Type: React component.
- Optional.
- The field `type` provides a default render based on `getValue` and `elements` (if provided).
- The field `type` provides a default render that uses `getValueFormatted` for value display and `elements` for label lookup (if provided).
- Props
- `item` value to be processed.
- `field` the own field config. Useful to access `getValue`, `elements`, etc.
Expand Down
92 changes: 48 additions & 44 deletions packages/dataviews/src/components/dataviews-filters/filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ import {
Icon,
} from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import { useRef } from '@wordpress/element';
import { useMemo, useRef } from '@wordpress/element';
import { closeSmall } from '@wordpress/icons';
import { dateI18n, getDate } from '@wordpress/date';

/**
* Internal dependencies
Expand All @@ -30,19 +29,12 @@ import { getOperatorByName } from '../../utils/operators';
import type {
Filter,
NormalizedField,
NormalizedFieldDate,
NormalizedFieldNumber,
NormalizedFieldInteger,
NormalizedFilter,
Operator,
Option,
View,
NormalizedFieldDatetime,
} from '../../types';
import useElements from '../../hooks/use-elements';
import parseDateTime from '../../field-types/utils/parse-date-time';
import { formatNumber } from '../../field-types/number';
import { formatInteger } from '../../field-types/integer';

const ENTER = 'Enter';
const SPACE = ' ';
Expand Down Expand Up @@ -190,55 +182,67 @@ export default function Filter( {
);

let activeElements: Option[] = [];
const field = useMemo( () => {
const currentField = fields.find( ( f ) => f.id === filter.field );
if ( currentField ) {
return {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't get why we do this.. Aren't getValue and setValue already there in the normalized field? Even if they weren't, wouldn't we possibly override a fields getValue etc..?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is because the filters don't use an Item. We don't know what's the data shape at this point, we pass a new object with the value of the filter. That means we need to make sure getValue/setValue know how to work with this new object. Does this help?

Copy link
Member Author

@oandregal oandregal Dec 12, 2025

Choose a reason for hiding this comment

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

Another way to put it, imagine the data shape was:

{
  author: {
    name: 'authorName'
  }
}

and the getValue implementation was:

getValue: ( { item } ) => item.author.name;

When we want to use the field to get the formatted value:

field.getValueFormatted( { [ field.id ]: filterInView.value }, field )

how do we know the shape of the data?

Copy link
Contributor

Choose a reason for hiding this comment

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

Still not following myself either (the last part of your explanation is a bit unclear)

Copy link
Contributor

Choose a reason for hiding this comment

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

Still don't get it, sorry. In your example how can we ensure the proper field value is displayed?

Also, even if it getValue is needed, is it the same for setValue? Why do we need that in filters?

Copy link
Member Author

Choose a reason for hiding this comment

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

I've discussed this on video with Nik. @youknowriad I'm going to share a better example here, but happy to hop on a quick call to clarify this.

This is the consumer code:

// Consumer code:
//
// - data
// - fields
// - etc.

type Item = {
  author: {
    publishedDate: string;
  };
};
const data: Item = {
  author: {
    publishedDate: "2025/12/01",
  },
};

const publishedDateField = {
  id: "publishedDate",
  getValue: (item: Item) => item.author.publishedDate,
  getValueFormatted: (item: Item, field) => {
    const value = field.getValue({ item });
    const valueFormatted = /* do something to format value*/ value;

    return valueFormatted;
  },
};

const fields = [
  publishedDateField,
  // etc.
];

At some point, the framework wants to work with it:

// Framework code: DataViews table, or DataForm layout, etc.
//
// It has access to data and fields, and retrieves the formatted value
// for a particular field.

const input = data;
const foundField = fields.find((f) => f.id === "publishedDate");
const value = foundField.getValueFormatted({ item: input });

// This works fine, and, value at this point is "December 1st, 2025".

In the filters (also framework code), we also want the formatted value, but the input is not data, but some user selection. For example, it could be 2023/09/26:

// The same code as before won't work: item is a string, but it needs to be an object.
const input = "2023/09/26";
const foundField = fields.find((field) => field.id === "publishedDate");
const value = foundField.getValueFormatted({ item: input });

// This also doesn't work.
//
// We don't know how to build an Item object, so we just build one with the field id as props.
// The issue with this is that getValue expects the item to follow the Item type, but it doesn't.
//
// {
//  publishedDate: "2023/09/26"
// }
const inputFromFilter = {
  [foundField.id]: filterValue,
};
const value = foundField.getValueFormatted({ item: inputFromFilter });

// This works.
//
// We don't have a way to know how to build the Item object.
// So, instead, we override getValue to work with the object we know how to build:

const inputFromFilter = {
  [foundField.id]: filterValue,
};
foundField.getValue = ({ item, field }) => item[field.id];
const value = foundField.getValueFormatted({ item: inputFromFilter });
// At this point, value is "September 26nd, 2023"

Note this is the same we do for field.Edit to work as user filters (code, PR).

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the explanation. I see how that would work, but I can't help but think it could be a code smell. Maybe one of the interfaces or abstractions could be better and absorb this directly; in contrast, this current approach is — essentially, and correct me if I'm wrong — to mock items in a new arbitrary shape.

That said, these are just hollow suggestions from someone who barely touches these pieces. 🤷

Copy link
Contributor

Choose a reason for hiding this comment

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

I also don't love it, but it makes sense and we shouldn't probably block this PR for this. We should consider though if we can improve it and the similar case for field.Edit.

Said that, setValue doesn't seem needed for this context though and we should remove it.

...currentField,
// Configure getValue as if Item was a plain object.
// See related input-widget.tsx
getValue: ( { item }: { item: any } ) =>
item[ currentField.id ],
};
}

return currentField;
}, [ fields, filter.field ] );

const { elements } = useElements( {
elements: filter.elements,
getElements: filter.getElements,
} );

if ( elements.length > 0 ) {
// When there are elements, we favor those
activeElements = elements.filter( ( element ) => {
if ( filter.singleSelection ) {
return element.value === filterInView?.value;
}
return filterInView?.value?.includes( element.value );
} );
} else if ( filterInView?.value !== undefined ) {
const field = fields.find( ( f ) => f.id === filter.field );
let label = filterInView.value;
} else if ( Array.isArray( filterInView?.value ) ) {
// or, filterInView.value can also be array
// for the between operator, as in [ 1, 2 ]
const label = filterInView.value.map( ( v ) => {
const formattedValue = field?.getValueFormatted( {
item: { [ field.id ]: v },
field,
} );
return formattedValue || String( v );
} );

if ( field?.type === 'date' && typeof label === 'string' ) {
try {
const dateValue = parseDateTime( label );
if ( dateValue !== null ) {
label = dateI18n(
( field as NormalizedFieldDate< any > ).format.date,
getDate( label )
);
}
} catch ( e ) {
label = filterInView.value;
}
} else if ( field?.type === 'datetime' && typeof label === 'string' ) {
try {
const dateValue = parseDateTime( label );
if ( dateValue !== null ) {
label = dateI18n(
( field as NormalizedFieldDatetime< any > ).format
.datetime,
getDate( label )
);
}
} catch ( e ) {
label = filterInView.value;
}
} else if ( field?.type === 'number' && typeof label === 'number' ) {
const numberField = field as NormalizedFieldNumber< any >;
label = formatNumber( label, numberField.format );
} else if ( field?.type === 'integer' && typeof label === 'number' ) {
const integerField = field as NormalizedFieldInteger< any >;
label = formatInteger( label, integerField.format );
}
activeElements = [
{
value: filterInView.value,
// @ts-ignore
label,
},
];
} else if ( typeof filterInView?.value === 'object' ) {
// or, it can also be object for the inThePast/over operators,
// as in { value: '1', units: 'days' }
activeElements = [
{ value: filterInView.value, label: filterInView.value },
];
} else if ( filterInView?.value !== undefined ) {
// otherwise, filterInView.value is a single value
const label =
field !== undefined
? field.getValueFormatted( {
item: { [ field.id ]: filterInView.value },
field,
} )
: String( filterInView.value );

activeElements = [
{
Expand Down
21 changes: 7 additions & 14 deletions packages/dataviews/src/dataform-controls/date.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ function CalendarDateControl< Item >( {
}: DataFormControlProps< Item > ) {
const {
id,
type,
label,
setValue,
getValue,
Expand All @@ -271,12 +270,9 @@ function CalendarDateControl< Item >( {
null
);

let weekStartsOn = getSettings().l10n.startOfWeek;
if ( type === 'date' ) {
// If the field type is date, we've already normalized the format,
// and so it's safe to tell TypeScript to trust us ("as Required<Format>").
weekStartsOn = ( fieldFormat as Required< FormatDate > ).weekStartsOn;
}
const weekStartsOn =
( fieldFormat as FormatDate ).weekStartsOn ??
getSettings().l10n.startOfWeek;

const fieldValue = getValue( { item: data } );
const value = typeof fieldValue === 'string' ? fieldValue : undefined;
Expand Down Expand Up @@ -425,7 +421,7 @@ function CalendarDateRangeControl< Item >( {
hideLabelFromVision,
validity,
}: DataFormControlProps< Item > ) {
const { id, type, label, getValue, setValue, format: fieldFormat } = field;
const { id, label, getValue, setValue, format: fieldFormat } = field;
let value: DateRange;
const fieldValue = getValue( { item: data } );
if (
Expand All @@ -436,12 +432,9 @@ function CalendarDateRangeControl< Item >( {
value = fieldValue as DateRange;
}

let weekStartsOn;
if ( type === 'date' ) {
// If the field type is date, we've already normalized the format,
// and so it's safe to tell TypeScript to trust us ("as Required<Format>").
weekStartsOn = ( fieldFormat as Required< FormatDate > ).weekStartsOn;
}
const weekStartsOn =
( fieldFormat as FormatDate ).weekStartsOn ??
getSettings().l10n.startOfWeek;

const onChangeCallback = useCallback(
( newValue: DateRange ) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/dataviews/src/dataform-controls/integer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
import type { DataFormControlProps } from '../types';
import ValidatedNumber from './utils/validated-number';

export default function Number< Item >( props: DataFormControlProps< Item > ) {
return <ValidatedNumber { ...props } decimals={ 0 } />;
export default function Integer< Item >( props: DataFormControlProps< Item > ) {
return <ValidatedNumber { ...props } />;
}
5 changes: 2 additions & 3 deletions packages/dataviews/src/dataform-controls/number.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/**
* Internal dependencies
*/
import type { DataFormControlProps, FormatNumber } from '../types';
import type { DataFormControlProps } from '../types';
import ValidatedNumber from './utils/validated-number';

export default function Number< Item >( props: DataFormControlProps< Item > ) {
const decimals = ( props.field.format as FormatNumber )?.decimals ?? 2;
return <ValidatedNumber { ...props } decimals={ decimals } />;
return <ValidatedNumber { ...props } />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { __ } from '@wordpress/i18n';
* Internal dependencies
*/
import { OPERATOR_BETWEEN } from '../../constants';
import type { DataFormControlProps } from '../../types';
import type { DataFormControlProps, FormatNumber } from '../../types';
import { unlock } from '../../lock-unlock';
import getCustomValidity from './get-custom-validity';

Expand Down Expand Up @@ -83,23 +83,15 @@ function BetweenControls( {
);
}

export type DataFormValidatedNumberControlProps< Item > =
DataFormControlProps< Item > & {
/**
* Number of decimals, acceps non-negative integer.
*/
decimals: number;
};

export default function ValidatedNumber< Item >( {
data,
field,
onChange,
hideLabelFromVision,
operator,
decimals,
validity,
}: DataFormValidatedNumberControlProps< Item > ) {
}: DataFormControlProps< Item > ) {
const decimals = ( field.format as FormatNumber )?.decimals ?? 0;
const step = Math.pow( 10, Math.abs( decimals ) * -1 );
const { label, description, getValue, setValue, isValid } = field;
const value = getValue( { item: data } ) ?? '';
Expand Down
18 changes: 15 additions & 3 deletions packages/dataviews/src/field-types/array.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,20 @@ import {
import isValidRequiredForArray from './utils/is-valid-required-for-array';
import isValidElements from './utils/is-valid-elements';

function getValueFormatted< Item >( {
item,
field,
}: {
item: Item;
field: NormalizedField< Item >;
} ): string {
const value = field.getValue( { item } );
const arr = Array.isArray( value ) ? value : [];
return arr.join( ', ' );
}

function render( { item, field }: DataViewRenderFieldProps< any > ) {
const value = field.getValue( { item } ) || [];
return value.join( ', ' );
return getValueFormatted( { item, field } );
}

function isValidCustom< Item >( item: Item, field: NormalizedField< Item > ) {
Expand Down Expand Up @@ -75,7 +86,8 @@ export default {
OPERATOR_IS_ALL,
OPERATOR_IS_NOT_ALL,
],
getFormat: () => ( {} ),
format: {},
getValueFormatted,
validate: {
required: isValidRequiredForArray,
elements: isValidElements,
Expand Down
Loading
Loading