Skip to content
Draft
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
27 changes: 27 additions & 0 deletions .changeset/7443-datetime-compact-style.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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(
<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('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');
});
});
22 changes: 17 additions & 5 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, 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';
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/utils/__tests__/dataset-format.date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -62,15 +62,15 @@ 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 }),
);
});

it('follows the display locale, like every other measure', () => {
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' }));
});
});

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/utils/dataset-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
92 changes: 81 additions & 11 deletions packages/core/src/utils/date-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
16 changes: 12 additions & 4 deletions packages/fields/src/__tests__/date-formatter-residue-4272.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

/**
Expand All @@ -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('—');
});
});
Loading
Loading