From 205d809d28c3be360247ba552ffcc2bf9a5e2c4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 10:14:33 +0000 Subject: [PATCH] fix(fields): honour `relative` and `short` on a datetime cell, as the date cell already did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DateCellRenderer` and `DateTimeCellRenderer` are neighbours reading ONE authored `field.format` and handing it to two formatters with different vocabularies: `formatDate` honours `'short'` and `'relative'`, while `formatDateTime`'s `options.style` honours `'compact'` alone. So `format: 'relative'` painted the relative face on a `date` column and the verbose absolute face on a `datetime` one — no error, no warning, no fallback. Driven end to end through a real ObjectGrid column before any change, one row, one instant, the key authored on both fields: the `date` cell painted `In 2 days` and the `datetime` cell beside it painted `Sep 11, 2026, 09:30 AM`. The cell now SELECTS a formatter instead of threading one, which is the ruling objectui#8352 already made for `formatMeasureDate`'s datetime arm — the same defect class one surface over. No published signature moved, and no spelling that rendered before is refused now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- .../datetime-cell-format-vocabulary-8853.md | 68 +++++ ...atetimeCell.formatVocabulary-8853.test.tsx | 261 ++++++++++++++++++ packages/fields/src/index.tsx | 66 ++++- 3 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 .changeset/datetime-cell-format-vocabulary-8853.md create mode 100644 packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx diff --git a/.changeset/datetime-cell-format-vocabulary-8853.md b/.changeset/datetime-cell-format-vocabulary-8853.md new file mode 100644 index 0000000000..07350255e4 --- /dev/null +++ b/.changeset/datetime-cell-format-vocabulary-8853.md @@ -0,0 +1,68 @@ +--- +'@object-ui/fields': minor +--- + +A `datetime` grid cell now honours the same authored `field.format` words a `date` cell already honoured (objectui#8853). + +`DateCellRenderer` and `DateTimeCellRenderer` are neighbours reading ONE authored key +and handing it to two formatters with different vocabularies: `formatDate` honours +`'short'` and `'relative'`, `formatDateTime`'s `options.style` honours `'compact'` +alone. So `format: 'relative'` painted the relative face on a `date` column and the +verbose absolute face on a `datetime` one — no error, no warning, no fallback. +Measured end to end through a real `ObjectGrid` column before anything was changed: +one object, one row, one instant (`2026-09-11T09:30:00.000Z`, clock pinned to +`2026-09-09T12:00:00.000Z`, `en-US`), `format: 'relative'` authored on both fields, +the grid's own body cells read + + ["1Open", "Row One", "In 2 days", "Sep 11, 2026, 09:30 AM"] + +with the `date` column honouring the key and the `datetime` column beside it dropping +it. After this change the same run reads `["1Open", "Row One", "In 2 days", "In 2 +days"]`. + +The cell now SELECTS a formatter instead of threading one, the way objectui#8352 +already ruled for `formatMeasureDate`'s datetime arm — the same defect class one +surface over: + +- `'relative'` resolves through `formatRelativeDate`, the same function the `date` + cell reaches, so one calendar day reads the same phrase in either column. +- `'short'` resolves to the dense face of this type, which for a `datetime` cell is + the compact face it already paints — the time of day is kept, and it stays + byte-identical to an unstyled cell. +- every other string, `'compact'` and date patterns such as `'YYYY-MM-DD'` included, + falls to the default face exactly as before. + +**Behaviour change, spelled out.** Two authored spellings render differently than they +did, and one of them loses a component: + +- `format: 'relative'` on a `datetime` field: was the verbose absolute face + (`Sep 11, 2026, 09:30 AM`), is now the relative phrase (`In 2 days`). +- `format: 'short'` on a `datetime` field: was that same verbose face, is now the + compact face (`9/11/2026 9:30 am`). +- ⚠️ **Beyond the ±7-day window a `'relative'` datetime now shows no time of day.** + `formatRelativeDate` falls back to an absolute DATE face out there, so + `2026-09-20T09:30:00.000Z` renders `Sep 20` where it rendered + `Sep 20, 2026, 09:30 AM` before. That window belongs to `formatRelativeDate` and is + inherited rather than re-decided at the call site — re-deciding it would put a + second copy of the convention in the renderer, which is objectui#4576. `'relative'` + is day-granular by construction (it shows no time inside the window either), and + any other fallback would make the two columns unequal again, which is the defect + being closed. + +Note what the delta is and is not: this renderer ignored both words outright before, +so nothing was taken from a working feature — it starts honouring a request whose +granularity is days. It reaches only a `datetime` field whose author actually wrote +one of those two words. An unstyled `datetime` cell, an explicit `'compact'` one, an +authored empty string, and any other spelling all render exactly as they did. + +No published signature moved. `formatDateTime(value, options?)` is unchanged and +still ignores `'relative'` and `'short'` in its `options.style` key — threading the +authored word into it would have honoured `'compact'`, the one word the `date` cell +does NOT honour, while still ignoring both words it does, which is the defect +inverted rather than closed. No spelling that rendered before is refused now; adding +a refusal would be a breaking narrowing of a published metadata surface. + +`dueLike` is deliberately not threaded and is filed separately as objectui#8958: it +is a different authored key whose affordance travels with red styling and a +field-name heuristic, and acquiring a second key's behaviour while honouring the +first would be an unruled change. diff --git a/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx b/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx new file mode 100644 index 0000000000..a70711ef3b --- /dev/null +++ b/packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx @@ -0,0 +1,261 @@ +/** + * 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#8853 — ONE authored `field.format`, one meaning, across the two + * neighbouring date cell renderers. + * + * ## What was measured, before anything was changed + * + * Driven end to end through a REAL `ObjectGrid` column (not inferred from a + * source read): one object with a `date` field and a `datetime` field, BOTH + * authored `format: 'relative'`, one row, one instant + * (`2026-09-11T09:30:00.000Z`), clock pinned to `2026-09-09T12:00:00.000Z`, + * `en-US`. The grid's own body cells came back as + * + * ["1Open", "Row One", "In 2 days", "Sep 11, 2026, 09:30 AM"] + * ^ date ^ datetime, same key, dropped + * + * and after the call-site mapping, as + * + * ["1Open", "Row One", "In 2 days", "In 2 days"] + * + * The defect's whole signature is a SILENT drop — the runtime accepts the key, + * parses it, drops it, and renders something that still looks like a + * legitimate date — so a green suite proves nothing by itself. Every case + * below therefore asserts a rendered FACE, with controls that say what the + * probe would have shown had it been fooled. + * + * ## The controls, and why each one is here + * + * - **Positive control** — the `date` cell, in the SAME run, on the SAME + * instant. It already honoured `'relative'`, so it is what "honoured" looks + * like; without it, a datetime assertion could be measuring a broken clock + * or a dead locale channel rather than the vocabulary. + * - **Negative control** — an OUT-OF-WINDOW value. `formatRelativeDate` falls + * back to the absolute form beyond ±7 days, so out there `format: + * 'relative'` and no format at all render IDENTICALLY on the fully working + * `date` cell. That is the trap objectui#8352's card recorded: a probe built + * on an out-of-window value reports "identical" on a working path and on a + * broken one alike. The pin below asserts that collapse explicitly, so the + * in-window choice everywhere else is a measured decision rather than luck. + * + * ## Directions + * + * Reverting the call-site mapping in `DateTimeCellRenderer` turns the repro, + * the `'short'` face and the out-of-window datetime face RED, and leaves every + * control and regression pin GREEN — the controls measure surfaces this change + * does not touch, which is exactly why they can vouch for the run. Widening + * `formatDateTime`'s published signature instead (the refused route) turns the + * "published signature untouched" pins RED. Adding a refusal for a spelling + * that renders today turns the regression pins RED. + */ +import { describe, it, expect, vi, 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 pinned clock, and two values chosen on either side of the ±7-day window. */ +const CLOCK = '2026-09-09T12:00:00.000Z'; +/** +2 days: inside the window, so the relative face is a PHRASE and discriminates. */ +const IN_WINDOW = '2026-09-11T09:30:00.000Z'; +/** +11 days: outside it, where the relative face collapses onto the absolute one. */ +const OUT_OF_WINDOW = '2026-09-20T09:30:00.000Z'; + +/** `en-US` plus one NON-US locale, the same pairing the #7443 pins use. */ +const LOCALES = ['en-US', 'zh', 'de-DE']; + +/** + * Same session harness as `datetime-compact-style-7443.test.tsx`: the tag is + * set as the TENANT locale because that is the channel objectui#4468 made + * every date branch read, and `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} + , + ); +} + +/** Render one cell at the pinned clock and return its text, nothing retained. */ +function faceOf(node: React.ReactElement, locale = 'en-US'): string { + vi.useFakeTimers(); + vi.setSystemTime(new Date(CLOCK)); + try { + const { container } = renderSession(locale, node); + return container.textContent ?? ''; + } finally { + cleanup(); + vi.useRealTimers(); + } +} + +const dateCell = (value: string, format?: string) => ( + +); + +const dateTimeCell = (value: string, format?: string) => ( + +); + +/** The compact face as the cell paints it — two spans, so no separating space. */ +function compactCellText(value: string, locale: string): string { + const parts = formatDateTimeCompactParts(value, { locale })!; + return `${parts.date}${parts.time}`; +} + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('THE REPRO — `format: relative` means the same thing in both cells (#8853)', () => { + it('a datetime cell honours `relative`, and renders the phrase its date sibling renders', () => { + const dateFace = faceOf(dateCell(IN_WINDOW, 'relative')); + const dateTimeFace = faceOf(dateTimeCell(IN_WINDOW, 'relative')); + + // The anchor, so a redesign that moved both sides together cannot pass. + expect(dateFace).toBe('In 2 days'); + expect(dateTimeFace).toBe('In 2 days'); + // The claim itself: ONE authored key, ONE meaning, across the two cells. + expect(dateTimeFace).toBe(dateFace); + // And what it used to be, named — not merely "something else". + expect(dateTimeFace).not.toBe('Sep 11, 2026, 09:30 AM'); + }); + + it.each(LOCALES)('%s — the two cells agree in a non-US locale too', (locale) => { + expect(faceOf(dateTimeCell(IN_WINDOW, 'relative'), locale)).toBe( + faceOf(dateCell(IN_WINDOW, 'relative'), locale), + ); + }); + + it('`short` reaches the dense face of THIS type — the time of day is kept', () => { + // objectui#8352 mapped `'short'` to the arm's own narrow face: `formatDate`'s + // `'short'` for a date, the compact datetime face for a datetime. Here that + // face is the one this cell already paints, so a measure tile and a grid + // cell showing the same instant agree. + expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe(compactCellText(IN_WINDOW, 'en-US')); + expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe('9/11/20269:30 am'); + expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).not.toBe('Sep 11, 2026, 09:30 AM'); + }); + + it('the relative face is painted in ONE span, not the compact two', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(CLOCK)); + const { container } = renderSession('en-US', dateTimeCell(IN_WINDOW, 'relative')); + expect(container.querySelectorAll('span > span')).toHaveLength(0); + vi.useRealTimers(); + }); +}); + +describe('POSITIVE CONTROL — the date cell, unchanged, in the same run', () => { + it('still honours `relative` and `short`, and still defaults to relative', () => { + expect(faceOf(dateCell(IN_WINDOW, 'relative'))).toBe('In 2 days'); + expect(faceOf(dateCell(IN_WINDOW, 'short'))).toBe("Sep 11, '26"); + expect(faceOf(dateCell(IN_WINDOW, undefined))).toBe('In 2 days'); + }); + + it("`compact` stays inert on a date cell — the vocabulary was widened on ONE side", () => { + // The reverse asymmetry the card names. Honouring it here would be a + // second, unruled change: `'compact'` falls to the date arm's default + // face, exactly as objectui#8352 declared for the measure path. + expect(faceOf(dateCell(IN_WINDOW, 'compact'))).toBe('Sep 11'); + expect(faceOf(dateCell(IN_WINDOW, 'compact'))).toBe(faceOf(dateCell(IN_WINDOW, 'no-such-face'))); + }); +}); + +describe('NEGATIVE CONTROL — out of the ±7-day window the probe cannot discriminate', () => { + it('on the WORKING date cell, `relative` and no format render identically out there', () => { + // This is the trap. Had the cases above used this value, "identical" + // would have been the answer on a working path and a broken one alike. + const authored = faceOf(dateCell(OUT_OF_WINDOW, 'relative')); + const absent = faceOf(dateCell(OUT_OF_WINDOW, undefined)); + expect(authored).toBe(absent); + expect(authored).toBe('Sep 20'); + }); + + it('the in-window value DOES discriminate on that same cell — the probe has teeth', () => { + expect(faceOf(dateCell(IN_WINDOW, 'relative'))).not.toBe('Sep 11'); + }); + + it('an out-of-window datetime renders the absolute DATE face — the declared cost', () => { + // ⚠️ The time of day is gone. `formatRelativeDate`'s out-of-window branch + // renders through `toLocaleDateString` and has no time component to add; + // that window belongs to that function and is INHERITED here rather than + // re-decided at this call site (objectui#4576). `'relative'` is day-granular + // by construction, so its degraded form is a day face — and nothing was + // taken from a working feature, since this cell ignored the word outright + // before. Pinned so the cost cannot drift silently in either direction. + expect(faceOf(dateTimeCell(OUT_OF_WINDOW, 'relative'))).toBe('Sep 20'); + expect(faceOf(dateTimeCell(OUT_OF_WINDOW, 'relative'))).toBe( + faceOf(dateCell(OUT_OF_WINDOW, 'relative')), + ); + }); +}); + +describe('REGRESSION — every datetime cell that rendered before renders the same', () => { + it.each(LOCALES)('%s — no format is still the compact face', (locale) => { + expect(faceOf(dateTimeCell(IN_WINDOW, undefined), locale)).toBe( + compactCellText(IN_WINDOW, locale), + ); + }); + + it('an explicit `compact` is still the compact face', () => { + expect(faceOf(dateTimeCell(IN_WINDOW, 'compact'))).toBe(compactCellText(IN_WINDOW, 'en-US')); + }); + + it('an authored EMPTY string is still the compact face, not the verbose one', () => { + // `||`, not `??` — the spelling objectui#7443 chose so an authored empty + // string stays on the compact face. The mapping above must not disturb it. + expect(faceOf(dateTimeCell(IN_WINDOW, ''))).toBe(compactCellText(IN_WINDOW, 'en-US')); + }); + + it('an unrecognised spelling still falls to the verbose default — no refusal was added', () => { + // ⛔ Rejecting a currently-accepted spelling would be a breaking narrowing + // of a published metadata surface. It renders, as it always did. + expect(faceOf(dateTimeCell(IN_WINDOW, 'default'))).toBe('Sep 11, 2026, 09:30 AM'); + expect(faceOf(dateTimeCell(IN_WINDOW, 'YYYY-MM-DD'))).toBe('Sep 11, 2026, 09:30 AM'); + }); + + it('an absent field is still tolerated, on the compact face', () => { + expect(faceOf()).toBe( + compactCellText(IN_WINDOW, 'en-US'), + ); + }); +}); + +describe('the PUBLISHED signature was not moved — the mapping lives at the call site', () => { + it('formatDateTime still declares exactly two parameters', () => { + // `(value, options?)` is 2; the refused `(value, style?, options?)` is 3. + expect(formatDateTime.length).toBe(2); + }); + + it("formatDateTime's own vocabulary is untouched: it still ignores `relative` and `short`", () => { + // The function was NOT widened. It answers the verbose face for both + // words, and the CELL is what maps them — which is why this file and the + // function's own pins can disagree about `'relative'` without either being + // wrong. + const verbose = formatDateTime(IN_WINDOW, { locale: 'en-US' }); + expect(formatDateTime(IN_WINDOW, { locale: 'en-US', style: 'relative' })).toBe(verbose); + expect(formatDateTime(IN_WINDOW, { locale: 'en-US', style: 'short' })).toBe(verbose); + expect(verbose).toBe('Sep 11, 2026, 09:30 AM'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 8e9e7621a9..9e593ba1ef 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -967,7 +967,71 @@ export function DateTimeCellRenderer({ value, field }: CellRendererProps): React // `format`, so the bare property read is `TS2339` — SOME cast is load-bearing. // `DateTimeFieldMetadata` is the narrowest one that carries it (objectui#7747); // `as any` would also silence a typo in the property name, this does not. - const style = (field as DateTimeFieldMetadata | undefined)?.format || 'compact'; + const authoredFormat = (field as DateTimeFieldMetadata | undefined)?.format || 'compact'; + + // ── The authored vocabulary is mapped HERE (objectui#8853) ────────────── + // `field.format` is ONE authored key, and until this mapping it meant two + // different things depending on which of two neighbouring cell renderers + // read it. Measured end to end through a real `ObjectGrid` column, one row, + // one instant, `format: 'relative'` on both fields: the `date` cell painted + // `In 2 days` and the `datetime` cell beside it painted + // `Sep 11, 2026, 09:30 AM` — no error, no warning, no fallback. The runtime + // accepted the key, parsed it, dropped it, and rendered something that still + // looks like a legitimate date, which is why a reader cannot tell an + // honoured style from a dropped one by looking at the cell. + // + // The two words are SELECTED here rather than threaded onward, and that is + // the ruling objectui#8352 already made for `formatMeasureDate`'s datetime + // arm — the same defect class one surface over. Threading `format` into + // `formatDateTime`'s `options.style` is NOT the fix and was measured there: + // that key's vocabulary is `'compact'` alone, so a pass-through would honour + // the one word the `date` cell does NOT honour while still ignoring both + // words it does — the defect inverted, not closed. Widening + // `formatDateTime(value, options?)` is refused for the reason it was refused + // there and in objectui#7443 ruling B: it is a PUBLISHED signature, and the + // parity this card asks for is reachable from the call site without moving + // it. Rejecting the currently-accepted spelling is refused too — that would + // be a breaking narrowing of a published metadata surface. + // + // `'relative'` -> `formatRelativeDate`, the SAME function `formatDate` + // resolves `'relative'` to, so one calendar day reads the + // same phrase in either column. + // `'short'` -> the dense face of THIS type, which for a `datetime` cell + // is the compact face painted below. `formatDate`'s + // `'short'` is a narrow DATE face; the datetime equivalent + // keeps the time of day, exactly as #8352 mapped it. + // anything else, `'compact'` and date patterns such as `'YYYY-MM-DD'` + // included, falls through unchanged to the default face. + // + // ⚠️ Beyond the ±7-day window `formatRelativeDate` renders an absolute DATE + // face, so an out-of-window `'relative'` datetime shows no time of day. That + // window belongs to that function and is INHERITED here, not re-decided — + // re-deciding it would put a second copy of the convention in this file, + // which is objectui#4576 exactly. `'relative'` is day-granular by + // construction (it shows no time inside the window either), and any other + // fallback would make the two columns unequal again, which is the defect + // being closed. Nothing is taken away from a working feature: this renderer + // ignored the word outright before, so it starts honouring a request whose + // granularity is days. + // + // ⚠️ `dueLike` is deliberately NOT threaded, and this is a bounded gap + // rather than an oversight. The `date` cell's overdue affordance — the + // "Overdue Nd" wording AND the red styling — is gated by a DIFFERENT + // authored key plus a field-name heuristic, and this renderer has never read + // either. Honouring `format` must not silently acquire a second key's + // behaviour; whether a `datetime` cell should paint overdue is its own call, + // filed rather than guessed (objectui#8958). `t` stays on the + // `formatDateTime` call below exactly as before, and is left off this branch + // because `formatRelativeDate` reads it only through `dueLike`. + const style = authoredFormat === 'short' ? 'compact' : authoredFormat; + + if (style === 'relative') { + return ( + + {formatRelativeDate(date, { locale })} + + ); + } // 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