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
18 changes: 18 additions & 0 deletions .changeset/25884-grid-date-absolute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/fields': patch
'@object-ui/plugin-grid': patch
---

fix(fields,plugin-grid): a grid's `date` column reads in one absolute face when its field declares no `format`

Clause-②: no
Metadata-protocol: none

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.

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

What to do: nothing. To keep the relative wording in a grid column, declare `format: 'relative'` on the field.
40 changes: 36 additions & 4 deletions ui/packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,23 @@ export interface CellRendererProps {
* the default face that drops the current year.
*/
alwaysShowYear?: boolean;
/**
* The cell is one of a grid COLUMN's cells, read against the cells above
* and below it (objectstack#25884). Read by the `date` cell only.
*
* A `date` field that declares no `format` then paints `formatDate`'s
* absolute default face instead of `'relative'`, and the relative phrase
* becomes the cell's tooltip. The relative face is absolute outside a ±7-day
* window, so as a column default it mixed faces in one column ("Yesterday",
* "Sep 25", "Sep 20, 2025"), and a sorted column did not look sorted. The
* ruling on the card: absolute in a sortable column, relative at most as a
* tooltip. A declared `format` still wins, `'relative'` included.
*
* `ObjectGrid` passes it on every column cell it builds. Absent or `false`
* changes nothing, so the record detail body, the highlights strip and the
* card surfaces keep the relative default.
*/
inGridColumn?: boolean;
}

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

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

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

// The relative phrase the grid default no longer paints ("Yesterday",
// "Overdue 3d") is the tooltip instead. Outside the ±7-day window the phrase
// IS the absolute face, which says nothing new, so the ISO `title` stays.
const relative = inGridColumn && declaredStyle === undefined
? formatDate(safe as string | Date, 'relative', options)
: undefined;
const title = relative !== undefined && relative !== formatted ? relative : date.toISOString();

return (
<span
className={`tabular-nums${isOverdue ? ' text-red-600' : ''}`}
title={date.toISOString()}
title={title}
>
{formatted}
</span>
Expand Down
26 changes: 17 additions & 9 deletions ui/packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,9 @@ interface TemporalGroupFace {
* empty key, the stored text otherwise.
*
* The formatting CALLS are the cells' own, with the cells' arguments:
* `DateCellRenderer` paints `formatDate(value, format || 'relative', …)`;
* `DateCellRenderer` in a grid column (`inGridColumn`, objectstack#25884)
* paints `formatDate(value, format || undefined, …)`, the absolute default
* face when no `format` is declared;
* `DateTimeCellRenderer` paints `formatRelativeDate` for `'relative'`, the
* compact face for no format and for `'short'`, and `formatDateTime` with the
* authored word otherwise (the compact face's string is the cell's two halves
Expand All @@ -661,7 +663,7 @@ function temporalGroupHeading(
if (!key) return undefined;
// The cells' validity guard, the shared parse step.
if (isNaN(toDisplayDate(key).getTime())) return undefined;
if (face.rendererType === 'date') return formatDate(key, face.format || 'relative', { locale });
if (face.rendererType === 'date') return formatDate(key, face.format || undefined, { locale });
// A `datetime` cell renders an INSTANT, from the engine's parse.
const instant = new Date(key);
const style = !face.format || face.format === 'short' ? 'compact' : face.format;
Expand Down Expand Up @@ -2707,6 +2709,12 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// one helper in `@object-ui/core`, with the scope and relation collapse
// this grid's conditional formatting uses; a field that is not a declared
// deadline answers `false` before any predicate runs.
//
// objectstack#25884 — every column cell built below also carries
// `inGridColumn`, so a `date` field with no declared `format` reads in
// one absolute face down the column (`CellRendererProps.inGridColumn`).
// The record panel and the mobile card's slots draw their cells without
// it, outside this function.
const settledFor = (field: unknown, row: Record<string, unknown> | undefined): boolean =>
isDeadlineSettled(field as Parameters<typeof isDeadlineSettled>[0], row, { scope: predicateScope, fields: objectFields });
// Map field type to column header icon (Airtable-style)
Expand Down Expand Up @@ -3030,7 +3038,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// Both link and action: link takes priority for navigation, action executes on secondary interaction
cellRenderer = (value: any, row: any) => {
const displayContent = pointerFace?.(row) ?? (LinkContentRenderer
? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />
? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />
: (value != null && value !== '' ? String(value) : <EmptyValue />));
return (
<LinkCell
Expand All @@ -3048,7 +3056,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// Link column: clicking navigates to the record detail
cellRenderer = (value: any, row: any) => {
const displayContent = pointerFace?.(row) ?? (LinkContentRenderer
? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />
? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />
: (value != null && value !== '' ? String(value) : <EmptyValue />));
return (
<LinkCell
Expand Down Expand Up @@ -3088,7 +3096,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// `dependentValues` for a cell whose value only means something
// beside a sibling column (objectui#12613).
cellRenderer = (value: any, row: any) => (
pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />
pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />
);
} else {
// Default renderer with empty value handling
Expand Down Expand Up @@ -3269,7 +3277,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({

if (isPrimaryField && LinkContentRenderer) {
cellRenderer = (value: any, row: any) => {
const displayContent = pointerFace?.(row) ?? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />;
const displayContent = pointerFace?.(row) ?? <LinkContentRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />;
return (
<LinkCell
testId="primary-field-link"
Expand All @@ -3293,7 +3301,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
</LinkCell>
);
} else if (CellRenderer) {
cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />;
cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />;
}

return {
Expand Down Expand Up @@ -3458,7 +3466,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
...(schema.showColumnTypeIcons && rendererType && { headerIcon: getTypeIcon(rendererType) }),
...(inferredAlign && { align: inferredAlign }),
...(CellRenderer && {
cell: (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} />,
cell: (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldMeta as any} dependentValues={row} deadlineSettled={settledFor(fieldMeta, row)} inGridColumn />,
}),
sortable: fieldDef?.sortable !== false,
};
Expand Down Expand Up @@ -3566,7 +3574,7 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// Aligned on the RENDERER key, like paths A-C: a `text` column with
// `format: 'currency'` renders as currency, so it aligns as currency.
...(!!rendererType && numericTypes.includes(rendererType) && { align: 'right' as const }),
cell: (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldForCell} dependentValues={row} deadlineSettled={settledFor(fieldForCell, row)} />,
cell: (value: any, row: any) => pointerFace?.(row) ?? <CellRenderer value={value} field={fieldForCell} dependentValues={row} deadlineSettled={settledFor(fieldForCell, row)} inGridColumn />,
sortable: field.sortable !== false,
});
});
Expand Down
Loading
Loading