Skip to content

Commit 9c3097e

Browse files
fix(fields,plugin-grid): a grid date column reads in one absolute face (#26341)
Fixes #25884 Clause-②: no Metadata-protocol: none ## What changes A list grid's `date` column used to mix three faces in one column, for example "Yesterday · 3 days ago · Sep 25 · Sep 20, 2025". The cause was `DateCellRenderer`, which defaulted an undeclared `format` to `'relative'`. `formatRelativeDate` is relative only within ±7 days and absolute outside that window. This PR follows the seat's route on the 2026-10-25 ruling: absolute in a sortable column, relative at most as a tooltip. - **`@object-ui/fields`** - `CellRendererProps` gains `inGridColumn?: boolean`. - When `inGridColumn` is set and the `date` field declares no `format`, `DateCellRenderer` paints `formatDate(v, undefined, …)`. That is the locale's short absolute date, with the year shown only when it is not the current year. No new formatter or i18n key is added, and `date-display.ts` is untouched. - The relative phrase the cell no longer paints ("Yesterday", "In 2 days", "Overdue 3d" on a declared `dueLike`) becomes the cell's `title`. It replaces the raw ISO `title`, but only when the phrase differs from the face. Outside the ±7-day window the phrase is the absolute face, so the ISO `title` stays there. - The overdue red is unchanged (`isOverdueInstant`). - A declared `format` still wins, `'relative'` included. - The `DateTimeCellRenderer` region is not touched. - **`@object-ui/plugin-grid`** - `ObjectGrid` passes `inGridColumn` on all seven column-cell call sites `generateColumns` builds (paths A to D, link cells included). - `temporalGroupHeading` restates the same selection (`format || undefined`), so a group heading still reads like the cell under it. - Its record panel (`renderFieldValue`) and the mobile card's percent slot draw their cells without the prop and keep the old default. ## Why a prop, not a shared flip `DateCellRenderer` is shared by the record detail body, the header highlights strip, kanban and gallery cards, the tree and RelatedList. Flipping its default would change every one of those surfaces. The seat asked that only grid columns change, and the prop is the seam for that. It follows the precedent of `alwaysShowYear`, a host-declared presentation input on the same props. ## Tests - **Red first, then green.** `ObjectGrid.dateColumnOneFace-25884.test.tsx` (new) drives every column path of the real grid: A object `columns`, B string `columns`, C the inline-data projection, D the object-schema default columns. One column holds dates at −1, −3, −6, +2, −40 and −400 days. - Before the fix (`4f461580`, the test alone on `e05e0de8`), 4/4 failed with `expected 'Yesterday' not to match …`. The in-window rows painted relative phrases and the out-of-window rows painted absolute dates. - After the fix, 4/4 pass. - The test also pins the tooltip (`Overdue 3d`, `In 2 days`, `Yesterday`) and the red on a past `dueLike` row. Its control is a field declaring `format: 'relative'`, which keeps "Yesterday" / "3 days ago". - **Pins flipped.** One file, `ui/packages/plugin-grid/src/__tests__/ObjectGrid.deadlineSettled-11815.test.tsx` (7 cases). It read "Overdue 5d" / "5 days ago" / "4 days ago" as the cell TEXT, which was the old default. It now reads them from the cell's `title`, asserts the cell text is the absolute date, and still asserts red on the open row and no red on the done row and on the undeclared control. - **Held unchanged.** - `groupDateHeading-13234.test.tsx` holds every heading equal to its cell for formats `undefined`, `'relative'`, `'short'` and `'YYYY-MM-DD'`. It stays green because both sides moved together. - The fields-level `DateCellRenderer`, `dateCells.deadlineSettled-11815`, `dateCell.overdueZone-10183`, `dateCell.unparsableValue-8581` and `date-locale-channel` suites stay green unchanged, because a cell without `inGridColumn` keeps `'relative'`. - The detail, kanban, tree and list `deadlineSettled-11815` pins and `HeaderHighlight.dateYear-13331` stay green unchanged. ## Verification (after merging `origin/main`, under the shared verify lock) The suites, type-checks and sdui record ran at `b5bc0328`. The head, `aa24a3a2`, differs from it only in the changeset's prose. The derived gates ran at `aa24a3a2`. - **Suites.** `pnpm exec vitest run packages/fields packages/plugin-grid` plus the eight cross-package date pins (plugin-list, plugin-detail, plugin-kanban, plugin-tree), run with `--maxWorkers=2`: Test Files 538 passed, 1 skipped (539); Tests 6566 passed, 7 skipped. - **Type-checks.** `type-check` passes in `@object-ui/fields` (exit 0) and in `@object-ui/plugin-grid` (exit 0). - **sdui record.** `node scripts/gen-sdui-manifest-node.mjs` re-recorded 2 of 27 package records, `fields` and `plugin-grid`, and left `sdui.manifest.json` unchanged. `node scripts/check-sdui-manifest.mjs` exits 0. - **Derived gates (at `aa24a3a2`).** `dispatch-gates --commands` derived 46 families; 45 ran with exit 0. `pnpm check:type-check-debt` exited 3 because the closure build it runs under the shared lock never got a turn, so it is NOT-MEASURED here; CI runs it in Lint & Repo Gates. `--ran` reconciles: 46 accounted, 0 UNRUN. ## Acceptance notes - **RelatedList keeps the old default.** `plugin-detail`'s `RelatedList` builds its own table cells, at `RelatedList.tsx` around line 1654, and does not pass `inGridColumn`. Its `date` columns therefore still mix faces. It is outside this claim's file surface and is left as is; RelatedList is not addressed here. - **One mobile-card case changes.** The grid's mobile card draws its title slot and its non-date slots through the column `cell`. A grid whose name or title column is a `date` field therefore reads absolute on mobile too. The card's other date slots already used the absolute `'short'` face. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b7f8a2e commit 9c3097e

5 files changed

Lines changed: 292 additions & 24 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/fields': patch
3+
'@object-ui/plugin-grid': patch
4+
---
5+
6+
fix(fields,plugin-grid): a grid's `date` column reads in one absolute face when its field declares no `format`
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A list grid's `date` column used to mix three faces in one column, for example "Yesterday · 3 days ago · Sep 25 · Sep 20, 2025". The cell's default face was `'relative'`, which writes a relative phrase within ±7 days of today and an absolute date outside that window. A sorted column did not look sorted, and "Yesterday" in an expiry column read as a status.
12+
13+
- **Default change for an undeclared `format` (visible from metadata).** In a grid column, a `date` field that declares no `format` now renders the locale's short absolute date: `Oct 25`, with the year added when the date is not in the current year. Every row of the column uses this one face. The group headings of a grid grouped by that field change with it, so a heading still reads like the cell under it.
14+
- **The relative phrase moves to the tooltip.** It is shown on hover ("Yesterday", "In 2 days", or "Overdue 3d" on a declared `dueLike` field). A date outside the ±7-day window has no relative phrase, so it keeps its ISO `title` as before. A past declared deadline is still red.
15+
- **A declared `format` still wins.** A field that declares `format: 'relative'` keeps the relative face in the grid. This is how an author who wants relative wording in a list keeps it.
16+
- **Other surfaces are unchanged.** The record detail body, the header highlights strip, kanban and gallery cards, the tree, a record page's related list and the grid's record panel render `date` cells as before. `DateCellRenderer` paints the absolute default only when its host passes the new `inGridColumn` prop, and `ObjectGrid` passes it on every column it builds. On mobile, a grid card draws its title through the column's cell, so a `date` title reads absolute there too; the card's other date slots were already absolute. `datetime` cells are not affected, because their default face was already absolute (`compact`).
17+
18+
What to do: nothing. To keep the relative wording in a grid column, declare `format: 'relative'` on the field.

‎ui/packages/fields/src/index.tsx‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,23 @@ export interface CellRendererProps {
425425
* the default face that drops the current year.
426426
*/
427427
alwaysShowYear?: boolean;
428+
/**
429+
* The cell is one of a grid COLUMN's cells, read against the cells above
430+
* and below it (objectstack#25884). Read by the `date` cell only.
431+
*
432+
* A `date` field that declares no `format` then paints `formatDate`'s
433+
* absolute default face instead of `'relative'`, and the relative phrase
434+
* becomes the cell's tooltip. The relative face is absolute outside a ±7-day
435+
* window, so as a column default it mixed faces in one column ("Yesterday",
436+
* "Sep 25", "Sep 20, 2025"), and a sorted column did not look sorted. The
437+
* ruling on the card: absolute in a sortable column, relative at most as a
438+
* tooltip. A declared `format` still wins, `'relative'` included.
439+
*
440+
* `ObjectGrid` passes it on every column cell it builds. Absent or `false`
441+
* changes nothing, so the record detail body, the highlights strip and the
442+
* card surfaces keep the relative default.
443+
*/
444+
inGridColumn?: boolean;
428445
}
429446

430447
// `coerceToSafeValue` lives in `./coerceToSafeValue.ts` (objectui#8580) so
@@ -1144,7 +1161,7 @@ function isOverdueInstant(date: Date, dueLike: boolean): boolean {
11441161
/**
11451162
* Date field cell renderer
11461163
*/
1147-
export function DateCellRenderer({ value, field, deadlineSettled, alwaysShowYear }: CellRendererProps): React.ReactElement {
1164+
export function DateCellRenderer({ value, field, deadlineSettled, alwaysShowYear, inGridColumn }: CellRendererProps): React.ReactElement {
11481165
// `useDisplayLocale()`, NOT `useLocalization().locale` (objectui#4468). The
11491166
// raw tenant locale is `undefined` on any workspace that never configured
11501167
// one, and `Intl` reads the MACHINE's locale from `undefined` — so a `zh`
@@ -1205,19 +1222,34 @@ export function DateCellRenderer({ value, field, deadlineSettled, alwaysShowYear
12051222
const date = new Date(safe as string | number);
12061223

12071224
const dateField = field as any;
1208-
const style = dateField.format || 'relative';
1225+
const declaredStyle: string | undefined = dateField.format || undefined;
1226+
// objectstack#25884 — in a grid column the undeclared default is the
1227+
// absolute face (`formatDate`'s default, `undefined`), one face per column;
1228+
// everywhere else it stays `'relative'`. ⚠️ `ObjectGrid`'s
1229+
// `temporalGroupHeading` restates this selection for its group headings,
1230+
// and `groupDateHeading-13234.test.tsx` holds the two equal.
1231+
const style = declaredStyle ?? (inGridColumn ? undefined : 'relative');
12091232

12101233
// Both halves of the affordance come from the shared reads above, so the
12111234
// `datetime` sibling one function down answers this question identically
12121235
// instead of carrying a second copy (objectui#8958).
12131236
const dueLike = resolveDueLike(field, deadlineSettled);
1214-
const formatted = formatDate(safe as string | Date, style, { dueLike, locale, t, alwaysShowYear });
1237+
const options = { dueLike, locale, t, alwaysShowYear };
1238+
const formatted = formatDate(safe as string | Date, style, options);
12151239
const isOverdue = isOverdueInstant(displayDate, dueLike);
12161240

1241+
// The relative phrase the grid default no longer paints ("Yesterday",
1242+
// "Overdue 3d") is the tooltip instead. Outside the ±7-day window the phrase
1243+
// IS the absolute face, which says nothing new, so the ISO `title` stays.
1244+
const relative = inGridColumn && declaredStyle === undefined
1245+
? formatDate(safe as string | Date, 'relative', options)
1246+
: undefined;
1247+
const title = relative !== undefined && relative !== formatted ? relative : date.toISOString();
1248+
12171249
return (
12181250
<span
12191251
className={`tabular-nums${isOverdue ? ' text-red-600' : ''}`}
1220-
title={date.toISOString()}
1252+
title={title}
12211253
>
12221254
{formatted}
12231255
</span>

‎ui/packages/plugin-grid/src/ObjectGrid.tsx‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,9 @@ interface TemporalGroupFace {
636636
* empty key, the stored text otherwise.
637637
*
638638
* The formatting CALLS are the cells' own, with the cells' arguments:
639-
* `DateCellRenderer` paints `formatDate(value, format || 'relative', …)`;
639+
* `DateCellRenderer` in a grid column (`inGridColumn`, objectstack#25884)
640+
* paints `formatDate(value, format || undefined, …)`, the absolute default
641+
* face when no `format` is declared;
640642
* `DateTimeCellRenderer` paints `formatRelativeDate` for `'relative'`, the
641643
* compact face for no format and for `'short'`, and `formatDateTime` with the
642644
* authored word otherwise (the compact face's string is the cell's two halves
@@ -661,7 +663,7 @@ function temporalGroupHeading(
661663
if (!key) return undefined;
662664
// The cells' validity guard, the shared parse step.
663665
if (isNaN(toDisplayDate(key).getTime())) return undefined;
664-
if (face.rendererType === 'date') return formatDate(key, face.format || 'relative', { locale });
666+
if (face.rendererType === 'date') return formatDate(key, face.format || undefined, { locale });
665667
// A `datetime` cell renders an INSTANT, from the engine's parse.
666668
const instant = new Date(key);
667669
const style = !face.format || face.format === 'short' ? 'compact' : face.format;
@@ -2707,6 +2709,12 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
27072709
// one helper in `@object-ui/core`, with the scope and relation collapse
27082710
// this grid's conditional formatting uses; a field that is not a declared
27092711
// deadline answers `false` before any predicate runs.
2712+
//
2713+
// objectstack#25884 — every column cell built below also carries
2714+
// `inGridColumn`, so a `date` field with no declared `format` reads in
2715+
// one absolute face down the column (`CellRendererProps.inGridColumn`).
2716+
// The record panel and the mobile card's slots draw their cells without
2717+
// it, outside this function.
27102718
const settledFor = (field: unknown, row: Record<string, unknown> | undefined): boolean =>
27112719
isDeadlineSettled(field as Parameters<typeof isDeadlineSettled>[0], row, { scope: predicateScope, fields: objectFields });
27122720
// Map field type to column header icon (Airtable-style)
@@ -3030,7 +3038,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
30303038
// Both link and action: link takes priority for navigation, action executes on secondary interaction
30313039
cellRenderer = (value: any, row: any) => {
30323040
const displayContent = pointerFace?.(row) ?? (LinkContentRenderer
3033-
? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />
3041+
? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />
30343042
: (value != null && value !== '' ? String(value) : <EmptyValue />));
30353043
return (
30363044
<LinkCell
@@ -3048,7 +3056,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
30483056
// Link column: clicking navigates to the record detail
30493057
cellRenderer = (value: any, row: any) => {
30503058
const displayContent = pointerFace?.(row) ?? (LinkContentRenderer
3051-
? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />
3059+
? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />
30523060
: (value != null && value !== '' ? String(value) : <EmptyValue />));
30533061
return (
30543062
<LinkCell
@@ -3088,7 +3096,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
30883096
// `dependentValues` for a cell whose value only means something
30893097
// beside a sibling column (objectui#12613).
30903098
cellRenderer = (value: any, row: any) => (
3091-
pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />
3099+
pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />
30923100
);
30933101
} else {
30943102
// Default renderer with empty value handling
@@ -3269,7 +3277,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
32693277

32703278
if (isPrimaryField && LinkContentRenderer) {
32713279
cellRenderer = (value: any, row: any) => {
3272-
const displayContent = pointerFace?.(row) ?? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />;
3280+
const displayContent = pointerFace?.(row) ?? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />;
32733281
return (
32743282
<LinkCell
32753283
testId="primary-field-link"
@@ -3293,7 +3301,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
32933301
</LinkCell>
32943302
);
32953303
} else if (CellRenderer) {
3296-
cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />;
3304+
cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />;
32973305
}
32983306

32993307
return {
@@ -3458,7 +3466,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
34583466
...(schema.showColumnTypeIcons && rendererType && { headerIcon: getTypeIcon(rendererType) }),
34593467
...(inferredAlign && { align: inferredAlign }),
34603468
...(CellRenderer && {
3461-
cell: (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />,
3469+
cell: (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />,
34623470
}),
34633471
sortable: fieldDef?.sortable !== false,
34643472
};
@@ -3566,7 +3574,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
35663574
// Aligned on the RENDERER key, like paths A-C: a `text` column with
35673575
// `format: 'currency'` renders as currency, so it aligns as currency.
35683576
...(!!rendererType && numericTypes.includes(rendererType) && { align: 'right' as const }),
3569-
cell: (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldForCell} dependentValues={row} deadlineSettled={settledFor(fieldForCell, row)} />,
3577+
cell: (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldForCell} dependentValues={row} deadlineSettled={settledFor(fieldForCell, row)} inGridColumn />,
35703578
sortable: field.sortable !== false,
35713579
});
35723580
});

0 commit comments

Comments
 (0)