Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/26679-drawer-in-place-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/components': patch
'@object-ui/app-shell': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-list': patch
'@object-ui/react': patch
'@object-ui/i18n': patch
---

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

Clause-②: no
Metadata-protocol: none

The console's record drawer is one drawer (objectstack#26679):

- **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.
- **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.
- **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.
- **⤢ 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.
- **On the record page**, a related row or a lookup opens its record in a drawer over the page, under the same rules.
- **`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.
- **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.

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 整页打开.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
* The object list handed to the view carries the invoice's and the account's
* definitions, as the console's metadata catalog does: the record page links
* only to an object it can route to.
*
* Since objectstack#26679 the record page is a record drawer host, so "opens
* the account" means: in a drawer over the request page (the account declares
* no `navigation.mode`, so it opens the drawer, maintainer ruling 5 on that
* card), the router unmoved, the link's href still the account's record path.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, within, fireEvent } from '@testing-library/react';
Expand Down Expand Up @@ -207,7 +212,9 @@ const host = {
findOne: vi.fn(async (resource: string, id: string) =>
resource === 'invoice'
? { id, name: TARGET_TITLE, amount: 12500, account_id: { id: ACCOUNT_ID, name: ACCOUNT_TITLE } }
: null),
: resource === 'account' && id === ACCOUNT_ID
? { id, name: ACCOUNT_TITLE }
: null),
create: vi.fn(), update: vi.fn(), delete: vi.fn(),
getObjectSchema: vi.fn(async (name: string) => DEFS[name] ?? null),
};
Expand Down Expand Up @@ -310,7 +317,13 @@ describe('the served request page: Enter on the target card lookup link (objectu
expect(document.activeElement).toBe(link);
await user.keyboard('{Enter}');

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

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

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

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

const notPrevented = fireEvent.click(screen.getByRole('link', { name: TARGET_TITLE }), { button: 0 });
expect(notPrevented).toBe(false);
await waitFor(() => expect(screen.getByTestId('router-pathname').textContent).toBe(recordPath(id)));
// The drawer opens with the target record in it …
const drawer = await screen.findByRole('dialog', undefined, { timeout: 8000 });
await waitFor(() => expect(within(drawer).getAllByText(TARGET_TITLE).length).toBeGreaterThan(0), { timeout: 8000 });
expect(host.findOne.mock.calls.some(([resource, rid]) => resource === 'invoice' && rid === id)).toBe(true);
// … over the request page: the router did not move.
expect(screen.getByTestId('router-pathname').textContent).toBe(requestPath);
// The href still names the record path, for a new tab or a copied link.
expect(linksTo(recordPath(id))).toHaveLength(1);
});

it('readable: a modified click is the browser\'s — not intercepted, no drawer', async () => {
const id = 'Rd4bl3RecordQQQQ';
targetAnswer = async (rid) => ({ id: rid, name: TARGET_TITLE, amount: 12500 });
const { container } = openServedPage({ record_id: id, status: 'pending', record_title: TARGET_TITLE });
await settledCard(container);

const link = screen.getByRole('link', { name: TARGET_TITLE });
const notPrevented = fireEvent.click(link, { button: 0, metaKey: true });
expect(notPrevented).toBe(true);
expect(new URL(link.getAttribute('href') as string, 'http://test.local').pathname).toBe(recordPath(id));
expect(screen.queryByRole('dialog')).toBeNull();
});

it('CONTROL: refused (the target read rejects 403) draws no link to the target anywhere', async () => {
Expand Down
21 changes: 21 additions & 0 deletions ui/content/docs/guide/user-state-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,24 @@ calendar, gantt, list, gallery, map and timeline all mount `@object-ui/plugin-de
`RecordDetailPanel`, so a record reads with its declared labels and typed
values, and without its `hidden` fields, whichever view it was opened from
(objectui#13458).

## The record drawer's stack

A record opened from a list sits at the root of the drawer, and the root is
**addressable**: `?recordId=` on the list's URL, so a reload, a shared link or
Back reopens it. Records opened **inside** the drawer — a related row, a lookup
value — open in the same drawer one level down (objectstack#26679), and those
levels are **in-memory** state: a reload shows the root record.

| State | Lives in | Survives a reload |
|---|---|---|
| The root record | `?recordId=` | Yes |
| The levels above it, their titles and sibling sets | Component state | No |
| Each level's record tab | `?tab=`, saved per level while another level is shown | The shown level's only |
| The stack across a `⤢` round trip | Router history state, consumed once on return | No |

`⤢` at any level opens that record's page with the levels below it as its
`?from=` trail, which is addressable like any record trail. The page it opens
offers "Collapse to drawer", which returns to the drawer with the stack it left;
the history state that carries the stack is rewritten without it as soon as the
drawer has it back, so a later reload or Back shows the root alone.
7 changes: 5 additions & 2 deletions ui/packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
// an object that declares `page` (or `new_window` / `none`) keeps its
// declaration.
const [recordOpenPreference, setRecordOpenPreference] = useRecordOpenPreference();
const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer } = useObjectViewRecordNavigation({
const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep } = useObjectViewRecordNavigation({
activeView,
objectDef,
objects,
Expand Down Expand Up @@ -815,6 +815,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
filterScope,
actions,
handleRowClick,
handleListRowsChange,
navOverlay,
recordOpen,
openAsPage,
Expand Down Expand Up @@ -1165,9 +1166,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
<ObjectViewRecordOverlay
navOverlay={navOverlay}
handleDrawerClose={handleDrawerClose}
objectLabel={objectLabel}
objectDef={objectDef}
handleExpandDrawer={handleExpandDrawer}
drawerRoot={drawerRoot}
drawerRestore={drawerRestore}
handleRootStep={handleRootStep}
t={t}
dataSource={dataSource}
objects={objects}
Expand Down
55 changes: 46 additions & 9 deletions ui/packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog
import { FlowRunner, type ScreenFlowState, type ScreenSpec } from './FlowRunner.js';
import { FlowRefusalNotice, type FlowRefusalState } from './FlowRefusalNotice.js';
import { RelatedRecordActionsBridge } from './RelatedRecordActionsBridge.js';
import { RecordDrawerReturnLink, RecordPageDrawerHost } from './recordDrawerStack.js';
import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync.js';
import { RECORD_DETAIL_TAB_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js';
import { resolveActionParams } from '@object-ui/fields';
Expand Down Expand Up @@ -3758,7 +3759,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
</>
);

return (
const pageTree = (
<div ref={setPageRootEl} className="h-full bg-background overflow-hidden flex flex-col relative" data-record-page-root="">
{/* On a print page the shared chrome joins the print chrome row below,
so the row's buttons are not drawn under it. */}
Expand Down Expand Up @@ -3888,14 +3889,25 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
>
<div className="flex-1 overflow-hidden flex flex-row">
<div className="flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48">
{originFrom?.pathname && originFrom?.label && (
<Link
to={originFrom.pathname}
className="inline-flex items-center gap-1 mb-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<span>{originFrom.label}</span>
</Link>
{/* The way back: the origin link, and on a page a drawer's ⤢
opened, the way back into that drawer (objectstack#26679).
Neither is drawn inside a drawer — the drawer's own bar is
its way back, and the page's history state is not its. */}
{!embedded && (
<div className="flex items-center justify-between gap-3 empty:hidden">
{originFrom?.pathname && originFrom?.label && (
<Link
to={originFrom.pathname}
className="inline-flex items-center gap-1 mb-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<span>{originFrom.label}</span>
</Link>
)}
<div className="mb-3 ml-auto empty:hidden">
<RecordDrawerReturnLink />
</div>
</div>
)}
{/* objectui#12906 — the print page's screen-only chrome. A page
that declares `print` holds only the printable component
Expand Down Expand Up @@ -4139,4 +4151,29 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
/>
</div>
);
// A drawer draws this view embedded and is its own host. A page is the host
// of the drawer its related rows and lookups open over it
// (objectstack#26679): the same in-place stack, drawn by this same view.
if (embedded) return pageTree;
return (
<RecordPageDrawerHost
appName={appName}
objects={objects}
pageObjectName={objectName}
pageRecordId={pureRecordId ?? undefined}
pageTitle={recordTitle}
renderRecord={(level) => (
<RecordDetailView
dataSource={dataSource}
objects={objects}
onEdit={onEdit}
objectNameOverride={level.objectName}
recordIdOverride={level.recordId}
embedded
/>
)}
>
{pageTree}
</RecordPageDrawerHost>
);
}
38 changes: 30 additions & 8 deletions ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
* create/edit form pages, or the per-object lifecycle affordances. This bridge
* (mounted inside the page's `ActionProvider`) closes that gap:
*
* - 查看详情 → navigate to the child record's detail route
* - 查看详情 → under a record drawer host (objectstack#26679), open the
* child record in that drawer, one level down, with the rows
* its list showed as the level's ↑ ↓ set — unless the child
* object declares another way of opening; elsewhere, navigate
* to the child record's detail route. A lookup value takes
* the same door (`openRecord`).
* - 增 / 改 → open the child form as an OVERLAY on the parent detail
* (objectstack-ai/objectstack#2604 D3: a child task's return target is ALWAYS the parent
* detail with the subtable refreshed — never a route, which
Expand Down Expand Up @@ -91,6 +96,7 @@ import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RE
import { documentHref } from './documentHref.js';
import { exactRecordListFilter, recordListPath } from './useOpenRecordList.js';
import { resolveRecordFormTarget } from '../utils/recordFormNavigation.js';
import { opensInRecordDrawer, useRecordDrawer } from './recordDrawerContext.js';

/**
* Notify open related lists for `objectName` to refetch.
Expand Down Expand Up @@ -383,16 +389,32 @@ export function RelatedRecordActionsBridge({
[recordPath, routerRootHref],
);

/** The record drawer host this bridge sits under, if any (objectstack#26679). */
const drawer = useRecordDrawer();

/**
* SPA-navigate to the destination {@link recordHref} addresses — by its
* router path, because `navigate` applies the basename itself.
* Open the record {@link recordHref} addresses. Under a record drawer host
* — the list's drawer, a record drawn in it, or a record page — a record
* whose object opens in the drawer opens there, one level down
* (objectstack#26679), with `siblings` as its ↑ ↓ set and this bridge's
* record as the level being left. Otherwise SPA-navigate by its router
* path, because `navigate` applies the basename itself. Either way only a
* routable target opens, as before: the drawer's ⤢ goes to that route.
*/
const openRecord = useCallback(
(objectName: string, recordId: string | number) => {
(objectName: string, recordId: string | number, siblings?: ReadonlyArray<string | number>) => {
const path = recordPath(objectName, recordId);
if (path) navigate(path);
if (!path) return;
if (drawer && opensInRecordDrawer(objects.find((o: any) => o?.name === objectName))) {
drawer.open(objectName, recordId, {
...(siblings ? { siblings } : {}),
...(parentTitle ? { fromTitle: parentTitle } : {}),
});
return;
}
navigate(path);
},
[recordPath, navigate],
[recordPath, navigate, drawer, objects, parentTitle],
);

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

/**
Expand Down Expand Up @@ -653,7 +675,7 @@ export function RelatedRecordActionsBridge({
return handlers;
},
recordHref,
openRecord,
openRecord: (objectName: string, recordId: string | number) => openRecord(objectName, recordId),
}),
// `parentRecord` / `parentObjectFields` / `predicateScope` join the deps for
// the #4646 create predicates: a parent record that changes (a save, a
Expand Down
Loading
Loading