Skip to content

Commit 273137d

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(plugin-detail,app-shell,components): record page round 2 — related OFF draws no column or rail, one segmented panel control in the header row, three columns in a 1440 window with the sidebar (#27473)
Fixes #27055 Fixes #27241 Clause-②: no Metadata-protocol: none Round 2 of the record page v3 (epic #26676), on `main` after #27310: the three rulings of #27241, the #27069 fence half of #27055, and a standing 「恢复默认顺序」 entry. Maintainer, 2026-10-28, verbatim: 「是:三栏阈值改成 1440 窗口(含侧栏)即三栏;开关改进头部动作行,关联 OFF 就没有栏」. ## What changes **A1 — the related switch decides whether the column exists.** `recordPageArrangementFor` sets `relatedRail` to `switches.related && !columns.includes('related')`, and `RecordPageColumns` draws the related column only when the arrangement places it. Related OFF: no related column and no rail toggle, in both column tiers. Related ON with no room (two-column tier): the rail toggle, which opens the column. With both switches off the page is the details column alone. **A2 — one segmented control at the end of the header's action row.** `PanelToggles` (`@object-ui/components`) is now one segmented control, an icon and a label per segment (Timeline with the activity glyph, Related column with the right-panel glyph). The console's record view hands it to `page:header` through a new host slot, `PageHeaderTrailingContext`. `PageHeaderActions` draws it as the last segment of its `role=toolbar` row, after `⋯`, behind a vertical hairline. It is drawn in both column tiers, and not in the tabbed tier or in the drawer. The toolbar gained `max-w-full`, so on a narrow header the segment wraps onto its own line, right-aligned, instead of running past the page (measured at a 1024 px window; see screenshot 08). The crumb row keeps only the ways back (origin link, 「Collapse to drawer」) and has no child without one, so `empty:hidden` removes it. Tooltip keycaps and `aria-keyshortcuts` are unchanged. The chord matcher accepts `code` `Period` / `Comma`, or `key` `.` / `>` and `,` / `<`. **A3 — thresholds.** Measured on the console from this branch (HotCRM contact, sidebar expanded): the sidebar is 255 px plus its 1 px border, and `[data-record-page-root]` is 1184 px in a 1440 window, 1024 px in 1280 and 768 px in 1024. The constants are `1440 − 256 − 64 = 1120` and `1024 − 256 − 8 = 760`, with the derivation in their docblock. The #26758 tier pins move to 1120 / 760, plus a pin on the three windows the thresholds serve. `e2e/live/record-history-display.spec.ts` opens the tab strip, so its viewport moves from 1200 to 1000, which keeps the record view under the new two-column floor. **B — the #27069 fence half.** - *Default order.* The premise was half stale. `deriveRelatedLists` already orders by the object's `relatedLists` (`resolveRelatedListOrder`, landed with #27215/#27303), and `synthParts` keeps that order. Only the fence comment said otherwise. The comment is replaced, and a mounted pin (`RecordDetailView.relatedListsOrder-27241`) holds the card order with and without `relatedLists`. - *Body-table placement.* `bodyTablePlacement` reads the field groups. A body table whose relationship a group's `relatedList` names (child object name, or `object.field`) is drawn at that group's position among `fieldGroups`. Other body tables keep the "after the first section" rule. The one reading of an entry, `relatedListEntryNames`, moved from app-shell into `@object-ui/plugin-detail` (app-shell re-exports it), so the order and the placement cannot read an entry two ways. **C — standing 「恢复默认顺序」.** `resolveRelatedArrangement` now reports `rearranged`: the shown order differs from the default, or a card is hidden. The column foot, which is the same line as 「已隐藏 N 张 · 显示」 and appears in both the related column and the tabbed tier's Related tab, then offers 「Restore default order」 (new key `detail.relatedRestoreDefaultOrder`, ten packs). It writes the new edit `restore-default-arrangement`: `order: []`, `hidden: []`, `basedOn` set to the current default's hash, `seen: {}`. Collapsed state is kept. The stored shape `{v,order,collapsed,hidden,basedOn,seen}` is unchanged. `RelatedDefaultChangedHint` is unchanged. Not touched: `packages/spec/**`, `navigation-overlay.tsx`, `recordDrawerStack.tsx`. ## Screenshots HotCRM contact record page. The console runs on vite from this branch; the backend is `objectstack dev --fresh --seed-admin` from hotcrm 1f3f8880; headless Chromium. The images are on the never-merged branch `claude/issue-27055-evidence-20261029`. 1. [1440 window, sidebar expanded: three columns, the segmented control in the header row](https://github.com/objectstack-ai/objectstack/blob/f6062d78b65b8cd830fbed665d60b8c0ea9441a7/evidence/27241/01-1440-three-columns-both-on.png?raw=true) · [header row close up](https://github.com/objectstack-ai/objectstack/blob/f6062d78b65b8cd830fbed665d60b8c0ea9441a7/evidence/27241/02-1440-header-row-segmented.png?raw=true) 2. [Related OFF: details and timeline, no rail toggle](https://github.com/objectstack-ai/objectstack/blob/f6062d78b65b8cd830fbed665d60b8c0ea9441a7/evidence/27241/03-1440-related-off.png?raw=true) 3. [Timeline OFF: details and related, the timeline folded at the related column's foot](https://github.com/objectstack-ai/objectstack/blob/f6062d78b65b8cd830fbed665d60b8c0ea9441a7/evidence/27241/04-1440-timeline-off.png?raw=true) 4. [A card moved: Restore default order at the column foot](https://github.com/objectstack-ai/objectstack/blob/f6062d78b65b8cd830fbed665d60b8c0ea9441a7/evidence/27241/05-1440-moved-restore-default-order.png?raw=true) · [foot line close up](https://github.com/objectstack-ai/objectstack/blob/f6062d78b65b8cd830fbed665d60b8c0ea9441a7/evidence/27241/06-1440-related-foot-line.png?raw=true). Clicking it put the default order back; stored value `{"order":[],"collapsed":[],"hidden":[],"basedOn":"e40133ac","v":1}`. 5. [1024 window, sidebar expanded: two columns, related ON, the rail toggle](https://github.com/objectstack-ai/objectstack/blob/f6062d78b65b8cd830fbed665d60b8c0ea9441a7/evidence/27241/07-1024-two-columns-related-on-rail.png?raw=true) · [related OFF: no rail toggle; the segmented control wraps](https://github.com/objectstack-ai/objectstack/blob/f6062d78b65b8cd830fbed665d60b8c0ea9441a7/evidence/27241/08-1024-two-columns-related-off-no-rail.png?raw=true) 6. [Dark theme header row, timeline off and related on](https://github.com/objectstack-ai/objectstack/blob/f6062d78b65b8cd830fbed665d60b8c0ea9441a7/evidence/27241/09-1440-dark-header-row-timeline-off.png?raw=true) ## Tests Head \`9c82d929a8\`. The build and type-check closure was rebuilt first (\`pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build\`, VERDICT command-exit 0, 72 of 127 projects, then \`@object-ui/i18n\`, \`components\` and \`plugin-detail\` again after the edits). Every run went through \`scripts/pm/os-verify-lock.sh\`, UNLOCKED (declared below). - \`@object-ui/plugin-detail\` whole package: \`Test Files 334 passed | 1 skipped (335)\`, \`Tests 3058 passed | 18 skipped\`. - \`@object-ui/components\` whole package, on the final head: \`Test Files 449 passed | 1 skipped (450)\`, \`Tests 4338 passed | 24 skipped\`. - \`@object-ui/i18n\` whole package: \`Test Files 89 passed (89)\`, \`Tests 1373 passed | 13 skipped\`. - \`@object-ui/app-shell\` \`src/views/\`: \`Test Files 1262 passed (1262)\`, \`Tests 11874 passed | 1 skipped\`. This includes \`panelToggles-27052\`, \`recordPageTiers-26678\` and the new \`relatedListsOrder-27241\`. - The changed and new files were re-run on the final head after the last edits: 7 files / 65 tests in app-shell, and 3 files / 11 tests in the components header suite. - Type-check, each script echoed (\`tsc --noEmit && tsc -p tsconfig.test.json\`), exit 0 with 0 \`error TS\`: components, plugin-detail, app-shell, i18n. - eslint on the changed files of the four packages: 0 errors. - ui \`check:new-line-citations\` reported 0 new citations, and ui \`check:spec-symbols\` exited 0. - \`ui/e2e/record-header-title-width.spec.ts\` ran locally under a throwaway config with no webServer (the spec uses \`page.setContent\`), with the installed \`chromium_headless_shell-1234\` given as \`executablePath\`: \`14 passed (1.8s)\`. The config was deleted after the run. ## Reverse verification Each new pin was run against its mutation through \`node scripts/ablation-replace.mjs\`. The tool's own verdict was \`ok mutation landed\` (anchor 1 → 0, blob moved), and it restored with \`git diff HEAD empty\` and the HEAD blob matching. All runs were vitest on the aliased \`src\`, so no dist step applies. 1. Related OFF keeps the rail (\`relatedRail: !columns.includes('related')\`): 7 failed (arrangement, \`RecordPageColumns\` and app-shell pins). 2. The header row drops the trailing segment: 10 failed (the components slot pin and the app-shell toggles file). The first attempt was refused by the tool as a no-op, because the replacement text already existed in the file. It was re-run with a distinct replacement. 3. The chord matcher reads \`code\` only: 1 failed (the code-less event pin). 4. Three-column floor back to 1280: 2 failed. The first run failed at import, with \`@objectstack/spec/data\` not found while the gate battery was running beside it. That run is not counted as a measurement, and the re-run is the reading. 5. Two-column floor back to 1024: 3 failed. 6. \`deriveRelatedLists\` drops \`relatedLists\`: 1 failed (the mounted order pin). 7. \`bodyTablePlacement\` places nothing: 5 failed. 8. The foot line never offers the restore: 2 failed. 9. \`restore-default-arrangement\` keeps \`hidden\`: 2 failed. 10. The pressed tint back to \`bg-muted\`: 1 failed. \`git diff HEAD\` was empty after the ten runs. ## Gates Derived with \`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack\` at \`9c82d929a8\`. The change set is 38 paths against merge base \`3bfb357aa3\`, which derives 34 commands. Each command's exit code was written before any pipe. \`--ran\` reports \`Run reconciliation — 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN\`. 33 commands exited 0. \`pnpm --dir ui exec vitest run scripts/\` exited 1, with \`Test Files 2 failed | 167 passed | 2 skipped (171)\` and \`Tests 3 failed | 4898 passed\`. All three failures are known host gaps on this macOS box, the same three as in rounds 0 and 1 of #27310. Two are the bash 3.2 \`mapfile: command not found\` in \`select-affected-tests-wiring.test.ts\`. The third is the \`check-i18n-dead-keys\` 「on the real tree」 case, which times out. These three are NOT MEASURED locally, and CI reads them. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so every build and test command in this PR was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran. ## Acceptance notes - **`text-accent` vs `text-accent-foreground`.** The ruling names `bg-accent` + `text-accent` and describes the result as 「accent 浅底深字」. In both console themes, `text-accent` is the same pale HSL as `bg-accent` (light: `--accent` 240 18% 94%, measured on the page). The literal pair would draw invisible text. The on segment is therefore `bg-accent` + `text-accent-foreground`, which is the light-tint, dark-text pair the ruling describes. An off segment is `text-muted-foreground`. This is flagged for the seat in the report. - **Wrapping on a narrow header.** At a 1024 px window (768 px record page) the header's actions plus the segmented control are wider than the header. The toolbar now wraps inside the header (`max-w-full`), and the segment ends its own line. Before this, the toolbar overflowed and a click scrolled the page sideways. `e2e/record-header-title-width.spec.ts` ran locally: 14 passed. - **A group naming a side card.** Per the ruling, `bodyTablePlacement` places body tables only. A field group whose `relatedList` names a relationship that is not a body table (not `'primary'`, not an inline-edited `master_detail`) places nothing, and the card stays in the related column. Before this, such a group drew nothing either. - `main` gained 7 commits since the base `3bfb357aa3`, none touching a changed path. GitHub reports no conflict, and no merge was made. --- _Generated by [Claude Code](https://claude.ai/code/session_cdb7a717def74e719988f089fbaa18ff)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7614f8f commit 273137d

38 files changed

Lines changed: 958 additions & 159 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
'@object-ui/components': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
fix(plugin-detail,app-shell,components): the record page's related switch decides whether the column exists, its panel switches are one segmented control in the header's action row, a 1440 px window with the sidebar is three columns, and the related order and body-table placement read the object's `relatedLists` and field groups
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
The synthesized default record page (an object with no assigned page). Nothing an author writes changes meaning: the two read points below are keys the spec already declares.
14+
15+
- **The related switch.** With the related column switched off there is no related column and no 「Related」 rail toggle, in every column tier. With it on and no room for it (the two-column tier), the rail toggle opens and closes it. Before, the switch did nothing in the two-column tier and the rail still opened the column.
16+
- **One segmented control in the header row.** The two panel switches are now one control with an icon and a label per segment (Timeline, Related column). It is drawn at the right end of the record header's action row, after `⋯`, behind a vertical hairline, in both column tiers. A segment that is on is filled with the accent tint; one that is off is muted text, so the state reads in the dark theme too. The crumb row keeps only the ways back and is not drawn when there is none. The shortcuts are unchanged (`⌘⇧.` / `⌘⇧,`); they now also match a key event that carries the typed character (`.` / `>`, `,` / `<`) but no physical key code.
17+
- **Thresholds.** Three columns from a 1120 px container, two from 760 px, the tabs below (were 1280 / 1024). Measured on the console: the expanded sidebar is 256 px, so a 1440 px window is 1184 px of record page (three columns), and a 1024 px window is 768 px (two columns). `RECORD_PAGE_THREE_COLUMN_MIN_WIDTH` and `RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` carry the new values and their derivation.
18+
- **Related order.** The default order of the related cards, the Related tab and the body tables is the object's `relatedLists` first, then the derived order. That order already reached the page through `deriveRelatedLists`; the stale comment that said it did not is gone, and a mounted pin now holds it.
19+
- **Body-table placement.** A field group whose `relatedList` names a body table's relationship (a child object name, or `object.field`) places that table at the group's position among the field groups. Other body tables stay after the first field section.
20+
- **Restore default order.** Once a reader has moved or hidden a related card, the foot of the related column (and of the Related tab) offers 「Restore default order」 on the same line as 「N hidden · Show」. It lays the default order back, shows every card, and records the current default as the one the arrangement is based on. The reader's stored arrangement keeps its shape. The hint shown when the author's default changed stays as it was.
21+
- **`@object-ui/components`.** `PanelToggles` is the segmented control. `PageHeaderTrailingContext` (new export) is the host slot at the end of `page:header`'s action row, and `PageHeaderActions` takes a `trailing` node for it. No metadata key reaches it.
22+
- **`@object-ui/plugin-detail`.** `relatedListEntryNames` (new export) is the one reading of a related-list entry; app-shell's related-list order and the Studio editors import it from here.
23+
- **`@object-ui/i18n`.** New `detail.relatedRestoreDefaultOrder` in the ten packs.
24+
25+
What to do: nothing.

‎ui/e2e/live/record-history-display.spec.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,12 @@ async function seededUsers(request: APIRequestContext): Promise<{ actor: SysUser
4040
* The synthesized record page lays itself out by the width of the view it
4141
* occupies (objectstack#26678): the tab strip this spec opens exists only
4242
* below the two-column floor (`RECORD_PAGE_TWO_COLUMN_MIN_WIDTH` in
43-
* `@object-ui/plugin-detail`). The Desktop Chrome viewport leaves the record
44-
* view right at that floor once the console sidebar takes its share, so the
45-
* spec pins a viewport that keeps the page in its tabbed tier.
43+
* `@object-ui/plugin-detail`). A 1024 px window with the console sidebar
44+
* expanded is already two columns (objectstack#27241), so the spec pins a
45+
* viewport whose record view stays under that floor once the 256 px sidebar
46+
* takes its share — the page's tabbed tier.
4647
*/
47-
test.use({ viewport: { width: 1200, height: 800 } });
48+
test.use({ viewport: { width: 1000, height: 800 } });
4849

4950
test('History tab shows display values (localized dates, option labels, resolved lookups, real actor) instead of raw audit payloads', async ({ page, request }) => {
5051
const { actor, other } = await seededUsers(request);

‎ui/packages/app-shell/src/utils/deriveRelatedLists.ts‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
*/
7272

7373
import { convertSortToQueryParams } from '@object-ui/core';
74+
import { relatedListEntryNames } from '@object-ui/plugin-detail';
7475

7576
/** Audit/ownership FKs that exist on nearly every object — never related lists. */
7677
const AUDIT_FK_FIELDS = new Set(['created_by', 'updated_by', 'owner_id']);
@@ -286,18 +287,12 @@ export function resolveRelatedListOrder<T extends { childObject: string; referen
286287

287288
/**
288289
* Does one `relatedLists` entry (or a field group's `relatedList`) name this
289-
* list? A bare child object name names every list of that child; `object.field`
290-
* names the one FK. The ONE reading of an entry, shared by
291-
* {@link resolveRelatedListOrder} and the Studio editors that author entries.
290+
* list? The ONE reading of an entry lives in `@object-ui/plugin-detail`, where
291+
* the synthesized record page places a body table by a field group's entry
292+
* (objectstack#27069); re-exported here for {@link resolveRelatedListOrder}
293+
* and the Studio editors that author entries.
292294
*/
293-
export function relatedListEntryNames(
294-
entry: string,
295-
list: { childObject: string; referenceField: string },
296-
): boolean {
297-
const dot = entry.indexOf('.');
298-
if (dot === -1) return list.childObject === entry;
299-
return list.childObject === entry.slice(0, dot) && list.referenceField === entry.slice(dot + 1);
300-
}
295+
export { relatedListEntryNames };
301296

302297
/** Normalize an object's `fields` (record or array) into `[name, def]` pairs. */
303298
function fieldEntries(fields: MergedObjectLike['fields']): Array<[string, any]> {

‎ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx‎

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,16 @@
2222
* - an object that is not worked opens with the timeline off — including
2323
* one whose only claim is being another object's `master_detail` parent;
2424
* - `⌘⇧.` / `⌘⇧,` flip the switches, matched by the physical key (Shift
25-
* types `>` / `<` there), and do nothing while a text field has focus;
26-
* the browser's own `⌘⇧T` / `⌘⇧R` are not theirs;
27-
* - the toggles are drawn only in a column tier: not in the tabbed tier, not
28-
* in the drawer / split pane.
25+
* types `>` / `<` there) or, for an event that carries no `code`, by the
26+
* character it typed (objectstack#27241), and do nothing while a text
27+
* field has focus; the browser's own `⌘⇧T` / `⌘⇧R` are not theirs;
28+
* - the toggles are ONE segmented control at the end of the record header's
29+
* action row (objectstack#27241), in both column tiers — not on the crumb
30+
* row, which keeps only the ways back and has no child without one;
31+
* - they are drawn only in a column tier: not in the tabbed tier, not in the
32+
* drawer / split pane;
33+
* - the related switch decides whether the related column exists: OFF in the
34+
* two-column tier leaves no rail toggle (objectstack#27241).
2935
*/
3036

3137
import * as React from 'react';
@@ -203,7 +209,16 @@ describe('RecordDetailView — the panel toggles (objectstack#27052)', () => {
203209
it('a worked object opens with three columns; timeline off draws details | related with a folded 「动态」 card; on restores', async () => {
204210
const { container } = renderPage();
205211
const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 });
206-
expect(group.closest('[data-record-crumb-row]')).not.toBeNull();
212+
// objectstack#27241 — the end of the header's action row, after ⋯ and a
213+
// hairline; the crumb row keeps only the ways back (none on a direct open).
214+
expect(group.closest('[data-record-crumb-row]')).toBeNull();
215+
const toolbar = group.closest('[role="toolbar"]')!;
216+
expect(toolbar).not.toBeNull();
217+
expect(toolbar.closest('[data-record-column]')).toBeNull();
218+
const trailing = group.closest('[data-page-header-trailing]')!;
219+
expect(trailing.querySelector('[role="separator"][aria-orientation="vertical"]')).not.toBeNull();
220+
expect(toolbar.lastElementChild).toBe(trailing);
221+
expect(container.querySelector('[data-record-crumb-row]')?.childElementCount ?? 0).toBe(0);
207222
expect(shown(container)).toBe('details timeline related');
208223
expect(toggle(/^Timeline$/).getAttribute('aria-pressed')).toBe('true');
209224
expect(toggle(/^Related column$/).getAttribute('aria-pressed')).toBe('true');
@@ -280,6 +295,35 @@ describe('RecordDetailView — the panel toggles (objectstack#27052)', () => {
280295
await waitFor(() => expect(shown(container)).toBe('details timeline'));
281296
});
282297

298+
it('an event that carries no `code` still flips the switches by the character it typed (objectstack#27241)', async () => {
299+
const { container } = renderPage();
300+
await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 });
301+
press({ key: '>', code: '' });
302+
await waitFor(() => expect(shown(container)).toBe('details related'));
303+
press({ key: '.', code: '' });
304+
await waitFor(() => expect(shown(container)).toBe('details timeline related'));
305+
press({ key: '<', code: '' });
306+
await waitFor(() => expect(shown(container)).toBe('details timeline'));
307+
press({ key: ',', code: '' });
308+
await waitFor(() => expect(shown(container)).toBe('details timeline related'));
309+
});
310+
311+
it('the two-column tier draws the same control in the header row; related OFF leaves no rail toggle (objectstack#27241)', async () => {
312+
const { container } = renderPage({ width: 900 });
313+
const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 });
314+
expect(container.querySelector('[data-record-page-tier]')?.getAttribute('data-record-page-tier')).toBe('two-column');
315+
expect(group.closest('[role="toolbar"]')).not.toBeNull();
316+
expect(shown(container)).toBe('details timeline');
317+
// Related ON with no room: the rail toggle, and it opens the column.
318+
fireEvent.click(screen.getByTestId('record-related-rail-toggle'));
319+
await waitFor(() => expect(container.querySelector('[data-record-column="related"]')).not.toBeNull());
320+
// Related OFF: no column, no rail toggle.
321+
fireEvent.click(toggle(/^Related column$/));
322+
await waitFor(() => expect(screen.queryByTestId('record-related-rail-toggle')).toBeNull());
323+
expect(container.querySelector('[data-record-column="related"]')).toBeNull();
324+
expect(shown(container)).toBe('details timeline');
325+
});
326+
283327
it('the tooltip names each shortcut, and the shortcut is announced', async () => {
284328
renderPage();
285329
await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 });
@@ -288,7 +332,7 @@ describe('RecordDetailView — the panel toggles (objectstack#27052)', () => {
288332
});
289333

290334
it('not drawn in the tabbed tier, nor in the drawer / split pane; the shortcut does nothing there', async () => {
291-
const tabbed = renderPage({ width: 900 });
335+
const tabbed = renderPage({ width: 700 });
292336
expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy();
293337
expect(screen.queryByRole('group', { name: 'Panels' })).toBeNull();
294338
press('timeline');

‎ui/packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
* the related column, and History is resident, so its read goes out at open.
2020
* - 1100 px: two columns and the related toggle; the related lists are not
2121
* mounted until the rail opens.
22-
* - 900 px, and an embedded view at any width: the tab strip as before.
22+
* - 700 px (under the 760 px two-column floor, objectstack#27241), and an
23+
* embedded view at any width: the tab strip as before.
2324
* - `?tab=` addresses the strip, so a column tier ignores it.
2425
*/
2526

@@ -218,8 +219,8 @@ describe('RecordDetailView — one record page at two widths (objectstack#26678)
218219
expect(column(container, 'related')!.textContent).toContain('Notes');
219220
});
220221

221-
it('900 px: the tab strip, as before; History waits for its tab', async () => {
222-
const { container } = renderPage({ width: 900 });
222+
it('700 px: the tab strip, as before; History waits for its tab', async () => {
223+
const { container } = renderPage({ width: 700 });
223224
expect(await screen.findByRole('tablist', undefined, { timeout: 5000 })).toBeTruthy();
224225
expect(screen.getByRole('tab', { name: /^Details/ })).toBeTruthy();
225226
expect(screen.getByRole('tab', { name: /^Related/ })).toBeTruthy();
@@ -234,7 +235,7 @@ describe('RecordDetailView — one record page at two widths (objectstack#26678)
234235
});
235236

236237
it('`?tab=` keeps working in the tabbed tier and is ignored in the column tiers', async () => {
237-
const tabbed = renderPage({ width: 900, search: '?tab=related' });
238+
const tabbed = renderPage({ width: 700, search: '?tab=related' });
238239
const relatedTab = await screen.findByRole('tab', { name: /^Related/ }, { timeout: 5000 });
239240
expect(relatedTab.getAttribute('aria-selected')).toBe('true');
240241
tabbed.unmount();
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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#27069 / objectstack#27241 — the record page's default related
11+
* order reads the object's declared `relatedLists`: a reader who never
12+
* arranged the related column sees the cards in that order, the rest after
13+
* them in the derived order (owned `master_detail` children first, then
14+
* lookups). Measured from a real `RecordDetailView` mount at three columns
15+
* (the `recordPageTiers-26678` harness: the root's measured width is the one
16+
* input varied).
17+
*/
18+
19+
import * as React from 'react';
20+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
21+
import { render, cleanup, waitFor } from '@testing-library/react';
22+
import { MemoryRouter } from 'react-router-dom';
23+
import { MetadataCtx } from '@object-ui/react';
24+
import { RelatedCountStore } from '@object-ui/components';
25+
26+
vi.mock('@object-ui/auth', async (importOriginal) => ({
27+
...(await importOriginal<Record<string, unknown>>()),
28+
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
29+
createAuthenticatedFetch: () => vi.fn(),
30+
}));
31+
32+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
33+
...(await importOriginal<Record<string, unknown>>()),
34+
useRecordPresence: () => [],
35+
PresenceAvatars: () => null,
36+
}));
37+
38+
vi.mock('sonner', () => ({
39+
toast: Object.assign(vi.fn(), {
40+
success: vi.fn(),
41+
error: vi.fn(),
42+
info: vi.fn(),
43+
warning: vi.fn(),
44+
loading: vi.fn(),
45+
dismiss: vi.fn(),
46+
}),
47+
}));
48+
49+
// Orthogonal chrome — stubbed so the only asynchrony here is the page's own.
50+
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
51+
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
52+
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
53+
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
54+
vi.mock('./MetadataInspector', () => ({
55+
MetadataPanel: () => null,
56+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
57+
}));
58+
59+
import { RecordDetailView } from './RecordDetailView';
60+
61+
const PARENT = 'showcase_project';
62+
const RECORD_ID = 'p-1';
63+
const BASE_FIELDS = { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } };
64+
65+
const objectsWith = (relatedLists?: string[]) => [
66+
{
67+
name: PARENT,
68+
label: 'Project',
69+
pluralLabel: 'Projects',
70+
managedBy: 'platform',
71+
...(relatedLists ? { relatedLists } : {}),
72+
fields: BASE_FIELDS,
73+
},
74+
{
75+
name: 'showcase_task',
76+
label: 'Task',
77+
pluralLabel: 'Tasks',
78+
managedBy: 'platform',
79+
fields: { ...BASE_FIELDS, project: { type: 'master_detail', reference: PARENT, label: 'Project' } },
80+
},
81+
{
82+
name: 'showcase_note',
83+
label: 'Note',
84+
pluralLabel: 'Notes',
85+
managedBy: 'platform',
86+
fields: { ...BASE_FIELDS, project: { type: 'lookup', reference: PARENT, label: 'Project' } },
87+
},
88+
{
89+
name: 'showcase_call',
90+
label: 'Call',
91+
pluralLabel: 'Calls',
92+
managedBy: 'platform',
93+
fields: { ...BASE_FIELDS, project: { type: 'lookup', reference: PARENT, label: 'Project' } },
94+
},
95+
];
96+
97+
function renderPage(objects: ReturnType<typeof objectsWith>) {
98+
const metadata = {
99+
objects,
100+
pages: [],
101+
loading: false,
102+
error: null,
103+
refresh: async () => {},
104+
invalidate: () => {},
105+
ensureType: async () => [],
106+
getItem: async () => null,
107+
getItemsByType: () => [],
108+
} as never;
109+
const dataSource = {
110+
find: vi.fn(async () => ({ data: [], total: 0 })),
111+
create: vi.fn(async (_o: string, row: unknown) => row),
112+
findOne: vi.fn(async (_o: string, recordId: string) => ({ id: recordId, name: `Project ${recordId}` })),
113+
update: vi.fn(async () => ({})),
114+
delete: vi.fn(async () => ({})),
115+
};
116+
return render(
117+
<MemoryRouter initialEntries={[`/app/demo/${PARENT}/${RECORD_ID}`]}>
118+
<MetadataCtx.Provider value={metadata}>
119+
<RecordDetailView
120+
dataSource={dataSource as never}
121+
objects={objects}
122+
onEdit={() => {}}
123+
objectNameOverride={PARENT}
124+
recordIdOverride={RECORD_ID}
125+
/>
126+
</MetadataCtx.Provider>
127+
</MemoryRouter>,
128+
);
129+
}
130+
131+
const cardOrder = (container: HTMLElement) =>
132+
Array.from(container.querySelectorAll('[data-record-column="related"] [data-related-card-id]')).map((el) =>
133+
el.getAttribute('data-related-card-id'),
134+
);
135+
136+
beforeEach(() => {
137+
cleanup();
138+
window.localStorage.clear();
139+
RelatedCountStore._reset();
140+
vi.stubGlobal(
141+
'fetch',
142+
vi.fn(async () =>
143+
new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }),
144+
),
145+
);
146+
const measure = HTMLElement.prototype.getBoundingClientRect;
147+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
148+
if (!this.hasAttribute('data-record-page-root')) return measure.call(this);
149+
return { x: 0, y: 0, top: 0, left: 0, right: 1440, bottom: 800, width: 1440, height: 800, toJSON: () => ({}) } as DOMRect;
150+
});
151+
});
152+
153+
afterEach(() => {
154+
vi.unstubAllGlobals();
155+
vi.restoreAllMocks();
156+
});
157+
158+
describe('RecordDetailView — the related cards follow the object\'s relatedLists (objectstack#27069)', () => {
159+
it('without relatedLists: the derived order — the owned child first, then the lookups in declaration order', async () => {
160+
const { container } = renderPage(objectsWith());
161+
await waitFor(() => expect(cardOrder(container)).toHaveLength(3), { timeout: 5000 });
162+
expect(cardOrder(container)).toEqual(['showcase_task.project', 'showcase_note.project', 'showcase_call.project']);
163+
});
164+
165+
it('with relatedLists: the declared lists first in that order, the rest after them in the derived order', async () => {
166+
const { container } = renderPage(objectsWith(['showcase_call', 'showcase_note.project']));
167+
await waitFor(() => expect(cardOrder(container)).toHaveLength(3), { timeout: 5000 });
168+
expect(cardOrder(container)).toEqual(['showcase_call.project', 'showcase_note.project', 'showcase_task.project']);
169+
});
170+
});

0 commit comments

Comments
 (0)