Skip to content

Commit 28804e8

Browse files
fix(plugin-detail): a related list's date column reads in one absolute face (#26519)
Fixes #26344 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:记录页相关列表(RelatedList)自建的表格单元格,现在和列表网格一样带上 `inGridColumn`;未声明 `format` 的日期列整列只显示一种绝对日期,相对说法("昨天"、"逾期 3 天")移到悬停提示里。 - 为什么改:#26341 只修了网格,相关列表的日期列仍是"Yesterday · 3 days ago · Sep 20, 2025"混排,排序后看不出顺序;裁决同一条:可排序的列用绝对日期,相对说法至多作提示。 - 风险与回滚:只改 `RelatedList.tsx` 一处单元格构建(加一个属性)、一个新 pin、一个旧 pin 改断言、一个 patch changeset。声明了 `format: 'relative'` 的字段照旧;手机宽度的卡片画廊、详情正文不受影响。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-detail/src/RelatedList.tsx` 的 `makeCell` 末尾 `inGridColumn: true`;新 pin `RelatedList.dateColumnOneFace-26344.test.tsx` 的四条路径。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/plugin-detail/src/__tests__/RelatedList.dateColumnOneFace-26344.test.tsx packages/plugin-detail/src/__tests__/RelatedList.deadlineSettled-11815.test.tsx`,预期 2 个文件 6 个用例全绿。 - 需要你做的:无;另请看 Acceptance notes 第一条(#25884 待发布说明里有一句会随本 PR 变得不准,是否改由你定)。 ## What changes - `ui/packages/plugin-detail/src/RelatedList.tsx`: the table cell builder (`makeCell`) passes `inGridColumn: true` on every cell it creates, the way `ObjectGrid` does since #26341. All three column sources reach that one builder (object `columns`, string `columns` and the object's `highlightFields` through `normalizeColumn`, and the field walk), so one line covers every table cell the list builds. An undeclared `date` column now reads in one absolute face, with the relative phrase as the cell's `title`; a declared `format` still wins (`DateCellRenderer` reads the flag only when the field declares none). - New pin `ui/packages/plugin-detail/src/__tests__/RelatedList.dateColumnOneFace-26344.test.tsx`, modelled on `ObjectGrid.dateColumnOneFace-25884`. It mounts the real list over the real data-table at desktop width and drives paths A (object `columns`), B (string `columns`), C (`highlightFields`) and D (the field walk). For each one it checks: one absolute face per date column (six rows at -1, -3, -6, +2, -40 and -400 days), the deadline's red kept with `Overdue 3d` / `In 2 days` as tooltip, `Yesterday` as the tooltip of an undeclared date, and the control: a field declaring `format: 'relative'` keeps `Yesterday` / `3 days ago`. - `RelatedList.deadlineSettled-11815.test.tsx`: its cell text expectations moved from the relative phrase to the absolute face plus the phrase as `title`. The per-row verdict it pins (open row red, settled row neutral) is unchanged. - Changeset `.changeset/26344-related-date-one-face.md` (`@object-ui/plugin-detail` patch), carrying both declaration lines. ## Tests - Red first: at `548f25d0` (pin only, no fix) the new pin failed 4 of 4, each on `expected 'Yesterday' not to match /ago|yesterday|today|tomorrow|^in \d|…/i`. - After the fix (`ca662813`), `pnpm exec vitest run packages/plugin-detail --maxWorkers=2` (from `ui/`, under the verify lock): 1 failed, the 11815 pin asserting the old relative text, as expected. That pin was updated in `d6a01e1d`. - `pnpm exec vitest run packages/plugin-detail --maxWorkers=2` on the tree of `d6a01e1d`: Test Files 309 passed, 1 skipped (310); Tests 2869 passed, 18 skipped (2887); lock VERDICT command-exit 0. The head `2811bbcc` differs from that tree only by restoring `.changeset/25884-grid-date-absolute.md` to its merge-base bytes. - `pnpm --filter @object-ui/plugin-detail type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): VERDICT command-exit 0. `tsc -p tsconfig.test.json --listFilesOnly` lists both touched test files (2 of 2). ## Gates At head `2811bbcc`: `node scripts/pm/dispatch-gates.mjs --commands` derived 34 families. All 34 ran with exit 0 and were reconciled with `--ran`: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED (a DERIVED zero)". These include `check-sdui-manifest` (intact, 106 components; no registry contract changed, so no regeneration), `check-empty-changeset`, `check-adr-0087-registration`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage`, `check:type-check-debt` and `pnpm --dir ui exec vitest run scripts/` (178 files passed, 2 skipped). Families CI runs outside the derived set are left to the PR's check-runs. ## Acceptance notes - The pending release note `.changeset/25884-grid-date-absolute.md` (from #26341) lists "a record page's related list" among the surfaces that "render `date` cells as before". Once this PR lands, that sentence is false for the release that ships both notes. An earlier commit on this branch corrected it, and `check-empty-changeset` refused the edit: rewriting another PR's pending note is a decision about a release, not part of this diff. The note is therefore left byte-identical to `main`, and this PR's own note states the new behaviour. Whether to drop the four words from the #25884 note is left to a person. - `CellRendererProps.inGridColumn`'s doc comment in `ui/packages/fields/src/index.tsx` says `ObjectGrid` passes the flag. That is still true but no longer complete, because a related list's table passes it too. The file is outside this card's surface, so the comment is unchanged. - The Add picker (`RecordPickerDialog`, `cellRenderer={getCellRenderer}`) draws its own candidate table in `@object-ui/fields`, which is not one of the cells this list builds, so it is untouched. Whether its date columns mix faces was not measured. - On a phone-width viewport the list renders as a gallery, which does not use these cells, so it is unchanged. - In path A the object columns are authored as `{ field, label }`. An object column with no display text is deliberately left headerless (`RelatedList.columnHeaderLabel`). --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4166a92 commit 28804e8

