diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index dfd29b578c4b31..737209f256955d 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -2,11 +2,15 @@ ## Unreleased +### Enhancements + +- Improve docs for Edit component. [#73202](https://github.com/WordPress/gutenberg/pull/73202) +- Field API: introduce the `format` prop to format the `date` field type. [#72999](https://github.com/WordPress/gutenberg/pull/72999) + ## 10.3.0 (2025-11-12) ### Enhancements -- Improve docs for Edit component. [#73202](https://github.com/WordPress/gutenberg/pull/73202) - DataForm: add new details layout. [#72355](https://github.com/WordPress/gutenberg/pull/72355) - DatViews list layout: remove link variant from primary actions's button. [#72920](https://github.com/WordPress/gutenberg/pull/72920) - DataForm: simplify form normalization. [#72848](https://github.com/WordPress/gutenberg/pull/72848) diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md index 5cbcd61ed35c7c..7288869cdb8e44 100644 --- a/packages/dataviews/README.md +++ b/packages/dataviews/README.md @@ -1675,6 +1675,30 @@ Example: } ``` +### `format` + +Display format configuration for fields. Currently supported for date fields. This configuration affects how the field is displayed in the `render` method, the `Edit` control, and filter controls. + +- Type: `object`. +- Optional. +- Properties: + - `date`: The format string using PHP date format (e.g., 'F j, Y' for 'March 10, 2023'). Optional, defaults to WordPress "Date Format" setting. + - `weekStartsOn`: Specifies the first day of the week for calendar controls. One of `'sunday'`, `'monday'`, `'tuesday'`, `'wednesday'`, `'thursday'`, `'friday'`, `'saturday'`. Optional, defaults to WordPress "Week Starts On" setting. + +Example: + +```js +{ + id: 'publishDate', + type: 'date', + label: 'Publish Date', + format: { + date: 'F j, Y', + weekStartsOn: 'monday', + }, +} +``` + ## Form Field API ### `id` diff --git a/packages/dataviews/src/components/dataviews-filters/filter.tsx b/packages/dataviews/src/components/dataviews-filters/filter.tsx index c02f1ea9b7a062..bb21598b43b225 100644 --- a/packages/dataviews/src/components/dataviews-filters/filter.tsx +++ b/packages/dataviews/src/components/dataviews-filters/filter.tsx @@ -19,6 +19,7 @@ import { import { __, sprintf } from '@wordpress/i18n'; import { useRef, createInterpolateElement } from '@wordpress/element'; import { closeSmall } from '@wordpress/icons'; +import { dateI18n, getDate } from '@wordpress/date'; const ENTER = 'Enter'; const SPACE = ' '; @@ -498,7 +499,16 @@ export default function Filter( { const field = fields.find( ( f ) => f.id === filter.field ); let label = filterInView.value; - if ( field?.type === 'datetime' && typeof label === 'string' ) { + if ( field?.type === 'date' && typeof label === 'string' ) { + try { + const dateValue = parseDateTime( label ); + if ( dateValue !== null ) { + label = dateI18n( field.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 ) { diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index 1b98c2f43b7e22..ae17fd3bd65869 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -51,6 +51,7 @@ import type { NormalizedField, } from '../types'; import getCustomValidity from './utils/get-custom-validity'; +import { weekStartsOnToNumber } from '../utils/week-starts-on'; const { DateCalendar, DateRangeCalendar } = unlock( componentsPrivateApis ); @@ -257,11 +258,24 @@ function CalendarDateControl< Item >( { hideLabelFromVision, validity, }: DataFormControlProps< Item > ) { - const { id, label, setValue, getValue, isValid } = field; + const { + id, + type, + label, + setValue, + getValue, + isValid, + format: fieldFormat, + } = field; const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( null ); + let weekStartsOn; + if ( type === 'date' ) { + weekStartsOn = weekStartsOnToNumber( fieldFormat.weekStartsOn ); + } + const fieldValue = getValue( { item: data } ); const value = typeof fieldValue === 'string' ? fieldValue : undefined; const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { @@ -320,7 +334,6 @@ function CalendarDateControl< Item >( { const { timezone: { string: timezoneString }, - l10n: { startOfWeek }, } = getSettings(); const displayLabel = isValid?.required @@ -396,7 +409,7 @@ function CalendarDateControl< Item >( { month={ calendarMonth } onMonthChange={ setCalendarMonth } timeZone={ timezoneString || undefined } - weekStartsOn={ startOfWeek } + weekStartsOn={ weekStartsOn } /> @@ -411,7 +424,7 @@ function CalendarDateRangeControl< Item >( { hideLabelFromVision, validity, }: DataFormControlProps< Item > ) { - const { id, label, getValue, setValue } = field; + const { id, type, label, getValue, setValue, format: fieldFormat } = field; let value: DateRange; const fieldValue = getValue( { item: data } ); if ( @@ -422,6 +435,11 @@ function CalendarDateRangeControl< Item >( { value = fieldValue as DateRange; } + let weekStartsOn; + if ( type === 'date' ) { + weekStartsOn = weekStartsOnToNumber( fieldFormat.weekStartsOn ); + } + const onChangeCallback = useCallback( ( newValue: DateRange ) => { onChange( @@ -521,7 +539,7 @@ function CalendarDateRangeControl< Item >( { [ value, updateDateRange ] ); - const { timezone, l10n } = getSettings(); + const { timezone } = getSettings(); const displayLabel = field.isValid?.required ? `${ label } (${ __( 'Required' ) })` @@ -609,7 +627,7 @@ function CalendarDateRangeControl< Item >( { month={ calendarMonth } onMonthChange={ setCalendarMonth } timeZone={ timezone.string || undefined } - weekStartsOn={ l10n.startOfWeek } + weekStartsOn={ weekStartsOn } /> diff --git a/packages/dataviews/src/field-types/date.tsx b/packages/dataviews/src/field-types/date.tsx index b6a2ccae440a77..fa23f660c4b464 100644 --- a/packages/dataviews/src/field-types/date.tsx +++ b/packages/dataviews/src/field-types/date.tsx @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { dateI18n, getDate, getSettings } from '@wordpress/date'; +import { dateI18n, getDate } from '@wordpress/date'; /** * Internal dependencies @@ -24,9 +24,6 @@ import { OPERATOR_BETWEEN, } from '../constants'; -const getFormattedDate = ( dateToDisplay: string | null ) => - dateI18n( getSettings().formats.date, getDate( dateToDisplay ) ); - function sort( a: any, b: any, direction: SortDirection ) { const timeA = new Date( a ).getTime(); const timeB = new Date( b ).getTime(); @@ -51,7 +48,16 @@ export default { return ''; } - return getFormattedDate( value ); + // Not all fields have format, but date fields do. + // + // At runtime, this method will never be called for non-date fields. + // However, the type system does not know this, so we need to check it. + // There's an opportunity here to improve the type system. + if ( field.type !== 'date' ) { + return ''; + } + + return dateI18n( field.format.date, getDate( value ) ); }, enableSorting: true, filterBy: { diff --git a/packages/dataviews/src/stories/field-types.story.tsx b/packages/dataviews/src/stories/field-types.story.tsx index c25aef6f14c7ef..4ab180067df057 100644 --- a/packages/dataviews/src/stories/field-types.story.tsx +++ b/packages/dataviews/src/stories/field-types.story.tsx @@ -832,14 +832,53 @@ export const DateComponent = ( { type, Edit, asyncElements, + formatDate, + formatWeekStartsOn, }: { type: PanelTypes; Edit: ControlTypes; asyncElements: boolean; + formatDate?: string; + formatWeekStartsOn?: + | 'sunday' + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday'; } ) => { const dateFields = useMemo( - () => fields.filter( ( field ) => field.type === 'date' ), - [] + () => + fields + .filter( ( field ) => field.type === 'date' ) + .map( ( field ) => { + if ( formatDate || formatWeekStartsOn !== undefined ) { + const format: { + date?: string; + weekStartsOn?: + | 'sunday' + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday'; + } = {}; + if ( formatDate ) { + format.date = formatDate; + } + if ( formatWeekStartsOn !== undefined ) { + format.weekStartsOn = formatWeekStartsOn; + } + return { + ...field, + format, + }; + } + return field; + } ), + [ formatDate, formatWeekStartsOn ] ); return ( @@ -852,6 +891,32 @@ export const DateComponent = ( { ); }; DateComponent.storyName = 'date'; +DateComponent.args = { + formatDate: '', + formatWeekStartsOn: undefined, +}; +DateComponent.argTypes = { + formatDate: { + control: 'text', + description: + 'Custom PHP date format string (e.g., "F j, Y" for "November 6, 2010"). Leave empty to use WordPress default.', + }, + formatWeekStartsOn: { + control: 'select', + options: { + Default: undefined, + Sunday: 'sunday', + Monday: 'monday', + Tuesday: 'tuesday', + Wednesday: 'wednesday', + Thursday: 'thursday', + Friday: 'friday', + Saturday: 'saturday', + }, + description: + 'Day that the week starts on. Leave as Default to use WordPress default.', + }, +}; export const EmailComponent = ( { type, diff --git a/packages/dataviews/src/test/normalize-fields.ts b/packages/dataviews/src/test/normalize-fields.ts index 817d6a42a09444..946abeff64cf82 100644 --- a/packages/dataviews/src/test/normalize-fields.ts +++ b/packages/dataviews/src/test/normalize-fields.ts @@ -333,4 +333,57 @@ describe( 'normalizeFields: default getValue', () => { } ); } ); } ); + + describe( 'format normalization', () => { + it( 'applies default format when not provided for date fields', () => { + const fields: Field< {} >[] = [ + { + id: 'publishDate', + type: 'date', + }, + ]; + const normalizedFields = normalizeFields( fields ); + expect( normalizedFields[ 0 ].format ).toBeDefined(); + expect( normalizedFields[ 0 ].format.date ).toBeDefined(); + expect( typeof normalizedFields[ 0 ].format.date ).toBe( 'string' ); + expect( normalizedFields[ 0 ].format.weekStartsOn ).toBeDefined(); + expect( typeof normalizedFields[ 0 ].format.weekStartsOn ).toBe( + 'string' + ); + } ); + + it( 'preserves custom format when provided', () => { + const fields: Field< {} >[] = [ + { + id: 'publishDate', + type: 'date', + format: { + date: 'F j, Y', + weekStartsOn: 'monday', + }, + }, + ]; + const normalizedFields = normalizeFields( fields ); + expect( normalizedFields[ 0 ].format.date ).toBe( 'F j, Y' ); + expect( normalizedFields[ 0 ].format.weekStartsOn ).toBe( + 'monday' + ); + } ); + + it( 'adds empty format for non-date field types', () => { + const fields: Field< {} >[] = [ + { + id: 'title', + type: 'text', + }, + { + id: 'count', + type: 'integer', + }, + ]; + const normalizedFields = normalizeFields( fields ); + expect( normalizedFields[ 0 ].format ).toEqual( {} ); + expect( normalizedFields[ 1 ].format ).toEqual( {} ); + } ); + } ); } ); diff --git a/packages/dataviews/src/types/field-api.ts b/packages/dataviews/src/types/field-api.ts index 17a6f48323449d..6a0cc90bbde59f 100644 --- a/packages/dataviews/src/types/field-api.ts +++ b/packages/dataviews/src/types/field-api.ts @@ -308,9 +308,36 @@ export type Field< Item > = { * Used for editing operations to update field values. */ setValue?: ( args: { item: Item; value: any } ) => DeepPartial< Item >; + + /** + * Display format configuration for fields. + */ + format?: FormatDate; }; -export type NormalizedField< Item > = Omit< Field< Item >, 'Edit' > & { +/** + * Format for date fields: + * + * - date: the format string (e.g., 'F j, Y' for WordPress default format like 'March 10, 2023') + * - weekStartsOn: to specify the first day of the week ('sunday', 'monday', etc.). + * + * If not provided, defaults to WordPress date format settings. + */ +type FormatDate = { + date?: string; + weekStartsOn?: DayString; +}; +export type DayNumber = 0 | 1 | 2 | 3 | 4 | 5 | 6; +export type DayString = + | 'sunday' + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday'; + +type NormalizedFieldBase< Item > = Omit< Field< Item >, 'Edit' > & { label: string; header: string | ReactElement; getValue: ( args: { item: Item } ) => any; @@ -326,6 +353,20 @@ export type NormalizedField< Item > = Omit< Field< Item >, 'Edit' > & { readOnly: boolean; }; +type NormalizedFieldDate< Item > = NormalizedFieldBase< Item > & { + type: 'date'; + format: Required< FormatDate >; +}; + +type NormalizedFieldGeneric< Item > = NormalizedFieldBase< Item > & { + type?: Exclude< FieldType, 'date' >; + format: {}; +}; + +export type NormalizedField< Item > = + | NormalizedFieldGeneric< Item > + | NormalizedFieldDate< Item >; + /** * A collection of dataview fields for a data type. */ diff --git a/packages/dataviews/src/utils/normalize-fields.ts b/packages/dataviews/src/utils/normalize-fields.ts index 961ee50a7898dd..42fda2e86a2e01 100644 --- a/packages/dataviews/src/utils/normalize-fields.ts +++ b/packages/dataviews/src/utils/normalize-fields.ts @@ -3,11 +3,17 @@ */ import type { FunctionComponent } from 'react'; +/** + * WordPress dependencies + */ +import { getSettings } from '@wordpress/date'; + /** * Internal dependencies */ import getFieldTypeDefinition from '../field-types'; import type { + DayString, DataViewRenderFieldProps, Field, FieldTypeDefinition, @@ -21,6 +27,7 @@ import { SINGLE_SELECTION_OPERATORS, } from '../constants'; import hasElements from './has-elements'; +import { numberToWeekStartsOn, DAYS_OF_WEEK } from './week-starts-on'; const getValueFromId = ( id: string ) => @@ -182,8 +189,22 @@ export default function normalizeFields< Item >( const filterBy = getFilterBy( field, fieldTypeDefinition ); - return { - ...field, + /** + * NormalizedField is a discriminated union type: the shape of the format property + * depends on the type property. For example, for the 'date' type, the format + * contains date or weekStartsOn — which are not valid for other types. + * + * Being type and format interdependent, we need to write the code + * in a way that TypeScript is able to statically infer the types. + * That's why we have a return branch for every item in the union type. + * + * See a longer explanation with examples at + * https://github.com/WordPress/gutenberg/pull/72999#discussion_r2523145453 + */ + const { type, ...fieldWithoutType } = field; + + const baseField = { + ...fieldWithoutType, label: field.label || field.id, header: field.header || field.label || field.id, getValue, @@ -200,6 +221,34 @@ export default function normalizeFields< Item >( true, filterBy, readOnly: field.readOnly ?? fieldTypeDefinition.readOnly ?? false, + format: {}, }; + + if ( field.type === 'date' ) { + const format = { + date: + field.format?.date !== undefined && + typeof field.format.date === 'string' + ? field.format.date + : getSettings().formats.date, + weekStartsOn: + field.format?.weekStartsOn !== undefined && + DAYS_OF_WEEK.includes( + field.format?.weekStartsOn as DayString + ) + ? field.format.weekStartsOn + : numberToWeekStartsOn( + getSettings().l10n.startOfWeek + ), + }; + + return { + ...baseField, + type: 'date', + format, + }; + } + + return { ...baseField, type: field.type, format: {} }; } ); } diff --git a/packages/dataviews/src/utils/week-starts-on.ts b/packages/dataviews/src/utils/week-starts-on.ts new file mode 100644 index 00000000000000..60651023751289 --- /dev/null +++ b/packages/dataviews/src/utils/week-starts-on.ts @@ -0,0 +1,46 @@ +/** + * Internal dependencies + */ +import type { DayNumber, DayString } from '../types/field-api'; + +export const DAYS_OF_WEEK: DayString[] = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +]; +const DEFAULT_DAY_STRING = 'sunday'; +const DEFAULT_DAY_NUMBER = 0; + +/** + * Converts a weekStartsOn string to a number (0-6). + * + * @param day - The day name ('sunday', 'monday', etc.) + * @return The corresponding number (0 for Sunday, 1 for Monday, etc.) + */ +export function weekStartsOnToNumber( day: DayString ): DayNumber { + const index = DAYS_OF_WEEK.indexOf( day ); + if ( index === -1 ) { + return DEFAULT_DAY_NUMBER; + } + + return index as DayNumber; +} + +/** + * Converts a weekStartsOn number (0-6) to a string. + * + * @param day - The day number (0 for Sunday, 1 for Monday, etc.) + * @return The corresponding day name ('sunday', 'monday', etc.) + */ +export function numberToWeekStartsOn( day: DayNumber ): DayString { + const result = DAYS_OF_WEEK[ day ]; + if ( result === undefined ) { + return DEFAULT_DAY_STRING; + } + + return result; +}