diff --git a/.changeset/26679-drawer-in-place-navigation.md b/.changeset/26679-drawer-in-place-navigation.md new file mode 100644 index 00000000000..d220d2c9de5 --- /dev/null +++ b/.changeset/26679-drawer-in-place-navigation.md @@ -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 整页打开. diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx index 63928c3ccdf..31650fd93fc 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageLookupEnter-12569.test.tsx @@ -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'; @@ -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), }; @@ -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(); }); diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx index dc9d8f2b994..0d6ead67188 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageRecordLink-12557.test.tsx @@ -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) => ({ @@ -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 () => { diff --git a/ui/content/docs/guide/user-state-persistence.md b/ui/content/docs/guide/user-state-persistence.md index 22279dcc330..e48049beb3f 100644 --- a/ui/content/docs/guide/user-state-persistence.md +++ b/ui/content/docs/guide/user-state-persistence.md @@ -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. diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 43072da53b2..9d49e515032 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -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, @@ -815,6 +815,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co filterScope, actions, handleRowClick, + handleListRowsChange, navOverlay, recordOpen, openAsPage, @@ -1165,9 +1166,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co ); - return ( + const pageTree = (
{/* On a print page the shared chrome joins the print chrome row below, so the row's buttons are not drawn under it. */} @@ -3888,14 +3889,25 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri >
- {originFrom?.pathname && originFrom?.label && ( - - - {originFrom.label} - + {/* 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 && ( +
+ {originFrom?.pathname && originFrom?.label && ( + + + {originFrom.label} + + )} +
+ +
+
)} {/* objectui#12906 — the print page's screen-only chrome. A page that declares `print` holds only the printable component @@ -4139,4 +4151,29 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri />
); + // 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 ( + ( + + )} + > + {pageTree} + + ); } diff --git a/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx b/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx index 39902d7a1e8..ea83a387bb0 100644 --- a/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx +++ b/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx @@ -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 @@ -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. @@ -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) => { 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 @@ -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), }; /** @@ -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 diff --git a/ui/packages/app-shell/src/views/__tests__/recordDrawerStack-26679.test.tsx b/ui/packages/app-shell/src/views/__tests__/recordDrawerStack-26679.test.tsx new file mode 100644 index 00000000000..6d9ea0f42ed --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/recordDrawerStack-26679.test.tsx @@ -0,0 +1,339 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26679 — the record drawer opens a related row or a lookup in + * place, one level down, and goes back one level at a time; ⤢ carries the + * path into the page and 「Collapse to drawer」 brings the stack back. + * + * Every case mounts the real `RelatedRecordActionsBridge` the record body + * mounts, under the real drawer stack, with a probe standing in for the + * related list and the lookup (both reach the bridge through the published + * `RelatedRecordActionsContext`, which is the door the real renderers take). + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation, useSearchParams } from 'react-router-dom'; +import '@testing-library/jest-dom'; +import { useRelatedRecordActions } from '@object-ui/react'; + +import { RelatedRecordActionsBridge } from '../RelatedRecordActionsBridge'; +import { + RecordDrawerReturnLink, + RecordPageDrawerHost, + RecordStackDrawer, + expandedRecordPath, + useRecordDrawerStack, + type RecordDrawerLevel, +} from '../recordDrawerStack'; + +afterEach(() => cleanup()); + +const APP = 'hotcrm'; +const OBJECTS = [ + { name: 'crm_lead', label: 'Lead' }, + { name: 'crm_task', label: 'Task' }, + { name: 'sys_user', label: 'User' }, + { name: 'crm_contract', label: 'Contract', navigation: { mode: 'page' } }, +]; +const TITLES: Record = { 'L-1': 'Mira Costa', 'T-1': 'Send quote', 'T-2': 'Call back', 'T-3': 'Demo' }; +const TASKS = ['T-1', 'T-2', 'T-3']; + +/** Where the router is: path, search and the history state's keys. */ +function RouterLocation() { + const location = useLocation(); + return ( + <> + {`${location.pathname}${location.search}`} + {JSON.stringify(location.state ?? null)} + + ); +} + +/** A record body: its bridge, a related list of tasks, two lookups, a tab strip. */ +function RecordBody({ level }: { level: { objectName: string; recordId: string } }) { + return ( + +
{`${level.objectName}/${level.recordId}`}
+ +
+ ); +} + +function Probe() { + const actions = useRelatedRecordActions(); + const [, setSearchParams] = useSearchParams(); + const tasks = actions?.resolve({ objectName: 'crm_task', relationshipField: 'lead', parentId: 'L-1' }); + return ( + <> + + + + + + ); +} + +/** The object list's host shape: a root the URL controls, closed by the host. */ +function ListHost({ onClose, onExpand }: { onClose: () => void; onExpand: (path: RecordDrawerLevel[]) => void }) { + const root = React.useMemo(() => ({ objectName: 'crm_lead', recordId: 'L-1' }), []); + const stack = useRecordDrawerStack({ root }); + return ( + } + /> + ); +} + +function mountList(initial = `/apps/${APP}/crm_lead?recordId=L-1`) { + const onClose = vi.fn(); + const onExpand = vi.fn(); + render( + + + } /> + } /> + + + , + ); + return { onClose, onExpand }; +} + +const body = () => screen.getByTestId('record-body').textContent; +const trail = () => screen.queryByTestId('record-drawer-trail')?.textContent ?? null; +const where = () => screen.getByTestId('router-location').textContent ?? ''; +const button = (name: string) => screen.getByRole('button', { name }); + +describe('a related row opens in the same drawer, one level down (ruling 5)', () => { + it('pushes the task, shows ← and the path, and ← returns to the lead', () => { + mountList(); + expect(body()).toBe('crm_lead/L-1'); + expect(screen.getByTestId('record-drawer-leading').textContent).toBe(''); + + fireEvent.click(button('open task row')); + expect(body()).toBe('crm_task/T-1'); + // Still one drawer, and the router did not move. + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(where()).toBe(`/apps/${APP}/crm_lead?recordId=L-1`); + expect(trail()).toBe('Lead Mira CostaTask'); + + fireEvent.click(button('Go back')); + expect(body()).toBe('crm_lead/L-1'); + expect(trail()).toBeNull(); + }); + + it('a lookup value takes the same door; each ancestor in the path returns to its level', () => { + mountList(); + fireEvent.click(button('open task row')); + fireEvent.click(button('open owner lookup')); + expect(body()).toBe('sys_user/U-1'); + expect(trail()).toBe('Lead Mira CostaTask Send quoteUser'); + fireEvent.click(screen.getByRole('button', { name: /Lead Mira Costa/ })); + expect(body()).toBe('crm_lead/L-1'); + }); + + it('Esc goes back one level; Esc at the root closes', () => { + const { onClose } = mountList(); + fireEvent.click(button('open task row')); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(body()).toBe('crm_lead/L-1'); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('control: an object that declares `page` still navigates, and the drawer stays put', () => { + mountList(); + fireEvent.click(button('open contract lookup')); + expect(where().split('?')[0]).toBe(`/apps/${APP}/crm_contract/record/K-1`); + }); +}); + +describe('↑ ↓ move through the level\'s sibling set, never across levels', () => { + it('steps through the related rows the list showed, disabled at the ends', () => { + mountList(); + fireEvent.click(button('open task row')); + expect((button('Previous record') as HTMLButtonElement).disabled).toBe(true); + fireEvent.keyDown(document.body, { key: 'ArrowDown' }); + expect(body()).toBe('crm_task/T-2'); + fireEvent.keyDown(document.body, { key: 'j' }); + expect(body()).toBe('crm_task/T-3'); + expect((button('Next record') as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(button('Previous record')); + expect(body()).toBe('crm_task/T-2'); + // Still one level down: ← returns to the lead, not to T-1. + fireEvent.click(button('Go back')); + expect(body()).toBe('crm_lead/L-1'); + }); + + it('a lookup has no sibling set: no ↑ ↓ are drawn for it', () => { + mountList(); + fireEvent.click(button('open owner lookup')); + expect(screen.queryByRole('button', { name: 'Next record' })).toBeNull(); + }); +}); + +describe('each level keeps its own ?tab=', () => { + it('a new level opens on its default tab, and ← brings the left tab back', () => { + mountList(); + fireEvent.click(button('show related tab')); + expect(where()).toContain('tab=related'); + fireEvent.click(button('open task row')); + expect(where()).not.toContain('tab='); + fireEvent.click(button('Go back')); + expect(where()).toContain('tab=related'); + }); +}); + +describe('⤢ carries the path into the page', () => { + it('hands the host the path from the root to the record shown', () => { + const { onExpand } = mountList(); + fireEvent.click(button('open task row')); + fireEvent.click(button('Open as full page')); + const [path] = onExpand.mock.calls[0]; + expect(path.map((l: RecordDrawerLevel) => `${l.objectName}/${l.recordId}`)).toEqual([ + 'crm_lead/L-1', + 'crm_task/T-1', + ]); + expect(path[0].title).toBe('Mira Costa'); + }); + + it('the page route names the ancestors as its ?from= trail', () => { + const to = expandedRecordPath(APP, OBJECTS, [ + { objectName: 'crm_lead', recordId: 'L-1', title: 'Mira Costa' }, + { objectName: 'crm_task', recordId: 'T-1' }, + ]); + const [path, query] = (to ?? '').split('?'); + expect(path).toBe(`/apps/${APP}/crm_task/record/T-1`); + expect(JSON.parse(new URLSearchParams(query).get('from') ?? '[]')).toEqual([ + { o: 'crm_lead', i: 'L-1', t: 'Mira Costa' }, + ]); + // The root has no ancestors in the drawer: no trail. + expect(expandedRecordPath(APP, OBJECTS, [{ objectName: 'crm_lead', recordId: 'L-1' }])).toBe( + `/apps/${APP}/crm_lead/record/L-1`, + ); + }); +}); + +/** A record page: the page host, its body, and the 「Collapse to drawer」 link. */ +function RecordPage() { + const location = useLocation(); + const [, objectName, , recordId] = location.pathname.split('/').slice(2); + const level = { objectName, recordId }; + return ( + } + > +
+ + +
+
+ ); +} + +function mountPage(initial: string) { + render( + + + } /> + + + , + ); +} + +const dialogBody = () => screen.getByRole('dialog').querySelector('[data-testid="record-body"]')?.textContent; + +describe('on the record page, a related row opens a drawer over the page (target 4)', () => { + it('opens the task over the lead page, steps its siblings at the root, and ✕ closes it', () => { + mountPage(`/apps/${APP}/crm_lead/record/L-1`); + expect(screen.queryByRole('dialog')).toBeNull(); + fireEvent.click(screen.getAllByRole('button', { name: 'open task row' })[0]); + expect(dialogBody()).toBe('crm_task/T-1'); + expect(where()).toBe(`/apps/${APP}/crm_lead/record/L-1`); + // The drawer's root is the task: nothing on the left, ↑ ↓ over the rows. + expect(screen.getByTestId('record-drawer-leading').textContent).toBe(''); + fireEvent.click(button('Next record')); + expect(dialogBody()).toBe('crm_task/T-2'); + fireEvent.click(screen.getByTitle('Close')); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('the page keeps its ?tab= while the drawer is open', () => { + mountPage(`/apps/${APP}/crm_lead/record/L-1?tab=related`); + fireEvent.click(screen.getAllByRole('button', { name: 'open task row' })[0]); + expect(where()).not.toContain('tab='); + fireEvent.click(screen.getByTitle('Close')); + expect(where()).toContain('tab=related'); + }); + + it('⤢ opens the page with the path, and 「Collapse to drawer」 restores the stack', async () => { + mountPage(`/apps/${APP}/crm_lead/record/L-1`); + fireEvent.click(screen.getAllByRole('button', { name: 'open task row' })[0]); + // One more level: the task's owner. + // The page beneath is hidden from the accessibility tree while the drawer + // is open, so this is the drawer's own lookup. + fireEvent.click(button('open owner lookup')); + expect(dialogBody()).toBe('sys_user/U-1'); + fireEvent.click(button('Open as full page')); + + const [path, query] = where().split('?'); + expect(path).toBe(`/apps/${APP}/sys_user/record/U-1`); + expect(JSON.parse(new URLSearchParams(query).get('from') ?? '[]')).toEqual([ + { o: 'crm_lead', i: 'L-1', t: 'Mira Costa' }, + { o: 'crm_task', i: 'T-1', t: 'Send quote' }, + ]); + expect(screen.queryByRole('dialog')).toBeNull(); + + await act(async () => { + fireEvent.click(screen.getByTestId('record-drawer-return')); + }); + expect(where()).toBe(`/apps/${APP}/crm_lead/record/L-1`); + expect(dialogBody()).toBe('sys_user/U-1'); + expect(trail()).toBe('Task Send quoteUser'); + // The restore is consumed: the history entry no longer carries it. + expect(screen.getByTestId('router-state').textContent).not.toContain('recordDrawerRestore'); + fireEvent.click(button('Go back')); + expect(dialogBody()).toBe('crm_task/T-1'); + }); +}); diff --git a/ui/packages/app-shell/src/views/__tests__/recordDrawerStack.rootStepper-26679.test.tsx b/ui/packages/app-shell/src/views/__tests__/recordDrawerStack.rootStepper-26679.test.tsx new file mode 100644 index 00000000000..46e6585e69b --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/recordDrawerStack.rootStepper-26679.test.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26679 — `↑` `↓` at the ROOT of the record drawer (ruling 3: the + * controls stay, no counter anywhere). + * + * The root level steps through the sibling set its host hands it — on the + * object list, the rows `ListView` reports through `onRowsChange` — exactly as + * a nested level steps through its parent's related rows: present when the + * host knows the set, absent when it passes none, disabled (never wrapped) at + * the first and the last row, and a step hands the new root to the host. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import '@testing-library/jest-dom'; + +import { RecordStackDrawer, useRecordDrawerStack } from '../recordDrawerStack'; + +afterEach(() => cleanup()); + +const OBJECTS = [{ name: 'crm_lead', label: 'Lead' }]; +const ROWS = ['L-1', 'L-2', 'L-3']; + +/** The list host's shape: the root is host state, stepped by `onRootStep`. */ +function ListHost({ start, siblings }: { start: string; siblings?: readonly string[] }) { + const [recordId, setRecordId] = React.useState(start); + const root = React.useMemo( + () => ({ objectName: 'crm_lead', recordId, ...(siblings ? { siblings } : {}) }), + [recordId, siblings], + ); + const stack = useRecordDrawerStack({ root, onRootStep: setRecordId }); + return ( + {}} + renderRecord={(level) =>
{level.recordId}
} + /> + ); +} + +function mount(start: string, siblings?: readonly string[]) { + render( + + + , + ); +} + +const body = () => screen.getByTestId('record-body').textContent; +const previous = () => screen.queryByRole('button', { name: 'Previous record' }) as HTMLButtonElement | null; +const next = () => screen.queryByRole('button', { name: 'Next record' }) as HTMLButtonElement | null; + +describe('the drawer root steps through the host\'s rows (objectstack#26679)', () => { + it('draws ↑ ↓ when the host knows the set, and a step moves the root', () => { + mount('L-2', ROWS); + expect(previous()).not.toBeNull(); + expect(next()).not.toBeNull(); + fireEvent.click(next()!); + expect(body()).toBe('L-3'); + fireEvent.keyDown(document.body, { key: 'k' }); + fireEvent.keyDown(document.body, { key: 'ArrowUp' }); + expect(body()).toBe('L-1'); + // Still the root: nothing on the left of the bar. + expect(screen.getByTestId('record-drawer-leading').textContent).toBe(''); + }); + + it('↑ at the first row and ↓ at the last are disabled, never wrapped', () => { + mount('L-1', ROWS); + expect(previous()!.disabled).toBe(true); + fireEvent.keyDown(document.body, { key: 'ArrowUp' }); + expect(body()).toBe('L-1'); + fireEvent.click(next()!); + fireEvent.click(next()!); + expect(body()).toBe('L-3'); + expect(next()!.disabled).toBe(true); + fireEvent.keyDown(document.body, { key: 'j' }); + expect(body()).toBe('L-3'); + }); + + it('control: no ↑ ↓ when the host passes no set', () => { + mount('L-2'); + expect(previous()).toBeNull(); + expect(next()).toBeNull(); + }); + + it('control: no ↑ ↓ when the root is not in the set the host passed', () => { + mount('L-9', ROWS); + expect(previous()).toBeNull(); + expect(next()).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx index 784a14c9544..b7355bc6886 100644 --- a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx +++ b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx @@ -70,6 +70,7 @@ export function useObjectViewListRenderer({ filterScope, actions, handleRowClick, + handleListRowsChange, navOverlay, recordOpen, openAsPage, @@ -103,6 +104,8 @@ export function useObjectViewListRenderer({ filterScope: FilterTokenScope; actions: ReturnType; handleRowClick: ReturnType['handleRowClick']; + /** objectstack#26679 — the rows the list shows, for the drawer's root `↑` `↓`. */ + handleListRowsChange: ReturnType['handleListRowsChange']; navOverlay: ReturnType['navOverlay']; /** objectstack#26681 — the Display menu's Open records section. */ recordOpen: NonNullable; @@ -870,6 +873,7 @@ export function useObjectViewListRenderer({ onRowClick={(record: any, event?: any) => { handleRowClick(record, event); }} + onRowsChange={handleListRowsChange} onSortChange={(sort: any) => { // objectui#12418 — this user's sort, not the view's. persistListSort(sort); @@ -928,7 +932,7 @@ export function useObjectViewListRenderer({ dataSource={ds} /> ); - }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar, viewTabRow, managedUsers, managedUserMarker, recordOpen, openAsPage]); + }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar, viewTabRow, managedUsers, managedUserMarker, recordOpen, openAsPage, handleListRowsChange]); return { renderListView }; } diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts index a71a02fbcc8..70cbe7d0ae5 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts @@ -4,9 +4,15 @@ * `?recordId=` URL state, "expand to full page", and the record links the * list publishes, moved out of `ObjectViewInner` verbatim (objectstack#25992, * slice 6). Every input is passed in; the component reads the returned values. + * + * The drawer is a stack since objectstack#26679 (`./recordDrawerStack.tsx`): + * `?recordId=` addresses its ROOT — the list row — and the levels a related + * row or a lookup opens above it live in memory, so a reload shows the root. + * `⤢` at any level opens that record's page, and the page's 「Collapse to + * drawer」 returns here with the stack it left (`drawerRestore`). */ -import { useMemo, useCallback, useEffect } from 'react'; +import { useMemo, useCallback, useEffect, useState } from 'react'; import type { NavigateFunction, Location, SetURLSearchParams } from 'react-router-dom'; import { overlayWidthFor } from '@object-ui/plugin-view'; import { useNavigationOverlay } from '@object-ui/react'; @@ -14,6 +20,12 @@ import type { ViewNavigationConfig } from '@object-ui/types'; import { RECORD_DRAWER_PARAM } from '../urlParams.js'; import { viewTabLabel } from './viewTabLabel.js'; import { listRecordDetailUrl, listRecordActionsValue } from './objectViewRecordLinks.js'; +import { + expandedRecordPath, + recordDrawerReturnState, + useTakenRestore, + type RecordDrawerLevel, +} from './recordDrawerStack.js'; import type { useServedViewItems } from '../hooks/useServedViewItems.js'; import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import type { buildViewTabs } from './objectViewTabs.js'; @@ -90,7 +102,7 @@ export function useObjectViewRecordNavigation({ const drawerRecordId = searchParams.get(RECORD_DRAWER_PARAM); // Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick const handleNavOverlayNavigate = useCallback( - (recordId: string | number, action?: string) => { + (recordId: string | number, action?: string, extraState?: Record) => { if (action === 'new_window') { // Open record detail in a new browser tab with Console-correct URL. // Same builder the list's link column publishes as its href @@ -113,6 +125,7 @@ export function useObjectViewRecordNavigation({ // already treated the name and the token identically, which is // exactly what made the substitution inaudible on this surface. const originState = { + ...extraState, from: { pathname: location.pathname + (location.search || ''), label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), @@ -151,7 +164,10 @@ export function useObjectViewRecordNavigation({ objectName: objectDef.name, onNavigate: handleNavOverlayNavigate, }); + // A ⤢ return's stack, taken out of the history state once (objectstack#26679). + const [drawerRestore, clearDrawerRestore] = useTakenRestore(); const handleDrawerClose = () => { + clearDrawerRestore(); navOverlay.close(); const newParams = new URLSearchParams(searchParams); newParams.delete(RECORD_DRAWER_PARAM); @@ -192,17 +208,71 @@ export function useObjectViewRecordNavigation({ [navOverlay, searchParams, setSearchParams] ); /** - * "Expand to full page" — invoked from the drawer header chevron. Closes - * the drawer (which clears `?recordId=…`) and router-pushes to the - * dedicated `/record/:id` route. Mirrors Linear/Notion peek-to-page. + * "Expand to full page" (`⤢`) at any level of the drawer's stack + * (objectstack#26679): closes the drawer (which clears `?recordId=…`) and + * router-pushes to the record's page. The root keeps its list-relative + * route and the list as its origin; a level above it opens + * `/apps/:app/:object/record/:id` with the levels below it as its `?from=` + * trail. Either way the page is handed the way back to this drawer + * (`recordDrawerReturnState`). Mirrors Linear/Notion peek-to-page. */ - const handleExpandDrawer = useCallback(() => { - const rec = navOverlay.selectedRecord as Record | null; - const id = rec && ((rec as any).id ?? (rec as any)._id); - if (id == null) return; + const handleExpandDrawer = useCallback((path?: RecordDrawerLevel[]) => { + const levels: RecordDrawerLevel[] = path && path.length > 0 + ? path + : drawerRecordId ? [{ objectName: objectDef.name, recordId: drawerRecordId }] : []; + const target = levels[levels.length - 1]; + if (!target) return; + const returnState = recordDrawerReturnState( + { pathname: location.pathname, search: location.search, state: location.state }, + levels, + ); + if (levels.length === 1) { + handleDrawerClose(); + handleNavOverlayNavigate(target.recordId, undefined, returnState); + return; + } + const to = expandedRecordPath(appName, objects, levels); + if (to === null) return; handleDrawerClose(); - handleNavOverlayNavigate(id as string | number); - }, [navOverlay.selectedRecord, handleNavOverlayNavigate]); + navigate(to, { state: returnState }); + }, [drawerRecordId, objectDef.name, location.pathname, location.search, location.state, handleNavOverlayNavigate, appName, objects, navigate, handleDrawerClose]); + /** + * The rows the list shows, in its order (`ListView`'s `onRowsChange`, + * objectstack#26679): the root level's sibling set, which the drawer's + * `↑` `↓` step through. Held as the ids' joined key so an equal report + * changes nothing. + */ + const [listRowIds, setListRowIds] = useState<{ key: string; ids: readonly string[] }>({ key: '', ids: [] }); + const handleListRowsChange = useCallback((rows: ReadonlyArray>) => { + const ids = rows + .map((row) => (row?.id ?? row?._id) as string | number | undefined) + .filter((id): id is string | number => id != null && id !== '') + .map(String); + const key = JSON.stringify(ids); + setListRowIds((prev) => (prev.key === key ? prev : { key, ids })); + }, []); + /** `↑` `↓` at the root: the sibling becomes the row `?recordId=` names. */ + const handleRootStep = useCallback((recordId: string) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set(RECORD_DRAWER_PARAM, recordId); + return next; + }, { replace: true }); + }, [setSearchParams]); + /** + * The drawer's root — the list row `?recordId=` names — or `null`, with the + * list's shown rows as its sibling set when the list reported any. + */ + const drawerRoot = useMemo( + () => (drawerRecordId + ? { + objectName: objectDef.name, + recordId: drawerRecordId, + ...(listRowIds.ids.length > 0 ? { siblings: listRowIds.ids } : {}), + } + : null), + [drawerRecordId, objectDef.name, listRowIds], + ); // Sync URL-based recordId to overlay state useEffect(() => { if (drawerRecordId && !navOverlay.isOpen) { @@ -211,5 +281,5 @@ export function useObjectViewRecordNavigation({ navOverlay.close(); } }, [drawerRecordId]); - return { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer }; + return { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep }; } diff --git a/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx b/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx index b09358bfb49..f376ec5ec11 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx +++ b/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx @@ -6,57 +6,69 @@ * prefers the record beside the list (`utils/recordOpenPreference.ts`, * objectstack#26677) — stays in the component, where it wraps the list * itself. Every value is passed in as a prop. + * + * Since objectstack#26679 the drawer is the in-place stack of + * `./recordDrawerStack.tsx`: its root is the list row `?recordId=` names, a + * related row or a lookup inside it opens one level down, and its bar names + * no object (maintainer ruling 2 on that card). */ -import { NavigationOverlay } from '@object-ui/components'; -import type { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import type { useObjectTranslation } from '@object-ui/i18n'; import { RecordDetailView } from './RecordDetailView.js'; +import { RecordStackDrawer, useRecordDrawerStack } from './recordDrawerStack.js'; import type { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; import type { ConsoleObjectViewProps } from './ObjectView.js'; +type RecordNavigation = ReturnType; + export function ObjectViewRecordOverlay({ navOverlay, handleDrawerClose, - objectLabel, objectDef, handleExpandDrawer, + drawerRoot, + drawerRestore, + handleRootStep, t, dataSource, objects, onEdit, }: { - navOverlay: ReturnType['navOverlay']; - handleDrawerClose: ReturnType['handleDrawerClose']; - objectLabel: ReturnType['objectLabel']; + navOverlay: RecordNavigation['navOverlay']; + handleDrawerClose: RecordNavigation['handleDrawerClose']; objectDef: ConsoleObjectViewProps['objects'][number]; - handleExpandDrawer: ReturnType['handleExpandDrawer']; + handleExpandDrawer: RecordNavigation['handleExpandDrawer']; + drawerRoot: RecordNavigation['drawerRoot']; + drawerRestore: RecordNavigation['drawerRestore']; + handleRootStep: RecordNavigation['handleRootStep']; t: ReturnType['t']; dataSource: ConsoleObjectViewProps['dataSource']; objects: ConsoleObjectViewProps['objects']; onEdit: ConsoleObjectViewProps['onEdit']; }) { + // The root steps through the rows the list shows (objectstack#26679). + const stack = useRecordDrawerStack({ root: drawerRoot, restoredPath: drawerRestore, onRootStep: handleRootStep }); return ( - { if (!open) handleDrawerClose(); }} - title={objectLabel(objectDef)} + - {(record: Record) => { - const recordId = (record.id || record._id) as string; - return ( - - ); - }} - + objects={objects} + renderRecord={(level) => ( + + )} + /> ); } diff --git a/ui/packages/app-shell/src/views/recordDrawerContext.ts b/ui/packages/app-shell/src/views/recordDrawerContext.ts new file mode 100644 index 00000000000..df77a6d88d3 --- /dev/null +++ b/ui/packages/app-shell/src/views/recordDrawerContext.ts @@ -0,0 +1,50 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The record drawer host's context (objectstack#26679): what a record link + * under a drawer host — a related row, a lookup value — calls to open a record + * in that drawer, one level down. The host and its stack live in + * `./recordDrawerStack.tsx`; this module holds only what + * `RelatedRecordActionsBridge` reads, so the bridge and the drawer do not + * import each other. + */ + +import { createContext, useContext } from 'react'; + +/** What a record link inside a drawer host calls to open a record in place. */ +export interface RecordDrawerApi { + open( + objectName: string, + recordId: string | number, + options?: { + /** The ids of the rows shown beside the record, in their order. */ + siblings?: ReadonlyArray; + /** The title of the record the link sits on — the level being left. */ + fromTitle?: string; + }, + ): void; +} + +export const RecordDrawerContext = createContext(null); + +/** The nearest record drawer host, or `null` where records open as pages. */ +export function useRecordDrawer(): RecordDrawerApi | null { + return useContext(RecordDrawerContext); +} + +/** + * Whether a record of this object opens in the drawer: its object's own way + * of opening, the drawer unless it declares another (objectstack#26677 — the + * spec's `NavigationModeSchema` defaults to `drawer`; `page`, `new_window` and + * `none` keep the record page). + */ +export function opensInRecordDrawer(objectDef: unknown): boolean { + const mode = (objectDef as { navigation?: { mode?: unknown } } | null | undefined)?.navigation?.mode; + return mode == null || mode === 'drawer'; +} diff --git a/ui/packages/app-shell/src/views/recordDrawerStack.tsx b/ui/packages/app-shell/src/views/recordDrawerStack.tsx new file mode 100644 index 00000000000..8558cb88d2f --- /dev/null +++ b/ui/packages/app-shell/src/views/recordDrawerStack.tsx @@ -0,0 +1,566 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The record drawer's in-place stack (objectstack#26679, maintainer rulings + * 2-5 on that card). + * + * A record drawer is ONE drawer. A related row, a lookup value or any other + * record link inside it opens that record in the same drawer, one level down, + * and the previous record stays on a stack: the bar's left side shows `←` and + * the path (each ancestor returns to its level), `←` / `Esc` go back one + * level, `Esc` at the root closes and `✕` closes the whole stack. `↑` `↓` + * step through the current level's sibling set — the rows its list showed + * beside it — and never cross a level. `⤢` at any level opens that record's + * page with the ancestors as its `?from=` trail, and the page's 「Collapse to + * drawer」 control returns to the drawer with the stack restored. + * + * **What survives a reload.** Only the root is addressable: on an object list + * it is `?recordId=`, which a reload reopens. The levels above it live in + * component memory (and, across the ⤢ round trip, in the router's history + * state, consumed once on return), so a reload shows the root record. + * + * **`?tab=`.** Every level reads the one record-tab URL parameter, so a level + * that is left keeps the tab it showed and gets it back when the stack returns + * to it; a newly opened level starts on its page's default tab. + * + * Two hosts mount it: the object list (`objectViewRecordOverlay.tsx`, the root + * is the list row the URL names) and the record page itself + * ({@link RecordPageDrawerHost}, mounted by `RecordDetailView` when it is not + * embedded: a related row or lookup on the page opens over it). Whether a + * record opens here at all is its object's own way of opening — the drawer by + * default, the page where the object declares `navigation.mode: 'page'` + * (`opensInRecordDrawer`, `./recordDrawerContext.ts`). + */ + +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import { ChevronRight, Minimize2 } from 'lucide-react'; +import { NavigationOverlay, type NavigationOverlayMode } from '@object-ui/components'; +import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { + RECORD_DETAIL_TAB_PARAM, + RECORD_TRAIL_PARAM, + appendRecordTrail, + encodeRecordTrail, + type RecordTrailEntry, +} from '../urlParams.js'; +import { appRecordPath, routableObjectNames } from './RelatedRecordActionsBridge.js'; +import { RecordDrawerContext, type RecordDrawerApi } from './recordDrawerContext.js'; + +export { opensInRecordDrawer, useRecordDrawer, type RecordDrawerApi } from './recordDrawerContext.js'; + +/** One level of the stack: a record, and what the drawer learned about it. */ +export interface RecordDrawerLevel { + objectName: string; + recordId: string; + /** The ids of the level's sibling set, in the order its list showed them. */ + siblings?: readonly string[]; + /** The record's title, learned when a level above it was opened from it. */ + title?: string; + /** The `?tab=` the level showed when a level above it was opened. */ + tab?: string | null; +} + +/** The history-state key the ⤢ round trip carries the stack under. */ +const RETURN_STATE_KEY = 'recordDrawerReturn'; +const RESTORE_STATE_KEY = 'recordDrawerRestore'; + +/** Where the record page's 「Collapse to drawer」 goes, written by ⤢. */ +interface RecordDrawerReturn { + pathname: string; + search: string; + state: unknown; + path: RecordDrawerLevel[]; +} + +function isLevel(value: unknown): value is RecordDrawerLevel { + const v = value as RecordDrawerLevel | null | undefined; + return !!v && typeof v.objectName === 'string' && v.objectName !== '' + && typeof v.recordId === 'string' && v.recordId !== ''; +} + +/** The stack a page's ⤢ return hands back to a host, or `undefined`. */ +export function readRestoredPath(state: unknown): RecordDrawerLevel[] | undefined { + const path = (state as Record | null | undefined)?.[RESTORE_STATE_KEY]; + return Array.isArray(path) && path.length > 0 && path.every(isLevel) ? path : undefined; +} + +function readReturn(state: unknown): RecordDrawerReturn | undefined { + const value = (state as Record | null | undefined)?.[RETURN_STATE_KEY] as + | RecordDrawerReturn + | undefined; + if (!value || typeof value.pathname !== 'string' || !Array.isArray(value.path)) return undefined; + return value.path.length > 0 && value.path.every(isLevel) ? value : undefined; +} + +/** History state without the restore key — the restore is consumed once. */ +function withoutRestore(state: unknown): unknown { + if (!state || typeof state !== 'object' || !(RESTORE_STATE_KEY in state)) return state; + const { [RESTORE_STATE_KEY]: _consumed, ...rest } = state as Record; + return Object.keys(rest).length > 0 ? rest : null; +} + +const levelKey = (level: { objectName: string; recordId: string } | null | undefined) => + level ? `${level.objectName}:${level.recordId}` : null; + +/** + * The `?tab=` reader and writer the stack saves and restores levels through. + * Written with `replace` (switching levels stacks no history entries) and + * with the entry's own history state, which the record page's back link + * reads. + */ +function useRecordTabParam() { + const [, setSearchParams] = useSearchParams(); + const location = useLocation(); + // The router's location, read at call time: a handler outlives the render + // it was made in. + const locationRef = useRef(location); + locationRef.current = location; + const read = useCallback( + () => new URLSearchParams(locationRef.current.search).get(RECORD_DETAIL_TAB_PARAM), + [], + ); + const write = useCallback( + (tab: string | null | undefined) => { + const sp = new URLSearchParams(locationRef.current.search); + if ((sp.get(RECORD_DETAIL_TAB_PARAM) ?? null) === (tab ?? null)) return; + if (tab) sp.set(RECORD_DETAIL_TAB_PARAM, tab); + else sp.delete(RECORD_DETAIL_TAB_PARAM); + setSearchParams(sp, { replace: true, state: locationRef.current.state }); + }, + [setSearchParams], + ); + return { read, write }; +} + +/** + * The stack over a host-controlled root (level 0). The levels reset whenever + * the root changes — another row, a step at the root, a close — except to the + * path a ⤢ return restores for that same root. + */ +export function useRecordDrawerStack({ + root, + restoredPath, + onRootStep, +}: { + root: RecordDrawerLevel | null; + restoredPath?: RecordDrawerLevel[]; + onRootStep?: (recordId: string) => void; +}) { + const rootKey = levelKey(root); + const restores = (candidate: RecordDrawerLevel[] | undefined) => + !!candidate && levelKey(candidate[0]) === rootKey; + const [state, setState] = useState<{ + rootKey: string | null; + restore: RecordDrawerLevel[] | undefined; + path: RecordDrawerLevel[]; + }>(() => ({ rootKey, restore: restoredPath, path: restores(restoredPath) ? restoredPath! : [] })); + let stored = state.path; + if (state.rootKey !== rootKey || state.restore !== restoredPath) { + // A new root starts a new stack; a restore handed over for THIS root + // replaces it. Compared by the restore's own value — the host keeps it in + // state until it closes — never by a memoised identity. + if (restores(restoredPath) && (state.restore !== restoredPath || state.rootKey !== rootKey)) { + stored = restoredPath!; + } else if (state.rootKey !== rootKey) { + stored = []; + } + setState({ rootKey, restore: restoredPath, path: stored }); + } + // Level 0 is the host's root, annotated with what the stack learned on it. + const path: RecordDrawerLevel[] = useMemo( + () => (root ? [{ ...root, title: stored[0]?.title, tab: stored[0]?.tab }, ...stored.slice(1)] : []), + [root, stored], + ); + const depth = path.length - 1; + const current = root ? path[depth] : null; + const tabParam = useRecordTabParam(); + + const push = useCallback( + (objectName, recordId, options) => { + if (!root) return; + const leftTab = tabParam.read(); + setState((prev) => { + const base = prev.path.length > 0 ? prev.path : [root]; + const top = base[base.length - 1]; + if (top.objectName === objectName && top.recordId === String(recordId)) return prev; + const annotated = [...base]; + annotated[annotated.length - 1] = { + ...top, + tab: leftTab, + ...(options?.fromTitle ? { title: options.fromTitle } : {}), + }; + annotated.push({ + objectName, + recordId: String(recordId), + ...(options?.siblings ? { siblings: options.siblings.map(String) } : {}), + }); + return { ...prev, path: annotated }; + }); + // The new level opens on its own page's default tab. + tabParam.write(null); + }, + [root, tabParam], + ); + + /** Return to level `target` (0 = the root), restoring the tab it showed. */ + const popTo = useCallback( + (target: number) => { + if (target < 0 || target >= depth) return; + tabParam.write(path[target].tab ?? null); + setState((prev) => ({ ...prev, path: prev.path.slice(0, target + 1) })); + }, + [depth, path, tabParam], + ); + + /** The current level's previous / next sibling, when it has a set. */ + const stepper = useMemo(() => { + if (!current?.siblings || current.siblings.length === 0) return undefined; + if (depth === 0 && !onRootStep) return undefined; + const index = current.siblings.indexOf(current.recordId); + if (index < 0) return undefined; + const go = (recordId: string) => () => { + if (depth === 0) { + onRootStep?.(recordId); + return; + } + setState((prev) => { + const next = [...prev.path]; + const top = next[next.length - 1]; + next[next.length - 1] = { objectName: top.objectName, recordId, siblings: top.siblings }; + return { ...prev, path: next }; + }); + }; + return { + onPrevious: index > 0 ? go(current.siblings[index - 1]) : undefined, + onNext: index < current.siblings.length - 1 ? go(current.siblings[index + 1]) : undefined, + }; + }, [current, depth, onRootStep]); + + return { path, depth, current, push, popTo, stepper, tabParam }; +} + +export type RecordDrawerStack = ReturnType; + +/** + * The drawer itself: the shared `NavigationOverlay` with the stack's bar — + * `←` and the path on the left, `↑` `↓` / `⤢` / `✕` on the right — drawing + * the current level through the host's `renderRecord`, inside the context + * that makes every record link below it open one level down. + */ +export function RecordStackDrawer({ + stack, + open, + mode = 'drawer', + width, + storageKey, + objects, + expandLabel, + onClose, + onExpand, + renderRecord, +}: { + stack: RecordDrawerStack; + open: boolean; + mode?: NavigationOverlayMode; + width?: string | number; + storageKey?: string; + objects: ReadonlyArray; + expandLabel?: string; + onClose: () => void; + /** ⤢ — the path from the root to the record shown, inclusive. */ + onExpand?: (path: RecordDrawerLevel[]) => void; + renderRecord: (level: RecordDrawerLevel) => ReactNode; +}) { + const { objectLabel } = useObjectLabel(); + const { path, depth, current, push, popTo, stepper } = stack; + const api = useMemo(() => ({ open: push }), [push]); + const labelOf = (objectName: string) => { + const def = objects.find((o) => (o as { name?: unknown } | null)?.name === objectName); + return def ? objectLabel(def as never) : objectName; + }; + const trail = + depth > 0 ? ( + + ) : undefined; + return ( + { + if (!next) onClose(); + }} + onExpand={onExpand && current ? () => onExpand(path) : undefined} + expandLabel={expandLabel} + storageKey={storageKey} + onBack={depth > 0 ? () => popTo(depth - 1) : undefined} + trail={trail} + stepper={stepper} + > + {() => ( + + {/* Keyed by level and object: a step to a sibling swaps the record + in place (the view follows `recordIdOverride`), while a level + change is a different record page. */} + + {current ? renderRecord(current) : null} + + + )} + + ); +} + +/** + * The page a ⤢ opens: the record's route, with the levels below it as the + * `?from=` trail its back link walks, after `leading` (the trail the host + * itself sits under, if any). `null` when the console cannot route there. + */ +export function expandedRecordPath( + appName: string | undefined, + objects: ReadonlyArray, + path: RecordDrawerLevel[], + leadingTrail?: string | null, +): string | null { + const target = path[path.length - 1]; + if (!target) return null; + const url = appRecordPath(appName, routableObjectNames(objects), target.objectName, target.recordId); + if (url === null) return null; + let trail = leadingTrail ?? null; + for (const level of path.slice(0, -1)) { + const entry: RecordTrailEntry = { o: level.objectName, i: level.recordId, ...(level.title ? { t: level.title } : {}) }; + trail = appendRecordTrail(trail, entry); + } + if (!trail || encodeRecordTrail([]) === trail) return url; + const sp = new URLSearchParams(); + sp.set(RECORD_TRAIL_PARAM, trail); + return `${url}?${sp.toString()}`; +} + +/** + * The history state a ⤢ writes on the page it opens: everything the page's + * 「Collapse to drawer」 needs to return to this host with the same stack. + */ +export function recordDrawerReturnState( + from: { pathname: string; search: string; state: unknown }, + path: RecordDrawerLevel[], +): Record { + const back: RecordDrawerReturn = { + pathname: from.pathname, + search: from.search, + state: withoutRestore(from.state), + path, + }; + return { [RETURN_STATE_KEY]: back }; +} + +/** + * Take a ⤢ return's restored stack out of the history state, once: it is + * handed to the host as state of its own, and the history entry is rewritten + * without it, so a later reload or Back shows the root alone. The host clears + * it when its drawer closes, so a later open of the same root starts fresh. + */ +export function useTakenRestore(): [RecordDrawerLevel[] | undefined, () => void] { + const location = useLocation(); + const navigate = useNavigate(); + const [taken, setTaken] = useState(); + const restored = readRestoredPath(location.state); + useEffect(() => { + if (!restored) return; + setTaken(restored); + navigate( + { pathname: location.pathname, search: location.search, hash: location.hash }, + { replace: true, state: withoutRestore(location.state) }, + ); + }, [restored, location, navigate]); + const clear = useCallback(() => setTaken(undefined), []); + return [taken, clear]; +} + +/** + * The record page's 「Collapse to drawer」 (objectstack#26679): drawn on a page a + * drawer's ⤢ opened, it returns to that drawer's host with the stack it left. + */ +export function RecordDrawerReturnLink() { + const location = useLocation(); + const navigate = useNavigate(); + const { t } = useObjectTranslation(); + const back = readReturn(location.state); + if (!back) return null; + const label = t('detail.collapseToDrawer', { defaultValue: 'Collapse to drawer' }); + const restoreState = { + ...((back.state && typeof back.state === 'object' ? back.state : {}) as Record), + [RESTORE_STATE_KEY]: back.path, + }; + return ( + + ); +} + +/** + * The record page as a drawer host (objectstack#26679 target 4): a related + * row or a lookup value on the page opens its record in a drawer over the + * page, under the same rules as the list's drawer; an object that declares + * `page` still navigates (the bridge asks {@link opensInRecordDrawer}). + * + * The page's own `?tab=` is set aside while the drawer is open and restored + * when it closes. + */ +export function RecordPageDrawerHost({ + appName, + objects, + pageObjectName, + pageRecordId, + pageTitle, + renderRecord, + children, +}: { + appName: string | undefined; + objects: ReadonlyArray; + pageObjectName: string | undefined; + pageRecordId: string | undefined; + pageTitle: string | undefined; + renderRecord: (level: RecordDrawerLevel) => ReactNode; + children: ReactNode; +}) { + const location = useLocation(); + const navigate = useNavigate(); + const { t } = useObjectTranslation(); + const [restored, clearRestored] = useTakenRestore(); + const [root, setRoot] = useState(null); + // A ⤢ return reopens the drawer at the root it left. + useEffect(() => { + if (!restored) return; + const [first] = restored; + setRoot({ objectName: first.objectName, recordId: first.recordId, ...(first.siblings ? { siblings: first.siblings } : {}) }); + }, [restored]); + const pageTab = useRef(null); + const onRootStep = useCallback( + (recordId: string) => setRoot((prev) => (prev ? { ...prev, recordId } : prev)), + [], + ); + const stack = useRecordDrawerStack({ root, restoredPath: restored, onRootStep }); + const { push, tabParam } = stack; + + const openFromPage = useCallback( + (objectName, recordId, options) => { + if (root) { + push(objectName, recordId, options); + return; + } + pageTab.current = tabParam.read(); + tabParam.write(null); + setRoot({ + objectName, + recordId: String(recordId), + ...(options?.siblings ? { siblings: options.siblings.map(String) } : {}), + }); + }, + [root, push, tabParam], + ); + const api = useMemo(() => ({ open: openFromPage }), [openFromPage]); + + const close = useCallback(() => { + setRoot(null); + clearRestored(); + tabParam.write(pageTab.current); + pageTab.current = null; + }, [tabParam, clearRestored]); + + const expand = useCallback( + (path: RecordDrawerLevel[]) => { + // The page itself heads the trail: it is the record the drawer opened + // over, after the trail the page already sits under. + const pageTrail = + pageObjectName && pageRecordId + ? appendRecordTrail(new URLSearchParams(location.search).get(RECORD_TRAIL_PARAM), { + o: pageObjectName, + i: pageRecordId, + ...(pageTitle ? { t: pageTitle } : {}), + }) + : null; + const to = expandedRecordPath(appName, objects, path, pageTrail); + if (!to) return; + // The way back returns to the page on the tab it showed. + const search = new URLSearchParams(location.search); + if (pageTab.current) search.set(RECORD_DETAIL_TAB_PARAM, pageTab.current); + else search.delete(RECORD_DETAIL_TAB_PARAM); + const searchText = search.toString(); + // The drawer closes: the page it opens may be drawn by this same view. + setRoot(null); + clearRestored(); + pageTab.current = null; + navigate(to, { + state: recordDrawerReturnState( + { pathname: location.pathname, search: searchText ? `?${searchText}` : '', state: location.state }, + path, + ), + }); + }, + [appName, objects, pageObjectName, pageRecordId, pageTitle, location, navigate, clearRestored], + ); + + const rootDef = root ? objects.find((o) => (o as { name?: unknown } | null)?.name === root.objectName) : undefined; + const rootWidth = (rootDef as { navigation?: { width?: string | number } } | undefined)?.navigation?.width; + + return ( + + {children} + + + ); +} diff --git a/ui/packages/components/src/__tests__/navigation-overlay-bar-26679.test.tsx b/ui/packages/components/src/__tests__/navigation-overlay-bar-26679.test.tsx new file mode 100644 index 00000000000..cf379461a51 --- /dev/null +++ b/ui/packages/components/src/__tests__/navigation-overlay-bar-26679.test.tsx @@ -0,0 +1,125 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The record drawer's bar (objectstack#26679, maintainer rulings 2-5). + * + * - The left side is empty unless the host hands it a way back: no object + * name and no position are drawn (the title is the accessible name only). + * - `onBack` draws `←` and takes `Esc`, so `Esc` goes back one level; without + * it `Esc` closes, as before. + * - `stepper` draws `↑` `↓`, each disabled at its end of the set, and the + * arrow keys and `k` / `j` step — never while a text field or a composite + * widget owns the keyboard, never with a modifier. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { NavigationOverlay, type NavigationOverlayProps } from '../custom/navigation-overlay'; + +afterEach(() => cleanup()); + +function renderBar(props: Partial = {}) { + const setIsOpen = vi.fn(); + render( + {}} + setIsOpen={setIsOpen} + title="Lead" + {...props} + > + {() => ( +
+ +
+
+ )} + , + ); + return { setIsOpen }; +} + +const leading = () => screen.getByTestId('record-drawer-leading'); + +describe('the drawer bar names no object and no position (rulings 2, 3)', () => { + it('draws nothing on the left at the root; the title stays an accessible name', () => { + renderBar({ onExpand: () => {} }); + expect(leading().textContent).toBe(''); + expect(leading().children).toHaveLength(0); + // Radix still names the dialog by the host's title. + expect(screen.getByRole('dialog', { name: 'Lead' })).toBeTruthy(); + expect(screen.getByText('Lead').className).toContain('sr-only'); + }); + + it('draws ← and the host trail when the host hands it a way back', () => { + const onBack = vi.fn(); + renderBar({ onBack, trail: Lead Mira Costa › Task }); + fireEvent.click(screen.getByRole('button', { name: 'Go back' })); + expect(onBack).toHaveBeenCalledTimes(1); + expect(leading().textContent).toContain('Lead Mira Costa › Task'); + }); +}); + +describe('Esc goes back one level, and closes only at the root (ruling 5)', () => { + it('with onBack: Esc pops and does not close', () => { + const onBack = vi.fn(); + const { setIsOpen } = renderBar({ onBack }); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(onBack).toHaveBeenCalledTimes(1); + expect(setIsOpen).not.toHaveBeenCalledWith(false); + }); + + it('control, no onBack: Esc closes, as before', () => { + const { setIsOpen } = renderBar(); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(setIsOpen).toHaveBeenCalledWith(false); + }); +}); + +describe('↑ ↓ step through the host set (ruling 3 keeps the controls)', () => { + it('draws the pair, disabled at the ends', () => { + renderBar({ stepper: { onNext: () => {} } }); + expect((screen.getByRole('button', { name: 'Previous record' }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole('button', { name: 'Next record' }) as HTMLButtonElement).disabled).toBe(false); + }); + + it('control, no stepper: no pair is drawn', () => { + renderBar(); + expect(screen.queryByRole('button', { name: 'Previous record' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Next record' })).toBeNull(); + }); + + it('ArrowUp / k and ArrowDown / j step while nothing owns the keyboard', () => { + const onPrevious = vi.fn(); + const onNext = vi.fn(); + renderBar({ stepper: { onPrevious, onNext } }); + fireEvent.keyDown(document.body, { key: 'ArrowDown' }); + fireEvent.keyDown(document.body, { key: 'j' }); + fireEvent.keyDown(document.body, { key: 'ArrowUp' }); + fireEvent.keyDown(document.body, { key: 'k' }); + expect(onNext).toHaveBeenCalledTimes(2); + expect(onPrevious).toHaveBeenCalledTimes(2); + }); + + it('never fires from a text field, a composite widget, or with a modifier', () => { + const onPrevious = vi.fn(); + const onNext = vi.fn(); + renderBar({ stepper: { onPrevious, onNext } }); + fireEvent.keyDown(screen.getByLabelText('Body field'), { key: 'ArrowDown' }); + fireEvent.keyDown(screen.getByLabelText('Body field'), { key: 'j' }); + fireEvent.keyDown(screen.getByLabelText('Body grid'), { key: 'ArrowUp' }); + fireEvent.keyDown(document.body, { key: 'k', metaKey: true }); + fireEvent.keyDown(document.body, { key: 'ArrowDown', shiftKey: true }); + expect(onNext).not.toHaveBeenCalled(); + expect(onPrevious).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx b/ui/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx index bc069e22998..db795980505 100644 --- a/ui/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx +++ b/ui/packages/components/src/__tests__/navigation-overlay-chrome-i18n.test.tsx @@ -108,7 +108,7 @@ describe('NavigationOverlay expand button — default label (objectstack#5506)', it('reads the zh bundle value under a zh session', () => { renderDrawer('zh', { title: 'Acme Corp', onExpand: () => {} }); - expect(screen.getByTitle('以完整页面打开').getAttribute('aria-label')).toBe('以完整页面打开'); + expect(screen.getByTitle('整页打开').getAttribute('aria-label')).toBe('整页打开'); expect(screen.queryByTitle('Open as full page')).toBeNull(); }); @@ -131,7 +131,7 @@ describe('NavigationOverlay expand button — default label (objectstack#5506)', }); expect(screen.getByTitle('主机自定义标签')).toBeTruthy(); - expect(screen.queryByTitle('以完整页面打开')).toBeNull(); + expect(screen.queryByTitle('整页打开')).toBeNull(); }); }); diff --git a/ui/packages/components/src/custom/navigation-overlay.tsx b/ui/packages/components/src/custom/navigation-overlay.tsx index 4d19b040454..e25dc224d80 100644 --- a/ui/packages/components/src/custom/navigation-overlay.tsx +++ b/ui/packages/components/src/custom/navigation-overlay.tsx @@ -56,7 +56,7 @@ */ import React from 'react'; -import { Maximize2, X } from 'lucide-react'; +import { ArrowLeft, ChevronDown, ChevronUp, Maximize2, X } from 'lucide-react'; import { cn } from '../lib/utils'; import { Sheet, @@ -94,9 +94,16 @@ const OVERLAY_DEFAULT_TRANSLATIONS: Record = { // Default accessible name + tooltip of the icon-only expand button. Hosts // may override it via the `expandLabel` prop; this is what ships otherwise. 'detail.openAsFullPage': 'Open as full page', - // VISIBLE overlay heading when the host passes no `title` — not merely an - // a11y name. + // The drawer's accessible title when the host passes no `title`. Since + // objectstack#26679 the drawer bar draws no title text at all (the record + // body names the record), so this is an accessible name only; the split + // face still draws it as its heading. 'detail.recordDetail': 'Record Detail', + // The bar's back control (`onBack`, objectstack#26679). + 'detail.goBack': 'Go back', + // The bar's previous / next pair (`stepper`, objectstack#26679). + 'detail.previousRecord': 'Previous record', + 'detail.nextRecord': 'Next record', // sr-only Sheet description used when the host passes no `description`. 'detail.recordDetailOverlay': 'Record detail overlay for {{title}}.', }; @@ -142,9 +149,16 @@ export interface NavigationOverlayProps { isOverlay: boolean; /** Target view/form name from NavigationConfig */ view?: string; - /** Title for the overlay header */ + /** + * The overlay's title. The split face draws it as its heading; the drawer + * keeps it as the accessible name only — its bar draws no title text + * (objectstack#26679, maintainer ruling 2). + */ title?: string; - /** Description for the overlay header */ + /** + * The overlay's description: drawn under the split face's heading, an + * accessible description only on the drawer (objectstack#26679). + */ description?: string; /** CSS class for the overlay container */ className?: string; @@ -211,8 +225,86 @@ export interface NavigationOverlayProps { * prefix again at a call site. */ legacyStorageKey?: string; + /** + * One level back (drawer mode only, objectstack#26679). When provided the + * bar draws a `←` control at its left edge and `Esc` calls this instead of + * closing the drawer: a host that opened a record IN PLACE (a related row, + * a lookup value) hands it the pop of its stack, so each `←` / `Esc` undoes + * one click. Absent at the stack's root, where `Esc` closes as before. + */ + onBack?: () => void; + /** + * The path drawn after the `←` control (drawer mode only, + * objectstack#26679) — the host's ancestors of the record shown, each one a + * control that returns to it. Nothing is drawn at the left of the bar when + * neither this nor {@link onBack} is passed: the bar names no object and no + * position (maintainer rulings 2 and 3 on objectstack#26679). + */ + trail?: React.ReactNode; + /** + * Previous / next record (drawer mode only, objectstack#26679). When passed + * the bar draws the `↑` `↓` pair, and `↑` / `↓` / `k` / `j` step through the + * host's sibling set while no text input, menu or grid has the keyboard. + * A missing handler draws its control disabled — the ends of the set. + */ + stepper?: { + onPrevious?: () => void; + onNext?: () => void; + }; } +/** + * Whether a key event belongs to the element it was typed in rather than to + * the drawer's own shortcuts: a text field, an editable region, or a + * composite widget that already moves with the arrow keys (a menu, a list + * box, a grid, a tab strip). + */ +function keyEventOwnedByTarget(target: EventTarget | null): boolean { + if (!target || !(target instanceof Element)) return false; + if (target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) { + return true; + } + return !!target.closest( + '[role="menu"], [role="menubar"], [role="listbox"], [role="combobox"], [role="grid"], [role="treegrid"], [role="tree"], [role="tablist"], [role="radiogroup"], [role="slider"], [role="spinbutton"]', + ); +} + +/** + * The drawer's previous / next shortcuts (objectstack#26679): `ArrowUp` / `k` + * and `ArrowDown` / `j`, on the document while the drawer is open, never with + * a modifier and never while the event belongs to its target + * ({@link keyEventOwnedByTarget}). + */ +function useStepperKeys( + active: boolean, + onPrevious: (() => void) | undefined, + onNext: (() => void) | undefined, +) { + const handlers = React.useRef({ onPrevious, onNext }); + handlers.current = { onPrevious, onNext }; + React.useEffect(() => { + if (!active || typeof document === 'undefined') return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + if (keyEventOwnedByTarget(e.target)) return; + let step: (() => void) | undefined; + if (e.key === 'ArrowUp' || e.key === 'k') step = handlers.current.onPrevious; + else if (e.key === 'ArrowDown' || e.key === 'j') step = handlers.current.onNext; + else return; + // The key is the drawer's even at an end of the set, so the body does + // not scroll under a disabled control. + e.preventDefault(); + step?.(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [active]); +} + +/** The bar's icon-button face, shared by every control on it. */ +const BAR_BUTTON_CLASS = + 'inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40'; + /** * The one `storageKey` spelling every record overlay uses, keyed by object. * @@ -374,6 +466,9 @@ export const NavigationOverlay: React.FC = ({ expandLabel, storageKey, legacyStorageKey, + onBack, + trail, + stepper, }) => { // Every chrome string below is locale-driven (objectstack#5430 for the two // close affordances, objectstack#5506 for the rest). Must stay above the @@ -395,6 +490,11 @@ export const NavigationOverlay: React.FC = ({ // Inline-edit dropdowns render in body-level poppers; without this guard the // click that closes an open dropdown also dismisses the drawer (#2156). const handleInteractOutside = usePopperAwareInteractOutside(); + useStepperKeys( + mode === 'drawer' && isOpen && !!selectedRecord && !!stepper, + stepper?.onPrevious, + stepper?.onNext, + ); // Non-overlay modes don't render anything if (mode === 'page' || mode === 'new_window' || mode === 'none') { @@ -446,6 +546,9 @@ export const NavigationOverlay: React.FC = ({ // documented choice rather than dropping it is what "one shared // overlay shell" has to mean when the two shells disagreed. onOpenAutoFocus={(e) => e.preventDefault()} + // objectstack#26679 — below the root of an in-place stack, `Esc` + // goes back one level; only the root's `Esc` closes. + onEscapeKeyDown={onBack ? (e) => { e.preventDefault(); onBack(); } : undefined} className={cn( // Mobile: full width (no inline cap, no max-w from base sheet). // sm+: honor the host-supplied width via `--ov-w` CSS var with a @@ -476,32 +579,62 @@ export const NavigationOverlay: React.FC = ({ className="hidden sm:block absolute left-0 top-0 bottom-0 z-30 w-1 cursor-col-resize touch-none bg-transparent hover:bg-primary/40 active:bg-primary/60 transition-colors" /> )} - {/* Chrome header — subdued breadcrumb-style label that does not - compete with the record-title rendered by the embedded content. - Buttons live in the same flex row so they share vertical - centering with the title text (no absolute positioning, no - pixel-perfect math to maintain). */} + {/* The bar (objectstack#26679, maintainer rulings 2-5): the left + side holds only the in-place path — `←` and the ancestors — + and is empty at the root; the right side holds ↑ ↓, ⤢ and ✕. + No object name and no position: the title and description stay + for assistive technology only (Radix requires the title). */} -
- - {resolvedTitle} - - {description ? ( - {description} - ) : ( - - {overlayDescription} - + {resolvedTitle} + + {description || overlayDescription} + +
+ {onBack && ( + )} + {trail}
+ {stepper && ( + <> + + + + )} {onExpand && ( @@ -511,7 +644,7 @@ export const NavigationOverlay: React.FC = ({ type="button" aria-label={t('common.close')} title={t('common.close')} - className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className={BAR_BUTTON_CLASS} > diff --git a/ui/packages/components/src/renderers/complex/__tests__/data-table-rowClickInDialog-26679.test.tsx b/ui/packages/components/src/renderers/complex/__tests__/data-table-rowClickInDialog-26679.test.tsx new file mode 100644 index 00000000000..c4c7a80cfa6 --- /dev/null +++ b/ui/packages/components/src/renderers/complex/__tests__/data-table-rowClickInDialog-26679.test.tsx @@ -0,0 +1,86 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#26679 — a table drawn INSIDE a dialog opens its rows. + * + * The row's click heuristic ignores a click that comes from a dialog, so a + * Radix overlay portalled out of the row (a confirm, a popover) cannot open + * the record through React's virtual bubbling. It used to ignore every click + * whose target had a dialog ancestor at all — and the record drawer is a + * dialog, so a related list inside a drawer drew `cursor-pointer` rows that + * opened nothing. Only a dialog that does not contain the row is the click's + * source now. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { createPortal } from 'react-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import '../data-table'; + +const ROWS = [ + { id: 'a', name: 'Ada' }, + { id: 'b', name: 'Grace' }, +]; + +function renderTable(insideDialog: boolean) { + const DataTable = ComponentRegistry.get('data-table') as any; + const onRowClick = vi.fn(); + const table = ( + + createPortal( +
+ {`portalled ${value}`} +
, + document.body, + ), + }, + ], + onRowClick, + }} + /> + ); + render(insideDialog ?
{table}
: table); + return { onRowClick }; +} + +describe('data-table row click and dialogs (objectstack#26679)', () => { + it('a table inside a dialog opens the clicked row', () => { + const { onRowClick } = renderTable(true); + fireEvent.click(screen.getByText('Grace')); + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick.mock.calls[0][0]).toEqual(ROWS[1]); + }); + + it('control: a click from a dialog portalled out of the row still opens nothing', () => { + const { onRowClick } = renderTable(true); + fireEvent.click(screen.getByText('portalled b')); + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it('control: outside any dialog, the row opens and the portalled overlay does not', () => { + const { onRowClick } = renderTable(false); + fireEvent.click(screen.getByText('portalled a')); + expect(onRowClick).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('Ada')); + expect(onRowClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/packages/components/src/renderers/complex/data-table.tsx b/ui/packages/components/src/renderers/complex/data-table.tsx index 8e14f45da0f..f0310a9b722 100644 --- a/ui/packages/components/src/renderers/complex/data-table.tsx +++ b/ui/packages/components/src/renderers/complex/data-table.tsx @@ -953,6 +953,17 @@ function isMaskedColumnKey(columns: readonly TableColumn[], accessorKey: string) const STICKY_ROW_TINT = 'bg-[image:linear-gradient(var(--os-row-tint,transparent),var(--os-row-tint,transparent))]'; +/** + * Whether `target` sits in a `selector` element that does NOT contain `row`: + * an overlay portalled out of the row whose React events still bubble to it + * (objectstack#26679). A table drawn inside a dialog is inside that dialog + * too, so the dialog it sits in is not one the click came from. + */ +function portalledFrom(target: HTMLElement, row: Element, selector: string): boolean { + const overlay = target.closest(selector); + return !!overlay && !overlay.contains(row); +} + /** * objectui#13188 — whether the viewer has selected text that touches `el`. * A drag that selects text ends in a `click` on the row or cell it started @@ -3483,8 +3494,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { target.closest('[role="menuitem"]') || target.closest('[role="menuitemcheckbox"]') || target.closest('[role="menuitemradio"]') || - target.closest('[role="dialog"]') || - target.closest('[role="alertdialog"]') || + // A dialog the click came FROM, portalled above + // this row — never the dialog the table itself + // sits in: a related list inside a record drawer + // opens its rows like any other (objectstack#26679). + portalledFrom(target, e.currentTarget, '[role="dialog"]') || + portalledFrom(target, e.currentTarget, '[role="alertdialog"]') || target.closest('[role="listbox"]') || target.closest('[role="option"]') || target.closest('[role="tab"]') || diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 78c176f8c4a..26d0d91b9eb 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1236,6 +1236,7 @@ const ar = { recordDetail: "تفاصيل السجل", recordDetailWithLabel: "تفاصيل {{label}}", openAsFullPage: "فتح كصفحة كاملة", + collapseToDrawer: "طي إلى اللوحة الجانبية", recordDetailOverlay: "طبقة تفاصيل السجل الخاصة بـ {{title}}.", addToFavorites: "إضافة إلى المفضلة", removeFromFavorites: "إزالة من المفضلة", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index b3ce0ae5a3d..b5338c8b1c9 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1110,6 +1110,7 @@ const de = { recordDetail: "Datensatzdetails", recordDetailWithLabel: "{{label}}-Details", openAsFullPage: "Als ganze Seite öffnen", + collapseToDrawer: "In Seitenleiste verkleinern", recordDetailOverlay: "Detail-Overlay des Datensatzes für {{title}}.", addToFavorites: "Zu Favoriten hinzufügen", removeFromFavorites: "Aus Favoriten entfernen", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 471caa66034..961338764d4 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1447,6 +1447,9 @@ const en = { // a possessive particle (ja/zh) writes its own arrangement here. recordDetailWithLabel: '{{label}} Detail', openAsFullPage: 'Open as full page', + // The record page's way back into the drawer it was expanded from + // (objectstack#26679). + collapseToDrawer: 'Collapse to drawer', recordDetailOverlay: 'Record detail overlay for {{title}}.', addToFavorites: 'Add to favorites', removeFromFavorites: 'Remove from favorites', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 2ce0bfe0e91..87096612a30 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1143,6 +1143,7 @@ const es = { recordDetail: "Detalle del registro", recordDetailWithLabel: "Detalle de {{label}}", openAsFullPage: "Abrir como página completa", + collapseToDrawer: "Contraer en panel", recordDetailOverlay: "Capa de detalle del registro para {{title}}.", addToFavorites: "Añadir a favoritos", removeFromFavorites: "Quitar de favoritos", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 80ce043e483..657bac356bc 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1140,6 +1140,7 @@ const fr = { recordDetail: "Détail de l'enregistrement", recordDetailWithLabel: "Détail — {{label}}", openAsFullPage: "Ouvrir en pleine page", + collapseToDrawer: "Réduire en panneau", recordDetailOverlay: "Panneau de détail de l'enregistrement pour {{title}}.", addToFavorites: "Ajouter aux favoris", removeFromFavorites: "Retirer des favoris", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index f9ac1f7f32e..9e323875e04 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1110,6 +1110,7 @@ const ja = { recordDetail: "レコード詳細", recordDetailWithLabel: "{{label}}の詳細", openAsFullPage: "フルページで開く", + collapseToDrawer: "ドロワーに戻す", recordDetailOverlay: "{{title}} のレコード詳細オーバーレイ。", addToFavorites: "お気に入りに追加", removeFromFavorites: "お気に入りから削除", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 55ffb353162..7de1e1bcbae 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1110,6 +1110,7 @@ const ko = { recordDetail: "레코드 세부 정보", recordDetailWithLabel: "{{label}} 세부 정보", openAsFullPage: "전체 페이지로 열기", + collapseToDrawer: "드로어로 접기", recordDetailOverlay: "{{title}}의 레코드 세부 정보 오버레이입니다.", addToFavorites: "즐겨찾기에 추가", removeFromFavorites: "즐겨찾기에서 제거", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 709c4981bde..2d1ca70e175 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1140,6 +1140,7 @@ const pt = { recordDetail: "Detalhe do registro", recordDetailWithLabel: "Detalhe de {{label}}", openAsFullPage: "Abrir como página inteira", + collapseToDrawer: "Recolher em painel", recordDetailOverlay: "Sobreposição de detalhe do registro para {{title}}.", addToFavorites: "Adicionar aos favoritos", removeFromFavorites: "Remover dos favoritos", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index a972b5bf442..77356fd10ee 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1177,6 +1177,7 @@ const ru = { recordDetail: "Сведения о записи", recordDetailWithLabel: "Сведения: {{label}}", openAsFullPage: "Открыть на всю страницу", + collapseToDrawer: "Свернуть в панель", recordDetailOverlay: "Наложение со сведениями о записи для {{title}}.", addToFavorites: "Добавить в избранное", removeFromFavorites: "Удалить из избранного", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 996974c7887..1db7b9eaa4d 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1184,7 +1184,8 @@ const zh = { reactionCount_other: '{{emoji}} {{count}} 个回应', recordDetail: '记录详情', recordDetailWithLabel: '{{label}}详情', - openAsFullPage: '以完整页面打开', + openAsFullPage: '整页打开', + collapseToDrawer: '收为抽屉', recordDetailOverlay: '{{title}} 的记录详情浮层。', addToFavorites: '添加到收藏', removeFromFavorites: '从收藏中移除', @@ -2230,7 +2231,7 @@ const zh = { resetView: '重置视图', resetViewTitle: '将此视图的排序、筛选和列宽恢复为视图自身的设置。只影响你自己。', cannotDeleteMetaView: '该视图为内置视图,由代码定义,无法删除。', - expandToPage: '以完整页面打开', + expandToPage: '整页打开', openAsPage: '整页打开', objectNotFound: '未找到对象', objectNotFoundDescription: '对象"{{objectName}}"在当前配置中不存在。', diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index bf33c62922f..869508af87a 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -147,8 +147,12 @@ export interface RelatedListProps { * annotated it `React.MouseEvent` — which a narrower declaration refuses * contravariantly. `BaseSchema`'s own `onClick` / `onChange` / `onSubmit` * already use this spelling for exactly this situation. + * + * The THIRD parameter (objectstack#26679) is the rows the list shows at the + * moment of the click, in its order — the clicked row's siblings, which a + * host that opens the row in a drawer steps through with ↑ ↓. */ - onRowClick?: (row: any, event?: any) => void; + onRowClick?: (row: any, event?: any, rows?: readonly any[]) => void; /** * Child-object row actions (`locations` `list_item`, or `record_related` * inside a parent record — objectui#11270), already localized by the host. @@ -2347,6 +2351,13 @@ export const RelatedList: React.FC = ({ // column's edge. Display only — the read, filter and sort are unchanged. const compact = React.useContext(RelatedListCompactContext); + // The row click hands the host the rows on show beside the clicked one + // (objectstack#26679), so its drawer can step through them. + const handleRowClick = React.useMemo( + () => (onRowClick ? (row: any, event?: any) => onRowClick(row, event, paginatedData) : undefined), + [onRowClick, paginatedData], + ); + const viewSchema = React.useMemo(() => { if (schema) return schema; @@ -2373,7 +2384,7 @@ export const RelatedList: React.FC = ({ coverField: MOBILE_GALLERY_COVER_FIELD, cardSize: 'medium', }, - onRowClick, + onRowClick: handleRowClick, }; } @@ -2416,7 +2427,7 @@ export const RelatedList: React.FC = ({ onRowDelete: onRowDelete ? handleDeleteRow : undefined, rowEditPredicates, rowDeletePredicates, - onRowClick, + onRowClick: handleRowClick, // Child-object row actions (`list_item`, or `record_related` inside a // parent record) rendered in the same overflow menu, dispatched with // the clicked row as target. @@ -2431,7 +2442,7 @@ export const RelatedList: React.FC = ({ default: return { type: 'div', children: 'No view configured' }; } - }, [type, paginatedData, tableColumns, effectiveColumns, schema, effectivePageSize, hasRowActions, hasCustomRowActions, rowActions, onRowAction, onRowEdit, onRowDelete, handleDeleteRow, onRowClick, isMobile, compact, api, objectSchema, activeSort, handleTableSort]); + }, [type, paginatedData, tableColumns, effectiveColumns, schema, effectivePageSize, hasRowActions, hasCustomRowActions, rowActions, onRowAction, onRowEdit, onRowDelete, handleDeleteRow, handleRowClick, isMobile, compact, api, objectSchema, activeSort, handleTableSort]); const headerClassName = collapsible ? 'cursor-pointer select-none' : undefined; const handleHeaderClick = collapsible ? () => setCollapsed((c) => !c) : undefined; diff --git a/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.hostactions.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.hostactions.test.tsx index 39277ebdb77..b33de03bee3 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.hostactions.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.hostactions.test.tsx @@ -76,6 +76,11 @@ describe('RecordRelatedListRenderer — host CRUD + action wiring', () => { p.onRowClick({ id: 'c1', name: 'Alice' }); expect(onView).toHaveBeenCalledWith('c1', { id: 'c1', name: 'Alice' }); + // objectstack#26679 — the rows on show reach the host as the clicked + // row's sibling ids, in the list's order, for a drawer's ↑ ↓. + p.onRowClick({ id: 'c2', name: 'Bob' }, undefined, [{ id: 'c1' }, { _id: 'c2' }, { name: 'no id' }]); + expect(onView).toHaveBeenLastCalledWith('c2', { id: 'c2', name: 'Bob' }, ['c1', 'c2']); + p.onNew(); expect(onCreate).toHaveBeenCalledTimes(1); diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.rowClickSiblings-26679.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.rowClickSiblings-26679.test.tsx new file mode 100644 index 00000000000..f2242e8ff83 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.rowClickSiblings-26679.test.tsx @@ -0,0 +1,94 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#26679 — a related list's row click hands the host the rows the + * list shows, in its order, as a third argument: the clicked record's sibling + * set, which the record drawer steps through with ↑ ↓ one level down. + * + * The node `RelatedList` hands `SchemaRenderer` is captured (the pattern of + * `RelatedList.mobileLookupTitle-12188.test.tsx`), and its `onRowClick` is + * invoked the way the table invokes it — `(row, event)`. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { RelatedList } from '../RelatedList'; + +const h = vi.hoisted(() => ({ schema: null as any })); +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + SchemaRenderer: (props: any) => { + h.schema = props.schema; + return null; + }, + }; +}); + +const OBJECT = 'crm_task'; +const ROWS = [ + { id: 't_1', subject: 'Send quote' }, + { id: 't_2', subject: 'Call back' }, + { id: 't_3', subject: 'Demo' }, +]; +const dataSource = { + getObjectSchema: vi.fn(async () => ({ name: OBJECT, fields: { subject: { type: 'text', label: 'Subject' } } })), + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), +}; + +afterEach(() => { + cleanup(); + h.schema = null; +}); + +describe('RelatedList row click carries the rows on show (objectstack#26679)', () => { + it('calls the host with (row, event, rows), rows in the list order', async () => { + const onRowClick = vi.fn(); + render( + , + ); + await waitFor(() => expect(h.schema?.data?.length ?? 0).toBe(3)); + const event = { button: 0 }; + h.schema.onRowClick(ROWS[1], event); + expect(onRowClick).toHaveBeenCalledTimes(1); + const [row, passedEvent, rows] = onRowClick.mock.calls[0]; + expect(row).toBe(ROWS[1]); + expect(passedEvent).toBe(event); + expect(rows.map((r: any) => r.id)).toEqual(['t_1', 't_2', 't_3']); + }); + + it('control, no host handler: the node carries no row click', async () => { + render( + , + ); + await waitFor(() => expect(h.schema?.data?.length ?? 0).toBe(3)); + expect(h.schema.onRowClick).toBeUndefined(); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx index 28e315046c3..e2c8b45ce97 100644 --- a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -715,12 +715,22 @@ const RecordRelatedListBody: React.FC = ({ // "View all": the child object's list page, scoped to this parent — // see `onViewAll` above for when it is offered. onViewAll={onViewAll} - // Open the child record's detail page on row click (查看记录详情). + // Open the child record's detail page on row click (查看记录详情), + // with the ids of the rows on show beside it (objectstack#26679) so a + // host drawer can step through them. onRowClick={ handlers?.onView - ? (row: any) => { + ? (row: any, _event?: any, rows?: readonly any[]) => { const id = rowId(row); - if (id != null) handlers.onView!(id, row); + if (id == null) return; + if (!rows) { + handlers.onView!(id, row); + return; + } + const siblings = rows + .map(rowId) + .filter((sibling): sibling is string | number => sibling != null); + handlers.onView!(id, row, siblings); } : undefined } diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 046f58fbebb..a654ba927fc 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -477,6 +477,18 @@ export interface ListViewProps { * that draws the rows it fetched answers; other kinds never call it. */ onTotalChange?: (total: number) => void; + /** + * Called with the rows the list shows, in the order it shows them, each + * time that set changes once a load settles — another page, sort, filter or + * search (objectstack#26679). The console's object page hands it to its + * record drawer, whose `↑` `↓` at the root step through these rows. Answered + * where `onTotalChange` is: a paged grid that draws the rows it fetched; any + * other kind (a board, a calendar, a grouped grid) has no single row order + * and answers with no rows, so a host never steps through a stale set. + * + * A HOST prop, never a schema key: the rows are the list's runtime state. + */ + onRowsChange?: (rows: ReadonlyArray>) => void; /** * The Display control's Open records section (objectstack#26681): how a row * opens its record — the mode the host resolved for this object and view, @@ -1543,6 +1555,7 @@ export const ListView = React.forwardRef(({ compact = false, capped = false, onTotalChange, + onRowsChange, viewDefaults, recordOpen, ...props @@ -5472,6 +5485,25 @@ export const ListView = React.forwardRef(({ React.useEffect(() => { if (settledTotal != null) onTotalChangeRef.current?.(settledTotal); }, [settledTotal]); + // objectstack#26679 — the rows on show, for a host that steps through them. + // Keyed on the ids in their order (never on the identity of `data` or of + // the callback, AGENTS.md #10), so an equal refetch reports nothing new; a + // load in flight keeps the last answer, as the count does. + const onRowsChangeRef = React.useRef(onRowsChange); + React.useEffect(() => { + onRowsChangeRef.current = onRowsChange; + }); + const rowSequenceShown = paginate && surfaceDrawsFetchedRows; + const shownRowsSettled = rowSequenceShown ? !loading && !loadError : true; + const shownRowsKey = rowSequenceShown + ? JSON.stringify(data.map((row: any) => row?.id ?? row?._id ?? null)) + : '[]'; + const shownRowsRef = React.useRef(data); + shownRowsRef.current = rowSequenceShown ? data : []; + React.useEffect(() => { + if (!shownRowsSettled) return; + onRowsChangeRef.current?.(shownRowsRef.current); + }, [shownRowsKey, shownRowsSettled]); return (
+ ({ + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => (name === OBJECT ? objectDef : null)), + }) as any; + +function mount(schemaExtra: Record) { + const dataSource = makeDataSource(); + const onRowsChange = vi.fn(); + render( + + + , + ); + return { dataSource, onRowsChange }; +} + +const lastIds = (fn: ReturnType) => + (fn.mock.calls.at(-1)?.[0] as Array<{ id: string }> | undefined)?.map((r) => r.id); + +afterEach(() => cleanup()); + +describe('ListView onRowsChange (objectstack#26679)', () => { + it('a paged grid reports the rows it shows, in their shown order', async () => { + const { dataSource, onRowsChange } = mount({ viewType: 'grid' }); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + await waitFor(() => expect(lastIds(onRowsChange)).toEqual(['l_3', 'l_1', 'l_2'])); + }); + + it('control: a board has no single row order and reports no rows', async () => { + const { dataSource, onRowsChange } = mount({ + viewType: 'kanban', + options: { kanban: { groupBy: 'status' } }, + }); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + await waitFor(() => expect(onRowsChange).toHaveBeenCalled()); + expect(onRowsChange.mock.calls.every(([rows]) => rows.length === 0)).toBe(true); + }); +}); diff --git a/ui/packages/react/src/context/RelatedRecordActionsContext.tsx b/ui/packages/react/src/context/RelatedRecordActionsContext.tsx index 1990465b4a9..066dd2f6c15 100644 --- a/ui/packages/react/src/context/RelatedRecordActionsContext.tsx +++ b/ui/packages/react/src/context/RelatedRecordActionsContext.tsx @@ -63,8 +63,12 @@ export interface RelatedRowActionDef { * optional: an omitted handler hides its affordance in the list. */ export interface RelatedRecordHandlers { - /** Open the child record's detail page (查看详情). */ - onView?: (recordId: string | number, record?: unknown) => void; + /** + * Open the child record's detail page (查看详情). `siblings` are the ids of + * the rows the list shows beside it, in its order (objectstack#26679): a + * host that opens the record in a drawer steps through them with ↑ ↓. + */ + onView?: (recordId: string | number, record?: unknown, siblings?: ReadonlyArray) => void; /** * Open a create form for a new child record. The host pre-links it to the * parent (via the relationship field + parent id passed to `resolve`), so no diff --git a/ui/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts b/ui/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts index e625892d530..a5c58264f3e 100644 --- a/ui/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts +++ b/ui/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts @@ -219,7 +219,7 @@ function readSource(rel: string): string { * the criterion. */ const HOOK_CALL = /useNavigationOverlay\s*\(/; -const IN_SITES: Array<{ rel: string; member: string; why: string; hop: RegExp; after?: string }> = [ +const IN_SITES: Array<{ rel: string; member: string; why: string; hop: RegExp; after?: string; arity?: number }> = [ { rel: 'packages/plugin-grid/src/ObjectGrid.tsx', member: 'onRowClick', why: 'fed to useNavigationOverlay as its onRowClick', hop: HOOK_CALL }, { rel: 'packages/plugin-kanban/src/ObjectKanban.tsx', member: 'onRowClick', @@ -242,9 +242,13 @@ const IN_SITES: Array<{ rel: string; member: string; why: string; hop: RegExp; a why: 'the onRowClick arm of `props.onRowClick ?? props.onCardClick`', hop: HOOK_CALL }, { rel: 'packages/plugin-list/src/ObjectGallery.tsx', member: 'onCardClick', why: 'the onCardClick arm of the same `??`', hop: HOOK_CALL }, + // Three since objectstack#26679: after the modifier payload, the rows the + // list shows (the drawer's sibling set). Optional like the second, so the + // payload this card pins is still declared and no caller is forced to pass + // either. { rel: 'packages/plugin-detail/src/RelatedList.tsx', member: 'onRowClick', why: 'placed on the object-gallery schema it renders, reaching ObjectGallery props', - hop: /type: 'object-gallery'/ }, + hop: /type: 'object-gallery'/, arity: 3 }, // ⭐ objectui#7804's `objectql.ts` slice closed the remaining hole on this // exact path. `RelatedList` above is IN *because* it writes `onRowClick` onto // the `object-gallery` NODE — and until that slice the node type declared @@ -367,12 +371,15 @@ describe('objectui#9357 — the arity counter, before it is pointed at the tree' }); describe('objectui#9357 — consumers on the modifier-forwarding path declare the payload', () => { - it.each(IN_SITES)('$rel declares two parameters for $member ($why)', ({ rel, member, after }) => { + it.each(IN_SITES)('$rel declares two parameters for $member ($why)', ({ rel, member, after, arity = 2 }) => { const params = declParams(readSource(rel), member, after); expect(params, `${rel} :: ${member} — no inline function-type declaration found`).not.toBeNull(); - expect(params, `${rel} :: ${member}`).toHaveLength(2); - // The second parameter is optional, so no existing caller is forced to pass it. - expect(params![1], `${rel} :: ${member} second parameter must stay optional`).toMatch(/^\w+\?:/); + expect(params, `${rel} :: ${member}`).toHaveLength(arity); + // The second parameter is optional, so no existing caller is forced to pass + // it — and so is any after it. + for (const param of params!.slice(1)) { + expect(param, `${rel} :: ${member} parameters after the first must stay optional`).toMatch(/^\w+\?:/); + } }); it('every IN site carries its own forwarding hop — the criterion, not the name', () => {