4 files changed

Lines changed: 209 additions & 3 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(plugin-detail): a related list's `date` column reads in one absolute face when its field declares no `format`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
- **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.
13+
- **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.
14+
- **A declared `format` still wins.** A field that declares `format: 'relative'` keeps the relative face.
15+
- **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.
16+
17+
What to do: nothing. To keep the relative wording in a related list's column, declare `format: 'relative'` on the field.

‎ui/packages/plugin-detail/src/RelatedList.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1666,6 +1666,11 @@ export const RelatedList: React.FC<RelatedListProps> = ({
16661666
// The CHILD row's own `settledWhen` verdict (objectui#11815), judged
16671667
// against the row the table hands this cell — never the parent record.
16681668
deadlineSettled: isDeadlineSettled(def, row, { scope: predicateScope, fields: objectSchema?.fields }),
1669+
// Every cell this builder makes is one of a table COLUMN's cells
1670+
// (objectstack#26344, the grid's objectstack#25884): an undeclared
1671+
// `date` reads in one absolute face down the column, its relative
1672+
// phrase the tooltip. A declared `format` still wins.
1673+
inGridColumn: true,
16691674
});
16701675
};
16711676
};
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#26344 — a related list's `date` column reads in ONE face, the
11+
* absolute short date, when its field declares no `format`.
12+
*
13+
* The record page's related lists draw their own table cells (`makeCell` in
14+
* `RelatedList.tsx`), apart from `ObjectGrid`'s. objectstack#25884 gave
15+
* `CellRendererProps` its `inGridColumn` flag and passed it on every grid
16+
* column cell; these cells did not pass it, so a related list's date column
17+
* still read "Yesterday · 3 days ago · Sep 20, 2025": the `'relative'` default
18+
* is relative only inside a ±7-day window and absolute outside it. The ruling
19+
* on #25884 holds here too: absolute in a sortable column, relative at most as
20+
* a tooltip.
21+
*
22+
* Every column source of the real list is driven (A object `columns`, B string
23+
* `columns`, C the object's declared `highlightFields`, D the field walk), so
24+
* a cell built down any one of them must say it sits in a column.
25+
*
26+
* CONTROLS: a field that DECLARES `format: 'relative'` keeps the relative face
27+
* in the same list, and a declared deadline keeps its red, its overdue wording
28+
* moving to the cell's tooltip.
29+
*
30+
* The real data-table renders (`SchemaRenderer` is NOT mocked) and the
31+
* viewport is desktop: a phone-width list swaps the table for a gallery that
32+
* draws none of these cells.
33+
*/
34+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
35+
import { cleanup, render, waitFor } from '@testing-library/react';
36+
import '@testing-library/jest-dom';
37+
import * as React from 'react';
38+
39+
// The real data-table (and its cell renderers) must be registered: this pin
40+
// reads what they DRAW.
41+
import '@object-ui/components';
42+
import { formatDate } from '@object-ui/fields';
43+
import { RelatedList } from '../RelatedList';
44+
45+
beforeEach(() => {
46+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
47+
});
48+
afterEach(cleanup);
49+
50+
/** A calendar day `offset` days from today, as a `date` field stores it. */
51+
function storedDay(offset: number): string {
52+
const d = new Date();
53+
d.setDate(d.getDate() + offset);
54+
const pad = (n: number) => String(n).padStart(2, '0');
55+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
56+
}
57+
58+
// Four days inside the relative window and two outside it, one of them in a
59+
// past year, so every branch of the old default is read in one column.
60+
const OFFSETS = [-1, -3, -6, 2, -40, -400];
61+
62+
const FIELDS = {
63+
title: { type: 'text', label: 'Title' },
64+
quoted_on: { type: 'date', label: 'Quoted on' },
65+
expires_on: { type: 'date', label: 'Expires on', dueLike: true },
66+
followed_up: { type: 'date', label: 'Followed up', format: 'relative' },
67+
};
68+
69+
const ROWS = OFFSETS.map((offset, i) => ({
70+
id: `q${i}`,
71+
title: `Quote ${i}`,
72+
account: 'a1',
73+
quoted_on: storedDay(offset),
74+
expires_on: storedDay(offset),
75+
followed_up: storedDay(offset),
76+
}));
77+
78+
const NAMES = ['title', 'quoted_on', 'expires_on', 'followed_up'];
79+
80+
function makeDataSource(schemaExtra: Record<string, unknown> = {}) {
81+
return {
82+
getObjectSchema: vi.fn(async () => ({ name: 'quote', fields: FIELDS, ...schemaExtra })),
83+
find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
84+
};
85+
}
86+
87+
async function mountList(columns: unknown[] | undefined, schemaExtra: Record<string, unknown> = {}) {
88+
const { container } = render(
89+
<RelatedList
90+
title="Quotes"
91+
type="table"
92+
api="quote"
93+
objectName="quote"
94+
referenceField="account"
95+
parentId="a1"
96+
dataSource={makeDataSource(schemaExtra) as any}
97+
{...(columns ? { columns: columns as any } : {})}
98+
/>,
99+
);
100+
await waitFor(() => expect(container.querySelector('table')).not.toBeNull());
101+
await waitFor(() => expect(container.textContent).toContain('Quote 5'));
102+
return container;
103+
}
104+
105+
/** The column's cell for each row, top to bottom, keyed by the row's title. */
106+
function columnCells(container: HTMLElement, label: string): Map<string, HTMLElement> {
107+
const headers = [...container.querySelectorAll('thead th')].map((th) => th.textContent ?? '');
108+
const index = headers.findIndex((text) => text.includes(label));
109+
if (index < 0) throw new Error(`no column headed ${label}; headers: ${JSON.stringify(headers)}`);
110+
const cells = new Map<string, HTMLElement>();
111+
for (const tr of container.querySelectorAll('tbody tr')) {
112+
const tds = tr.querySelectorAll('td');
113+
const title = [...tds].map((td) => td.textContent ?? '').find((text) => /^Quote \d$/.test(text));
114+
if (title && tds.length > index) cells.set(title, tds[index] as HTMLElement);
115+
}
116+
return cells;
117+
}
118+
119+
// Every phrase the relative face writes in `en`, inside its window.
120+
const RELATIVE_WORDING = /ago|yesterday|today|tomorrow|^in \d|overdue/i;
121+
122+
function expectOneAbsoluteFace(container: HTMLElement, label: string, field: 'quoted_on' | 'expires_on') {
123+
const cells = columnCells(container, label);
124+
expect(cells.size, `every row reached the ${label} column`).toBe(ROWS.length);
125+
ROWS.forEach((row, i) => {
126+
const text = cells.get(`Quote ${i}`)?.textContent ?? '';
127+
expect(text).not.toMatch(RELATIVE_WORDING);
128+
expect(text).toBe(formatDate(row[field], undefined, { locale: 'en-US' }));
129+
});
130+
}
131+
132+
function expectListFaces(container: HTMLElement) {
133+
// The defect: one face per column, whatever the distance from today.
134+
expectOneAbsoluteFace(container, 'Quoted on', 'quoted_on');
135+
expectOneAbsoluteFace(container, 'Expires on', 'expires_on');
136+
137+
// The deadline keeps its red on the past rows, and the overdue wording the
138+
// cell no longer paints is its tooltip.
139+
const expires = columnCells(container, 'Expires on');
140+
const threeDaysPast = expires.get('Quote 1')!.querySelector('span.tabular-nums')!;
141+
expect(threeDaysPast.className).toContain('text-red-600');
142+
expect(threeDaysPast.getAttribute('title')).toBe('Overdue 3d');
143+
const inTwoDays = expires.get('Quote 3')!.querySelector('span.tabular-nums')!;
144+
expect(inTwoDays.className).not.toContain('text-red-600');
145+
expect(inTwoDays.getAttribute('title')).toBe('In 2 days');
146+
147+
// An undeclared date's relative phrase is its tooltip too.
148+
const yesterday = columnCells(container, 'Quoted on').get('Quote 0')!.querySelector('span.tabular-nums')!;
149+
expect(yesterday.getAttribute('title')).toBe('Yesterday');
150+
151+
// CONTROL: a declared `format: 'relative'` keeps the relative face.
152+
const followed = columnCells(container, 'Followed up');
153+
expect(followed.get('Quote 0')?.textContent).toBe('Yesterday');
154+
expect(followed.get('Quote 1')?.textContent).toBe('3 days ago');
155+
}
156+
157+
describe('objectstack#26344 — a related list date column reads in one absolute face', () => {
158+
it('A: object `columns`', async () => {
159+
// Spec-spelled `{ field, label }`: an object column with no display text is
160+
// left headerless on purpose (`RelatedList.columnHeaderLabel`).
161+
const columns = NAMES.map((field) => ({ field, label: FIELDS[field as keyof typeof FIELDS].label }));
162+
expectListFaces(await mountList(columns));
163+
});
164+
165+
it('B: string `columns`', async () => {
166+
expectListFaces(await mountList(NAMES));
167+
});
168+
169+
it("C: the object's declared `highlightFields`", async () => {
170+
expectListFaces(await mountList(undefined, { highlightFields: NAMES }));
171+
});
172+
173+
it('D: the field walk (no `columns`, no `highlightFields`)', async () => {
174+
expectListFaces(await mountList(undefined));
175+
});
176+
});

‎ui/packages/plugin-detail/src/__tests__/RelatedList.deadlineSettled-11815.test.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
1515
import { cleanup, render, waitFor } from '@testing-library/react';
1616
import * as React from 'react';
17+
import { formatDate } from '@object-ui/fields';
1718
import { RelatedList } from '../RelatedList';
1819

1920
beforeAll(() => {
@@ -79,16 +80,23 @@ const drawCell = (row: Record<string, unknown>) => {
7980
const col = (h.schema?.columns ?? []).find((c: any) => c.accessorKey === 'due_date');
8081
const { container } = render(<>{col.cell(row.due_date, row)}</>);
8182
const span = container.querySelector('span.tabular-nums') as HTMLElement | null;
82-
return { text: span?.textContent ?? '', red: !!span?.className.includes('text-red-600') };
83+
return {
84+
text: span?.textContent ?? '',
85+
title: span?.getAttribute('title') ?? '',
86+
red: !!span?.className.includes('text-red-600'),
87+
};
8388
};
8489

8590
describe('RelatedList — each child row is judged on its own (objectui#11815)', () => {
8691
it("an open row's declared deadline reads overdue, a done row's reads neutral", async () => {
8792
const ds = makeDataSource();
8893
renderList(ds);
8994
await waitFor(() => expect((h.schema?.columns ?? []).some((c: any) => c.accessorKey === 'due_date')).toBe(true));
90-
expect(drawCell(ROWS[0])).toEqual({ text: 'Overdue 5d', red: true });
91-
expect(drawCell(ROWS[1])).toEqual({ text: '5 days ago', red: false });
95+
// A table cell paints the absolute face and carries the relative phrase as
96+
// its tooltip (objectstack#26344), so the verdict reads in both.
97+
const absolute = formatDate(ROWS[0].due_date, undefined, { locale: 'en-US' });
98+
expect(drawCell(ROWS[0])).toEqual({ text: absolute, title: 'Overdue 5d', red: true });
99+
expect(drawCell(ROWS[1])).toEqual({ text: absolute, title: '5 days ago', red: false });
92100
});
93101

94102
it("asks for the settledWhen's operand when no column shows it", async () => {

0 commit comments

Comments
 (0)