diff --git a/.changeset/7620-data-table-date-convention.md b/.changeset/7620-data-table-date-convention.md new file mode 100644 index 0000000000..d8815092b6 --- /dev/null +++ b/.changeset/7620-data-table-date-convention.md @@ -0,0 +1,26 @@ +--- +'@object-ui/components': minor +--- + +One home for the `date` display convention in `data-table` (objectui#7620). + +`data-table`'s fallback cell (`formatCellValue`) sniffs ISO strings and +formats them. Its date-only branch built its own `Intl.DateTimeFormat` bag — +`{ year: 'numeric', month: 'short', day: 'numeric' }` — while the shared +`formatDate` drops the year inside the CURRENT year on purpose (the year +rarely helps on an in-progress record and crowds the cell). So one table +rendered two faces for the same value depending on which path a cell took: +the `date` field cell showed `Jul 4` and the fallback cell showed +`Jul 4, 2026`. The branch now calls `formatDate` (default style). + +**Visible change**: in every `data-table`, a current-year date-only value in a +column that renders through the fallback cell loses its year — `Jul 4, 2026` +becomes `Jul 4` in `en-US` — and now matches the `date` field cell beside it. +Past- and future-year dates are byte-identical (`Jul 4, 2024`), which is why +the split was easy to miss: the two faces only ever diverged on the dates +users look at most. The datetime branch, the non-date passthrough and every +cell with its own renderer are untouched. + +A column that genuinely wants the year on every row is an explicit `format` +style honoured by both paths, not a second option bag — the objectui#7443 / +objectui#4576 lesson, one type over. diff --git a/packages/components/src/__tests__/data-table-date-convention-7620.test.tsx b/packages/components/src/__tests__/data-table-date-convention-7620.test.tsx new file mode 100644 index 0000000000..ee4c81ebdd --- /dev/null +++ b/packages/components/src/__tests__/data-table-date-convention-7620.test.tsx @@ -0,0 +1,179 @@ +/** + * 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#7620 — the DATE-only half of `formatCellValue` converges too. + * + * ── What was measured ──────────────────────────────────────────────────── + * objectui#7443 routed the datetime half of `data-table`'s `formatCellValue` + * through `formatDateTime` and left the date-only branch building its own + * `Intl.DateTimeFormat` bag, because routing it through `formatDate` WOULD + * move a pixel and #7443's ruling forbade that. This is that deferred + * question, ruled A by the maintainer: one home, the table included. + * + * The two faces, `en-US`, for the SAME ISO date-only value: + * + * | path | current year | past year | + * | --------------------------------------------- | ------------ | ------------- | + * | `date` field cell (-> `formatDate` default) | `Jul 4` | `Jul 4, 2024` | + * | `data-table` date-only cell, BEFORE (own bag) | `Jul 4, 2026`| `Jul 4, 2024` | + * | `data-table` date-only cell, AFTER (this PR) | `Jul 4` | `Jul 4, 2024` | + * + * ⭐ The fork was CURRENT-YEAR ONLY — `formatDate`'s default face drops the + * year inside the current year on purpose (Salesforce / HubSpot / Linear all + * do; the year crowds in-progress records), and the table's own bag always + * asked for `year: 'numeric'`. Past years already agreed, which is why the + * split went unnoticed, and why a fixture with a hard-coded past year cannot + * measure this change at all. + * + * ── The property these pins exist to prove ─────────────────────────────── + * Two claims, and neither can be made with one fixture: + * + * 1. the current-year cell MOVED, onto exactly what `formatDate` renders — + * asserted against the shared function AND against `FORMER_DATE_BAG`, + * the bag copied verbatim from `origin/main`, which it must now differ + * from; + * 2. the past-year cell did NOT move — asserted equal to that same former + * bag, byte for byte. + * + * `FIXTURE VALIDITY` below asserts the premise both rest on (the two + * formatters disagree on the current-year value and agree on the past-year + * one), so a fixture that silently stopped exercising the fork — the way a + * hard-coded `2024-07-04` would — fails loudly instead of passing for free. + * + * ── Directions ─────────────────────────────────────────────────────────── + * Reverting `formatCellValue`'s date-only branch to its own bag turns every + * CURRENT-YEAR case RED (the cell would carry a year the shared function + * drops) and leaves every PAST-YEAR case GREEN — that asymmetry IS the + * defect, so both halves are pinned. Changing `formatDate`'s default face + * moves the shared-function expectations and the `en` literal together, and + * the literal is what stops a silent redesign from passing. + */ +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, formatDate } 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'; + +/** + * A current-year date, read from the clock the same way `formatDate` reads it. + * July 4 is deliberate: `Date.parse` reads a date-only ISO string as UTC + * midnight and the cell formats it in the runner's zone, so a January 1 or + * December 31 fixture would fall into the neighbouring year under a negative + * or positive offset and stop being a current-year date at all. + */ +const CURRENT_YEAR_DATE = `${new Date().getFullYear()}-07-04`; +/** The card's past-year value — the row that must not move. */ +const PAST_YEAR_DATE = '2024-07-04'; + +/** + * The bag `formatCellValue`'s date-only branch inlined before this change, + * copied verbatim from `origin/main`. Every claim below is measured against + * THIS, not against a literal typed by hand. + */ +const FORMER_DATE_BAG: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', +}; + +const former = (iso: string, locale: string) => + new Intl.DateTimeFormat(locale, FORMER_DATE_BAG).format(new Date(Date.parse(iso))); + +const shared = (iso: string, locale: string) => + formatDate(new Date(Date.parse(iso)), undefined, { locale }); + +/** + * Reports the tag the table itself resolves. `formatCellValue` localizes from + * `useTableTranslation().language`, so expectations are built from THE SAME + * tag the component read rather than from the one this file asked for — + * hard-coding a tag the harness may not resolve would measure the harness. + */ +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('FIXTURE VALIDITY — the premise the two halves rest on', () => { + it.each(['en', 'de', 'zh'])('%s — the former bag and formatDate disagree on a CURRENT-year date', (locale) => { + expect(shared(CURRENT_YEAR_DATE, locale)).not.toBe(former(CURRENT_YEAR_DATE, locale)); + }); + + it.each(['en', 'de', 'zh'])('%s — and agree on a PAST-year date', (locale) => { + expect(shared(PAST_YEAR_DATE, locale)).toBe(former(PAST_YEAR_DATE, locale)); + }); + + it('the fixture really is the current year', () => { + expect(new Date(Date.parse(CURRENT_YEAR_DATE)).getFullYear()).toBe(new Date().getFullYear()); + expect(new Date(Date.parse(PAST_YEAR_DATE)).getFullYear()).not.toBe(new Date().getFullYear()); + }); +}); + +describe('the current-year date-only cell converges onto formatDate', () => { + it.each(['en', 'de', 'zh'])('%s — the rendered cell equals the shared function', (language) => { + const { container, language: resolved } = renderTable(language, CURRENT_YEAR_DATE); + expect(cellText(container)).toBe(shared(CURRENT_YEAR_DATE, resolved())); + }); + + it.each(['en', 'de', 'zh'])('%s — and no longer equals the former bag', (language) => { + const { container, language: resolved } = renderTable(language, CURRENT_YEAR_DATE); + expect(cellText(container)).not.toBe(former(CURRENT_YEAR_DATE, resolved())); + }); + + it('en renders the exact face the ruling named, with no year token', () => { + const { container } = renderTable('en', CURRENT_YEAR_DATE); + expect(cellText(container)).toBe('Jul 4'); + expect(cellText(container)).not.toMatch(/\d{4}/); + }); +}); + +describe('the past-year date-only cell does NOT move', () => { + it.each(['en', 'de', 'zh'])('%s — still byte-identical to the former bag', (language) => { + const { container, language: resolved } = renderTable(language, PAST_YEAR_DATE); + expect(cellText(container)).toBe(former(PAST_YEAR_DATE, resolved())); + }); + + it('en still carries the year the card recorded for this row', () => { + const { container } = renderTable('en', PAST_YEAR_DATE); + expect(cellText(container)).toBe('Jul 4, 2024'); + }); + + it('neither row grows a time — this branch has no time to render', () => { + for (const iso of [CURRENT_YEAR_DATE, PAST_YEAR_DATE]) { + const { container } = renderTable('en', iso); + expect(cellText(container)).not.toMatch(/\d\d:\d\d/); + cleanup(); + } + }); +}); 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 index 0185336ffb..9fa28a637d 100644 --- a/packages/components/src/__tests__/data-table-datetime-convention-7443.test.tsx +++ b/packages/components/src/__tests__/data-table-datetime-convention-7443.test.tsx @@ -23,11 +23,19 @@ * 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. + * ── The date-only half converged later, in objectui#7620 ───────────────── + * It was left alone HERE on purpose: routing it through `formatDate` moves a + * pixel (the default face drops the year inside the current year) and #7443's + * ruling forbade that, so the question went to its own card. The maintainer + * ruled A on #7620 and the branch now calls `formatDate` too. + * + * That does not retire the pin below, it narrows what it says: `DATE_ONLY` is + * a PAST-year value, and past years are exactly where the two faces already + * agreed, so this row is byte-identical before #7620, after #7620, and after + * any future move of the date-only branch that keeps the shared function's + * past-year face. The CURRENT-year row — the one that did move — is pinned in + * `data-table-date-convention-7620.test.tsx`, which is also where the + * fixture-validity assertions live. */ import { describe, it, expect, afterEach } from 'vitest'; import React from 'react'; @@ -112,8 +120,11 @@ describe('the datetime cell is byte-identical before and after the convergence', }); }); -describe('the date-only cell is untouched', () => { - it.each(['en', 'de'])('%s — still the date-only bag, with no time appended', (language) => { +// A PAST-year value, so this describes the half of the date-only branch that +// objectui#7620 did NOT move; the current-year half it did move is pinned in +// `data-table-date-convention-7620.test.tsx`. +describe('the date-only cell is untouched for a past-year date', () => { + it.each(['en', 'de'])('%s — still the former 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/); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 2f19a1436a..afdc269847 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, formatDateTime, getSortValue } from '@object-ui/core'; +import { ComponentRegistry, compareSortValues, evalRowPredicate, formatDate, 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'; @@ -772,16 +772,21 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // 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), { 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)); + // The DATE-only half is `formatDate`'s DEFAULT style — the same one home, + // one type over (objectui#7620, maintainer ruling A). It used to build its + // own `Intl.DateTimeFormat` bag here, which asked for `year: 'numeric'` + // unconditionally while `formatDate` drops the year INSIDE the current + // year on purpose; so one table showed two faces for one value, picked by + // which path the cell happened to take. Current-year cells move with this + // (`Jul 4, 2026` → `Jul 4`, matching the `date` field cell beside them); + // past-year cells are byte-identical, which is why the split went + // unnoticed. A column that genuinely wants the year on every row is an + // explicit `format` style honoured by both paths, never a second bag. + // + // `undefined` in the positional slot is how the published signature + // `formatDate(value, style?, options?)` asks for the default face; the + // positional argument outranks `options.style` (objectui#7745). + return formatDate(new Date(ts), undefined, { locale: language }); } catch { return value; }