Skip to content

Commit 34c2407

Browse files
objectstack-fleet[bot]hotlongclaude
authored
feat(app-shell,components): the record drawer opens a related row or a lookup in place, one level at a time, and its bar names no object (#27038)
Fixes #26679 Clause-②: no Metadata-protocol: none ## What changes The console's record drawer is one drawer, and a record opened from inside it opens in place (maintainer rulings 2-5 on the card, quoted there verbatim). - **The bar (`NavigationOverlay`, `@object-ui/components`).** The left side is empty at the root: no object name, no "n / N". The title stays as the dialog's accessible name only (Radix requires one), on every host of the shared overlay. Three optional props draw the rest: `onBack` (a `←` control; `Esc` calls it instead of closing), `trail` (the path after `←`) and `stepper` (`↑` `↓`, each disabled at its end; `ArrowUp` / `k` and `ArrowDown` / `j` on the document while the drawer is open, never from a text field, a contenteditable region or a composite widget — menu, listbox, grid, tree, tablist, … — and never with a modifier). `⤢` stays icon-only, `✕` closes. - **The root steps through the list.** `ListView` (`@object-ui/plugin-list`) gains an `onRowsChange` host prop beside `onTotalChange`: the rows it shows, in their order, re-reported when the shown set changes after a load settles (page, sort, filter, search), keyed on the ids in order so an equal refetch reports nothing. A paged grid answers it; a board, a calendar or a grouped grid answers with no rows, so a host never steps through a stale set. The object page threads it to its drawer: `↑` `↓` at the root replace `?recordId=` (history replace) by the previous / next row, disabled at the first and the last; a list that reported no rows draws no `↑` `↓`. - **The stack (`app-shell/src/views/recordDrawerStack.tsx`, new).** `useRecordDrawerStack` holds the levels above a host-controlled root; `RecordStackDrawer` draws the current level through the host's `renderRecord` inside `RecordDrawerContext`. `←` / `Esc` pop one level, `Esc` at the root closes, each ancestor in the path returns to its level, `↑` `↓` replace the current level by its previous / next sibling and never cross a level. Each level keeps its own `?tab=`: the left level's tab is saved and the new level opens on its default tab; going back restores it. - **The door (`RelatedRecordActionsBridge`).** `openRecord` — which the related list's `onView` and every lookup value (`ReferencedRecordLink` → `host.openRecord`) already go through — opens the record in the nearest drawer host when the target object opens in the drawer (`navigation.mode` absent or `drawer`); an object that declares `page` / `new_window` / `none` navigates as before, and with no host (Studio, a custom page, the split pane, the other embedded hosts) nothing changes. The decision reads a context, not the `embedded` flag: the record page is a host too. - **Siblings.** `RelatedList` hands its `onRowClick` the rows on show as a third argument, and the `record:related_list` renderer turns them into `RelatedRecordHandlers.onView`'s optional third argument (sibling ids). Called with two arguments the renderer still calls `onView` with two. - **The list host.** `ObjectViewRecordOverlay` draws `RecordStackDrawer` over the root `?recordId=` names. `⤢` at the root keeps its list-relative route and the list as origin; at a deeper level it opens `/apps/:app/:object/record/:id?from=TRAIL` (TRAIL = the levels below it), the existing `?from=` trail, so the page's back link reads the record it was opened from and each ancestor's page carries the trail above it. Either way the page receives the way back in history state. - **The page host.** `RecordDetailView`, when not embedded, wraps its page in `RecordPageDrawerHost`: a related row or lookup on the page opens a drawer over it under the same rules (level 0 steps through the page's related rows), the page's own `?tab=` is set aside while the drawer is open, and `⤢` opens the record's page with the page record heading the trail. The embedded view no longer draws the page's origin back link inside a drawer. - **The served approval-request page** is a drawer host like any record page. Its two pins (`servedPageLookupEnter-12569`, `servedPageRecordLink-12557`) now assert that the drawer opens over the page with the target record in it, the router does not move, the href stays the record path, and a modified click is the browser's. - **`data-table` inside a dialog.** A table drawn inside a dialog opens its rows. The click heuristic stopped every click with a dialog ancestor, so related-list rows in the record drawer opened nothing. Now only a dialog portalled out of the row (one that does not contain it) stops the click (`data-table-rowClickInDialog-26679.test.tsx`). - **「Collapse to drawer」.** A page opened by `⤢` shows "Collapse to drawer" beside its back link; it returns to the host with the stack it left. The restored stack is taken out of the history entry on arrival, so a reload or Back shows the root alone. - **What a reload keeps**, documented in the module docblock and in `ui/content/docs/guide/user-state-persistence.md`: the root (`?recordId=`); the levels above it are memory. - **i18n.** `detail.collapseToDrawer` in all ten packs; the bar's `Go back` / `Previous record` / `Next record` reuse the existing `detail.goBack` / `detail.previousRecord` / `detail.nextRecord`. In zh the `⤢` label reads 整页打开 (`detail.openAsFullPage` and `console.objectView.expandToPage`, kept byte-identical by `objectView-value-language-3582`), the same spelling as #27031's `console.objectView.openAsPage`. ## What is not here - **The top bar crumb.** The card's target breadcrumb (「线索 / 全部线索 / Mira Costa / 任务 / 跟进报价」) has no carrier on main: `AppHeader` draws no crumb after the app pill on an object route, record pages included (its comment: an object route "draws NO crumb after the pill"). The path rides the existing `?from=` trail, read by the page's back link; no crumb is added here. - **The split face** (the reader's Drawer | Split preference from #27031) draws no `↑` `↓`: it is not a stack host. ## Screenshots HotCRM lead Mira Costa → task, this branch's console (evidence branch `claude/issue-26679-evidence-20261028`, never merged): drawer root https://github.com/objectstack-ai/objectstack/blob/8ef7f64917413235c23cf1030260423f76b2d382/docs/evidence/issue-26679/1-drawer-root-lead.png · one level down https://github.com/objectstack-ai/objectstack/blob/8ef7f64917413235c23cf1030260423f76b2d382/docs/evidence/issue-26679/2-drawer-one-level-down-task.png · after `⤢` https://github.com/objectstack-ai/objectstack/blob/8ef7f64917413235c23cf1030260423f76b2d382/docs/evidence/issue-26679/3-expanded-task-page.png. Taken before the list-root `↑` `↓` landed; the seat's console run on the final head covers that control. ## Acceptance notes - Other drawers that embed `RecordDetailView` (`InterfaceListPage`, `ObjectDataPage`, `ObjectView`'s split pane) are not drawer hosts yet: inside them a related row still navigates. Adopting `RecordStackDrawer` there is the same swap `ObjectViewRecordOverlay` makes. - The split pane still draws its object-label heading (reader preference face, #26681's surface). - `ui/content/docs/guide/user-state-persistence.md`'s "Authoring note" still lists `modal` / `split` / `popover` as `navigation.mode` values, retired by #26804; not edited here. - No ObjectView-level integration pin wires `ListView` into a mounted `ObjectView`; the producer (`ListView.onRowsChange-26679`) and the consumer (`recordDrawerStack.rootStepper-26679`) are pinned separately and the hook glue between them is about 25 lines. ## Tests Final head `811a10fa14` (branch merged with `origin/main` `da8e6e719f`, which includes #27031), local, macOS: - Libraries built (`pnpm --filter '@object-ui/console^...' build`), then `tsc --noEmit` plus `tsconfig.test.json` for plugin-list and app-shell: exit 0. - components + plugin-detail + i18n + react + plugin-list + the two console approval pins: 1148 files passed, 2 skipped; 11809 tests. - app-shell full (ObjectView tests included): 1817 passed, 1 failed under load (`ListView.gridRowColorSelect-12393`, a loading-state assertion); that file re-run alone passes 2/2. - New in this PR: `navigation-overlay-bar-26679` (components, 9), `recordDrawerStack-26679` (app-shell, 12), `recordDrawerStack.rootStepper-26679` (app-shell, 4), `ListView.onRowsChange-26679` (plugin-list, 2), `RelatedList.rowClickSiblings-26679` (plugin-detail, 2), `data-table-rowClickInDialog-26679` (components, 3), one sibling-ids case in `RecordRelatedListRenderer.hostactions.test.tsx`; the two console pins re-pinned as above. - Reverse verification, each with the fix committed and a `grep -c` count proving the mutation landed: the `ListView` emit line replaced → both `onRowsChange` cases red; root stepping disabled in the stack → the two presence / end cases red while the two absence controls stay green; the old `closest('[role="dialog"]')` line restored → "a table inside a dialog opens the clicked row" red while its two controls stay green. All restored by `git checkout HEAD`, `git diff HEAD` 0 lines. Verification ran in the lock's DECLARED UNLOCKED MODE: os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized (macOS, no util-linux). ## Gates Derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `811a10fa14` (34 families), each run with its exit code captured before any pipe; `--ran` reconciliation: 34 derived, 34 run, 0 UNRUN. - 33 families exit 0. - `pnpm --dir ui exec vitest run scripts/`: exit 1 with 6 files — 5 `Test timed out in 15000ms` under load (4 of them pass re-run alone: 44 tests) plus `select-affected-tests-wiring` (`mapfile: command not found`, macOS bash 3.2, a host fact); `select-affected-tests-wiring` and the `check-i18n-dead-keys` real-tree case stay NOT MEASURED locally; CI's `Test UI` / `Lint & Repo Gates` read them. - `pnpm check:new-line-citations` (ui/AGENTS.md #11): 0 new citations. --- _Generated by [Claude Code](https://claude.ai/code/session_cdb7a717-def7-4e71-9988-f089fbaa18ff)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent a98daf7 commit 34c2407

37 files changed

Lines changed: 2015 additions & 107 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@object-ui/components': patch
3+
'@object-ui/app-shell': patch
4+
'@object-ui/plugin-detail': patch
5+
'@object-ui/plugin-list': patch
6+
'@object-ui/react': patch
7+
'@object-ui/i18n': patch
8+
---
9+
10+
feat(app-shell,components): the record drawer opens a related row or a lookup in place, one level at a time, and its bar names no object
11+
12+
Clause-②: no
13+
Metadata-protocol: none
14+
15+
The console's record drawer is one drawer (objectstack#26679):
16+
17+
- **The bar.** Its left side is empty at the root: no object name and no "n / N" position. Its right side holds `↑` `↓` (previous / next through the level's sibling set, disabled at the ends; `↑` / `↓` and `k` / `j` while no text field, menu or grid has the keyboard), `⤢` (icon only) and `✕`. `NavigationOverlay` gains three optional props for it — `onBack`, `trail` and `stepper` — and keeps its title as the accessible name only, on every host.
18+
- **The root steps through the list.** `ListView` reports the rows it shows, in their order, through a new `onRowsChange` host prop (beside `onTotalChange`, answered by a paged grid; a board or a grouped grid reports no rows). The object page hands them to its drawer, so `↑` `↓` at the root move to the previous / next row, disabled at the first and the last.
19+
- **In place.** A related row, a lookup value or any record link inside the drawer opens that record in the same drawer and keeps the previous one on a stack. The bar shows `←` and the path (each ancestor returns to its level); `←` or `Esc` goes back one level, `Esc` at the root closes, `✕` closes the whole stack. `↑` `↓` one level down step through the rows the parent's related list showed, never across levels. Each level keeps its own `?tab=`. An object that declares `navigation.mode: 'page'` still opens its page.
20+
- **⤢ at any level** opens that record's page with the levels below it as its `?from=` trail, so its back link reads the record it was opened from; the page offers "Collapse to drawer", which returns to the drawer with the stack restored.
21+
- **On the record page**, a related row or a lookup opens its record in a drawer over the page, under the same rules.
22+
- **`data-table` inside a dialog.** A table drawn inside a dialog now opens its rows: the row click ignored every click with a dialog ancestor, so a related list inside a record drawer drew pointer rows that opened nothing. Only a dialog portalled out of the row (one that does not contain it) still stops the click.
23+
- **What a reload keeps.** The root stays `?recordId=` on an object list; the levels above it live in memory, so a reload shows the root record.
24+
25+
Published-type additions, all optional: `RelatedRecordHandlers.onView` receives the ids of the rows on show as a third argument, and `RelatedList`'s `onRowClick` receives those rows as its third argument. The i18n packs gain `detail.collapseToDrawer` in all ten languages. In zh, the `⤢` label (`detail.openAsFullPage` and `console.objectView.expandToPage`, kept byte-identical) reads 整页打开.

‎ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
* The object list handed to the view carries the invoice's and the account's
2424
* definitions, as the console's metadata catalog does: the record page links
2525
* only to an object it can route to.
26+
*
27+
* Since objectstack#26679 the record page is a record drawer host, so "opens
28+
* the account" means: in a drawer over the request page (the account declares
29+
* no `navigation.mode`, so it opens the drawer, maintainer ruling 5 on that
30+
* card), the router unmoved, the link's href still the account's record path.
2631
*/
2732
import { describe, it, expect, vi, afterEach } from 'vitest';
2833
import { render, screen, cleanup, waitFor, within, fireEvent } from '@testing-library/react';
@@ -207,7 +212,9 @@ const host = {
207212
findOne: vi.fn(async (resource: string, id: string) =>
208213
resource === 'invoice'
209214
? { id, name: TARGET_TITLE, amount: 12500, account_id: { id: ACCOUNT_ID, name: ACCOUNT_TITLE } }
210-
: null),
215+
: resource === 'account' && id === ACCOUNT_ID
216+
? { id, name: ACCOUNT_TITLE }
217+
: null),
211218
create: vi.fn(), update: vi.fn(), delete: vi.fn(),
212219
getObjectSchema: vi.fn(async (name: string) => DEFS[name] ?? null),
213220
};
@@ -310,7 +317,13 @@ describe('the served request page: Enter on the target card lookup link (objectu
310317
expect(document.activeElement).toBe(link);
311318
await user.keyboard('{Enter}');
312319

313-
await waitFor(() => expect(screen.getByTestId('router-pathname').textContent).toBe(accountPath));
320+
// The account opens in a drawer over the request page …
321+
const drawer = await screen.findByRole('dialog', undefined, { timeout: 8000 });
322+
await waitFor(() => expect(within(drawer).getAllByText(ACCOUNT_TITLE).length).toBeGreaterThan(0), { timeout: 8000 });
323+
expect(host.findOne.mock.calls.some(([resource, id]) => resource === 'account' && id === ACCOUNT_ID)).toBe(true);
324+
// … the router did not move, and the link still addresses the account's page.
325+
expect(screen.getByTestId('router-pathname').textContent).toBe(`/apps/${APP}/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`);
326+
expect(new URL(link.getAttribute('href') as string, 'http://test.local').pathname).toBe(accountPath);
314327
expect(writeText).not.toHaveBeenCalled();
315328
});
316329

