Skip to content

Commit 755794f

Browse files
feat(console): on a grouped list or a board, the record pane's counter and up/down step within the record's group or lane (#28492)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoUkJQrrXvVSkecCi4t5DBVd)_ Fixes #28461 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:分组列表(如按客户分组的联系人列表)和看板里打开记录后,右侧记录面板现在有「1 / 3」计数和 ↓ ↑,只在该记录所在的分组/看板列内切换,计数的提示写明分组名(「Acme Corporation 中第 1 条,共 3 条」)。 - **为什么改**:销售在分组列表或看板里看完一条记录,要关掉面板再点下一行;不分组的列表早就能用 ↓ ↑ / `j` `k` 连续翻看(#27524 Target 7 的遗留项)。 - **风险与回滚**:只加了三个可选的宿主回调(表格、看板、ListView 第二个参数),不传就与原来一致;不分组列表的行为与提示不变。回滚即 revert 本 PR。 - **先看**:`ui/packages/plugin-grid/src/ObjectGrid.tsx`(分组上报:只报展开且当前页可见的分组)、`ui/packages/app-shell/src/views/objectViewRecordNavigation.ts`(按记录所在分组取兄弟集合与分组名)、`ui/packages/plugin-kanban/src/KanbanImpl.tsx`(看板列上报)。 - **两分钟验证**:HotCRM 打开按客户分组的联系人列表,点 Acme Corporation 下第一位联系人:面板顶栏显示「1 / 3」,悬停提示「Acme Corporation 中第 1 条,共 3 条」,按 ↓ 到同组第二位,同组最后一位的 ↓ 置灰。 - **需要你做的**:无,席位落地。 ## What changes - **`@object-ui/plugin-grid`** — `ObjectGrid` gains an optional host prop `onRowGroupsChange(groups)`: on a grouped grid it reports every open leaf group on the current page of groups, in display order, with its heading (a nested group by its path of headings, joined by ` › `) and the rows drawn under it (a server-grouped group's current page). A collapsed group, or an open one with no rows yet, is left out. Reported on a change of content (keys, headings, row ids), never of identity; an ungrouped grid reports nothing. The band heading and the leaf rows are read through one helper each (`groupHeadingOf`, `leafRowsOf`), so the report and the bands cannot disagree. New type `ObjectGridRowGroup`. - **`@object-ui/plugin-kanban`** — `ObjectKanban` gains the same optional host prop, relayed to the board (`KanbanBoardProps.onLaneRowsChange`): every open lane in board order with its title and its cards in the order it draws them; on a swimlane board, row by row down the open swimlanes. A collapsed lane is left out. New type `KanbanLaneRows`, exported from the barrel. - **`@object-ui/plugin-list`** — `ListView` hands the grouped grid and the board that channel (only when a host passed `onRowsChange`) and relays what they report: `onRowsChange(rows, groups)`, the rows end to end and the groups as an optional second argument (`ListViewRowGroup`). A report is held with the view kind that sent it and never relayed once another kind is on screen. A paged ungrouped grid reports exactly as before (no second argument). - **`@object-ui/app-shell`** — the object page keeps the groups (`listShownRowsOf`) and hands the pane's root the opened record's own group as its sibling set, named by the group (`listSiblingSetOf`; `RecordDrawerLevel.scope`). `RecordStackDrawer` names the root's set by its `scope` before the list's view label; the record page opened from a row (`from` state, or the ↗ way back) carries the same set and name, and its counter names it. `ObjectView.tsx` and `record-top-bar.tsx` are untouched: the tooltip key `detail.recordOfScope` already words Target 7 in all ten packs. ## Tests - `plugin-grid/src/__tests__/rowGroupsReport-28461.test.tsx` — real `ObjectGrid` grouping on the server: open groups in display order with heading and rows; a group the reader collapses leaves the report; control: an ungrouped grid reports nothing. - `plugin-kanban/src/ObjectKanban.laneRowsReport-28461.test.tsx` — real board: open lanes in order with their cards; the lost lane (collapsed by its lifecycle kind) is left out until opened. - `plugin-list/src/__tests__/ListView.rowGroups-28461.test.tsx` — the relay: a grouped grid and a board each yield rows plus groups; control: an ungrouped grid is not handed the channel and reports rows with no groups. - `app-shell/src/views/__tests__/recordPaneGroupScope-28461.test.tsx` — the card's Tests: grouped grid counter and group-named tooltip, ↓ to the next row of the same group, the last row of a group has no ↓, the first row of a later group has no ↑; the board the same within a lane; control: an ungrouped list keeps the whole list and the view's name; the object page's root, and the ↗ page's stepper, carry the group's set and name. - `plugin-list/src/__tests__/ListView.onRowsChange-26679.test.tsx` — its "no single row order" control moves from a board (which now reports its lanes) to a calendar, which still reports no rows and no groups. ## Acceptance notes - **Collapsed groups (the order left this open):** the reading the reader sees — shown rows only. A collapsed group or lane draws no rows, so it is not in the set; expanding it brings it back. A record opened by URL inside a collapsed group gets no counter. - **Server-grouped paging:** a server-grouped group steps through the page of it on screen, not the group's whole count (the other pages are not fetched); the counter says `n / rows on screen`. Paging whole groups (the grouped pager) likewise scopes to the groups on the current page. - **Swimlane boards:** a lane is the board column; its cards are ordered row by row down the open swimlanes (the order the eye reads the column), and a collapsed swimlane's cards are left out. - **Nested grouping:** the scope name is the path of headings (`Acme Corporation › Open`), because the leaf heading alone is ambiguous across parents. - **Neighbours:** `ObjectView.tsx` and `objectViewRecordNavigation.ts` are shared with PR #28477 (#27058) — this PR does not touch `ObjectView.tsx`, and edits only the row-set plumbing in `objectViewRecordNavigation.ts`. ## Verification Head `012ae4d6` on `claude/project-thread-4omx0a`, base `768ec4bf`. <details><summary>New and moved tests — 7 files, 25 tests passed</summary> ``` pnpm --dir ui exec vitest run (the four new files + ListView.onRowsChange-26679 + recordDrawerStack.rootStepper-26679 + objectViewRecordNavigation.listRows-27524) Test Files 7 passed (7) Tests 25 passed (25) ``` </details> <details><summary>Ablations — each reds its test, each restored to HEAD</summary> ``` ablation-replace ListView.tsx: shownRowGroups forced to null (the grouped report not relayed) mutation landed: anchor 1 -> 0, blob 153dc09 -> f3f2576d12ce × a grouped grid: the rows end to end, with the groups they sit in × a board: the cards end to end, with the lanes they sit in Tests 2 failed | 1 passed (3) (the ungrouped control stays green) restored: blob == HEAD (153dc09), git diff HEAD empty ablation-replace ObjectGrid.tsx: the open leaf group not pushed into the report mutation landed: anchor 1 -> 0, blob 1fc257e -> 964ccdd1c912 × reports each open group in display order, with its heading and the rows it draws × a group the reader collapses draws no rows and leaves the report Tests 2 failed | 1 passed (3) (the ungrouped control stays green) restored: blob == HEAD (1fc257e), git diff HEAD empty ``` Both subjects are imported from source (`../ListView`, `../ObjectGrid`), so no dist rebuild sits between mutation and run. </details> <details><summary>Type-check — the four packages</summary> ``` pnpm --filter @object-ui/plugin-grid run type-check -> tsc --noEmit && tsc -p tsconfig.test.json · VERDICT command-exit 0 pnpm --filter @object-ui/plugin-kanban run type-check -> tsc --noEmit && tsc -p tsconfig.test.json · VERDICT command-exit 0 pnpm --filter @object-ui/plugin-list run type-check -> tsc --noEmit && tsc -p tsconfig.test.json · VERDICT command-exit 0 pnpm --filter @object-ui/app-shell run type-check -> tsc --noEmit && tsc -p tsconfig.test.json · VERDICT command-exit 0 ``` </details> <details><summary>Package suites — plugin-grid, plugin-kanban, plugin-list, app-shell</summary> ``` pnpm --dir ui exec vitest run packages/plugin-grid/ packages/plugin-kanban/ packages/plugin-list/ + the 7 app-shell files that import recordDrawerStack / objectViewRecordNavigation / onRowsChange Test Files 539 passed (539) Tests 4653 passed (4653) VERDICT command-exit 0 Declared narrowing: app-shell's whole suite (2,017 files) is left to CI's Test UI; locally its 7 files on the pane / the list's row set ran (the 3 suites above run in full). ``` </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran)</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 dispatch-gates --ran: 35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED (type-check-debt re-measure: OK, 0 raw tsc errors above record; ui scripts/: 169 files passed, 2 skipped) ui check:new-line-citations: 0 new citation(s) -> exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibvFWwhiepJS7DD4DXb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2dbc774 commit 755794f

16 files changed

Lines changed: 947 additions & 47 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
'@object-ui/plugin-kanban': patch
4+
'@object-ui/plugin-list': patch
5+
'@object-ui/app-shell': patch
6+
---
7+
8+
feat(console): on a grouped list or a kanban board, the record pane's counter and ↓ ↑ step within the opened record's group or lane, and the counter's tooltip names it
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
A record opened from a grouped list (a contact list grouped by account) or from a board drew no 「3 / 21」 counter and no ↓ ↑ in its pane, so stepping to the next record meant closing the pane and clicking the next row. Now:
14+
15+
- **The pane steps within the group.** The counter counts the rows of the opened record's group (a board: its lane), ↓ ↑ step through them in the order the list draws them, and the last row of a group has no ↓. The counter's tooltip names the group — 「Acme Corporation 中第 1 条,共 3 条」 — where an ungrouped list's names the view. A nested group is named by its path of headings. The record page opened from the row's ↗ steps the same way.
16+
- **Shown rows only.** A collapsed group or lane draws no rows and is not stepped through; a server-grouped group steps through the page of it on screen.
17+
- **An ungrouped list is unchanged**: its counter and tooltip are as before.
18+
19+
Host channels, never authoring keys: `ObjectGrid` and `ObjectKanban` gain an optional `onRowGroupsChange` prop that reports the open groups (lanes) with their headings and rows (`ObjectGridRowGroup`, `KanbanLaneRows`), and `ListView`'s `onRowsChange` gains an optional second argument, `groups` (`ListViewRowGroup`), on a grouped grid and a board.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#28461 — on a grouped list or a board, the record pane's counter
5+
* and ↓ ↑ step within the opened record's group or lane, and the counter's
6+
* tooltip names it (#27524 Target 7: 「Acme Corporation 中第 1 条,共 3 条」).
7+
*
8+
* The object page holds what `ListView` reports through `onRowsChange` — the
9+
* rows, and on a grouped grid or a board the groups they sit in — and hands
10+
* the pane's root the opened record's own group as its sibling set, named by
11+
* the group. The last row of a group has no ↓, whatever the list shows after
12+
* it. Control: an ungrouped list keeps the whole list as the set, named by
13+
* the list's view.
14+
*/
15+
16+
import * as React from 'react';
17+
import { describe, it, expect, vi, afterEach } from 'vitest';
18+
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react';
19+
import { MemoryRouter, type Location } from 'react-router-dom';
20+
import '@testing-library/jest-dom';
21+
22+
import { RecordStackDrawer, useRecordDrawerStack, useRecordPageReturn, type RecordDrawerLevel } from '../recordDrawerStack';
23+
import { listShownRowsOf, listSiblingSetOf, useObjectViewRecordNavigation } from '../objectViewRecordNavigation';
24+
25+
afterEach(() => cleanup());
26+
27+
const OBJECTS = [{ name: 'crm_contact', label: 'Contact' }];
28+
const ROWS = [{ id: 'c-1' }, { id: 'c-2' }, { id: 'c-3' }, { id: 'c-4' }, { id: 'c-5' }];
29+
/** HotCRM's contact list grouped by account: the rows in display order, by group. */
30+
const GROUPS = [
31+
{ label: 'Acme Corporation', rows: [{ id: 'c-1' }, { id: 'c-2' }, { id: 'c-3' }] },
32+
{ label: 'Globex', rows: [{ id: 'c-4' }, { id: 'c-5' }] },
33+
];
34+
/** A board's report: its open lanes and their cards. */
35+
const LANES = [
36+
{ label: 'New', rows: [{ id: 'd-1' }, { id: 'd-3' }] },
37+
{ label: 'Won', rows: [{ id: 'd-2' }] },
38+
];
39+
40+
/** The object page's shape: the root is host state, stepped by `onRootStep`. */
41+
function ListHost({ rows, groups, start }: {
42+
rows: ReadonlyArray<Record<string, unknown>>;
43+
groups?: typeof GROUPS;
44+
start: string;
45+
}) {
46+
const [recordId, setRecordId] = React.useState(start);
47+
const shown = React.useMemo(() => listShownRowsOf(rows, groups), [rows, groups]);
48+
const root = React.useMemo<RecordDrawerLevel>(
49+
() => ({ objectName: 'crm_contact', recordId, ...(listSiblingSetOf(shown, recordId) ?? {}) }),
50+
[recordId, shown],
51+
);
52+
const stack = useRecordDrawerStack({ root, onRootStep: setRecordId });
53+
return (
54+
<RecordStackDrawer
55+
stack={stack}
56+
open
57+
objects={OBJECTS}
58+
stepScope="All contacts"
59+
onClose={() => {}}
60+
renderRecord={(level) => <div data-testid="record-body">{level.recordId}</div>}
61+
/>
62+
);
63+
}
64+
65+
function mountPane(start: string, rows: ReadonlyArray<Record<string, unknown>>, groups?: typeof GROUPS) {
66+
render(
67+
<MemoryRouter>
68+
<ListHost rows={rows} groups={groups} start={start} />
69+
</MemoryRouter>,
70+
);
71+
}
72+
73+
const body = () => screen.getByTestId('record-body').textContent;
74+
const counter = () => document.querySelector('[data-record-top-bar-position]') as HTMLElement | null;
75+
const next = () => screen.queryByRole('button', { name: 'Next record' }) as HTMLButtonElement | null;
76+
const previous = () => screen.queryByRole('button', { name: 'Previous record' }) as HTMLButtonElement | null;
77+
78+
describe('the record pane steps within the opened row\'s group (objectstack#28461)', () => {
79+
it('grouped grid: the counter counts the group, and its tooltip names it', () => {
80+
mountPane('c-1', ROWS, GROUPS);
81+
expect(counter()!.textContent).toBe('1 / 3');
82+
expect(counter()!.getAttribute('title')).toBe('1 of 3 in Acme Corporation');
83+
});
84+
85+
it('grouped grid: ↓ goes to the next row of the same group; the last row of a group has no ↓', () => {
86+
mountPane('c-2', ROWS, GROUPS);
87+
fireEvent.click(next()!);
88+
expect(body()).toBe('c-3');
89+
expect(counter()!.textContent).toBe('3 / 3');
90+
// c-4 follows in the list, but in another group.
91+
expect(next()!.disabled).toBe(true);
92+
fireEvent.keyDown(document.body, { key: 'j' });
93+
expect(body()).toBe('c-3');
94+
});
95+
96+
it('grouped grid: the first row of a later group has no ↑ and counts that group', () => {
97+
mountPane('c-4', ROWS, GROUPS);
98+
expect(previous()!.disabled).toBe(true);
99+
expect(counter()!.getAttribute('title')).toBe('1 of 2 in Globex');
100+
});
101+
102+
it('board: the same within a lane', () => {
103+
mountPane('d-1', [{ id: 'd-1' }, { id: 'd-3' }, { id: 'd-2' }], LANES);
104+
expect(counter()!.getAttribute('title')).toBe('1 of 2 in New');
105+
fireEvent.click(next()!);
106+
expect(body()).toBe('d-3');
107+
expect(next()!.disabled).toBe(true);
108+
});
109+
110+
it('control: an ungrouped list steps through every row, named by the list\'s view', () => {
111+
mountPane('c-3', ROWS);
112+
expect(counter()!.textContent).toBe('3 / 5');
113+
expect(counter()!.getAttribute('title')).toBe('3 of 5 in All contacts');
114+
fireEvent.click(next()!);
115+
expect(body()).toBe('c-4');
116+
});
117+
118+
it('a record no shown group holds gets no counter', () => {
119+
mountPane('c-9', ROWS, GROUPS);
120+
expect(counter()).toBeNull();
121+
expect(next()).toBeNull();
122+
});
123+
});
124+
125+
describe('the object page hands the opened row\'s group along (objectstack#28461)', () => {
126+
const LIST = { pathname: '/apps/crm/crm_contact', search: '?view=all', state: null, hash: '', key: 'k' } as Location;
127+
128+
function mountNavigation(search: string) {
129+
const navigate = vi.fn();
130+
const { result } = renderHook(() =>
131+
useObjectViewRecordNavigation({
132+
activeView: { id: 'all', name: 'all', label: 'All contacts' } as never,
133+
objectDef: { name: 'crm_contact', label: 'Contact', fields: {} } as never,
134+
objects: OBJECTS as never,
135+
appName: 'crm',
136+
viewId: undefined,
137+
searchParams: new URLSearchParams(search),
138+
setSearchParams: vi.fn(),
139+
location: LIST,
140+
navigate,
141+
routerRootHref: '/',
142+
servedViews: { views: [], objects: [] } as never,
143+
viewLabel: (_object: string, _view: string, fallback: string) => fallback,
144+
unlabelledViewText: 'Untitled view',
145+
language: 'en',
146+
recordOpenPreference: 'object' as never,
147+
// A desktop console: on a phone every record opens as a page.
148+
phone: false,
149+
}),
150+
{ wrapper: ({ children }: { children: React.ReactNode }) => <MemoryRouter initialEntries={[`/apps/crm/crm_contact?${search}`]}>{children}</MemoryRouter> },
151+
);
152+
return { result, navigate };
153+
}
154+
155+
it('the pane\'s root: the group\'s rows, named by the group', () => {
156+
const { result } = mountNavigation('view=all&recordId=c-5');
157+
act(() => result.current.handleListRowsChange(ROWS, GROUPS));
158+
expect(result.current.drawerRoot).toEqual({
159+
objectName: 'crm_contact',
160+
recordId: 'c-5',
161+
siblings: ['c-4', 'c-5'],
162+
scope: 'Globex',
163+
});
164+
});
165+
166+
it('control: an ungrouped list hands every row and no name of its own', () => {
167+
const { result } = mountNavigation('view=all&recordId=c-5');
168+
act(() => result.current.handleListRowsChange(ROWS));
169+
expect(result.current.drawerRoot).toEqual({
170+
objectName: 'crm_contact',
171+
recordId: 'c-5',
172+
siblings: ['c-1', 'c-2', 'c-3', 'c-4', 'c-5'],
173+
});
174+
});
175+
176+
it('the row\'s ↗: the page steps within the group and its counter names it', () => {
177+
const { result, navigate } = mountNavigation('view=all');
178+
act(() => result.current.handleListRowsChange(ROWS, GROUPS));
179+
act(() => result.current.handleOpenRowAsPage('c-2'));
180+
const state = navigate.mock.calls.at(-1)![1].state;
181+
expect(state.recordDrawerReturn.path).toEqual([
182+
{ objectName: 'crm_contact', recordId: 'c-2', siblings: ['c-1', 'c-2', 'c-3'], scope: 'Acme Corporation' },
183+
]);
184+
const page = renderHook(() => useRecordPageReturn('c-2'), {
185+
wrapper: ({ children }: { children: React.ReactNode }) => (
186+
<MemoryRouter initialEntries={[{ pathname: '/apps/crm/crm_contact/record/c-2', state }]}>{children}</MemoryRouter>
187+
),
188+
});
189+
expect(page.result.current.stepper?.position).toEqual({ index: 1, total: 3 });
190+
expect(page.result.current.stepper?.scope).toBe('Acme Corporation');
191+
});
192+
});

‎ui/packages/app-shell/src/views/objectViewRecordNavigation.ts‎

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,53 @@ import type { buildViewTabs } from './objectViewTabs.js';
4343
import type { ConsoleObjectViewProps } from './ObjectView.js';
4444
import type { RecordOpenPreference } from '../utils/recordOpenPreference.js';
4545

46+
/**
47+
* The rows a list shows, as the object page holds them (objectstack#26679):
48+
* their ids in order, and on a grouped grid or a board the groups they sit in
49+
* (objectstack#28461), each with its heading. `key` is the content's key.
50+
*/
51+
export interface ListShownRows {
52+
key: string;
53+
ids: readonly string[];
54+
groups: ReadonlyArray<{ label: string; ids: readonly string[] }> | null;
55+
}
56+
57+
const NO_LIST_ROWS: ListShownRows = { key: '', ids: [], groups: null };
58+
59+
const rowIdsOf = (rows: ReadonlyArray<Record<string, unknown>>): string[] =>
60+
rows
61+
.map((row) => (row?.id ?? row?._id) as string | number | undefined)
62+
.filter((id): id is string | number => id != null && id !== '')
63+
.map(String);
64+
65+
/** `ListView`'s `onRowsChange` report, as {@link ListShownRows}. */
66+
export function listShownRowsOf(
67+
rows: ReadonlyArray<Record<string, unknown>>,
68+
groups?: ReadonlyArray<{ label: string; rows: ReadonlyArray<Record<string, unknown>> }>,
69+
): ListShownRows {
70+
const ids = rowIdsOf(rows);
71+
const held = groups ? groups.map((group) => ({ label: group.label, ids: rowIdsOf(group.rows) })) : null;
72+
return { key: JSON.stringify([ids, held]), ids, groups: held };
73+
}
74+
75+
/**
76+
* The set a record's `↑` `↓` step through, and the name its counter's tooltip
77+
* says: on a grouped list the rows of the record's own group, named by the
78+
* group (objectstack#28461, 「Acme Corporation 中第 1 条,共 3 条」); otherwise
79+
* every row the list shows, and no name of its own — the host names the list.
80+
* `undefined` when the list does not show the record.
81+
*/
82+
export function listSiblingSetOf(
83+
rows: ListShownRows,
84+
recordId: string,
85+
): { siblings: readonly string[]; scope?: string } | undefined {
86+
if (rows.groups) {
87+
const group = rows.groups.find((candidate) => candidate.ids.includes(recordId));
88+
return group ? { siblings: group.ids, scope: group.label } : undefined;
89+
}
90+
return rows.ids.includes(recordId) ? { siblings: rows.ids } : undefined;
91+
}
92+
4693
type OverlaySizeBucket = 'sm' | 'md' | 'lg' | 'xl' | 'full';
4794

4895
/**
@@ -143,7 +190,7 @@ export function useObjectViewRecordNavigation({
143190
// The rows the list shows (`handleListRowsChange` below), read when a
144191
// handler runs: a page this list opens steps through them
145192
// (objectstack#27524, Target 7).
146-
const listRowIdsRef = useRef<readonly string[]>([]);
193+
const listRowsRef = useRef<ListShownRows>(NO_LIST_ROWS);
147194
// The list's name — its view's tab label: what the page's way back says,
148195
// and the set the pane's counter names (objectstack#27524, Target 7).
149196
const listLabel = useMemo(
@@ -178,7 +225,7 @@ export function useObjectViewRecordNavigation({
178225
// The rows the list shows go along when the record is one of
179226
// them: the page's top bar counts and steps through them
180227
// (objectstack#27524, Target 7).
181-
const siblings = listRowIdsRef.current;
228+
const set = listSiblingSetOf(listRowsRef.current, String(recordId));
182229
// The list as the page's origin, without the drawer's
183230
// `?recordId=` a phone redirect leaves behind (objectstack#27058).
184231
const originParams = new URLSearchParams(search);
@@ -189,7 +236,7 @@ export function useObjectViewRecordNavigation({
189236
from: {
190237
pathname: pathname + (originSearch ? `?${originSearch}` : ''),
191238
label: listLabel,
192-
...(siblings.includes(String(recordId)) ? { siblings } : {}),
239+
...(set ?? {}),
193240
},
194241
};
195242
if (viewId) {
@@ -315,32 +362,32 @@ export function useObjectViewRecordNavigation({
315362
const { pathname, search, state } = locationRef.current;
316363
const back = new URLSearchParams(search);
317364
back.set(RECORD_DRAWER_PARAM, String(recordId));
318-
const siblings = listRowIdsRef.current;
365+
const set = listSiblingSetOf(listRowsRef.current, String(recordId));
319366
const returnState = recordDrawerReturnState(
320367
{ pathname, search: `?${back.toString()}`, state },
321368
[{
322369
objectName: objectDef.name,
323370
recordId: String(recordId),
324-
...(siblings.includes(String(recordId)) ? { siblings } : {}),
371+
...(set ?? {}),
325372
}],
326373
);
327374
handleNavOverlayNavigate(recordId, undefined, returnState);
328375
}, [objectDef.name, handleNavOverlayNavigate]);
329376
/**
330377
* The rows the list shows, in its order (`ListView`'s `onRowsChange`,
331-
* objectstack#26679): the root level's sibling set, which the drawer's
332-
* `↑` `↓` step through. Held as the ids' joined key so an equal report
378+
* objectstack#26679), and on a grouped grid or a board its groups
379+
* (objectstack#28461): the root level's sibling set, which the drawer's
380+
* `↑` `↓` step through. Held with its content's key so an equal report
333381
* changes nothing.
334382
*/
335-
const [listRowIds, setListRowIds] = useState<{ key: string; ids: readonly string[] }>({ key: '', ids: [] });
336-
listRowIdsRef.current = listRowIds.ids;
337-
const handleListRowsChange = useCallback((rows: ReadonlyArray<Record<string, unknown>>) => {
338-
const ids = rows
339-
.map((row) => (row?.id ?? row?._id) as string | number | undefined)
340-
.filter((id): id is string | number => id != null && id !== '')
341-
.map(String);
342-
const key = JSON.stringify(ids);
343-
setListRowIds((prev) => (prev.key === key ? prev : { key, ids }));
383+
const [listRows, setListRows] = useState<ListShownRows>(NO_LIST_ROWS);
384+
listRowsRef.current = listRows;
385+
const handleListRowsChange = useCallback((
386+
rows: ReadonlyArray<Record<string, unknown>>,
387+
groups?: ReadonlyArray<{ label: string; rows: ReadonlyArray<Record<string, unknown>> }>,
388+
) => {
389+
const next = listShownRowsOf(rows, groups);
390+
setListRows((prev) => (prev.key === next.key ? prev : next));
344391
}, []);
345392
/** `↑` `↓` at the root: the sibling becomes the row `?recordId=` names. */
346393
const handleRootStep = useCallback((recordId: string) => {
@@ -359,10 +406,10 @@ export function useObjectViewRecordNavigation({
359406
? {
360407
objectName: objectDef.name,
361408
recordId: drawerRecordId,
362-
...(listRowIds.ids.length > 0 ? { siblings: listRowIds.ids } : {}),
409+
...(listSiblingSetOf(listRows, drawerRecordId) ?? {}),
363410
}
364411
: null),
365-
[drawerRecordId, objectDef.name, listRowIds],
412+
[drawerRecordId, objectDef.name, listRows],
366413
);
367414
// Sync URL-based recordId to overlay state. On a phone there is no pane to
368415
// open: the drawer link is answered by the record's page, in its place in

0 commit comments

Comments
 (0)