From d8b2bf7acbf50ed79a882c96b9af175ee038f7ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:07:11 +0000 Subject: [PATCH 1/5] test(plugin-detail): pin one absolute face for a related list date column (red) Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ...latedList.dateColumnOneFace-26344.test.tsx | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 ui/packages/plugin-detail/src/__tests__/RelatedList.dateColumnOneFace-26344.test.tsx diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.dateColumnOneFace-26344.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.dateColumnOneFace-26344.test.tsx new file mode 100644 index 00000000000..31a8178b8b9 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.dateColumnOneFace-26344.test.tsx @@ -0,0 +1,173 @@ +/** + * 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. + */ + +/** + * objectstack#26344 — a related list's `date` column reads in ONE face, the + * absolute short date, when its field declares no `format`. + * + * The record page's related lists draw their own table cells (`makeCell` in + * `RelatedList.tsx`), apart from `ObjectGrid`'s. objectstack#25884 gave + * `CellRendererProps` its `inGridColumn` flag and passed it on every grid + * column cell; these cells did not pass it, so a related list's date column + * still read "Yesterday · 3 days ago · Sep 20, 2025": the `'relative'` default + * is relative only inside a ±7-day window and absolute outside it. The ruling + * on #25884 holds here too: absolute in a sortable column, relative at most as + * a tooltip. + * + * Every column source of the real list is driven (A object `columns`, B string + * `columns`, C the object's declared `highlightFields`, D the field walk), so + * a cell built down any one of them must say it sits in a column. + * + * CONTROLS: a field that DECLARES `format: 'relative'` keeps the relative face + * in the same list, and a declared deadline keeps its red, its overdue wording + * moving to the cell's tooltip. + * + * The real data-table renders (`SchemaRenderer` is NOT mocked) and the + * viewport is desktop: a phone-width list swaps the table for a gallery that + * draws none of these cells. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; + +// The real data-table (and its cell renderers) must be registered: this pin +// reads what they DRAW. +import '@object-ui/components'; +import { formatDate } from '@object-ui/fields'; +import { RelatedList } from '../RelatedList'; + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); +afterEach(cleanup); + +/** A calendar day `offset` days from today, as a `date` field stores it. */ +function storedDay(offset: number): string { + const d = new Date(); + d.setDate(d.getDate() + offset); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +// Four days inside the relative window and two outside it, one of them in a +// past year, so every branch of the old default is read in one column. +const OFFSETS = [-1, -3, -6, 2, -40, -400]; + +const FIELDS = { + title: { type: 'text', label: 'Title' }, + quoted_on: { type: 'date', label: 'Quoted on' }, + expires_on: { type: 'date', label: 'Expires on', dueLike: true }, + followed_up: { type: 'date', label: 'Followed up', format: 'relative' }, +}; + +const ROWS = OFFSETS.map((offset, i) => ({ + id: `q${i}`, + title: `Quote ${i}`, + account: 'a1', + quoted_on: storedDay(offset), + expires_on: storedDay(offset), + followed_up: storedDay(offset), +})); + +const NAMES = ['title', 'quoted_on', 'expires_on', 'followed_up']; + +function makeDataSource(schemaExtra: Record = {}) { + return { + getObjectSchema: vi.fn(async () => ({ name: 'quote', fields: FIELDS, ...schemaExtra })), + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + }; +} + +async function mountList(columns: unknown[] | undefined, schemaExtra: Record = {}) { + const { container } = render( + , + ); + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()); + await waitFor(() => expect(container.textContent).toContain('Quote 5')); + return container; +} + +/** The column's cell for each row, top to bottom, keyed by the row's title. */ +function columnCells(container: HTMLElement, label: string): Map { + const headers = [...container.querySelectorAll('thead th')].map((th) => th.textContent ?? ''); + const index = headers.findIndex((text) => text.includes(label)); + if (index < 0) throw new Error(`no column headed ${label}; headers: ${JSON.stringify(headers)}`); + const cells = new Map(); + for (const tr of container.querySelectorAll('tbody tr')) { + const tds = tr.querySelectorAll('td'); + const title = [...tds].map((td) => td.textContent ?? '').find((text) => /^Quote \d$/.test(text)); + if (title && tds.length > index) cells.set(title, tds[index] as HTMLElement); + } + return cells; +} + +// Every phrase the relative face writes in `en`, inside its window. +const RELATIVE_WORDING = /ago|yesterday|today|tomorrow|^in \d|overdue/i; + +function expectOneAbsoluteFace(container: HTMLElement, label: string, field: 'quoted_on' | 'expires_on') { + const cells = columnCells(container, label); + expect(cells.size, `every row reached the ${label} column`).toBe(ROWS.length); + ROWS.forEach((row, i) => { + const text = cells.get(`Quote ${i}`)?.textContent ?? ''; + expect(text).not.toMatch(RELATIVE_WORDING); + expect(text).toBe(formatDate(row[field], undefined, { locale: 'en-US' })); + }); +} + +function expectListFaces(container: HTMLElement) { + // The defect: one face per column, whatever the distance from today. + expectOneAbsoluteFace(container, 'Quoted on', 'quoted_on'); + expectOneAbsoluteFace(container, 'Expires on', 'expires_on'); + + // The deadline keeps its red on the past rows, and the overdue wording the + // cell no longer paints is its tooltip. + const expires = columnCells(container, 'Expires on'); + const threeDaysPast = expires.get('Quote 1')!.querySelector('span.tabular-nums')!; + expect(threeDaysPast.className).toContain('text-red-600'); + expect(threeDaysPast.getAttribute('title')).toBe('Overdue 3d'); + const inTwoDays = expires.get('Quote 3')!.querySelector('span.tabular-nums')!; + expect(inTwoDays.className).not.toContain('text-red-600'); + expect(inTwoDays.getAttribute('title')).toBe('In 2 days'); + + // An undeclared date's relative phrase is its tooltip too. + const yesterday = columnCells(container, 'Quoted on').get('Quote 0')!.querySelector('span.tabular-nums')!; + expect(yesterday.getAttribute('title')).toBe('Yesterday'); + + // CONTROL: a declared `format: 'relative'` keeps the relative face. + const followed = columnCells(container, 'Followed up'); + expect(followed.get('Quote 0')?.textContent).toBe('Yesterday'); + expect(followed.get('Quote 1')?.textContent).toBe('3 days ago'); +} + +describe('objectstack#26344 — a related list date column reads in one absolute face', () => { + it('A: object `columns`', async () => { + expectListFaces(await mountList(NAMES.map((field) => ({ field })))); + }); + + it('B: string `columns`', async () => { + expectListFaces(await mountList(NAMES)); + }); + + it("C: the object's declared `highlightFields`", async () => { + expectListFaces(await mountList(undefined, { highlightFields: NAMES })); + }); + + it('D: the field walk (no `columns`, no `highlightFields`)', async () => { + expectListFaces(await mountList(undefined)); + }); +}); From 548f25d0ac58c9263d460e2a3ddf395169dfbba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:12:51 +0000 Subject: [PATCH 2/5] test(plugin-detail): author path A columns in the spec shape Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../__tests__/RelatedList.dateColumnOneFace-26344.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.dateColumnOneFace-26344.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.dateColumnOneFace-26344.test.tsx index 31a8178b8b9..cf0e4a9acd1 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedList.dateColumnOneFace-26344.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.dateColumnOneFace-26344.test.tsx @@ -156,7 +156,10 @@ function expectListFaces(container: HTMLElement) { describe('objectstack#26344 — a related list date column reads in one absolute face', () => { it('A: object `columns`', async () => { - expectListFaces(await mountList(NAMES.map((field) => ({ field })))); + // Spec-spelled `{ field, label }`: an object column with no display text is + // left headerless on purpose (`RelatedList.columnHeaderLabel`). + const columns = NAMES.map((field) => ({ field, label: FIELDS[field as keyof typeof FIELDS].label })); + expectListFaces(await mountList(columns)); }); it('B: string `columns`', async () => { From ca66281391839051ad6f478e7c05666d74504e35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:13:31 +0000 Subject: [PATCH 3/5] fix(plugin-detail): a related list's date column reads in one absolute face RelatedList's table cell builder passes inGridColumn on every cell it makes, as ObjectGrid does, so an undeclared date column paints one absolute face with the relative phrase as its title. A declared format still wins. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .changeset/25884-grid-date-absolute.md | 2 +- .changeset/26344-related-date-one-face.md | 17 +++++++++++++++++ ui/packages/plugin-detail/src/RelatedList.tsx | 5 +++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 .changeset/26344-related-date-one-face.md diff --git a/.changeset/25884-grid-date-absolute.md b/.changeset/25884-grid-date-absolute.md index c3bc64d71db..8c0d01c3604 100644 --- a/.changeset/25884-grid-date-absolute.md +++ b/.changeset/25884-grid-date-absolute.md @@ -13,6 +13,6 @@ A list grid's `date` column used to mix three faces in one column, for example " - **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`). +- **Other surfaces are unchanged.** The record detail body, the header highlights strip, kanban and gallery cards, the tree 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. diff --git a/.changeset/26344-related-date-one-face.md b/.changeset/26344-related-date-one-face.md new file mode 100644 index 00000000000..3adae1220d9 --- /dev/null +++ b/.changeset/26344-related-date-one-face.md @@ -0,0 +1,17 @@ +--- +'@object-ui/plugin-detail': patch +--- + +fix(plugin-detail): a related list's `date` column reads in one absolute face when its field declares no `format` + +Clause-②: no +Metadata-protocol: none + +A record page's related list drew its `date` column in mixed faces, for example "Yesterday · 3 days ago · Sep 20, 2025", the way list grids did before their own fix. The related list builds its own table cells and did not tell the date cell that it sits in a column. + +- **Default change for an undeclared `format` (visible from metadata).** In a related list's table, a `date` field that declares no `format` now renders the locale's short absolute date (`Oct 25`, with the year when the date is not in the current year), one face for every row, as a list grid already does. +- **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 past declared deadline is still red. +- **A declared `format` still wins.** A field that declares `format: 'relative'` keeps the relative face. +- **Other surfaces are unchanged.** The phone-width gallery of a related list, the record detail body and the header highlights strip render `date` values as before. `datetime` cells are not affected. + +What to do: nothing. To keep the relative wording in a related list's column, declare `format: 'relative'` on the field. diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index f589848100d..056b6cd3924 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -1666,6 +1666,11 @@ export const RelatedList: React.FC = ({ // The CHILD row's own `settledWhen` verdict (objectui#11815), judged // against the row the table hands this cell — never the parent record. deadlineSettled: isDeadlineSettled(def, row, { scope: predicateScope, fields: objectSchema?.fields }), + // Every cell this builder makes is one of a table COLUMN's cells + // (objectstack#26344, the grid's objectstack#25884): an undeclared + // `date` reads in one absolute face down the column, its relative + // phrase the tooltip. A declared `format` still wins. + inGridColumn: true, }); }; }; From d6a01e1d08f9b0bac3e177e6762225c748621d3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:26:18 +0000 Subject: [PATCH 4/5] test(plugin-detail): the 11815 pin reads the verdict from face and tooltip A related list's table cell now paints the absolute face with the relative phrase as its title, so the per-row deadline pin asserts both. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../RelatedList.deadlineSettled-11815.test.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.deadlineSettled-11815.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.deadlineSettled-11815.test.tsx index cc98dd65dce..629e958ea00 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedList.deadlineSettled-11815.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.deadlineSettled-11815.test.tsx @@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; import { cleanup, render, waitFor } from '@testing-library/react'; import * as React from 'react'; +import { formatDate } from '@object-ui/fields'; import { RelatedList } from '../RelatedList'; beforeAll(() => { @@ -79,7 +80,11 @@ const drawCell = (row: Record) => { const col = (h.schema?.columns ?? []).find((c: any) => c.accessorKey === 'due_date'); const { container } = render(<>{col.cell(row.due_date, row)}); const span = container.querySelector('span.tabular-nums') as HTMLElement | null; - return { text: span?.textContent ?? '', red: !!span?.className.includes('text-red-600') }; + return { + text: span?.textContent ?? '', + title: span?.getAttribute('title') ?? '', + red: !!span?.className.includes('text-red-600'), + }; }; describe('RelatedList — each child row is judged on its own (objectui#11815)', () => { @@ -87,8 +92,11 @@ describe('RelatedList — each child row is judged on its own (objectui#11815)', const ds = makeDataSource(); renderList(ds); await waitFor(() => expect((h.schema?.columns ?? []).some((c: any) => c.accessorKey === 'due_date')).toBe(true)); - expect(drawCell(ROWS[0])).toEqual({ text: 'Overdue 5d', red: true }); - expect(drawCell(ROWS[1])).toEqual({ text: '5 days ago', red: false }); + // A table cell paints the absolute face and carries the relative phrase as + // its tooltip (objectstack#26344), so the verdict reads in both. + const absolute = formatDate(ROWS[0].due_date, undefined, { locale: 'en-US' }); + expect(drawCell(ROWS[0])).toEqual({ text: absolute, title: 'Overdue 5d', red: true }); + expect(drawCell(ROWS[1])).toEqual({ text: absolute, title: '5 days ago', red: false }); }); it("asks for the settledWhen's operand when no column shows it", async () => { From 2811bbcc621c5f6673948006c2644e86960079ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:39:00 +0000 Subject: [PATCH 5/5] chore(changeset): leave the #25884 release note as it stands on main Correcting another PR's pending release note is a release decision for a person, not part of this diff. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .changeset/25884-grid-date-absolute.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25884-grid-date-absolute.md b/.changeset/25884-grid-date-absolute.md index 8c0d01c3604..c3bc64d71db 100644 --- a/.changeset/25884-grid-date-absolute.md +++ b/.changeset/25884-grid-date-absolute.md @@ -13,6 +13,6 @@ A list grid's `date` column used to mix three faces in one column, for example " - **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 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`). +- **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.