‎ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,15 @@
2525
*
2626
* Links are counted by the pathname their href resolves to, so the pins hold
2727
* whatever query string the record page's builder adds (its `?from=` trail).
28+
*
29+
* Since objectstack#26679 the record page is a record drawer host: a plain
30+
* click on the target opens it in a drawer over the request page (the invoice
31+
* declares no `navigation.mode`, so it opens the drawer, maintainer ruling 5 on
32+
* that card), while the href stays the record path for a new tab or a copied
33+
* link, and a modified click is left to the browser.
2834
*/
2935
import { describe, it, expect, vi, afterEach } from 'vitest';
30-
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
36+
import { render, screen, cleanup, waitFor, fireEvent, within } from '@testing-library/react';
3137
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
3238

3339
vi.mock('@object-ui/auth', async (importOriginal) => ({
@@ -284,15 +290,36 @@ describe('the served request page links a readable target to its record (objectu
284290
expect(screen.getByRole('link', { name: TARGET_TITLE })).toBe(links[0]);
285291
});
286292

287-
it('readable: a plain click navigates in-app to the record path', async () => {
293+
it('readable: a plain click opens the record in a drawer over the page, and the page stays put', async () => {
288294
const id = 'Rd4bl3RecordQQQQ';
289295
targetAnswer = async (rid) => ({ id: rid, name: TARGET_TITLE, amount: 12500 });
290296
const { container } = openServedPage({ record_id: id, status: 'pending', record_title: TARGET_TITLE });
291297
await settledCard(container);
298+
const requestPath = `/apps/${APP}/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`;
292299

293300
const notPrevented = fireEvent.click(screen.getByRole('link', { name: TARGET_TITLE }), { button: 0 });
294301
expect(notPrevented).toBe(false);
295-
await waitFor(() => expect(screen.getByTestId('router-pathname').textContent).toBe(recordPath(id)));
302+
// The drawer opens with the target record in it …
303+
const drawer = await screen.findByRole('dialog', undefined, { timeout: 8000 });
304+
await waitFor(() => expect(within(drawer).getAllByText(TARGET_TITLE).length).toBeGreaterThan(0), { timeout: 8000 });
305+
expect(host.findOne.mock.calls.some(([resource, rid]) => resource === 'invoice' && rid === id)).toBe(true);
306+
// … over the request page: the router did not move.
307+
expect(screen.getByTestId('router-pathname').textContent).toBe(requestPath);
308+
// The href still names the record path, for a new tab or a copied link.
309+
expect(linksTo(recordPath(id))).toHaveLength(1);
310+
});
311+
312+
it('readable: a modified click is the browser\'s — not intercepted, no drawer', async () => {
313+
const id = 'Rd4bl3RecordQQQQ';
314+
targetAnswer = async (rid) => ({ id: rid, name: TARGET_TITLE, amount: 12500 });
315+
const { container } = openServedPage({ record_id: id, status: 'pending', record_title: TARGET_TITLE });
316+
await settledCard(container);
317+
318+
const link = screen.getByRole('link', { name: TARGET_TITLE });
319+
const notPrevented = fireEvent.click(link, { button: 0, metaKey: true });
320+
expect(notPrevented).toBe(true);
321+
expect(new URL(link.getAttribute('href') as string, 'http://test.local').pathname).toBe(recordPath(id));
322+
expect(screen.queryByRole('dialog')).toBeNull();
296323
});
297324

298325
it('CONTROL: refused (the target read rejects 403) draws no link to the target anywhere', async () => {

‎ui/content/docs/guide/user-state-persistence.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,24 @@ calendar, gantt, list, gallery, map and timeline all mount `@object-ui/plugin-de
323323
`RecordDetailPanel`, so a record reads with its declared labels and typed
324324
values, and without its `hidden` fields, whichever view it was opened from
325325
(objectui#13458).
326+
327+
## The record drawer's stack
328+
329+
A record opened from a list sits at the root of the drawer, and the root is
330+
**addressable**: `?recordId=` on the list's URL, so a reload, a shared link or
331+
Back reopens it. Records opened **inside** the drawer — a related row, a lookup
332+
value — open in the same drawer one level down (objectstack#26679), and those
333+
levels are **in-memory** state: a reload shows the root record.
334+
335+
| State | Lives in | Survives a reload |
336+
|---|---|---|
337+
| The root record | `?recordId=` | Yes |
338+
| The levels above it, their titles and sibling sets | Component state | No |
339+
| Each level's record tab | `?tab=`, saved per level while another level is shown | The shown level's only |
340+
| The stack across a `⤢` round trip | Router history state, consumed once on return | No |
341+
342+
`⤢` at any level opens that record's page with the levels below it as its
343+
`?from=` trail, which is addressable like any record trail. The page it opens
344+
offers "Collapse to drawer", which returns to the drawer with the stack it left;
345+
the history state that carries the stack is rewritten without it as soon as the
346+
drawer has it back, so a later reload or Back shows the root alone.

‎ui/packages/app-shell/src/views/ObjectView.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
721721
// an object that declares `page` (or `new_window` / `none`) keeps its
722722
// declaration.
723723
const [recordOpenPreference, setRecordOpenPreference] = useRecordOpenPreference();
724-
const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer } = useObjectViewRecordNavigation({
724+
const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep } = useObjectViewRecordNavigation({
725725
activeView,
726726
objectDef,
727727
objects,
@@ -815,6 +815,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
815815
filterScope,
816816
actions,
817817
handleRowClick,
818+
handleListRowsChange,
818819
navOverlay,
819820
recordOpen,
820821
openAsPage,
@@ -1165,9 +1166,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
11651166
<ObjectViewRecordOverlay
11661167
navOverlay={navOverlay}
11671168
handleDrawerClose={handleDrawerClose}
1168-
objectLabel={objectLabel}
11691169
objectDef={objectDef}
11701170
handleExpandDrawer={handleExpandDrawer}
1171+
drawerRoot={drawerRoot}
1172+
drawerRestore={drawerRestore}
1173+
handleRootStep={handleRootStep}
11711174
t={t}
11721175
dataSource={dataSource}
11731176
objects={objects}

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

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog
3232
import { FlowRunner, type ScreenFlowState, type ScreenSpec } from './FlowRunner.js';
3333
import { FlowRefusalNotice, type FlowRefusalState } from './FlowRefusalNotice.js';
3434
import { RelatedRecordActionsBridge } from './RelatedRecordActionsBridge.js';
35+
import { RecordDrawerReturnLink, RecordPageDrawerHost } from './recordDrawerStack.js';
3536
import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync.js';
3637
import { RECORD_DETAIL_TAB_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js';
3738
import { resolveActionParams } from '@object-ui/fields';
@@ -3758,7 +3759,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
37583759
</>
37593760
);
37603761

3761-
return (
3762+
const pageTree = (
37623763
<div ref={setPageRootEl} className="h-full bg-background overflow-hidden flex flex-col relative" data-record-page-root="">
37633764
{/* On a print page the shared chrome joins the print chrome row below,
37643765
so the row's buttons are not drawn under it. */}
@@ -3888,14 +3889,25 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
38883889
>
38893890
<div className="flex-1 overflow-hidden flex flex-row">
38903891
<div className="flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48">
3891-
{originFrom?.pathname && originFrom?.label && (
3892-
<Link
3893-
to={originFrom.pathname}
3894-
className="inline-flex items-center gap-1 mb-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
3895-
>
3896-
<ChevronLeft className="h-4 w-4" />
3897-
<span>{originFrom.label}</span>
3898-
</Link>
3892+
{/* The way back: the origin link, and on a page a drawer's ⤢
3893+
opened, the way back into that drawer (objectstack#26679).
3894+
Neither is drawn inside a drawer — the drawer's own bar is
3895+
its way back, and the page's history state is not its. */}
3896+
{!embedded && (
3897+
<div className="flex items-center justify-between gap-3 empty:hidden">
3898+
{originFrom?.pathname && originFrom?.label && (
3899+
<Link
3900+
to={originFrom.pathname}
3901+
className="inline-flex items-center gap-1 mb-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
3902+
>
3903+
<ChevronLeft className="h-4 w-4" />
3904+
<span>{originFrom.label}</span>
3905+
</Link>
3906+
)}
3907+
<div className="mb-3 ml-auto empty:hidden">
3908+
<RecordDrawerReturnLink />
3909+
</div>
3910+
</div>
38993911
)}
39003912
{/* objectui#12906 — the print page's screen-only chrome. A page
39013913
that declares `print` holds only the printable component
@@ -4139,4 +4151,29 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
41394151
/>
41404152
</div>
41414153
);
4154+
// A drawer draws this view embedded and is its own host. A page is the host
4155+
// of the drawer its related rows and lookups open over it
4156+
// (objectstack#26679): the same in-place stack, drawn by this same view.
4157+
if (embedded) return pageTree;
4158+
return (
4159+
<RecordPageDrawerHost
4160+
appName={appName}
4161+
objects={objects}
4162+
pageObjectName={objectName}
4163+
pageRecordId={pureRecordId ?? undefined}
4164+
pageTitle={recordTitle}
4165+
renderRecord={(level) => (
4166+
<RecordDetailView
4167+
dataSource={dataSource}
4168+
objects={objects}
4169+
onEdit={onEdit}
4170+
objectNameOverride={level.objectName}
4171+
recordIdOverride={level.recordId}
4172+
embedded
4173+
/>
4174+
)}
4175+
>
4176+
{pageTree}
4177+
</RecordPageDrawerHost>
4178+
);
41424179
}

‎ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
* create/edit form pages, or the per-object lifecycle affordances. This bridge
1616
* (mounted inside the page's `ActionProvider`) closes that gap:
1717
*
18-
* - 查看详情 → navigate to the child record's detail route
18+
* - 查看详情 → under a record drawer host (objectstack#26679), open the
19+
* child record in that drawer, one level down, with the rows
20+
* its list showed as the level's ↑ ↓ set — unless the child
21+
* object declares another way of opening; elsewhere, navigate
22+
* to the child record's detail route. A lookup value takes
23+
* the same door (`openRecord`).
1924
* - 增 / 改 → open the child form as an OVERLAY on the parent detail
2025
* (objectstack-ai/objectstack#2604 D3: a child task's return target is ALWAYS the parent
2126
* detail with the subtable refreshed — never a route, which
@@ -91,6 +96,7 @@ import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RE
9196
import { documentHref } from './documentHref.js';
9297
import { exactRecordListFilter, recordListPath } from './useOpenRecordList.js';
9398
import { resolveRecordFormTarget } from '../utils/recordFormNavigation.js';
99+
import { opensInRecordDrawer, useRecordDrawer } from './recordDrawerContext.js';
94100

95101
/**
96102
* Notify open related lists for `objectName` to refetch.
@@ -383,16 +389,32 @@ export function RelatedRecordActionsBridge({
383389
[recordPath, routerRootHref],
384390
);
385391

392+
/** The record drawer host this bridge sits under, if any (objectstack#26679). */
393+
const drawer = useRecordDrawer();
394+
386395
/**
387-
* SPA-navigate to the destination {@link recordHref} addresses — by its
388-
* router path, because `navigate` applies the basename itself.
396+
* Open the record {@link recordHref} addresses. Under a record drawer host
397+
* — the list's drawer, a record drawn in it, or a record page — a record
398+
* whose object opens in the drawer opens there, one level down
399+
* (objectstack#26679), with `siblings` as its ↑ ↓ set and this bridge's
400+
* record as the level being left. Otherwise SPA-navigate by its router
401+
* path, because `navigate` applies the basename itself. Either way only a
402+
* routable target opens, as before: the drawer's ⤢ goes to that route.
389403
*/
390404
const openRecord = useCallback(
391-
(objectName: string, recordId: string | number) => {
405+
(objectName: string, recordId: string | number, siblings?: ReadonlyArray<string | number>) => {
392406
const path = recordPath(objectName, recordId);
393-
if (path) navigate(path);
407+
if (!path) return;
408+
if (drawer && opensInRecordDrawer(objects.find((o: any) => o?.name === objectName))) {
409+
drawer.open(objectName, recordId, {
410+
...(siblings ? { siblings } : {}),
411+
...(parentTitle ? { fromTitle: parentTitle } : {}),
412+
});
413+
return;
414+
}
415+
navigate(path);
394416
},
395-
[recordPath, navigate],
417+
[recordPath, navigate, drawer, objects, parentTitle],
396418
);
397419

398420
// objectstack-ai/objectstack#2604 D3 — open a child create/edit task as the console's global record
@@ -550,7 +572,7 @@ export function RelatedRecordActionsBridge({
550572
const handlers: RelatedRecordHandlers = {
551573
// Viewing a child record is always allowed when the list is visible.
552574
// Same route the lookup links address — `recordPath` above.
553-
onView: (id) => openRecord(objectName, id),
575+
onView: (id, _record, siblings) => openRecord(objectName, id, siblings),
554576
};
555577

556578
/**
@@ -653,7 +675,7 @@ export function RelatedRecordActionsBridge({
653675
return handlers;
654676
},
655677
recordHref,
656-
openRecord,
678+
openRecord: (objectName: string, recordId: string | number) => openRecord(objectName, recordId),
657679
}),
658680
// `parentRecord` / `parentObjectFields` / `predicateScope` join the deps for
659681
// the #4646 create predicates: a parent record that changes (a save, a

0 commit comments

Comments
 (0)