Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/7620-data-table-date-convention.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
<LanguageProbe report={(l) => { resolved = l; }} />
<Component schema={schema} />
</I18nProvider>,
);
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();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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/);
Expand Down
27 changes: 16 additions & 11 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
Loading