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
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': patch
---

One home for the `datetime` display convention (objectui#7443).

`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.

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`.
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), { 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
108 changes: 97 additions & 11 deletions packages/core/src/utils/date-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,50 @@
* 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
* in through the INJECTED `options.t`, the same way `buildDatasetFieldHelpers`
* 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, unknown>) => 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;
}

/**
Expand Down Expand Up @@ -136,22 +163,81 @@ 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, { 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.
*/
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()`.
* `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 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.
*
* 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
* 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 {
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 (options?.style === 'compact') {
const parts = formatDateTimeCompactParts(date, options);
return parts ? `${parts.date} ${parts.time}` : '—';
}

return date.toLocaleDateString(options?.locale, {
year: 'numeric',
month: 'short',
Expand Down
Loading
Loading