From 460232c3b9699e8445f47521aea583229d57d142 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 07:29:19 +0000 Subject: [PATCH 1/2] fix(fields): one home for the datetime display convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `formatDateTime` gains a named `'compact'` style — today's `DateTimeCellRenderer` face, byte-identical — and the cell renders through it instead of inlining its own pair of `Intl` option bags. The cell also reads `field` (it destructured `value` only), so `field.format` selects a datetime style the way it already did for `date`. `data-table`'s `formatCellValue` calls the same function for its datetime branch instead of a third, independently authored bag. `formatDateTime`'s signature is now `(value, style?, options?)`, matching `formatDate`; the `options` added in objectui#4272 moved to position three and every call site moved with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .changeset/7443-datetime-compact-style.md | 27 ++ ...ta-table-datetime-convention-7443.test.tsx | 128 +++++++++ .../src/renderers/complex/data-table.tsx | 22 +- .../__tests__/dataset-format.date.test.ts | 6 +- packages/core/src/utils/dataset-format.ts | 2 +- packages/core/src/utils/date-display.ts | 92 ++++++- .../date-formatter-residue-4272.test.ts | 16 +- .../datetime-compact-style-7443.test.tsx | 255 ++++++++++++++++++ packages/fields/src/index.tsx | 49 ++-- .../DatasetWidget.dateMeasure.test.tsx | 6 +- packages/plugin-gantt/src/ObjectGantt.tsx | 4 +- 11 files changed, 560 insertions(+), 47 deletions(-) create mode 100644 .changeset/7443-datetime-compact-style.md create mode 100644 packages/components/src/__tests__/data-table-datetime-convention-7443.test.tsx create mode 100644 packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx diff --git a/.changeset/7443-datetime-compact-style.md b/.changeset/7443-datetime-compact-style.md new file mode 100644 index 0000000000..cc9ee16d56 --- /dev/null +++ b/.changeset/7443-datetime-compact-style.md @@ -0,0 +1,27 @@ +--- +'@object-ui/core': minor +'@object-ui/fields': minor +'@object-ui/components': minor +'@object-ui/plugin-gantt': minor +--- + +One home for the `datetime` display convention (objectui#7443). + +`formatDateTime` gains a named `'compact'` style — the dense grid face, +`7/4/2024 7:00 am` in `en-US` — which `DateTimeCellRenderer` used to build from +its own inlined `Intl` option bags. The cell now reads `field.format` (it +destructured `value` only, so a `datetime` field could not reach the style +vocabulary a `date` field has) and renders through the shared function, and +`data-table`'s `formatCellValue` calls `formatDateTime` instead of a third, +independently authored option bag. Every existing cell renders byte-identically; +`'compact'` is today's face named and rehoused, not a new one. + +BREAKING (source-compatible only after moving one argument): `formatDateTime`'s +signature is now `(value, style?, options?)`, matching `formatDate`. The +`options` parameter added in objectui#4272 moved from position two to position +three — `formatDateTime(v, { locale })` becomes +`formatDateTime(v, undefined, { locale })`. TypeScript rejects the old form at +every call site; a JavaScript caller that does not move the argument silently +loses its locale, which is the objectui#4272 defect. Marked `minor`, per this +repo's fixed-group rule that a breaking change is described rather than +majored. diff --git a/packages/components/src/__tests__/data-table-datetime-convention-7443.test.tsx b/packages/components/src/__tests__/data-table-datetime-convention-7443.test.tsx new file mode 100644 index 0000000000..0185336ffb --- /dev/null +++ b/packages/components/src/__tests__/data-table-datetime-convention-7443.test.tsx @@ -0,0 +1,128 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7443 — the THIRD spelling of the datetime convention converges. + * + * `formatCellValue` sniffed ISO strings and built its own + * `Intl.DateTimeFormat` with `{ year:'numeric', month:'short', day:'numeric', + * hour:'2-digit', minute:'2-digit' }` — close to `formatDateTime` but + * independently authored, so nothing kept the two in step. It now calls + * `formatDateTime` (default style). + * + * ── The stop-condition this file answers ───────────────────────────────── + * The ruling required `data-table`'s output measured BEFORE and AFTER, and a + * separate line in the PR if a pixel changed. It did not: the bag it used to + * build is the same bag `formatDateTime`'s default branch builds. These pins + * are the measurement — `FORMER_DATETIME_BAG` below is the bag copied verbatim + * from `origin/main`, and the rendered cell is asserted equal to it, so the + * two can never silently diverge again either. + * + * ── The date-only half is deliberately NOT converged ───────────────────── + * `formatDateTime` always carries a time and `formatDate`'s default drops the + * year inside the current year, so routing the date-only branch through either + * WOULD change what renders. #7443's subject is the datetime convention; the + * date-only bag keeps its own spelling here and is pinned unchanged. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +// Registers the renderers at module scope, NOT inside a `beforeAll` — there the +// cold transform is billed to `hookTimeout` (objectui#3010/#3021). +import '../renderers'; + +const INSTANT = '2024-07-04T07:00:00.000Z'; +const DATE_ONLY = '2024-07-04'; + +/** The bags `formatCellValue` inlined before this change, copied verbatim. */ +const FORMER_DATETIME_BAG: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', +}; +const FORMER_DATE_BAG: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', +}; + +const former = (iso: string, locale: string, bag: Intl.DateTimeFormatOptions) => + new Intl.DateTimeFormat(locale, bag).format(new Date(Date.parse(iso))); + +/** + * Reports the tag the table itself resolves. `formatCellValue` localizes from + * `useTableTranslation().language`, so the expectation is built from THE SAME + * tag the component read rather than from the one this file asked for — the + * property under test is "identical to the former bag", and hard-coding a tag + * the harness may not actually resolve would measure the harness instead. + */ +function LanguageProbe({ report }: { report: (language: string) => void }) { + report(useObjectTranslation().language); + return null; +} + +function renderTable(language: string, value: string) { + const Component = ComponentRegistry.get('data-table')!; + const schema = { + type: 'data-table', + columns: [{ header: 'When', accessorKey: 'when' }], + data: [{ id: 'r1', when: value }], + } as any; + let resolved = ''; + const result = render( + + { resolved = l; }} /> + + , + ); + return { ...result, language: () => resolved }; +} + +const cellText = (container: HTMLElement) => + container.querySelector('tbody tr td')?.textContent ?? ''; + +afterEach(() => cleanup()); + +describe('the datetime cell is byte-identical before and after the convergence', () => { + it.each(['en', 'de'])('%s — the rendered cell equals the former bag', (language) => { + const { container, language: resolved } = renderTable(language, INSTANT); + expect(cellText(container)).toBe(former(INSTANT, resolved(), FORMER_DATETIME_BAG)); + }); + + it('en renders the exact string the card recorded for this path', () => { + const { container } = renderTable('en', INSTANT); + expect(cellText(container)).toBe('Jul 4, 2024, 07:00 AM'); + }); + + it('the shared function and the former bag agree — that is why nothing moved', () => { + for (const language of ['en', 'de', 'zh']) { + const shared = new Date(INSTANT).toLocaleDateString(language, FORMER_DATETIME_BAG); + expect(shared).toBe(former(INSTANT, language, FORMER_DATETIME_BAG)); + } + }); +}); + +describe('the date-only cell is untouched', () => { + it.each(['en', 'de'])('%s — still the date-only bag, with no time appended', (language) => { + const { container, language: resolved } = renderTable(language, DATE_ONLY); + expect(cellText(container)).toBe(former(DATE_ONLY, resolved(), FORMER_DATE_BAG)); + expect(cellText(container)).not.toMatch(/\d\d:\d\d/); + }); +}); + +describe('non-date values are still returned untouched', () => { + it('a plain string is not sniffed into a date', () => { + const { container } = renderTable('en', 'not-a-date-at-all'); + expect(cellText(container)).toBe('not-a-date-at-all'); + }); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 8df517bcf5..637ef23393 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -12,7 +12,7 @@ import { cn } from '../../lib/utils'; import { resolveIcon } from '../action/resolve-icon'; import { useGridFieldAuthoring } from '../../context/gridFieldAuthoring'; import { describeIgnoredBind, describeNonArrayData } from './dataTableBindDiagnostic'; -import { ComponentRegistry, compareSortValues, evalRowPredicate, getSortValue } from '@object-ui/core'; +import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDateTime, getSortValue } from '@object-ui/core'; import type { DataTableSchema, TableSortItem, TableColumnType } from '@object-ui/types'; import { SchemaRenderer, useRowPredicate, usePredicateScope } from '@object-ui/react'; import { createSafeTranslation } from '@object-ui/i18n'; @@ -766,10 +766,22 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { if (Number.isNaN(ts)) return value; const hasTime = value.includes('T'); try { - const fmt = new Intl.DateTimeFormat(language, hasTime - ? { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' } - : { year: 'numeric', month: 'short', day: 'numeric' }); - return fmt.format(new Date(ts)); + // The datetime half is `formatDateTime`'s DEFAULT style — the one home + // for this convention (objectui#7443). It used to be a third, + // independently authored `Intl.DateTimeFormat` bag here, close to but + // not derived from the shared function. Byte-identical in en-US, zh and + // de-DE, so no table cell changes. + if (hasTime) return formatDateTime(new Date(ts), undefined, { locale: language }); + // The DATE-only half keeps its own bag on purpose: `formatDateTime` + // always carries a time, and `formatDate`'s default drops the year in + // the current year — routing this branch through either WOULD change + // what renders. #7443's subject is the datetime convention; the + // date-only divergence is recorded separately. + return new Intl.DateTimeFormat(language, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(ts)); } catch { return value; } diff --git a/packages/core/src/utils/__tests__/dataset-format.date.test.ts b/packages/core/src/utils/__tests__/dataset-format.date.test.ts index 28e3dfffe6..717600e16f 100644 --- a/packages/core/src/utils/__tests__/dataset-format.date.test.ts +++ b/packages/core/src/utils/__tests__/dataset-format.date.test.ts @@ -50,7 +50,7 @@ describe('formatMeasure routes a date-shaped measure through the shared date pat it('renders a datetime measure as a datetime, not as its raw ISO string', () => { const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN); expect(out).not.toBe(ISO_DATETIME); - expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN })); + expect(out).toBe(formatDateTime(ISO_DATETIME, undefined, { locale: EN })); }); it('renders a date-only measure as a date, not as its raw ISO string', () => { @@ -62,7 +62,7 @@ describe('formatMeasure routes a date-shaped measure through the shared date pat it('accepts the space-separated ISO spelling a backend may send', () => { const spaced = '2024-07-04 07:00:00'; expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe( - formatDateTime(spaced, { locale: EN }), + formatDateTime(spaced, undefined, { locale: EN }), ); }); @@ -70,7 +70,7 @@ describe('formatMeasure routes a date-shaped measure through the shared date pat const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE'); const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN); expect(de).not.toBe(en); - expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' })); + expect(de).toBe(formatDateTime(ISO_DATETIME, undefined, { locale: 'de-DE' })); }); }); diff --git a/packages/core/src/utils/dataset-format.ts b/packages/core/src/utils/dataset-format.ts index 4c176afe22..5cea93584f 100644 --- a/packages/core/src/utils/dataset-format.ts +++ b/packages/core/src/utils/dataset-format.ts @@ -191,7 +191,7 @@ function formatMeasureDate(v: unknown, format: string | undefined, locale: strin return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale }); } if (ISO_DATETIME_RE.test(v)) { - return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale }); + return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, undefined, { locale }); } return undefined; } diff --git a/packages/core/src/utils/date-display.ts b/packages/core/src/utils/date-display.ts index b924b02847..afe1ec3c6e 100644 --- a/packages/core/src/utils/date-display.ts +++ b/packages/core/src/utils/date-display.ts @@ -29,9 +29,16 @@ * upper package re-exports it, so there is one home and nothing to drift. * * `@object-ui/fields` re-exports every symbol below under its original name, - * so `formatDate` / `formatDateTime` / `formatRelativeDate` / - * `DateDisplayOptions` keep working unchanged for `ObjectGrid`, `ObjectGantt`, - * `plugin-dashboard`'s `recordFields` and the `date` cell renderer. + * so `formatDate` / `formatDateTime` / `formatDateTimeCompactParts` / + * `formatRelativeDate` / `DateDisplayOptions` keep working unchanged for + * `ObjectGrid`, `ObjectGantt`, `plugin-dashboard`'s `recordFields` and the + * `date` cell renderer. + * + * The `datetime` CELL face joined this file in objectui#7443. It used to be a + * second convention inlined in `DateTimeCellRenderer`: two `Intl` option bags + * for one field type, kept in step by nothing, while `date` had exactly one. + * It is `formatDateTime`'s `'compact'` style now, byte-identical to what the + * cell rendered before. * * Pure by construction (no React, no i18n): the only ambient inputs are `Intl` * and the clock, and the one phrase `Intl` cannot produce ("Overdue Nd") comes @@ -136,22 +143,85 @@ export function formatDate(value: string | Date | number, style?: string, option }); } +/** + * The `'compact'` datetime face as the two halves a grid cell paints + * separately — `7/4/2024` and `7:00 am` for `2024-07-04T07:00:00Z` in `en-US`. + * + * `formatDateTime(value, 'compact', options)` is exactly `date + ' ' + time` + * of what this returns, so a caller that wants the face as ONE string and a + * caller that wants to style the halves differently cannot drift apart. That + * drift is what objectui#7443 recorded: `DateTimeCellRenderer` inlined these + * two option bags and never called this module, so `datetime` had two display + * conventions while `date` had one — the same shape as objectui#4576, which + * this repo has already paid for once. + * + * `null` for a value this module renders as `'—'`; the cell renders its own + * empty state for those, so it never sees the dash. + */ +export function formatDateTimeCompactParts( + value: string | Date | number, + options?: DateDisplayOptions, +): { date: string; time: string } | null { + if (value === null || value === undefined || value === '') return null; + const date = value instanceof Date ? value : new Date(value as any); + if (!(date instanceof Date) || isNaN(date.getTime())) return null; + + return { + date: date.toLocaleDateString(options?.locale, { + month: 'numeric', + day: 'numeric', + year: 'numeric', + }), + // `hour12` stays declared: this is the compact Airtable-style cell, and + // the 12-hour face is its design, not a locale artefact. Locales that + // write no am/pm marker simply ignore it. + time: date.toLocaleTimeString(options?.locale, { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }).toLowerCase(), + }; +} + /** * Format datetime value. * - * `options` mirrors {@link formatDate}'s and is optional, so an existing - * caller that passes nothing keeps the exact runtime-default behavior it had. - * Before objectui#4272 the parameter did not exist at all, which meant no - * caller could localize this function however hard it tried — it always handed - * `Intl` an `undefined` tag, i.e. the MACHINE's locale, which is neither of - * the repo's two locale channels. Callers should pass the tag from - * `useDisplayLocale()`. + * `style` selects a named face, exactly as it does on {@link formatDate}: + * + * - `'compact'` — the dense grid face, `7/4/2024 7:00 am` in `en-US`. It is + * what every `datetime` CELL renders, and what `DateTimeCellRenderer` + * used to build from its own inlined `Intl` bags (objectui#7443). + * - anything else, including `undefined` — the verbose default, + * `Jul 4, 2024, 07:00 AM` in `en-US`. Unchanged, and still what a + * non-cell caller (dataset measure, gantt tooltip, data-table) gets. + * + * ⚠️ `style` sits in the SAME position it does on `formatDate`, which means it + * displaced the `options` parameter objectui#4272 had added here in position + * two. Every call passing options positionally had to move them along one; + * the two functions being callable the same way is the point — a fourth + * author copying whichever is nearest now copies a consistent pair. + * + * `options` is optional, so a caller that passes nothing keeps the exact + * runtime-default behavior it had. Before objectui#4272 the parameter did not + * exist at all, which meant no caller could localize this function however + * hard it tried — it always handed `Intl` an `undefined` tag, i.e. the + * MACHINE's locale, which is neither of the repo's two locale channels. + * Callers should pass the tag from `useDisplayLocale()`. */ -export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string { +export function formatDateTime( + value: string | Date | number, + style?: string, + options?: DateDisplayOptions, +): string { if (value === null || value === undefined || value === '') return '—'; const date = value instanceof Date ? value : new Date(value as any); if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; + if (style === 'compact') { + const parts = formatDateTimeCompactParts(date, options); + return parts ? `${parts.date} ${parts.time}` : '—'; + } + return date.toLocaleDateString(options?.locale, { year: 'numeric', month: 'short', diff --git a/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts b/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts index b8ed8eace6..9164ab55fb 100644 --- a/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts +++ b/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts @@ -82,14 +82,22 @@ describe("formatDate 'short' honors the threaded locale (objectui#4272)", () => }); }); +/** + * ⚠️ Position moved, contract did not (objectui#7443). `formatDateTime` grew a + * `style` parameter in position two — the slot `formatDate` has always used — + * so the options this describe-block exists to protect now travel in position + * three. Every expected string below is unchanged: what #4272 bought is that + * the tag REACHES `Intl`, and it still does. Passing `undefined` for the style + * is the default face, which is what these cases always measured. + */ describe('formatDateTime accepts a locale at all (objectui#4272)', () => { it('zh renders the Chinese datetime form', () => { - expect(formatDateTime(INSTANT, { locale: 'zh' })).toBe('2024年1月5日 08:30'); + expect(formatDateTime(INSTANT, undefined, { locale: 'zh' })).toBe('2024年1月5日 08:30'); }); /** PIN, green on both sides — see the `en` note above. */ it('en output is byte-identical (must-not-change)', () => { - expect(formatDateTime(INSTANT, { locale: 'en' })).toBe('Jan 5, 2024, 08:30 AM'); + expect(formatDateTime(INSTANT, undefined, { locale: 'en' })).toBe('Jan 5, 2024, 08:30 AM'); }); /** @@ -114,7 +122,7 @@ describe('formatDateTime accepts a locale at all (objectui#4272)', () => { }); it('the empty / invalid guards are untouched', () => { - expect(formatDateTime('', { locale: 'zh' })).toBe('—'); - expect(formatDateTime('not-a-date', { locale: 'zh' })).toBe('—'); + expect(formatDateTime('', undefined, { locale: 'zh' })).toBe('—'); + expect(formatDateTime('not-a-date', undefined, { locale: 'zh' })).toBe('—'); }); }); diff --git a/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx b/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx new file mode 100644 index 0000000000..5bf93949ac --- /dev/null +++ b/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx @@ -0,0 +1,255 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7443 — ONE home for the `datetime` display convention. + * + * ── What was measured ──────────────────────────────────────────────────── + * `date` cells routed through the shared `formatDate` and read `field.format` + * as a display style. `datetime` cells did neither: `DateTimeCellRenderer` + * destructured `value` only and built its OWN pair of `Intl` option bags, so + * one field type had two display conventions kept in step by nothing, and the + * style vocabulary `date` had was unreachable for `datetime`. + * + * | path | rendered | + * | --------------------------------- | ----------------------- | + * | `DateTimeCellRenderer` (the cell) | `7/4/2024 7:00 am` | + * | `formatDateTime` (the module's) | `Jul 4, 2024, 07:00 AM` | + * + * That is the shape of objectui#4576, which this repo has already paid for + * once (`1.234,5 %` beside `1.234,5%` in a German session). + * + * ── The property these pins exist to prove ─────────────────────────────── + * The compact face was NOT redesigned. It was named `'compact'`, moved into + * `formatDateTime`, and every existing cell still renders it — BYTE-identical, + * not merely "still a string". Each expectation below is therefore computed + * from the EXACT option bags the renderer used to inline (`FORMER_*` at the + * top of the file), so an ICU or Node upgrade moves both sides together and + * the pin keeps measuring drift from the former face rather than the age of + * the runner. The `en-US` literal from the card is asserted alongside them, on + * the same instant, so a silent redesign cannot pass by moving both sides. + * + * ── Directions ─────────────────────────────────────────────────────────── + * Reverting `formatDateTime` to a single default face turns every `'compact'` + * case RED. Reverting the renderer to its inlined bags leaves the `'compact'` + * cases GREEN (they measure the function) and turns the `field.format` cases + * RED (the renderer would read no field at all). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { + DateCellRenderer, + DateTimeCellRenderer, + formatDateTime, + formatDateTimeCompactParts, +} from '../index'; + +/** The card's instant, in the card's locale, with the card's expected face. */ +const INSTANT = '2024-07-04T07:00:00.000Z'; + +/** + * The two option bags `DateTimeCellRenderer` inlined before this change, + * copied verbatim from `origin/main`. Every "byte-identical" claim below is + * measured against THESE, not against a literal typed by hand. + */ +const FORMER_DATE_BAG: Intl.DateTimeFormatOptions = { + month: 'numeric', + day: 'numeric', + year: 'numeric', +}; +const FORMER_TIME_BAG: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + hour12: true, +}; + +/** Exactly what the cell used to compute, for a locale. */ +function formerCellFace(iso: string, locale: string): { date: string; time: string } { + const d = new Date(iso); + return { + date: d.toLocaleDateString(locale, FORMER_DATE_BAG), + time: d.toLocaleTimeString(locale, FORMER_TIME_BAG).toLowerCase(), + }; +} + +/** `en-US` plus one NON-US locale, as the ruling requires. */ +const LOCALES = ['en-US', 'zh', 'de-DE']; + +/** + * `useDisplayLocale()` resolves tenant locale -> active UI language -> `'en'`, + * so the tag is set as the TENANT locale: it is the channel objectui#4468 + * made every date branch read, and it pins the exact tag rather than whatever + * a language code happens to widen to. `persistLanguage={false}` keeps each + * case on its own language instead of inheriting the previous one's. + */ +function renderSession(locale: string, node: React.ReactElement) { + return render( + + {node} + , + ); +} + +afterEach(() => cleanup()); + +describe("formatDateTime's 'compact' style is the former cell face, byte-identical", () => { + it.each(LOCALES)('%s — the joined face', (locale) => { + const former = formerCellFace(INSTANT, locale); + expect(formatDateTime(INSTANT, 'compact', { locale })).toBe( + `${former.date} ${former.time}`, + ); + }); + + it.each(LOCALES)('%s — the halves the cell paints separately', (locale) => { + const former = formerCellFace(INSTANT, locale); + expect(formatDateTimeCompactParts(INSTANT, { locale })).toEqual(former); + }); + + it('the en-US face is the one the card recorded', () => { + expect(formatDateTime(INSTANT, 'compact', { locale: 'en-US' })).toBe('7/4/2024 7:00 am'); + }); + + it('the joined face is exactly the two halves and one space', () => { + const parts = formatDateTimeCompactParts(INSTANT, { locale: 'en-US' })!; + expect(`${parts.date} ${parts.time}`).toBe( + formatDateTime(INSTANT, 'compact', { locale: 'en-US' }), + ); + }); + + it('empty and invalid values answer the module dash, not a broken face', () => { + expect(formatDateTime('', 'compact', { locale: 'en-US' })).toBe('—'); + expect(formatDateTime('not-a-date', 'compact', { locale: 'en-US' })).toBe('—'); + expect(formatDateTimeCompactParts('', { locale: 'en-US' })).toBeNull(); + expect(formatDateTimeCompactParts('not-a-date', { locale: 'en-US' })).toBeNull(); + }); +}); + +describe('the DEFAULT style is untouched — the non-cell convention still renders', () => { + it.each(LOCALES)('%s — unchanged verbose face', (locale) => { + const verbose = new Date(INSTANT).toLocaleDateString(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + expect(formatDateTime(INSTANT, undefined, { locale })).toBe(verbose); + }); + + it('en-US default is the second row of the card table, not the compact face', () => { + expect(formatDateTime(INSTANT, undefined, { locale: 'en-US' })).toBe('Jul 4, 2024, 07:00 AM'); + expect(formatDateTime(INSTANT, undefined, { locale: 'en-US' })).not.toBe( + formatDateTime(INSTANT, 'compact', { locale: 'en-US' }), + ); + }); + + it('an unrecognised style falls to the default face, as it does on formatDate', () => { + expect(formatDateTime(INSTANT, 'no-such-style', { locale: 'en-US' })).toBe( + formatDateTime(INSTANT, undefined, { locale: 'en-US' }), + ); + }); +}); + +describe('every existing datetime cell renders unchanged', () => { + it.each(LOCALES)('%s — a field with no format still paints the compact halves', (locale) => { + const former = formerCellFace(INSTANT, locale); + const { container } = renderSession( + locale, + , + ); + const spans = container.querySelectorAll('span > span'); + expect(spans).toHaveLength(2); + expect(spans[0].textContent).toBe(former.date); + expect(spans[1].textContent).toBe(former.time); + // The time half stays muted and offset — the two-tone face is the visual + // half of "renders unchanged", and collapsing it to one string would be a + // visible change even with identical text. + expect(spans[1].className).toMatch(/text-muted-foreground/); + expect(spans[1].className).toMatch(/ml-2/); + }); + + it('an authored empty format is still the compact face, not the verbose one', () => { + const former = formerCellFace(INSTANT, 'en-US'); + const { container } = renderSession( + 'en-US', + , + ); + expect(container.textContent).toBe(`${former.date}${former.time}`); + }); +}); + +describe('field.format now works for datetime, the way it already did for date', () => { + it('an explicit compact format renders the same face as no format at all', () => { + const { container: implicit } = renderSession( + 'en-US', + , + ); + const implicitText = implicit.textContent; + cleanup(); + const { container: explicit } = renderSession( + 'en-US', + , + ); + expect(explicit.textContent).toBe(implicitText); + }); + + it('an authored non-compact format reaches formatDateTime — the vocabulary is live', () => { + const { container } = renderSession( + 'en-US', + , + ); + expect(container.textContent).toBe('Jul 4, 2024, 07:00 AM'); + // Before this change the renderer never saw `field` at all, so this string + // was unreachable from metadata however it was authored. + expect(container.textContent).not.toBe('7/4/2024 7:00 am'); + }); + + it('an absent field is tolerated — the compact face is the default', () => { + const { container } = renderSession( + 'en-US', + , + ); + expect(container.textContent).toBe('7/4/20247:00 am'); + }); +}); + +describe('the date cell path is untouched', () => { + it('a date field still renders through formatDate, not the datetime convention', () => { + const { container } = renderSession( + 'en-US', + , + ); + expect(container.textContent).toBe("Jul 4, '24"); + }); + + it("a date field's default style is still relative, not compact", () => { + const { container } = renderSession( + 'en-US', + , + ); + expect(container.textContent).not.toContain('7:00 am'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 593f7564e3..cf9a945634 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -8,7 +8,7 @@ import React from 'react'; import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types'; -import { ComponentRegistry, percentDisplayValue, getRecordDisplayName, humanizeLabel, isMissingForRequired, formatDate, formatDateTime, formatRelativeDate, type ComponentMeta, type DateDisplayOptions } from '@object-ui/core'; +import { ComponentRegistry, percentDisplayValue, getRecordDisplayName, humanizeLabel, isMissingForRequired, formatDate, formatDateTime, formatDateTimeCompactParts, formatRelativeDate, type ComponentMeta, type DateDisplayOptions } from '@object-ui/core'; // The platform's own value-shape contract, asked rather than restated // (objectui#6744). See `locationStoredValueSchemaFor` below for why this is a // runtime import in the barrel and not a hand-written coordinate range. @@ -589,7 +589,7 @@ export { humanizeLabel }; * moving was a second date convention in `dataset-format.ts`, which is the * drift #4576 already paid for once with percent. */ -export { formatDate, formatDateTime, formatRelativeDate }; +export { formatDate, formatDateTime, formatDateTimeCompactParts, formatRelativeDate }; export type { DateDisplayOptions }; /** @@ -863,36 +863,49 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea /** * DateTime field cell renderer (Airtable-style with date and time visually separated) */ -export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement { +export function DateTimeCellRenderer({ value, field }: CellRendererProps): React.ReactElement { // Hook before every early return — a value flipping between null and set // must not change the hook count between renders (same rule as the number / // currency renderers above). This is the site objectui#4468 caught rendering // `8/11/2026 12:00 am` inside an otherwise Chinese grid: both calls passed // `undefined`, i.e. the machine's locale, on every session. const locale = useDisplayLocale(); + const t = useFieldTranslate(); if (!value) return ; const safe = coerceToSafeValue(value); const date = safe != null ? new Date(safe as string | number) : null; if (date === null || isNaN(date.getTime())) return ; - const datePart = date.toLocaleDateString(locale, { - month: 'numeric', - day: 'numeric', - year: 'numeric', - }); - // `hour12` stays declared: this is the compact Airtable-style cell, and the - // 12-hour face is its design, not a locale artefact. Locales that write no - // am/pm marker simply ignore it. - const timePart = date.toLocaleTimeString(locale, { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }).toLowerCase(); + // `field.format` is read as a display style here for the same reason + // `DateCellRenderer` reads it one function up: `datetime` had no style + // vocabulary at all because this renderer destructured `value` only + // (objectui#7443). `||`, not `??`, matches the `date` cell and keeps an + // authored empty string on the compact face rather than dropping it into + // the verbose default. + const style = (field as any)?.format || 'compact'; + + // The compact face is painted in two halves — the time is muted and offset + // — so this branch asks the shared module for the halves rather than the + // joined string. Both come out of `formatDateTimeCompactParts`, which is + // also what `formatDateTime(value, 'compact')` joins, so the cell and every + // string caller of the compact face render the same instant identically. + // `null` is unreachable: the invalid/empty values it answers for already + // returned `` above. + if (style === 'compact') { + const parts = formatDateTimeCompactParts(date, { locale }); + if (parts) { + return ( + + {parts.date} + {parts.time} + + ); + } + } return ( - {datePart} - {timePart} + {formatDateTime(date, style, { locale, t })} ); } diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx index 2322c47bd3..0b1970f399 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx @@ -72,14 +72,14 @@ describe('DatasetWidget metric tile renders a date measure as a date (objectui#7 it('shows what a list cell shows, not the raw ISO string', async () => { renderIn(EN, METRIC, makeSource([{ oldest_touch: OLDEST }], DATE_FIELDS)); - const expected = formatDateTime(OLDEST, { locale: EN }); + const expected = formatDateTime(OLDEST, undefined, { locale: EN }); expect(await screen.findByText(expected)).toBeInTheDocument(); expect(screen.queryByText(OLDEST)).not.toBeInTheDocument(); }); it('follows the display locale, through that same path', async () => { renderIn(DE, METRIC, makeSource([{ oldest_touch: OLDEST }], DATE_FIELDS)); - const expected = formatDateTime(OLDEST, { locale: DE }); + const expected = formatDateTime(OLDEST, undefined, { locale: DE }); expect(await screen.findByText(expected)).toBeInTheDocument(); }); @@ -96,7 +96,7 @@ describe('DatasetWidget dataset table renders a date measure as a date (objectui it('shows what a list cell shows in the measure cell', async () => { renderIn(EN, TABLE, makeSource([{ owner: 'Ada', oldest_touch: OLDEST }], FIELDS)); - const expected = formatDateTime(OLDEST, { locale: EN }); + const expected = formatDateTime(OLDEST, undefined, { locale: EN }); await waitFor(() => expect(screen.getByText(expected)).toBeInTheDocument()); expect(screen.queryByText(OLDEST)).not.toBeInTheDocument(); }); diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 165afb79bb..c53f95bb08 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -943,14 +943,14 @@ export const ObjectGantt: React.FC = ({ if (type == null && typeof value === 'string') { if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return formatDate(value, undefined, { locale: displayLocale }); if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value) && !isNaN(new Date(value).getTime())) { - return formatDateTime(value, { locale: displayLocale }); + return formatDateTime(value, undefined, { locale: displayLocale }); } } switch (type) { case 'date': return formatDate(value as any, undefined, { locale: displayLocale }); case 'datetime': - return formatDateTime(value as any, { locale: displayLocale }); + return formatDateTime(value as any, undefined, { locale: displayLocale }); // The numeric rows take the same `displayLocale` as the temporal ones // above (objectui#4553). Without it these reached // `new Intl.NumberFormat(undefined, …)`, i.e. the MACHINE's locale — From a43aa8101c2da6154678b3f7b2b8301902f9f85c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 13:34:41 +0000 Subject: [PATCH 2/2] fix(core): formatDateTime keeps (value, options?); 'compact' rides in options.style Maintainer ruling B (objectui#7443, batch #34): the published signature formatDateTime(value, options?) is unchanged and the named 'compact' style is selected through options.style. DateDisplayOptions gains the optional key; the cell calls formatDateTime(date, { style, locale, t }) with the '||' default; the ten-plus in-repo call-site migrations the positional shape forced are reverted to origin/main byte for byte; every pin from ruling 1' is kept and an arity pin guards the signature. Changeset rewritten without the breaking paragraph. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .changeset/7443-datetime-compact-style.md | 38 +++++------ .../src/renderers/complex/data-table.tsx | 2 +- .../__tests__/dataset-format.date.test.ts | 6 +- packages/core/src/utils/dataset-format.ts | 2 +- packages/core/src/utils/date-display.ts | 58 +++++++++++------ .../date-formatter-residue-4272.test.ts | 16 ++--- .../datetime-compact-style-7443.test.tsx | 65 +++++++++++++++---- packages/fields/src/index.tsx | 7 +- .../DatasetWidget.dateMeasure.test.tsx | 6 +- packages/plugin-gantt/src/ObjectGantt.tsx | 4 +- 10 files changed, 127 insertions(+), 77 deletions(-) diff --git a/.changeset/7443-datetime-compact-style.md b/.changeset/7443-datetime-compact-style.md index cc9ee16d56..c7190f6f26 100644 --- a/.changeset/7443-datetime-compact-style.md +++ b/.changeset/7443-datetime-compact-style.md @@ -1,27 +1,27 @@ --- '@object-ui/core': minor '@object-ui/fields': minor -'@object-ui/components': minor -'@object-ui/plugin-gantt': minor +'@object-ui/components': patch --- One home for the `datetime` display convention (objectui#7443). -`formatDateTime` gains a named `'compact'` style — the dense grid face, -`7/4/2024 7:00 am` in `en-US` — which `DateTimeCellRenderer` used to build from -its own inlined `Intl` option bags. The cell now reads `field.format` (it -destructured `value` only, so a `datetime` field could not reach the style -vocabulary a `date` field has) and renders through the shared function, and -`data-table`'s `formatCellValue` calls `formatDateTime` instead of a third, -independently authored option bag. Every existing cell renders byte-identically; -`'compact'` is today's face named and rehoused, not a new one. +`formatDateTime` gains a named `'compact'` style, selected through +`options.style` — the dense grid face, `7/4/2024 7:00 am` in `en-US` — which +`DateTimeCellRenderer` used to build from its own inlined `Intl` option bags. +The cell now reads `field.format` (it destructured `value` only, so a +`datetime` field could not reach the style vocabulary a `date` field has) and +renders through the shared function, and `data-table`'s `formatCellValue` +calls `formatDateTime` instead of a third, independently authored option bag. +Every existing cell renders byte-identically; `'compact'` is today's face +named and rehoused, not a new one. -BREAKING (source-compatible only after moving one argument): `formatDateTime`'s -signature is now `(value, style?, options?)`, matching `formatDate`. The -`options` parameter added in objectui#4272 moved from position two to position -three — `formatDateTime(v, { locale })` becomes -`formatDateTime(v, undefined, { locale })`. TypeScript rejects the old form at -every call site; a JavaScript caller that does not move the argument silently -loses its locale, which is the objectui#4272 defect. Marked `minor`, per this -repo's fixed-group rule that a breaking change is described rather than -majored. +Additive, no signature change: `formatDateTime(value, options?)` is unchanged +and `formatDateTime(v, { locale })` keeps meaning what it meant. +`DateDisplayOptions` gains an optional `style` key (read by `formatDateTime` +only; `formatDate` still takes its style positionally), and +`formatDateTimeCompactParts` is a new export of `@object-ui/core`, re-exported +by `@object-ui/fields`, returning the compact face as the two halves a grid +cell paints separately. `@object-ui/components` changes no rendered output — +the table's datetime cell is measured identical before and after in `en-US`, +`zh` and `de-DE`. diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 637ef23393..0af09e4480 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -771,7 +771,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // independently authored `Intl.DateTimeFormat` bag here, close to but // not derived from the shared function. Byte-identical in en-US, zh and // de-DE, so no table cell changes. - if (hasTime) return formatDateTime(new Date(ts), undefined, { locale: language }); + if (hasTime) return formatDateTime(new Date(ts), { locale: language }); // The DATE-only half keeps its own bag on purpose: `formatDateTime` // always carries a time, and `formatDate`'s default drops the year in // the current year — routing this branch through either WOULD change diff --git a/packages/core/src/utils/__tests__/dataset-format.date.test.ts b/packages/core/src/utils/__tests__/dataset-format.date.test.ts index 717600e16f..28e3dfffe6 100644 --- a/packages/core/src/utils/__tests__/dataset-format.date.test.ts +++ b/packages/core/src/utils/__tests__/dataset-format.date.test.ts @@ -50,7 +50,7 @@ describe('formatMeasure routes a date-shaped measure through the shared date pat it('renders a datetime measure as a datetime, not as its raw ISO string', () => { const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN); expect(out).not.toBe(ISO_DATETIME); - expect(out).toBe(formatDateTime(ISO_DATETIME, undefined, { locale: EN })); + expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN })); }); it('renders a date-only measure as a date, not as its raw ISO string', () => { @@ -62,7 +62,7 @@ describe('formatMeasure routes a date-shaped measure through the shared date pat it('accepts the space-separated ISO spelling a backend may send', () => { const spaced = '2024-07-04 07:00:00'; expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe( - formatDateTime(spaced, undefined, { locale: EN }), + formatDateTime(spaced, { locale: EN }), ); }); @@ -70,7 +70,7 @@ describe('formatMeasure routes a date-shaped measure through the shared date pat const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE'); const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN); expect(de).not.toBe(en); - expect(de).toBe(formatDateTime(ISO_DATETIME, undefined, { locale: 'de-DE' })); + expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' })); }); }); diff --git a/packages/core/src/utils/dataset-format.ts b/packages/core/src/utils/dataset-format.ts index 26a46a9bec..ee67e70495 100644 --- a/packages/core/src/utils/dataset-format.ts +++ b/packages/core/src/utils/dataset-format.ts @@ -192,7 +192,7 @@ function formatMeasureDate(v: unknown, format: string | undefined, locale: strin return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale }); } if (ISO_DATETIME_RE.test(v)) { - return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, undefined, { locale }); + return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale }); } return undefined; } diff --git a/packages/core/src/utils/date-display.ts b/packages/core/src/utils/date-display.ts index afe1ec3c6e..8d5716224c 100644 --- a/packages/core/src/utils/date-display.ts +++ b/packages/core/src/utils/date-display.ts @@ -46,13 +46,33 @@ * in `dataset-format.ts` takes `fieldLabel`. */ -/** Options shared by {@link formatDate} / {@link formatRelativeDate}. */ +/** + * Options shared by {@link formatDate} / {@link formatRelativeDate} / + * {@link formatDateTime}. One bag, and each function reads the keys it needs: + * `dueLike` and `t` only matter on the relative path, `style` is read by + * `formatDateTime` only (see below). + */ export interface DateDisplayOptions { dueLike?: boolean; /** BCP-47 display locale (ADR-0053 tenant default); falls back to the runtime locale. */ locale?: string; /** i18n translate fn for phrases `Intl` can't produce (the "Overdue Nd" wording). */ t?: (key: string, params?: Record) => string; + /** + * Named face, read by {@link formatDateTime}: `'compact'` is the dense grid + * cell face (objectui#7443); anything else, or absent, is the default face. + * + * It rides here rather than in a second positional parameter because + * `formatDateTime(value, options?)` is a PUBLISHED signature with `options` + * in position two (objectui#4272). A positional `style` would have displaced + * it: TypeScript would reject the old call, but a JavaScript caller would + * silently hand its options bag to the style slot and lose its locale — + * the #4272 defect again. {@link formatDate} still takes its style + * positionally and does NOT read this key; the symmetric long-run shape + * (both functions reading `options.style`) is additive on `formatDate` and + * deliberately not part of #7443. + */ + style?: string; } /** @@ -147,13 +167,13 @@ export function formatDate(value: string | Date | number, style?: string, option * The `'compact'` datetime face as the two halves a grid cell paints * separately — `7/4/2024` and `7:00 am` for `2024-07-04T07:00:00Z` in `en-US`. * - * `formatDateTime(value, 'compact', options)` is exactly `date + ' ' + time` - * of what this returns, so a caller that wants the face as ONE string and a - * caller that wants to style the halves differently cannot drift apart. That - * drift is what objectui#7443 recorded: `DateTimeCellRenderer` inlined these - * two option bags and never called this module, so `datetime` had two display - * conventions while `date` had one — the same shape as objectui#4576, which - * this repo has already paid for once. + * `formatDateTime(value, { style: 'compact', ...options })` is exactly + * `date + ' ' + time` of what this returns, so a caller that wants the face + * as ONE string and a caller that wants to style the halves differently + * cannot drift apart. That drift is what objectui#7443 recorded: + * `DateTimeCellRenderer` inlined these two option bags and never called this + * module, so `datetime` had two display conventions while `date` had one — + * the same shape as objectui#4576, which this repo has already paid for once. * * `null` for a value this module renders as `'—'`; the cell renders its own * empty state for those, so it never sees the dash. @@ -186,20 +206,20 @@ export function formatDateTimeCompactParts( /** * Format datetime value. * - * `style` selects a named face, exactly as it does on {@link formatDate}: + * `options.style` selects a named face: * * - `'compact'` — the dense grid face, `7/4/2024 7:00 am` in `en-US`. It is * what every `datetime` CELL renders, and what `DateTimeCellRenderer` * used to build from its own inlined `Intl` bags (objectui#7443). - * - anything else, including `undefined` — the verbose default, + * - anything else, including absent — the verbose default, * `Jul 4, 2024, 07:00 AM` in `en-US`. Unchanged, and still what a * non-cell caller (dataset measure, gantt tooltip, data-table) gets. * - * ⚠️ `style` sits in the SAME position it does on `formatDate`, which means it - * displaced the `options` parameter objectui#4272 had added here in position - * two. Every call passing options positionally had to move them along one; - * the two functions being callable the same way is the point — a fourth - * author copying whichever is nearest now copies a consistent pair. + * The signature is `(value, options?)`, unchanged: `style` is a key of + * `options`, not a positional parameter, so every existing call — + * `formatDateTime(v, { locale })` included — keeps meaning exactly what it + * meant (see the note on `DateDisplayOptions.style` for why the positional + * shape `formatDate` uses was refused here). * * `options` is optional, so a caller that passes nothing keeps the exact * runtime-default behavior it had. Before objectui#4272 the parameter did not @@ -208,16 +228,12 @@ export function formatDateTimeCompactParts( * MACHINE's locale, which is neither of the repo's two locale channels. * Callers should pass the tag from `useDisplayLocale()`. */ -export function formatDateTime( - value: string | Date | number, - style?: string, - options?: DateDisplayOptions, -): string { +export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string { if (value === null || value === undefined || value === '') return '—'; const date = value instanceof Date ? value : new Date(value as any); if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; - if (style === 'compact') { + if (options?.style === 'compact') { const parts = formatDateTimeCompactParts(date, options); return parts ? `${parts.date} ${parts.time}` : '—'; } diff --git a/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts b/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts index 9164ab55fb..b8ed8eace6 100644 --- a/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts +++ b/packages/fields/src/__tests__/date-formatter-residue-4272.test.ts @@ -82,22 +82,14 @@ describe("formatDate 'short' honors the threaded locale (objectui#4272)", () => }); }); -/** - * ⚠️ Position moved, contract did not (objectui#7443). `formatDateTime` grew a - * `style` parameter in position two — the slot `formatDate` has always used — - * so the options this describe-block exists to protect now travel in position - * three. Every expected string below is unchanged: what #4272 bought is that - * the tag REACHES `Intl`, and it still does. Passing `undefined` for the style - * is the default face, which is what these cases always measured. - */ describe('formatDateTime accepts a locale at all (objectui#4272)', () => { it('zh renders the Chinese datetime form', () => { - expect(formatDateTime(INSTANT, undefined, { locale: 'zh' })).toBe('2024年1月5日 08:30'); + expect(formatDateTime(INSTANT, { locale: 'zh' })).toBe('2024年1月5日 08:30'); }); /** PIN, green on both sides — see the `en` note above. */ it('en output is byte-identical (must-not-change)', () => { - expect(formatDateTime(INSTANT, undefined, { locale: 'en' })).toBe('Jan 5, 2024, 08:30 AM'); + expect(formatDateTime(INSTANT, { locale: 'en' })).toBe('Jan 5, 2024, 08:30 AM'); }); /** @@ -122,7 +114,7 @@ describe('formatDateTime accepts a locale at all (objectui#4272)', () => { }); it('the empty / invalid guards are untouched', () => { - expect(formatDateTime('', undefined, { locale: 'zh' })).toBe('—'); - expect(formatDateTime('not-a-date', undefined, { locale: 'zh' })).toBe('—'); + expect(formatDateTime('', { locale: 'zh' })).toBe('—'); + expect(formatDateTime('not-a-date', { locale: 'zh' })).toBe('—'); }); }); diff --git a/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx b/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx index 5bf93949ac..4094ff94fe 100644 --- a/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx +++ b/packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx @@ -34,11 +34,22 @@ * the runner. The `en-US` literal from the card is asserted alongside them, on * the same instant, so a silent redesign cannot pass by moving both sides. * + * ── The call shape (maintainer ruling B, objectui#7443) ────────────────── + * `'compact'` rides INSIDE `options` — `formatDateTime(v, { style: 'compact', + * locale })` — and the published signature stays `(value, options?)`. A + * positional `style` parameter would have displaced the `options` objectui#4272 + * put in position two, and a JavaScript caller that did not move its argument + * would silently lose its locale. The arity pin below is the mechanical guard: + * `formatDateTime.length` is 2 and turns 3 the moment a positional slot is + * inserted again. + * * ── Directions ─────────────────────────────────────────────────────────── * Reverting `formatDateTime` to a single default face turns every `'compact'` * case RED. Reverting the renderer to its inlined bags leaves the `'compact'` * cases GREEN (they measure the function) and turns the `field.format` cases - * RED (the renderer would read no field at all). + * RED (the renderer would read no field at all). Re-inserting a positional + * `style` turns the arity pin RED and, because `{ style, locale }` would then + * land in the style slot, every localized case with it. */ import { describe, it, expect, afterEach } from 'vitest'; import React from 'react'; @@ -106,7 +117,7 @@ afterEach(() => cleanup()); describe("formatDateTime's 'compact' style is the former cell face, byte-identical", () => { it.each(LOCALES)('%s — the joined face', (locale) => { const former = formerCellFace(INSTANT, locale); - expect(formatDateTime(INSTANT, 'compact', { locale })).toBe( + expect(formatDateTime(INSTANT, { style: 'compact', locale })).toBe( `${former.date} ${former.time}`, ); }); @@ -117,19 +128,19 @@ describe("formatDateTime's 'compact' style is the former cell face, byte-identic }); it('the en-US face is the one the card recorded', () => { - expect(formatDateTime(INSTANT, 'compact', { locale: 'en-US' })).toBe('7/4/2024 7:00 am'); + expect(formatDateTime(INSTANT, { style: 'compact', locale: 'en-US' })).toBe('7/4/2024 7:00 am'); }); it('the joined face is exactly the two halves and one space', () => { const parts = formatDateTimeCompactParts(INSTANT, { locale: 'en-US' })!; expect(`${parts.date} ${parts.time}`).toBe( - formatDateTime(INSTANT, 'compact', { locale: 'en-US' }), + formatDateTime(INSTANT, { style: 'compact', locale: 'en-US' }), ); }); it('empty and invalid values answer the module dash, not a broken face', () => { - expect(formatDateTime('', 'compact', { locale: 'en-US' })).toBe('—'); - expect(formatDateTime('not-a-date', 'compact', { locale: 'en-US' })).toBe('—'); + expect(formatDateTime('', { style: 'compact', locale: 'en-US' })).toBe('—'); + expect(formatDateTime('not-a-date', { style: 'compact', locale: 'en-US' })).toBe('—'); expect(formatDateTimeCompactParts('', { locale: 'en-US' })).toBeNull(); expect(formatDateTimeCompactParts('not-a-date', { locale: 'en-US' })).toBeNull(); }); @@ -144,19 +155,49 @@ describe('the DEFAULT style is untouched — the non-cell convention still rende hour: '2-digit', minute: '2-digit', }); - expect(formatDateTime(INSTANT, undefined, { locale })).toBe(verbose); + expect(formatDateTime(INSTANT, { locale })).toBe(verbose); }); it('en-US default is the second row of the card table, not the compact face', () => { - expect(formatDateTime(INSTANT, undefined, { locale: 'en-US' })).toBe('Jul 4, 2024, 07:00 AM'); - expect(formatDateTime(INSTANT, undefined, { locale: 'en-US' })).not.toBe( - formatDateTime(INSTANT, 'compact', { locale: 'en-US' }), + expect(formatDateTime(INSTANT, { locale: 'en-US' })).toBe('Jul 4, 2024, 07:00 AM'); + expect(formatDateTime(INSTANT, { locale: 'en-US' })).not.toBe( + formatDateTime(INSTANT, { style: 'compact', locale: 'en-US' }), ); }); it('an unrecognised style falls to the default face, as it does on formatDate', () => { - expect(formatDateTime(INSTANT, 'no-such-style', { locale: 'en-US' })).toBe( - formatDateTime(INSTANT, undefined, { locale: 'en-US' }), + expect(formatDateTime(INSTANT, { style: 'no-such-style', locale: 'en-US' })).toBe( + formatDateTime(INSTANT, { locale: 'en-US' }), + ); + }); +}); + +describe("the signature stays (value, options?) — 'compact' rides in options (ruling B)", () => { + it('formatDateTime declares exactly two parameters: no positional style slot', () => { + // `Function.length` counts declared parameters. `(value, options?)` is 2; + // the refused `(value, style?, options?)` shape is 3. + expect(formatDateTime.length).toBe(2); + }); + + it('the objectui#4272 call shape — options in position two — still localizes', () => { + const zh = formatDateTime(INSTANT, { locale: 'zh' }); + const en = formatDateTime(INSTANT, { locale: 'en-US' }); + expect(zh).toBe( + new Date(INSTANT).toLocaleDateString('zh', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }), + ); + expect(zh).not.toBe(en); + }); + + it('style is read from options, so the compact face is reachable with the locale beside it', () => { + const former = formerCellFace(INSTANT, 'de-DE'); + expect(formatDateTime(INSTANT, { style: 'compact', locale: 'de-DE' })).toBe( + `${former.date} ${former.time}`, ); }); }); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 294efa2031..bb09749989 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -887,8 +887,9 @@ export function DateTimeCellRenderer({ value, field }: CellRendererProps): React // The compact face is painted in two halves — the time is muted and offset // — so this branch asks the shared module for the halves rather than the // joined string. Both come out of `formatDateTimeCompactParts`, which is - // also what `formatDateTime(value, 'compact')` joins, so the cell and every - // string caller of the compact face render the same instant identically. + // also what `formatDateTime(value, { style: 'compact' })` joins, so the + // cell and every string caller of the compact face render the same instant + // identically. // `null` is unreachable: the invalid/empty values it answers for already // returned `` above. if (style === 'compact') { @@ -905,7 +906,7 @@ export function DateTimeCellRenderer({ value, field }: CellRendererProps): React return ( - {formatDateTime(date, style, { locale, t })} + {formatDateTime(date, { style, locale, t })} ); } diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx index 0b1970f399..2322c47bd3 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx @@ -72,14 +72,14 @@ describe('DatasetWidget metric tile renders a date measure as a date (objectui#7 it('shows what a list cell shows, not the raw ISO string', async () => { renderIn(EN, METRIC, makeSource([{ oldest_touch: OLDEST }], DATE_FIELDS)); - const expected = formatDateTime(OLDEST, undefined, { locale: EN }); + const expected = formatDateTime(OLDEST, { locale: EN }); expect(await screen.findByText(expected)).toBeInTheDocument(); expect(screen.queryByText(OLDEST)).not.toBeInTheDocument(); }); it('follows the display locale, through that same path', async () => { renderIn(DE, METRIC, makeSource([{ oldest_touch: OLDEST }], DATE_FIELDS)); - const expected = formatDateTime(OLDEST, undefined, { locale: DE }); + const expected = formatDateTime(OLDEST, { locale: DE }); expect(await screen.findByText(expected)).toBeInTheDocument(); }); @@ -96,7 +96,7 @@ describe('DatasetWidget dataset table renders a date measure as a date (objectui it('shows what a list cell shows in the measure cell', async () => { renderIn(EN, TABLE, makeSource([{ owner: 'Ada', oldest_touch: OLDEST }], FIELDS)); - const expected = formatDateTime(OLDEST, undefined, { locale: EN }); + const expected = formatDateTime(OLDEST, { locale: EN }); await waitFor(() => expect(screen.getByText(expected)).toBeInTheDocument()); expect(screen.queryByText(OLDEST)).not.toBeInTheDocument(); }); diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index bce85c02da..e01d65ff9d 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -919,14 +919,14 @@ export const ObjectGantt: React.FC = ({ if (type == null && typeof value === 'string') { if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return formatDate(value, undefined, { locale: displayLocale }); if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value) && !isNaN(new Date(value).getTime())) { - return formatDateTime(value, undefined, { locale: displayLocale }); + return formatDateTime(value, { locale: displayLocale }); } } switch (type) { case 'date': return formatDate(value as any, undefined, { locale: displayLocale }); case 'datetime': - return formatDateTime(value as any, undefined, { locale: displayLocale }); + return formatDateTime(value as any, { locale: displayLocale }); // The numeric rows take the same `displayLocale` as the temporal ones // above (objectui#4553). Without it these reached // `new Intl.NumberFormat(undefined, …)`, i.e. the MACHINE's locale —