diff --git a/.changeset/27524-record-top-bar.md b/.changeset/27524-record-top-bar.md new file mode 100644 index 00000000000..c9412270237 --- /dev/null +++ b/.changeset/27524-record-top-bar.md @@ -0,0 +1,25 @@ +--- +'@object-ui/components': patch +'@object-ui/app-shell': patch +'@object-ui/plugin-detail': patch +'@object-ui/i18n': patch +--- + +feat(app-shell): one top bar above the record header on every face — the breadcrumb on the left; the counter with ↓ ↑, ⊟, ⤢ / ⤡ and ✕ on the right, each only where it applies + +Clause-②: yes (widening: `@object-ui/components` gains `RecordTopBar`, `RecordTopBarButton`, `useRecordStepKeys` and the types `RecordTopBarProps` and `RecordTopBarStepper`, `NavigationOverlay` gains an optional `toggles` prop and its `stepper` an optional `position` and `scope`; `@object-ui/i18n` gains `detail.recordOfScope`, `detail.relatedFilter`, `detail.relatedChips`, `detail.relatedChipAll`, `detail.relatedChipMine`, `detail.relatedChipOverdue`, `detail.relatedExport` and `detail.relatedRows` in all ten locales; no export retires and no accept set moves) +Metadata-protocol: none + +The record page v4 draws one 40 px top bar above the record header, on every face: the full page, the split pane, the overlay drawer, the maximized pane and a phone. The bar holds the frame controls. The record header's action row keeps the business actions only. + +- **Left: the breadcrumb.** At the root it reads 「对象 › 记录名」 (for example 「Leads › Mira Costa」). The object returns to the list: in a pane it closes the pane, and on a page it goes to the list the page was opened from, or to the object's list. One level down in a pane, the bar keeps 「← 路径」 as before. A page opened under a `?from=` trail shows ← and the same path, and each ancestor links to its page. +- **Right, in a fixed order.** First the counter 「3 / 21」 and ↓ ↑. Its tooltip names the list's view. Then ⊟, then ⤢ / ⤡, then ✕. Each control is drawn only where it applies: + - In a pane, the counter and ↓ ↑ show whenever the pane has a list context. ✕ shows on the pane faces only, and it closes the whole path. + - On a page, the counter and ↓ ↑ show only for a page a list opened: the row's ↗, or a row click or record link on a list whose rows open as pages. ↓ ↑ and `j` / `k` step through that list's rows: a step replaces the page with the sibling's page. The arrow keys still scroll the page. + - ⤡ 「收为抽屉」 (Collapse to drawer) shows only for a page the row's ↗ opened. It returns to the list with the record you stepped to open in the pane. + - A page opened directly shows only ⊟. + - While a pane is open over a page, the pane owns `j` / `k`. +- **⊟ moves into the bar.** The right-column switch (「右栏」, ⌘⇧,) leaves the record header's action row. It is drawn in the top bar only where the face's tier has a right column. In a pane, the record puts it into the pane's own bar. A host's plain overlay without the console's pane keeps it at the end of the header's action row. +- **The crumb row goes.** The row above the record header that held 「‹ origin」 and 「Collapse to drawer」 is removed. Both now live in the top bar. +- **The enlarged related list draws a toolbar.** When a related list is the active tab, it draws a toolbar over the table instead of the related list's card header and foot. The toolbar holds a filter input, the chips 全部 N / 我的 / 逾期, the child object's toolbar actions, 新建 and 导出, and a 「N 条 · 第 x / y 页」 line sits above the table. 我的 is drawn where the child object has an `owner_id` column. 逾期 is drawn where the child object declares a deadline (`dueLike: true` on a `date` or `datetime` field) with no `settledWhen`, because no query can ask which deadlines are settled. 导出 downloads a CSV through the server with the same parent, filter, chip, filter text and order the table shows. +- **动态 count on a deep link.** A link that opens the record on another tab, or a page with the right column switched off, still shows the 动态 tab's count. The count comes from the feed the page already holds; it starts no read of its own. diff --git a/ui/packages/app-shell/src/urlParams.ts b/ui/packages/app-shell/src/urlParams.ts index d56957d4134..384287160bc 100644 --- a/ui/packages/app-shell/src/urlParams.ts +++ b/ui/packages/app-shell/src/urlParams.ts @@ -104,6 +104,13 @@ export const RECORD_DETAIL_TAB_PARAM = 'tab'; */ export const RECORD_RELATED_ENLARGE_PARAM = 'related'; +/** + * History-state flag beside {@link RECORD_RELATED_ENLARGE_PARAM}: the record + * view pushed the `?related=` entry, so closing the tab pops it. Not a URL + * parameter; a navigation that replaces the entry drops it. + */ +export const RECORD_RELATED_ENLARGE_PUSHED_STATE = '__relatedEnlargePushed'; + /** Command palette overlay (ADR-0054 Phase 1; alias `cmdk`). */ export const COMMAND_PALETTE_PARAM = 'palette'; diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 9f5711f8e0b..6eaa90a53ca 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -745,7 +745,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // an object that declares `page` (or `new_window` / `none`) keeps its // declaration. objectstack#27545 — the pane beside the list is the default. const [recordOpenPreference, setRecordOpenPreference] = useRecordOpenPreference(); - const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleOpenAsPage, handleOpenRowAsPage, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep } = useObjectViewRecordNavigation({ + const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleOpenAsPage, handleOpenRowAsPage, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep, listLabel } = useObjectViewRecordNavigation({ activeView, objectDef, objects, @@ -1166,6 +1166,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co drawerRoot={drawerRoot} drawerRestore={drawerRestore} handleRootStep={handleRootStep} + stepScope={listLabel} dataSource={dataSource} objects={objects} onEdit={onEdit} diff --git a/ui/packages/app-shell/src/views/RecordDetailView.pageHeaderTitleFls-10499.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.pageHeaderTitleFls-10499.test.tsx index b44450a0cd1..23ed37df10d 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.pageHeaderTitleFls-10499.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.pageHeaderTitleFls-10499.test.tsx @@ -204,11 +204,16 @@ describe('RecordDetailView — `page:header` builds the H1 from the fields the v // `titleFormat: '{email}'` it hid `name` while the H1 printed the denied // email, and the readable name was on screen nowhere. The H1 now reads the // same row: it shows the name, and the hidden `name` row is its duplicate, - // as on a backend that strips `email`. The name is on the page once, in - // the H1; it does not come back as a body row. + // as on a backend that strips `email`. The name is in the record once, in + // the H1; it does not come back as a body row. The top bar's crumb + // 「对象 › 记录名」 (objectstack#27524) names the record with the same + // gated title, never the denied email. await renderRecordPage({ titleFormat: '{email}' }, denying('email'), 'Ada Lovelace'); + const bar = document.querySelector('[data-record-top-bar]'); + expect(bar?.textContent).toContain('Ada Lovelace'); + expect(bar?.textContent).not.toContain(EMAIL); const text = document.body.textContent ?? ''; - expect(text.split('Ada Lovelace').length - 1).toBe(1); + expect(text.split('Ada Lovelace').length - 1 - ((bar?.textContent ?? '').split('Ada Lovelace').length - 1)).toBe(1); expect(text).toContain('555-0100'); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx index b420b7418fa..29695ae072b 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx @@ -23,9 +23,11 @@ * for an event that carries no `code`, by the character it typed * (objectstack#27241), and does nothing while a text field has focus; * `⌘⇧.` (the timeline column's) is no longer the page's; - * - ⊟ is ONE icon, named by its label, at the end of the record header's - * action row (objectstack#27241) — not on the crumb row, which keeps only - * the ways back and has no child without one; + * - ⊟ is ONE icon, named by its label, in the record's top bar + * (objectstack#27524, Target 7: after the step group, before ⤢ / ⤡) — not + * in the header's action row, which keeps the business actions, and the + * crumb row is gone; a record drawn with no top bar of its own (a host's + * plain overlay) keeps it at the end of that row (objectstack#27241); * - it is drawn only where the tier has a right column: both two-column * tiers, the embedded record at those widths too, never the one column; * - the chord is the topmost record view's: a drawer wide enough for its @@ -218,16 +220,19 @@ describe('RecordDetailView — the right-column switch ⊟ (objectstack#27052, o it('one ⊟ icon at the end of the header\'s action row, on; off draws the details alone; on restores', async () => { const { container } = renderPage(); const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); - // objectstack#27241 — the end of the header's action row, after ⋯ and a - // hairline; the crumb row keeps only the ways back (none on a direct open). - expect(group.closest('[data-record-crumb-row]')).toBeNull(); - const toolbar = group.closest('[role="toolbar"]')!; - expect(toolbar).not.toBeNull(); - expect(toolbar.closest('[data-record-column]')).toBeNull(); - const trailing = group.closest('[data-page-header-trailing]')!; - expect(trailing.querySelector('[role="separator"][aria-orientation="vertical"]')).not.toBeNull(); - expect(toolbar.lastElementChild).toBe(trailing); - expect(container.querySelector('[data-record-crumb-row]')?.childElementCount ?? 0).toBe(0); + // objectstack#27524, Target 7 — in the page's top bar, among its frame + // controls: no list context on a direct open, so no step group before it. + const bar = group.closest('[data-record-top-bar]')!; + expect(bar).not.toBeNull(); + expect(group.closest('[data-record-top-bar-controls]')).not.toBeNull(); + expect(bar.querySelector('[data-record-top-bar-step]')).toBeNull(); + // Not in the header's action row any more, and the crumb row is gone. + expect(group.closest('[role="toolbar"]')).toBeNull(); + expect(container.querySelector('[data-page-header-trailing]')).toBeNull(); + expect(container.querySelector('[data-record-crumb-row]')).toBeNull(); + // The bar sits above the record header. + const header = container.querySelector('[role="toolbar"]')!; + expect(bar.compareDocumentPosition(header) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); // ONE switch, an icon named by its label: the segmented text control and // the timeline segment are gone. expect(group.querySelectorAll('button')).toHaveLength(1); @@ -315,7 +320,7 @@ describe('RecordDetailView — the right-column switch ⊟ (objectstack#27052, o const { container } = renderPage({ width: 900 }); const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); expect(container.querySelector('[data-record-page-tier]')?.getAttribute('data-record-page-tier')).toBe('two-column'); - expect(group.closest('[role="toolbar"]')).not.toBeNull(); + expect(group.closest('[data-record-top-bar]')).not.toBeNull(); expect(shown(container)).toBe('details right'); fireEvent.click(rightColumnSwitch()); await waitFor(() => expect(shown(container)).toBe('details')); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.topBar-27524.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.topBar-27524.test.tsx new file mode 100644 index 00000000000..56fa328ef03 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.topBar-27524.test.tsx @@ -0,0 +1,312 @@ +/** + * 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#27524, Target 7 — the record's top bar on each face, from a real + * `RecordDetailView` mount (the `panelToggles-27052` harness). + * + * - the page opened directly: the breadcrumb 「对象 › 记录名」 (the object goes + * to the object's list), ⊟ where the tier has a right column, nothing else + * — no counter, no ↓ ↑, no ⤡; the crumb row is gone; + * - the page the list row's ↗ opened: the counter (its tooltip naming the + * list's view), ↓ ↑ and `j` / `k` over the list's rows — a step replaces the + * page with the sibling's — ⤡ 「Collapse to drawer」 back to the list with + * the record stepped to open, and the object crumb back to the list; + * - the page under a `?from=` trail: ← and the path, each ancestor a link; + * - the record in a pane: the pane's bar names it at the root, and its ⊟ sits + * in that bar, not in the header's action row. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; +import { RelatedCountStore } from '@object-ui/components'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { RecordDetailView } from './RecordDetailView'; +import { RecordStackDrawer, recordDrawerReturnState, useRecordDrawerStack } from './recordDrawerStack'; + +const APP = 'demo'; +const LEAD = 'crm_lead'; +const BASE_FIELDS = { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }; +const OBJECTS = [ + { name: LEAD, label: 'Lead', pluralLabel: 'Leads', fields: BASE_FIELDS }, + { name: 'crm_task', label: 'Task', pluralLabel: 'Tasks', fields: { ...BASE_FIELDS, lead: { type: 'lookup', reference: LEAD, label: 'Lead' } } }, +]; +const ROWS = ['r-1', 'r-2', 'r-3']; + +let rootWidth = 1440; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0 })), + create: vi.fn(async (_o: string, row: unknown) => row), + findOne: vi.fn(async (_o: string, recordId: string) => ({ id: recordId, name: `Record ${recordId}` })), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + }; +} + +const metadata = { + objects: OBJECTS, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +} as never; + +function Where() { + const location = useLocation(); + return ( + <> + {`${location.pathname}${location.search}`} + {JSON.stringify(location.state ?? null)} + + ); +} + +function mountPage(entry: string | { pathname: string; search?: string; state?: unknown }) { + const dataSource = makeDataSource(); + render( + + + + {}} />} + /> + } /> + + + + , + ); + return dataSource; +} + +const where = () => screen.getByTestId('where').textContent; +const pageBar = () => document.querySelector('[data-record-page-root] > [data-record-top-bar]') as HTMLElement | null; + +beforeEach(() => { + cleanup(); + rootWidth = 1440; + window.localStorage.clear(); + RelatedCountStore._reset(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } })), + ); + const measure = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (!this.hasAttribute('data-record-page-root')) return measure.call(this); + const width = rootWidth; + return { x: 0, y: 0, top: 0, left: 0, right: width, bottom: 800, width, height: 800, toJSON: () => ({}) } as DOMRect; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('the page\'s top bar (objectstack#27524, Target 7)', () => { + it('opened directly: 「对象 › 记录名」 and ⊟, nothing else; the crumb row is gone', async () => { + mountPage(`/apps/${APP}/${LEAD}/record/r-1`); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + const bar = pageBar()!; + expect(bar).not.toBeNull(); + await waitFor(() => expect(screen.getByTestId('record-page-crumb').textContent).toBe('LeadsRecord r-1')); + const object = bar.querySelector('[data-record-crumb-object]') as HTMLAnchorElement; + expect(object.getAttribute('href')).toBe(`/apps/${APP}/${LEAD}`); + expect(bar.querySelector('[data-record-top-bar-step]')).toBeNull(); + expect(bar.querySelector('[data-record-top-bar-position]')).toBeNull(); + expect(screen.queryByTestId('record-drawer-return')).toBeNull(); + expect(bar.querySelector('[role="group"][aria-label="Panels"]')).not.toBeNull(); + expect(document.querySelector('[data-record-crumb-row]')).toBeNull(); + }); + + it('opened from the list row\'s ↗: the counter, ↓ ↑ and j / k over the list\'s rows, ⤡ and the object crumb back to the list', async () => { + const back = recordDrawerReturnState( + { pathname: `/apps/${APP}/${LEAD}`, search: '?recordId=r-2', state: null }, + [{ objectName: LEAD, recordId: 'r-2', siblings: ROWS }], + ); + mountPage({ + pathname: `/apps/${APP}/${LEAD}/record/r-2`, + state: { ...back, from: { pathname: `/apps/${APP}/${LEAD}`, label: 'All leads' } }, + }); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + const position = () => pageBar()!.querySelector('[data-record-top-bar-position]'); + expect(position()?.textContent).toBe('2 / 3'); + expect(position()?.getAttribute('title')).toBe('2 of 3 in All leads'); + // ⊟ after the step group, ⤡ at the end. + const controls = Array.from(pageBar()!.querySelector('[data-record-top-bar-controls]')!.children); + expect(controls.at(-1)?.getAttribute('data-testid')).toBe('record-drawer-return'); + + fireEvent.click(screen.getByRole('button', { name: 'Next record' })); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-3`)); + await waitFor(() => expect(position()?.textContent).toBe('3 / 3')); + expect((screen.getByRole('button', { name: 'Next record' }) as HTMLButtonElement).disabled).toBe(true); + // `k` steps back; the arrow keys are the page's scroll, not the bar's. + fireEvent.keyDown(document.body, { key: 'ArrowUp' }); + expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-3`); + fireEvent.keyDown(document.body, { key: 'k' }); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-2`)); + + // ⤡ returns to the list with the record stepped to open in its pane. + fireEvent.click(screen.getByTestId('record-drawer-return')); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?recordId=r-2`)); + expect(screen.getByTestId('where-state').textContent).toContain('"recordDrawerRestore":[{"objectName":"crm_lead","recordId":"r-2"'); + }); + + it('opened by a list whose rows open as pages: the counter and ↓ ↑ over its rows, and no ⤡', async () => { + const from = { pathname: `/apps/${APP}/${LEAD}`, label: 'All leads', siblings: ROWS }; + mountPage({ pathname: `/apps/${APP}/${LEAD}/record/r-1`, state: { from } }); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + const position = () => pageBar()!.querySelector('[data-record-top-bar-position]'); + expect(position()?.textContent).toBe('1 / 3'); + expect(position()?.getAttribute('title')).toBe('1 of 3 in All leads'); + // That list opens no pane: there is nothing to collapse to. + expect(screen.queryByTestId('record-drawer-return')).toBeNull(); + // The object crumb is the list it came from. + expect(pageBar()!.querySelector('[data-record-crumb-object]')?.getAttribute('href')).toBe(`/apps/${APP}/${LEAD}`); + + fireEvent.keyDown(document.body, { key: 'j' }); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-2`)); + await waitFor(() => expect(position()?.textContent).toBe('2 / 3')); + // The next page keeps the same list. + expect(JSON.parse(screen.getByTestId('where-state').textContent!)).toEqual({ from }); + fireEvent.click(screen.getByRole('button', { name: 'Previous record' })); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}/record/r-1`)); + }); + + it('control: a list\'s origin without its rows draws no counter', async () => { + mountPage({ pathname: `/apps/${APP}/${LEAD}/record/r-1`, state: { from: { pathname: `/apps/${APP}/${LEAD}`, label: 'All leads' } } }); + await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + expect(pageBar()!.querySelector('[data-record-top-bar-position]')).toBeNull(); + expect(pageBar()!.querySelector('[data-record-top-bar-step]')).toBeNull(); + }); + + it('the object crumb of a ↗ page returns to the list with no record open', async () => { + const back = recordDrawerReturnState( + { pathname: `/apps/${APP}/${LEAD}`, search: '?view=all&recordId=r-1', state: null }, + [{ objectName: LEAD, recordId: 'r-1', siblings: ROWS }], + ); + mountPage({ pathname: `/apps/${APP}/${LEAD}/record/r-1`, state: { ...back, from: { pathname: `/apps/${APP}/${LEAD}?view=all`, label: 'All leads' } } }); + const object = await waitFor(() => { + const el = pageBar()?.querySelector('[data-record-crumb-object]'); + expect(el).toBeTruthy(); + return el as HTMLElement; + }); + expect(object.textContent).toBe('Leads'); + fireEvent.click(object); + await waitFor(() => expect(where()).toBe(`/apps/${APP}/${LEAD}?view=all`)); + }); + + it('under a ?from= trail: ← and the path, each ancestor a link to its page', async () => { + const trail = encodeURIComponent(JSON.stringify([{ o: LEAD, i: 'L-1', t: 'Mira Costa' }])); + mountPage(`/apps/${APP}/crm_task/record/t-1?from=${trail}`); + const path = await screen.findByTestId('record-page-trail', undefined, { timeout: 5000 }); + expect(path.textContent).toBe('Lead Mira CostaTask'); + const ancestor = path.querySelector('a')!; + expect(ancestor.getAttribute('href')).toBe(`/apps/${APP}/${LEAD}/record/L-1`); + const backLink = pageBar()!.querySelector('[data-record-crumb-back]')!; + expect(backLink.getAttribute('aria-label')).toBe('Go back'); + expect(screen.queryByTestId('record-page-crumb')).toBeNull(); + }); +}); + +/** The list host's pane, drawing the record embedded (`objectViewRecordOverlay.tsx`'s shape). */ +function PaneHost({ dataSource }: { dataSource: unknown }) { + const [recordId, setRecordId] = React.useState('r-1'); + const root = React.useMemo(() => ({ objectName: LEAD, recordId, siblings: ROWS }), [recordId]); + const stack = useRecordDrawerStack({ root, onRootStep: setRecordId }); + return ( + } + objects={OBJECTS} + stepScope="All leads" + onClose={() => {}} + renderRecord={(level) => ( + {}} + objectNameOverride={level.objectName} + recordIdOverride={level.recordId} + embedded + /> + )} + /> + ); +} + +describe('the pane\'s top bar holds the record\'s ⊟ and names it (objectstack#27524)', () => { + it('the record reports its title to the breadcrumb, and its ⊟ sits in the pane\'s bar, not the header', async () => { + const dataSource = makeDataSource(); + render( + + + + + , + ); + await waitFor(() => expect(screen.getByTestId('record-drawer-crumb').textContent).toBe('LeadsRecord r-1'), { timeout: 5000 }); + const group = await screen.findByRole('group', { name: 'Panels' }, { timeout: 5000 }); + expect(group.closest('[data-record-pane-toggles]')).not.toBeNull(); + expect(group.closest('[data-record-top-bar]')).not.toBeNull(); + expect(group.closest('[data-record-page-root]')).toBeNull(); + expect(document.querySelector('[data-page-header-trailing]')).toBeNull(); + // The pane draws its own bar; the record inside draws none. + expect(document.querySelectorAll('[data-record-top-bar]')).toHaveLength(1); + expect(document.querySelector('[data-record-top-bar-position]')?.getAttribute('title')).toBe('1 of 3 in All leads'); + + // A step names the next record once it reports its title. + fireEvent.click(screen.getByRole('button', { name: 'Next record' })); + await waitFor(() => expect(screen.getByTestId('record-drawer-crumb').textContent).toBe('LeadsRecord r-2'), { timeout: 5000 }); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index f3da4893b86..1ed44015143 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -11,7 +11,7 @@ import { useState, useEffect, useLayoutEffect, useCallback, useContext, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; import { activityRowToFeedItem, auditEntriesToFeedItems, bodyTableIdsOf, claimQueuedRecord, RecordFieldChangesContext, DefaultRecordPageContext, EMPTY_RELATED_ARRANGEMENT, InlineEditSaveBar, RecordBodyTablesContext, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageTierHasRightColumn, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; -import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PageHeaderTrailingContext, PanelToggles, type PanelToggleItem } from '@object-ui/components'; +import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PageHeaderTrailingContext, PanelToggles, RecordTopBar, cn, useRecordStepKeys, type PanelToggleItem, type RecordTopBarStepper } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { useDisplayLocale, useLocalization, pickLocalized } from '@object-ui/i18n'; @@ -19,7 +19,7 @@ import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLoca import { buildExpandFields, captureUpdateUndoData, recordDelete, resolveAffordance, resolveRecordIdParamSeed, userActionPredicates, withoutDeniedFields, type AffordanceGrantPrincipal, type SchemaLike } from '@object-ui/core'; import { toast } from 'sonner'; import { useRecordPresence, PresenceAvatars, isCommentAuthor } from '@object-ui/collaboration'; -import { Database, ChevronLeft, Lock, AlertTriangle, RotateCw, Printer, FileText, CheckCircle2, Inbox, PanelRight } from 'lucide-react'; +import { Database, ArrowLeft, ChevronRight, Lock, AlertTriangle, RotateCw, Printer, FileText, CheckCircle2, Inbox, PanelRight } from 'lucide-react'; import { MetadataPanel, useMetadataInspector } from './MetadataInspector.js'; import { SkeletonDetail } from '../skeletons/index.js'; import { ManagedByBadge, useManagedByNote } from '../components/ManagedByBadge.js'; @@ -33,13 +33,10 @@ import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog import { FlowRunner, type ScreenFlowState, type ScreenSpec } from './FlowRunner.js'; import { FlowRefusalNotice, type FlowRefusalState } from './FlowRefusalNotice.js'; import { RelatedRecordActionsBridge } from './RelatedRecordActionsBridge.js'; -import { PrefetchUpcomingRelatedCounts, RecordDrawerReturnLink, RecordDrawerUpcomingContext, RecordPageDrawerHost, useRecordPane } from './recordDrawerStack.js'; +import { PrefetchUpcomingRelatedCounts, RecordDrawerReturnLink, RecordDrawerUpcomingContext, RecordPageDrawerHost, RecordPaneBarToggles, useRecordPane, useRecordPaneBar, useRecordPageReturn } from './recordDrawerStack.js'; import { recordPageRelatedLists, recordPageRequestOf, seedRecordPageReaders } from './recordPagePayload.js'; import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync.js'; -import { RECORD_DETAIL_TAB_PARAM, RECORD_RELATED_ENLARGE_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js'; - -/** History-state flag: this view pushed the `?related=` entry, so closing pops it. */ -const RELATED_ENLARGE_PUSHED = '__relatedEnlargePushed'; +import { RECORD_DETAIL_TAB_PARAM, RECORD_DRAWER_PARAM, RECORD_RELATED_ENLARGE_PARAM, RECORD_RELATED_ENLARGE_PUSHED_STATE as RELATED_ENLARGE_PUSHED, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js'; import { resolveActionParams } from '@object-ui/fields'; import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js'; import { withNewTabUrlFastPath } from '../utils/newTabUrlFastPath.js'; @@ -751,12 +748,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri sp.delete(RECORD_RELATED_ENLARGE_PARAM); setTabSearchParams(sp, { replace: true, state: location.state }); }, [setTabSearchParams, location.state, navigate]); - // Inline "← back to parent" link shown above the record body. Prefers an - // explicit history-state `from` (legacy, in-session only), then falls back - // to the last ancestor in the `?from=` URL trail — which, unlike history - // state, survives refresh and shared links. This is the immediate-parent - // affordance, and since the top bar draws no record crumb (objectui#12461) - // the only one: each parent's own link carries the trail above it. + // The way back to where the page was opened from, which the page's top bar + // draws (objectstack#27524, Target 7). Prefers an explicit history-state + // `from` (legacy, in-session only), then falls back to the last ancestor in + // the `?from=` URL trail — which, unlike history state, survives refresh and + // shared links. Since the app's top bar draws no record crumb + // (objectui#12461) this is the only one: each parent's own link carries the + // trail above it. const originFromState = (location.state as any)?.from as { pathname?: string; label?: string } | undefined; const originFrom = useMemo<{ pathname?: string; label?: string } | undefined>(() => { if (originFromState?.pathname && originFromState?.label) return originFromState; @@ -770,6 +768,20 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri label: parent.t || `#${shortId}`, }; }, [originFromState, location.search, appName]); + // objectstack#27524, Target 7 — a page a list opened knows that list: its + // top bar steps the list's rows with the counter, ↓ ↑ and `j` / `k` (the + // arrow keys scroll the page). A page the row's ↗ opened also offers ⤡ + // 「Collapse to drawer」, and its object crumb returns to the list. The + // counter names the list's view (the origin's label). A record drawn in a + // pane has the pane's bar. + const pageReturn = useRecordPageReturn(embedded ? undefined : recordId); + const pageStepper = useMemo( + () => (pageReturn.stepper && originFromState?.label + ? { ...pageReturn.stepper, scope: originFromState.label } + : pageReturn.stepper), + [pageReturn.stepper, originFromState?.label], + ); + useRecordStepKeys(!embedded && !!pageStepper, pageStepper, { arrows: false }); // `i18n` with `language`: what a flow launch translates the flow's own // toasts in (objectui#12075). const { t, language, i18n } = useObjectTranslation(); @@ -819,6 +831,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // can display "Acme Platform Upgrade" instead of "#9U1_MmmxjiGR…". Skip // when embedded (drawer/split): the parent list route owns the breadcrumb. useRecordBreadcrumbTitle(embedded ? undefined : recordTitle); + // In a pane, the record's title is what its top bar's breadcrumb names + // (objectstack#27524, Target 7); the pane's slot for the record's ⊟ is there + // too (`RecordPaneBarToggles` below). + const paneBar = useRecordPaneBar(); + useEffect(() => { + if (embedded && recordId) paneBar?.reportTitle(recordId, recordTitle); + }, [embedded, paneBar, recordId, recordTitle]); // Use the URL recordId as-is — it contains the actual record id. // Navigation code passes `record.id || record._id` directly into the URL @@ -4176,11 +4195,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // no hook may run. const bodyTableIds = assignedPage || namedPageBlocked ? new Set() : bodyTableIdsOf(renderedPage); - // objectstack#27052 / objectstack#27524, Target 4 — the right-column switch: - // ONE ⊟ icon (「右栏」), drawn at the end of the record header's action row - // (`PageHeaderTrailingContext`; the top bar takes it in a later slice of - // #27524), only where the tier has a right column and the page has a panel - // for it — never in the stack. + // objectstack#27052 / objectstack#27524, Targets 4 and 7 — the right-column + // switch: ONE ⊟ icon (「右栏」), drawn in the record's top bar after its step + // group (the page's own bar below; a pane's bar through + // `RecordPaneBarToggles`), only where the tier has a right column and the + // page has a panel for it — never in the stack. const panelToggleItems: PanelToggleItem[] = []; if ( pageColumns @@ -4200,9 +4219,123 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri drawnPanelSwitch.current = panelToggleItems.length > 0; const panelToggles = panelToggleItems.length > 0 ? ( - + ) : null; + // objectstack#27524, Target 7 — the page's top bar: the frame controls in + // one 40 px row above the record header, which keeps the business actions. + // Left, the breadcrumb: under a `?from=` trail `←` and the path (each + // ancestor returns to its page), else 「对象 › 记录名」, the object returning + // to the list the page was opened from (or the object's list). Right, each + // only where it applies: the counter and ↓ ↑ (a list context), ⊟, ⤡. + const pageTrail = embedded ? [] : decodeRecordTrail(new URLSearchParams(location.search).get(RECORD_TRAIL_PARAM)); + const objectListPath = appName && objectName ? `/apps/${appName}/${objectName}` : undefined; + const originPathOnly = originFromState?.pathname?.split('?')[0]; + // An origin that is a list of this object's records (a view of it) is named + // by the object; any other origin (an inbox, a dashboard) by its own label. + const originIsObjectList = !!objectListPath && !!originPathOnly + && (originPathOnly === objectListPath || originPathOnly.startsWith(`${objectListPath}/`)); + const withoutPaneParam = (href: string) => { + const [path, query = ''] = href.split('?'); + const sp = new URLSearchParams(query); + sp.delete(RECORD_DRAWER_PARAM); + const qs = sp.toString(); + return qs ? `${path}?${qs}` : path; + }; + const crumbClass = + 'min-w-0 truncate rounded px-1 hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'; + const shortRecordId = (id: string) => `#${id.length > 12 ? `${id.slice(0, 8)}…` : id}`; + const objectDefOf = (name: string) => objects.find((o: any) => o?.name === name); + const pageLeading = embedded ? null : pageTrail.length > 0 ? ( + <> + {originFrom?.pathname && ( + + + + )} + + + ) : ( + + ); + const pageBar = embedded ? null : ( + : undefined} + /> + ); + // objectui#11958 (card ② of the objectstack#8346 ruling, M1, zero server) — // the record page's "print" action. The page this view renders for the // record is the page bound to it, and when that page declares `print` it is @@ -4233,10 +4366,19 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const pageTree = (
+ {pageBar} + {/* In a pane, the record's ⊟ goes into the pane's own top bar. */} + {embedded && paneBar && {panelToggles}} {/* On a print page the shared chrome joins the print chrome row below, - so the row's buttons are not drawn under it. */} + so the row's buttons are not drawn under it. On the page it sits + under the top bar, where it sat before the bar. */} {!pageDeclaresPrint && ( -
+
{sharedChrome}
)} @@ -4368,32 +4510,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri >
- {/* The crumb row: the ways back only — the origin link on the - left and, on a page a drawer's ⤢ opened, the way back into - that drawer on the right (objectstack#26679). The reader's - panel switches are not here: they end the record header's - action row (objectstack#27241). With neither way back the - row has no child, and `empty:hidden` takes it out of the - flow. None of it 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 types, and `page:header` is not one of them, so the record's @@ -4496,7 +4612,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri Nothing is drawn while the tier is unmeasured (one unpainted commit). */} {pageColumns && recordPageTier ? ( - + // A record drawn with no top bar of its own (a host's plain + // overlay, not the console's pane) keeps its ⊟ at the end of + // the header's action row. + { mountList(); expect(body()).toBe('crm_lead/L-1'); - expect(screen.getByTestId('record-drawer-leading').textContent).toBe(''); + // objectstack#27524, Target 7 — at the root the bar's left end is the + // breadcrumb 「对象 › 记录名」, not ← and a path. + expect(screen.getByTestId('record-drawer-crumb').textContent).toBe('Lead#L-1'); + expect(trail()).toBeNull(); fireEvent.click(button('open task row')); expect(body()).toBe('crm_task/T-1'); @@ -345,8 +348,9 @@ describe('on the record page, a related row opens a drawer over the page (target 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(''); + // The drawer's root is the task: its breadcrumb on the left + // (objectstack#27524), ↑ ↓ over the rows. + expect(screen.getByTestId('record-drawer-crumb').textContent).toBe('Task#T-1'); fireEvent.click(button('Next record')); expect(dialogBody()).toBe('crm_task/T-2'); fireEvent.click(screen.getByTitle('Close')); 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 index 46e6585e69b..8c9e8e27431 100644 --- 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 @@ -2,7 +2,9 @@ /** * objectstack#26679 — `↑` `↓` at the ROOT of the record drawer (ruling 3: the - * controls stay, no counter anywhere). + * controls stay). Since objectstack#27524 (Target 7) the top bar draws the + * counter 「n / N」 before them, and its left end at the root is the + * breadcrumb 「对象 › 记录名」. * * The root level steps through the sibling set its host hands it — on the * object list, the rows `ListView` reports through `onRowsChange` — exactly as @@ -65,8 +67,12 @@ describe('the drawer root steps through the host\'s rows (objectstack#26679)', ( 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(''); + // Still the root: the breadcrumb, no ← and no path (objectstack#27524) — + // the record names itself by its id until it reports a title. + expect(screen.getByTestId('record-drawer-crumb').textContent).toBe('Lead#L-1'); + expect(screen.queryByTestId('record-drawer-trail')).toBeNull(); + // objectstack#27524, Target 7 — the counter follows the step. + expect(screen.getByText('1 / 3')).toBeInTheDocument(); }); it('↑ at the first row and ↓ at the last are disabled, never wrapped', () => { diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.listRows-27524.test.tsx b/ui/packages/app-shell/src/views/objectViewRecordNavigation.listRows-27524.test.tsx new file mode 100644 index 00000000000..77651773000 --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.listRows-27524.test.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27524, Target 7 — a page a list opens counts and steps through + * the rows that list showed. The list hands them along: in the page's `from` + * state whenever the record is one of them (a row click on an object whose + * rows open as pages, a record link), and in the ↗'s way back to the pane as + * well (`recordDrawerReturnState`). A record the list does not show gets the + * origin alone, so its page draws no counter. + */ + +import * as React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { MemoryRouter, type Location } from 'react-router-dom'; + +import { useObjectViewRecordNavigation } from './objectViewRecordNavigation'; + +const ROWS = [{ id: 'r-1' }, { id: 'r-2' }, { id: 'r-3' }]; +const LIST = { pathname: '/apps/crm/crm_lead', search: '?view=all', state: null, hash: '', key: 'k' } as Location; + +function mount(navigation?: { mode: 'page' | 'drawer' }) { + const navigate = vi.fn(); + const { result } = renderHook(() => + useObjectViewRecordNavigation({ + activeView: { id: 'all', name: 'all', label: 'All leads' } as never, + objectDef: { name: 'crm_lead', label: 'Lead', fields: {}, ...(navigation ? { navigation } : {}) } as never, + objects: [{ name: 'crm_lead', label: 'Lead' }] as never, + appName: 'crm', + viewId: undefined, + searchParams: new URLSearchParams('view=all'), + setSearchParams: vi.fn(), + location: LIST, + navigate, + routerRootHref: '/', + servedViews: { views: [], objects: [] } as never, + viewLabel: (_object: string, _view: string, fallback: string) => fallback, + unlabelledViewText: 'Untitled view', + language: 'en', + recordOpenPreference: 'object' as never, + }), + { wrapper: ({ children }: { children: React.ReactNode }) => {children} }, + ); + act(() => result.current.handleListRowsChange(ROWS)); + return { result, navigate }; +} + +const lastState = (navigate: ReturnType) => navigate.mock.calls.at(-1)![1].state; + +describe('a page the list opens carries the list\'s rows (objectstack#27524, Target 7)', () => { + it('a row that opens as a page: the origin, its view\'s name and the rows shown', () => { + const { result, navigate } = mount({ mode: 'page' }); + act(() => result.current.handleRowClick({ id: 'r-2' })); + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate.mock.calls[0][0]).toBe('record/r-2'); + expect(lastState(navigate)).toEqual({ + from: { pathname: '/apps/crm/crm_lead?view=all', label: 'All leads', siblings: ['r-1', 'r-2', 'r-3'] }, + }); + }); + + it('control: a record the list does not show gets the origin alone', () => { + const { result, navigate } = mount({ mode: 'page' }); + act(() => result.current.handleRowClick({ id: 'r-9' })); + expect(lastState(navigate)).toEqual({ from: { pathname: '/apps/crm/crm_lead?view=all', label: 'All leads' } }); + }); + + it('the row\'s ↗: the rows go in the way back to the pane, and in the origin', () => { + const { result, navigate } = mount(); + act(() => result.current.handleOpenRowAsPage('r-3')); + const state = lastState(navigate); + expect(state.from.siblings).toEqual(['r-1', 'r-2', 'r-3']); + expect(state.recordDrawerReturn.path).toEqual([{ objectName: 'crm_lead', recordId: 'r-3', siblings: ['r-1', 'r-2', 'r-3'] }]); + expect(state.recordDrawerReturn.search).toBe('?view=all&recordId=r-3'); + }); +}); diff --git a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts index df8f49bb16e..d16d38be82b 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts +++ b/ui/packages/app-shell/src/views/objectViewRecordNavigation.ts @@ -127,6 +127,16 @@ export function useObjectViewRecordNavigation({ // (objectstack#27545) — and the location changes with it. const locationRef = useRef(location); locationRef.current = location; + // The rows the list shows (`handleListRowsChange` below), read when a + // handler runs: a page this list opens steps through them + // (objectstack#27524, Target 7). + const listRowIdsRef = useRef([]); + // The list's name — its view's tab label: what the page's way back says, + // and the set the pane's counter names (objectstack#27524, Target 7). + const listLabel = useMemo( + () => viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), + [activeView?.id, activeView?.name, activeView?.label, objectDef.name, servedViews, viewLabel, unlabelledViewText, language], + ); // Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick const handleNavOverlayNavigate = useCallback( (recordId: string | number, action?: string, extraState?: Record) => { @@ -152,11 +162,16 @@ export function useObjectViewRecordNavigation({ // already treated the name and the token identically, which is // exactly what made the substitution inaudible on this surface. const { pathname, search } = locationRef.current; + // The rows the list shows go along when the record is one of + // them: the page's top bar counts and steps through them + // (objectstack#27524, Target 7). + const siblings = listRowIdsRef.current; const originState = { ...extraState, from: { pathname: pathname + (search || ''), - label: viewTabLabel({ id: activeView?.id, name: activeView?.name, label: activeView?.label }, objectDef.name, servedViews, viewLabel, unlabelledViewText, language), + label: listLabel, + ...(siblings.includes(String(recordId)) ? { siblings } : {}), }, }; if (viewId) { @@ -165,7 +180,7 @@ export function useObjectViewRecordNavigation({ navigate(`record/${encodeURIComponent(String(recordId))}`, { state: originState }); } }, - [navigate, viewId, objectDef, activeView?.id, activeView?.name, activeView?.label, viewLabel, unlabelledViewText, servedViews, language] + [navigate, viewId, listLabel] ); /** * The list surface's half of the record-link mechanism (objectui#4490): @@ -274,15 +289,22 @@ export function useObjectViewRecordNavigation({ * The row's `↗` (objectstack#26681): the record's page, handed the way back * to this list with that record open — the page's 「Collapse to drawer」 * (`recordDrawerReturnState`), drawn only for a reader who arrived this - * way (objectstack#27545). + * way (objectstack#27545) — and the rows the list shows, which that page's + * top bar steps through with its counter and ↓ ↑ (objectstack#27524, + * Target 7). Read at click time: the handler never changes with the rows. */ const handleOpenRowAsPage = useCallback((recordId: string | number) => { const { pathname, search, state } = locationRef.current; const back = new URLSearchParams(search); back.set(RECORD_DRAWER_PARAM, String(recordId)); + const siblings = listRowIdsRef.current; const returnState = recordDrawerReturnState( { pathname, search: `?${back.toString()}`, state }, - [{ objectName: objectDef.name, recordId: String(recordId) }], + [{ + objectName: objectDef.name, + recordId: String(recordId), + ...(siblings.includes(String(recordId)) ? { siblings } : {}), + }], ); handleNavOverlayNavigate(recordId, undefined, returnState); }, [objectDef.name, handleNavOverlayNavigate]); @@ -293,6 +315,7 @@ export function useObjectViewRecordNavigation({ * changes nothing. */ const [listRowIds, setListRowIds] = useState<{ key: string; ids: readonly string[] }>({ key: '', ids: [] }); + listRowIdsRef.current = listRowIds.ids; const handleListRowsChange = useCallback((rows: ReadonlyArray>) => { const ids = rows .map((row) => (row?.id ?? row?._id) as string | number | undefined) @@ -331,5 +354,5 @@ export function useObjectViewRecordNavigation({ navOverlay.close(); } }, [drawerRecordId]); - return { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleOpenAsPage, handleOpenRowAsPage, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep }; + return { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleOpenAsPage, handleOpenRowAsPage, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep, listLabel }; } diff --git a/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx b/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx index 5b467e5445e..4eab9366ced 100644 --- a/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx +++ b/ui/packages/app-shell/src/views/objectViewRecordOverlay.tsx @@ -13,8 +13,9 @@ * * Since objectstack#26679 the pane 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). + * related row or a lookup inside it opens one level down. Its top bar + * (objectstack#27524, Target 7) names the object and the record at the root, + * and its counter names the list's view (`stepScope`). */ import type { ReactNode } from 'react'; @@ -37,6 +38,7 @@ export function ObjectViewRecordOverlay({ drawerRoot, drawerRestore, handleRootStep, + stepScope, dataSource, objects, onEdit, @@ -55,6 +57,8 @@ export function ObjectViewRecordOverlay({ drawerRoot: RecordNavigation['drawerRoot']; drawerRestore: RecordNavigation['drawerRestore']; handleRootStep: RecordNavigation['handleRootStep']; + /** The list's name (its view), which the pane's counter says. */ + stepScope?: string; dataSource: ConsoleObjectViewProps['dataSource']; objects: ConsoleObjectViewProps['objects']; onEdit: ConsoleObjectViewProps['onEdit']; @@ -70,6 +74,7 @@ export function ObjectViewRecordOverlay({ mainContent={list} onClose={handleDrawerClose} onOpenAsPage={handleOpenAsPage} + stepScope={stepScope} storageKey={`drawer-width:${objectDef.name}`} objects={objects} renderRecord={(level) => ( diff --git a/ui/packages/app-shell/src/views/recordDrawerContext.ts b/ui/packages/app-shell/src/views/recordDrawerContext.ts index 5246561606d..f040123fa4a 100644 --- a/ui/packages/app-shell/src/views/recordDrawerContext.ts +++ b/ui/packages/app-shell/src/views/recordDrawerContext.ts @@ -55,6 +55,26 @@ export function useRecordPane(): RecordPaneApi | null { return useContext(RecordPaneContext); } +/** + * The pane's top bar, as the record drawn in it reaches it (objectstack#27524, + * Target 7): the bar's slot for the record's panel switches — the right + * column's ⊟, which only the record knows applies — and where the record says + * its title for the bar's breadcrumb 「对象 › 记录名」. `null` outside a pane. + */ +export interface RecordPaneBarApi { + /** The bar's element the record portals its panel switches into; `null` until it mounts. */ + toggleSlot: HTMLElement | null; + /** The title of the record `recordId` names, once its row has loaded. */ + reportTitle(recordId: string, title: string | undefined): void; +} + +export const RecordPaneBarContext = createContext(null); + +/** The top bar of the pane the record is drawn in, or `null` on its own page. */ +export function useRecordPaneBar(): RecordPaneBarApi | null { + return useContext(RecordPaneBarContext); +} + /** * 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 diff --git a/ui/packages/app-shell/src/views/recordDrawerStack.tsx b/ui/packages/app-shell/src/views/recordDrawerStack.tsx index 90f2843927c..d248ce03d8a 100644 --- a/ui/packages/app-shell/src/views/recordDrawerStack.tsx +++ b/ui/packages/app-shell/src/views/recordDrawerStack.tsx @@ -52,8 +52,15 @@ import { type ReactNode, } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import { createPortal } from 'react-dom'; import { ChevronRight, Minimize2 } from 'lucide-react'; -import { NavigationOverlay, type NavigationOverlayMode } from '@object-ui/components'; +import { + NavigationOverlay, + RecordTopBarButton, + cn, + type NavigationOverlayMode, + type RecordTopBarStepper, +} from '@object-ui/components'; import { overlayWidthFor } from '@object-ui/plugin-view'; import type { FieldContainerLike } from '@object-ui/core'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; @@ -61,15 +68,33 @@ import { prefetchRelatedCounts, relatedCountTargetsIn } from '@object-ui/plugin- import { useFilterScope } from '@object-ui/react'; import { RECORD_DETAIL_TAB_PARAM, + RECORD_DRAWER_PARAM, + RECORD_RELATED_ENLARGE_PARAM, + RECORD_RELATED_ENLARGE_PUSHED_STATE, RECORD_TRAIL_PARAM, appendRecordTrail, encodeRecordTrail, type RecordTrailEntry, } from '../urlParams.js'; import { appRecordPath, routableObjectNames } from './RelatedRecordActionsBridge.js'; -import { RecordDrawerContext, RecordPaneContext, type RecordDrawerApi, type RecordPaneApi } from './recordDrawerContext.js'; - -export { opensInRecordDrawer, useRecordDrawer, useRecordPane, type RecordDrawerApi, type RecordPaneApi } from './recordDrawerContext.js'; +import { + RecordDrawerContext, + RecordPaneBarContext, + RecordPaneContext, + type RecordDrawerApi, + type RecordPaneApi, + type RecordPaneBarApi, +} from './recordDrawerContext.js'; + +export { + opensInRecordDrawer, + useRecordDrawer, + useRecordPane, + useRecordPaneBar, + type RecordDrawerApi, + type RecordPaneApi, + type RecordPaneBarApi, +} from './recordDrawerContext.js'; /** One level of the stack: a record, and what the drawer learned about it. */ export interface RecordDrawerLevel { @@ -294,8 +319,8 @@ export function useRecordDrawerStack({ // in that direction is the one prefetched (`upcoming` below). const [stepDirection, setStepDirection] = useState<1 | -1>(1); - /** The current level's previous / next sibling, when it has a set. */ - const stepper = useMemo(() => { + /** The current level's previous / next sibling, when it has a set, and its place in it. */ + 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); @@ -316,6 +341,7 @@ export function useRecordDrawerStack({ return { onPrevious: index > 0 ? go(current.siblings[index - 1], -1) : undefined, onNext: index < current.siblings.length - 1 ? go(current.siblings[index + 1], 1) : undefined, + position: { index, total: current.siblings.length }, }; }, [current, depth, onRootStep]); @@ -345,10 +371,14 @@ export type RecordDrawerStack = ReturnType; /** * The pane itself: the shared `NavigationOverlay` — the drawer, or the pane - * beside `mainContent` (the list) on the split face — with the stack's bar: - * `←` and the path on the left, `↑` `↓` / `⤢` / `✕` on the right. It draws the - * current level through the host's `renderRecord`, inside the contexts that - * make every record link below it open one level down and the record header's + * beside `mainContent` (the list) on the split face — with the record's top + * bar (objectstack#27524, Target 7): at the root the breadcrumb 「对象 › 记录名」 + * (the object returns to the list: it closes the pane), one level down `←` + * and the path (objectstack#26679); on the right the counter and ↓ ↑, the + * record's ⊟ when its tier has a right column (portalled into the bar by the + * record, `RecordPaneBarContext`), ⤢ / ⤡ and ✕. It draws the current level + * through the host's `renderRecord`, inside the contexts that make every + * record link below it open one level down and the record header's * 「Open as full page」 open the level shown. */ export function RecordStackDrawer({ @@ -359,6 +389,7 @@ export function RecordStackDrawer({ storageKey, objects, mainContent, + stepScope, onClose, onOpenAsPage, renderRecord, @@ -371,43 +402,81 @@ export function RecordStackDrawer({ objects: ReadonlyArray; /** The list the pane opens from (`NavigationOverlay.mainContent`). */ mainContent?: ReactNode; + /** The root level's set by name — the list view it came from — for the counter's tooltip. */ + stepScope?: string; onClose: () => void; /** The record header's 「Open as full page」 — the path from the root to the record shown, inclusive. */ onOpenAsPage?: (path: RecordDrawerLevel[]) => void; renderRecord: (level: RecordDrawerLevel) => ReactNode; }) { - const { objectLabel } = useObjectLabel(); + const { objectLabel, objectPluralLabel } = useObjectLabel(); + const { t } = useObjectTranslation(); const { path, depth, current, push, popTo, stepper, upcoming } = stack; const api = useMemo(() => ({ open: push }), [push]); const pane = useMemo( () => (onOpenAsPage ? { openAsPage: () => onOpenAsPage(path) } : null), [onOpenAsPage, path], ); + // The bar's slot for the record's ⊟, and the titles the records drawn + // reported, by level key: the breadcrumb names the record, and an ancestor + // that was not opened with a title is still named by the one it reported. + const [toggleSlot, setToggleSlot] = useState(null); + const [titles, setTitles] = useState>>({}); + const currentObject = current?.objectName; + const bar = useMemo( + () => ({ + toggleSlot, + reportTitle: (recordId: string, title: string | undefined) => { + if (!currentObject || !title) return; + const key = `${currentObject}:${recordId}`; + setTitles((prev) => (prev[key] === title ? prev : { ...prev, [key]: title })); + }, + }), + [toggleSlot, currentObject], + ); + const defOf = (objectName: string) => + objects.find((o) => (o as { name?: unknown } | null)?.name === objectName); const labelOf = (objectName: string) => { - const def = objects.find((o) => (o as { name?: unknown } | null)?.name === objectName); + const def = defOf(objectName); return def ? objectLabel(def as never) : objectName; }; - const trail = - depth > 0 ? ( - - ) : undefined; + const pluralLabelOf = (objectName: string) => { + const def = defOf(objectName); + return def ? objectPluralLabel(def as never) : objectName; + }; + const shortId = (recordId: string) => `#${recordId.length > 12 ? `${recordId.slice(0, 8)}…` : recordId}`; + const titleOf = (level: RecordDrawerLevel) => titles[levelKey(level)!] || level.title || shortId(level.recordId); + const crumbClass = + 'min-w-0 truncate rounded px-1 hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'; + const trail = !current ? undefined : depth > 0 ? ( + + ) : ( + + ); + const scopedStepper = stepper && depth === 0 && stepScope ? { ...stepper, scope: stepScope } : stepper; return ( 0 ? () => popTo(depth - 1) : undefined} trail={trail} - stepper={stepper} + stepper={scopedStepper} + toggles={} > {() => ( - - {/* 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} - - + + + {/* 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} + + + )} @@ -445,6 +517,18 @@ export function RecordStackDrawer({ ); } +/** + * Portal the record's panel switches into the top bar of the pane it is drawn + * in (objectstack#27524, Target 7): the bar owns the place, the record owns + * whether a switch applies. Draws nothing until the bar's slot has mounted, + * and nothing outside a pane. + */ +export function RecordPaneBarToggles({ children }: { children: ReactNode }) { + const bar = useContext(RecordPaneBarContext); + if (!bar?.toggleSlot || children == null || children === false) return null; + return createPortal(children, bar.toggleSlot); +} + /** * The page the record header's 「Open as full page」 opens from a pane level: * the record's route, with the levels below it as the `?from=` trail its back @@ -513,33 +597,159 @@ export function useTakenRestore(): [RecordDrawerLevel[] | undefined, () => void] return [taken, clear]; } +/** `state` without `keys` — `null` when nothing else is left. */ +function withoutKeys(state: unknown, keys: readonly string[]): Record { + const rest = { ...((state && typeof state === 'object' ? state : {}) as Record) }; + for (const key of keys) delete rest[key]; + return rest; +} + +/** `pathname` with the record segment `/record/` naming `to` instead, or `null`. */ +function siblingRecordPath(pathname: string, from: string, to: string): string | null { + for (const id of [encodeURIComponent(from), from]) { + const marker = `/record/${id}`; + const at = pathname.lastIndexOf(marker); + const after = pathname.slice(at + marker.length); + if (at >= 0 && (after === '' || after.startsWith('/'))) { + return `${pathname.slice(0, at)}/record/${encodeURIComponent(to)}${after}`; + } + } + return null; +} + +/** What a page opened from a list knows about that list. */ +export interface RecordPageReturn { + /** + * The counter and ↓ ↑ over the list's rows (objectstack#27524, Target 7): + * a step replaces the page with the sibling's, the way back following it. + */ + stepper?: RecordTopBarStepper; + /** ⤡ 「Collapse to drawer」: the list, with the record shown open in its pane. */ + collapse?: () => void; + /** The object crumb: the list as it was left, with no record open. */ + toList?: () => void; +} + /** - * The record page's 「Collapse to drawer」 (objectstack#26679): drawn on a page - * the list row's ↗ opened (objectstack#27545), it returns to that list with the - * record open. + * The rows a list showed, handed in the page's `from` state by a list whose + * rows open as pages (objectstack#27524): its row click and its record links. */ -export function RecordDrawerReturnLink() { +function readFromSiblings(state: unknown): readonly string[] | undefined { + const from = (state as { from?: { siblings?: unknown } } | null | undefined)?.from; + const siblings = from?.siblings; + return Array.isArray(siblings) && siblings.every((id) => typeof id === 'string') + ? (siblings as string[]) + : undefined; +} + +/** + * The record page's list context (objectstack#27524, Target 7). A page the + * list row's ↗ opened carries the way back to that list in its history state + * (`recordDrawerReturnState`, objectstack#27545), and with it the rows the + * list showed: it gets the counter, ↓ ↑ and ⤡. A page a list opened as a page + * (a row click or a record link on an object whose rows open as pages) carries + * those rows in its `from` state: it gets the counter and ↓ ↑, and no ⤡, since + * that list opens no pane; so does the pane root's 「Open as full page」. + * Empty on a page opened any other way — a direct link, a link opened in a + * new tab, a level above the pane's root — which therefore draws no counter, + * no ↓ ↑ and no ⤡. + */ +export function useRecordPageReturn(recordId: string | undefined): RecordPageReturn { const location = useLocation(); const navigate = useNavigate(); + return useMemo(() => { + const back = readReturn(location.state); + if (!back) { + const siblings = readFromSiblings(location.state); + const index = recordId && siblings ? siblings.indexOf(recordId) : -1; + if (!recordId || !siblings || index < 0) return {}; + // The `from` state goes along unchanged: the next page has the same list. + const go = (nextId: string) => () => { + const to = siblingRecordPath(location.pathname, recordId, nextId); + if (to === null) return; + const pageSearch = new URLSearchParams(location.search); + pageSearch.delete(RECORD_RELATED_ENLARGE_PARAM); + const qs = pageSearch.toString(); + navigate( + { pathname: to, search: qs ? `?${qs}` : '' }, + { replace: true, state: withoutKeys(location.state, [RECORD_RELATED_ENLARGE_PUSHED_STATE]) }, + ); + }; + return { + stepper: { + onPrevious: index > 0 ? go(siblings[index - 1]) : undefined, + onNext: index < siblings.length - 1 ? go(siblings[index + 1]) : undefined, + position: { index, total: siblings.length }, + }, + }; + } + const top = back.path[back.path.length - 1]; + const collapse = () => { + const restoreState = { ...withoutKeys(back.state, []), [RESTORE_STATE_KEY]: back.path }; + navigate({ pathname: back.pathname, search: back.search }, { state: restoreState }); + }; + const toList = () => { + const sp = new URLSearchParams(back.search); + sp.delete(RECORD_DRAWER_PARAM); + const qs = sp.toString(); + navigate({ pathname: back.pathname, search: qs ? `?${qs}` : '' }, { state: back.state }); + }; + if (!recordId || top.recordId !== recordId) return { collapse, toList }; + const siblings = top.siblings ?? []; + const index = siblings.indexOf(recordId); + if (index < 0) return { collapse, toList }; + const go = (nextId: string) => () => { + const to = siblingRecordPath(location.pathname, recordId, nextId); + if (to === null) return; + const path = [...back.path.slice(0, -1), { ...top, recordId: nextId }]; + // At the list's own level the way back opens the record stepped to. + let search = back.search; + if (back.path.length === 1) { + const sp = new URLSearchParams(back.search); + sp.set(RECORD_DRAWER_PARAM, nextId); + search = `?${sp.toString()}`; + } + // The sibling opens on the same `?tab=`; an open related tab is the + // record's own, and the history entry it pushed is the previous page's. + const pageSearch = new URLSearchParams(location.search); + pageSearch.delete(RECORD_RELATED_ENLARGE_PARAM); + const qs = pageSearch.toString(); + const state = { + ...withoutKeys(location.state, [RECORD_RELATED_ENLARGE_PUSHED_STATE]), + [RETURN_STATE_KEY]: { ...back, search, path } satisfies RecordDrawerReturn, + }; + navigate({ pathname: to, search: qs ? `?${qs}` : '' }, { replace: true, state }); + }; + return { + collapse, + toList, + stepper: { + onPrevious: index > 0 ? go(siblings[index - 1]) : undefined, + onNext: index < siblings.length - 1 ? go(siblings[index + 1]) : undefined, + position: { index, total: siblings.length }, + }, + }; + }, [location.state, location.pathname, location.search, recordId, navigate]); +} + +/** + * The record page's 「Collapse to drawer」 (objectstack#26679), the ⤡ of its + * top bar (objectstack#27524, Target 7): drawn on a page the list row's ↗ + * opened (objectstack#27545), it returns to that list with the record open. + */ +export function RecordDrawerReturnLink({ collapse }: { collapse?: () => void } = {}) { + const own = useRecordPageReturn(undefined); 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, - }; + const onCollapse = collapse ?? own.collapse; + if (!onCollapse) return null; return ( - + ); } diff --git a/ui/packages/components/src/__tests__/record-top-bar-27524.test.tsx b/ui/packages/components/src/__tests__/record-top-bar-27524.test.tsx new file mode 100644 index 00000000000..c3c752c34fe --- /dev/null +++ b/ui/packages/components/src/__tests__/record-top-bar-27524.test.tsx @@ -0,0 +1,146 @@ +/** + * 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#27524, Target 7 — the record's top bar: ONE 40 px row of frame + * controls above the record header, on every face. Left, what the host hands + * (the breadcrumb, or 「← 路径」); right, in a fixed order and each only when + * handed: the counter 「n / N」 and ↓ ↑ (a list context, the tooltip naming + * the list), a hairline, the panel switches (⊟), the frame controls + * (⤢ / ⤡ / ✕). The step keys belong to the latest host that holds them: a + * pane opened over a page owns `j` / `k` until it closes. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { RecordTopBar, RecordTopBarButton, useRecordStepKeys, type RecordTopBarStepper } from '../custom/record-top-bar'; +import { NavigationOverlay } from '../custom/navigation-overlay'; + +afterEach(() => cleanup()); + +const order = () => + Array.from(document.querySelector('[data-record-top-bar-controls]')!.children).map( + (el) => + el.getAttribute('data-record-top-bar-step') + ?? (el.hasAttribute('data-record-top-bar-position') ? 'position' : null) + ?? (el.hasAttribute('data-record-top-bar-divider') ? 'divider' : null) + ?? el.getAttribute('data-testid'), + ); + +describe('RecordTopBar (objectstack#27524, Target 7)', () => { + it('right end in the fixed order: counter, ↓ ↑, hairline, ⊟, then the frame controls', () => { + const onNext = vi.fn(); + render( + Leads › Mira Costa} + stepper={{ onNext, position: { index: 2, total: 21 }, scope: 'All leads' }} + toggles={} + frame={✕} + />, + ); + const bar = document.querySelector('[data-record-top-bar]')!; + expect(bar.className).toContain('h-10'); + expect(screen.getByText('Leads › Mira Costa')).toBeInTheDocument(); + expect(order()).toEqual(['position', 'next', 'previous', 'divider', 'toggles', 'close']); + const position = document.querySelector('[data-record-top-bar-position]')!; + expect(position.textContent).toBe('3 / 21'); + // The tooltip names the list the counter counts. + expect(position.getAttribute('title')).toBe('3 of 21 in All leads'); + // At an end of the set its control is drawn disabled. + expect((screen.getByRole('button', { name: 'Previous record' }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'Next record' })); + expect(onNext).toHaveBeenCalledTimes(1); + }); + + it('draws only what it is handed: no list context, no counter, no ↓ ↑ and no hairline', () => { + render(} />); + expect(order()).toEqual(['toggles']); + expect(screen.queryByRole('button', { name: 'Next record' })).toBeNull(); + }); + + it('a set without a place in it draws the pair alone, no counter', () => { + render( {} }} />); + expect(order()).toEqual(['next', 'previous']); + }); +}); + +function KeyHost({ stepper, active = true, arrows }: { stepper?: RecordTopBarStepper; active?: boolean; arrows?: boolean }) { + useRecordStepKeys(active, stepper, { arrows }); + return null; +} + +describe('useRecordStepKeys — the latest host owns j / k (objectstack#27524)', () => { + it('a page steps with j / k but not the arrows; a pane opened over it takes the keys until it closes', () => { + const page = { onNext: vi.fn(), onPrevious: vi.fn() }; + const pane = { onNext: vi.fn(), onPrevious: vi.fn() }; + const { rerender } = render(); + fireEvent.keyDown(document.body, { key: 'j' }); + fireEvent.keyDown(document.body, { key: 'ArrowDown' }); + expect(page.onNext).toHaveBeenCalledTimes(1); + + rerender( + <> + + + , + ); + fireEvent.keyDown(document.body, { key: 'j' }); + fireEvent.keyDown(document.body, { key: 'ArrowUp' }); + expect(pane.onNext).toHaveBeenCalledTimes(1); + expect(pane.onPrevious).toHaveBeenCalledTimes(1); + expect(page.onNext).toHaveBeenCalledTimes(1); + + rerender(); + fireEvent.keyDown(document.body, { key: 'k' }); + expect(page.onPrevious).toHaveBeenCalledTimes(1); + }); + + it('a pane with no set still holds the keys from the page under it, and lets them through', () => { + const page = { onNext: vi.fn() }; + render( + <> + + + , + ); + const event = new KeyboardEvent('keydown', { key: 'j', bubbles: true, cancelable: true }); + document.body.dispatchEvent(event); + expect(page.onNext).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); +}); + +describe('NavigationOverlay draws the shared bar as the pane\'s (objectstack#27524)', () => { + it('the step group, then the host\'s panel switches, then ⤢ and ✕', () => { + render( + {}} + setIsOpen={() => {}} + maximizable + mainContent={
list
} + stepper={{ onNext: () => {}, position: { index: 0, total: 3 } }} + toggles={⊟} + trail={Leads › Mira Costa} + > + {() =>
record
} +
, + ); + const bar = document.querySelector('[data-record-top-bar]')!; + expect(bar).not.toBeNull(); + expect(screen.getByTestId('record-drawer-leading').textContent).toBe('Leads › Mira Costa'); + const controls = Array.from(bar.querySelector('[data-record-top-bar-controls]')!.querySelectorAll('button, [data-testid="pane-toggles"]')); + const names = controls.map((el) => el.getAttribute('aria-label') ?? el.getAttribute('data-testid')); + expect(names).toEqual(['Next record', 'Previous record', 'pane-toggles', 'Maximize', 'Close panel']); + }); +}); diff --git a/ui/packages/components/src/custom/index.ts b/ui/packages/components/src/custom/index.ts index aea80e045e3..8df2d1668b6 100644 --- a/ui/packages/components/src/custom/index.ts +++ b/ui/packages/components/src/custom/index.ts @@ -30,6 +30,7 @@ export { type RecordListFace, } from './record-list-face'; export * from './panel-toggles'; +export * from './record-top-bar'; export * from './resizable'; export * from './section-header'; export * from './spinner'; diff --git a/ui/packages/components/src/custom/navigation-overlay.tsx b/ui/packages/components/src/custom/navigation-overlay.tsx index e169600110a..4c6b7287d6d 100644 --- a/ui/packages/components/src/custom/navigation-overlay.tsx +++ b/ui/packages/components/src/custom/navigation-overlay.tsx @@ -65,7 +65,7 @@ */ import React from 'react'; -import { ArrowLeft, ArrowUpRight, ChevronDown, ChevronUp, Maximize2, Minimize2, X } from 'lucide-react'; +import { ArrowLeft, ArrowUpRight, Maximize2, Minimize2, X } from 'lucide-react'; import { cn } from '../lib/utils'; import { Sheet, @@ -83,6 +83,7 @@ import { recordListFaceFor, type RecordListFace, } from './record-list-face'; +import { RecordTopBar, RecordTopBarButton, useRecordStepKeys, type RecordTopBarStepper } from './record-top-bar'; /** * English defaults for every string this overlay renders as chrome @@ -265,77 +266,32 @@ export interface NavigationOverlayProps { */ onBack?: () => void; /** - * The path drawn after the `←` control (drawer and split, - * 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). + * The left end of the bar after the `←` control (drawer and split): the + * host's breadcrumb — 「对象 › 记录名」 at the root of a record pane, and one + * level down the ancestors of the record shown, each one a control that + * returns to it (objectstack#26679, objectstack#27524 Target 7). Nothing is + * drawn at the left of the bar when neither this nor {@link onBack} is + * passed. */ trail?: React.ReactNode; /** * Previous / next record (drawer and split, 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. + * the bar draws the counter 「3 / 21」 (with `position`; its tooltip names + * `scope`) and the `↓` `↑` pair (objectstack#27524, Target 7), 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]); + stepper?: RecordTopBarStepper; + /** + * The host's panel switches — the record's right-column ⊟ — drawn in the + * bar after the step pair and before `⤢` / `✕` (objectstack#27524, Target + * 7). The pane draws whatever it is handed; whether a switch applies is the + * host's to decide. + */ + toggles?: React.ReactNode; } -/** 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. * @@ -601,6 +557,7 @@ export const NavigationOverlay: React.FC = ({ onBack, trail, stepper, + toggles, }) => { // Every chrome string below is locale-driven (objectstack#5430 for the two // close affordances, objectstack#5506 for the rest). Must stay above the @@ -639,7 +596,7 @@ export const NavigationOverlay: React.FC = ({ const maximized = !!maximizable && shown && maximizedState; const sidebarWidth = useLiveSidebarWidth(); const handleInteractOutside = usePopperAwareInteractOutside(); - useStepperKeys(paneMode && shown && !!stepper, stepper?.onPrevious, stepper?.onNext); + useRecordStepKeys(paneMode && shown, stepper); // `Esc`: from maximized it restores, below the stack's root it goes back one // level, at the root it closes. The drawer's dialog layer asks the same // through `onEscapeKeyDown` below. @@ -709,86 +666,48 @@ export const NavigationOverlay: React.FC = ({ // panel's close, as it always was. const closeLabel = hasList && face === 'split' ? t('common.closePanel') : t('common.close'); - /* 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. */ - const barControls = ( - <> -
- {onBack && ( - - )} - {trail} -
-
- {stepper && ( - <> - - - - )} - {onExpand && ( - - )} - {maximizable && ( - - )} - -
- + {maximized ? : } + + )} + + + + + } + /> ); - const barClass = 'shrink-0 flex-row items-center justify-between gap-2 space-y-0 px-4 py-2 border-b bg-muted/30'; // --- Drawer (Sheet) --- // Over a list the host handed over, non-modal and undimmed @@ -834,14 +753,14 @@ export const NavigationOverlay: React.FC = ({ data-maximized={maximized ? '' : undefined} > {resizeHandle} - + {/* The title and description stay for assistive technology only (Radix requires the title). */} {resolvedTitle} {description || overlayDescription} - {barControls} + {bar}
{renderContent(selectedRecord)} @@ -893,7 +812,7 @@ export const NavigationOverlay: React.FC = ({ data-maximized={maximized ? '' : undefined} > {resizeHandle} -
{barControls}
+ {bar}
{renderContent(selectedRecord)}
diff --git a/ui/packages/components/src/custom/record-top-bar.tsx b/ui/packages/components/src/custom/record-top-bar.tsx new file mode 100644 index 00000000000..e7d8e3ffc39 --- /dev/null +++ b/ui/packages/components/src/custom/record-top-bar.tsx @@ -0,0 +1,232 @@ +/** + * 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's top bar (objectstack#27524, Target 7): ONE 40 px bar above the + * record header on every face — the page, the split pane, the drawer over a + * list or a page, the maximized pane, the phone. It holds frame controls only; + * the record header below it keeps the business actions. + * + * Left: what the host hands as `leading` — the breadcrumb 「对象 › 记录名」, or + * one level down in a pane 「← 路径」. Right, in a fixed order, each drawn only + * when the host hands it: the counter 「3 / 21」 and ↓ ↑ (`stepper`, a list + * context), a hairline, the panel switches (`toggles`, the right column's ⊟), + * then the frame controls (`frame`: ⤢ / ⤡, 「收为抽屉」, ✕). + * + * Pure presentation: every control's handler, and whether it applies, belong to + * the host — `NavigationOverlay` draws it as the pane's bar, the console's + * record view as the page's. + */ + +import React from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { createSafeTranslation } from '@object-ui/i18n'; +import { cn } from '../lib/utils'; + +/** + * English defaults for the bar's own strings. The probe key `common.close` is + * in all ten packs, so a mounted `I18nProvider` always resolves it and the real + * `t` is used; with no provider every key falls back to its English default. + */ +const TOP_BAR_DEFAULT_TRANSLATIONS: Record = { + 'common.close': 'Close', + 'detail.previousRecord': 'Previous record', + 'detail.nextRecord': 'Next record', + 'detail.recordOf': '{{current}} of {{total}}', + 'detail.recordOfScope': '{{current}} of {{total}} in {{scope}}', +}; + +const useTopBarTranslation = createSafeTranslation(TOP_BAR_DEFAULT_TRANSLATIONS, 'common.close'); + +/** The bar's icon-button face, shared by every control on it. */ +const RECORD_TOP_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 list context the bar steps through: the counter and ↓ ↑. */ +export interface RecordTopBarStepper { + /** The previous record of the set; absent at its start, which draws ↑ disabled. */ + onPrevious?: () => void; + /** The next record of the set; absent at its end, which draws ↓ disabled. */ + onNext?: () => void; + /** + * Where the record sits in the set — `index` from 0 — for the counter + * 「3 / 21」. Absent, or outside the set: no counter, the pair alone. + */ + position?: { index: number; total: number }; + /** The set's name (the list view it came from), which the counter's tooltip says. */ + scope?: string; +} + +export interface RecordTopBarProps { + /** The left end: the breadcrumb, or 「← 路径」. */ + leading?: React.ReactNode; + /** `data-testid` of the left end, for a host that addresses it. */ + leadingTestId?: string; + /** The counter and ↓ ↑, drawn only with a list context. */ + stepper?: RecordTopBarStepper; + /** The panel switches (the right column's ⊟), after the step group. */ + toggles?: React.ReactNode; + /** The frame controls — ⤢ / ⤡, 「收为抽屉」, ✕ — at the right end. */ + frame?: React.ReactNode; + className?: string; +} + +/** One icon control of the bar: its label is its accessible name and its tooltip. */ +export const RecordTopBarButton = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes & { label: string } +>(({ label, title, className, type, ...rest }, ref) => ( + + ); + })} +
+ )} +
+ {onToolbarAction && permittedToolbarActions.map((a) => ( + + ))} + {add && pickerObject && dataSource && addGranted && ( + + )} + {onNew && ( + + )} + {exportable && ( + + )} +
+
+ {exportState.error && ( +
+ {exportState.error} +
+ )} + {loadFailure ? ( + + ) : loading && relatedData.length === 0 ? ( +
{t('detail.loading')}
+ ) : isEmpty || shownCount === 0 ? ( +
+ {t('detail.noRelatedRecords')} +
+ ) : ( + <> +
+ {t('detail.relatedRows', { count: shownCount, defaultValue: '{{count}} rows' })} + {showPagination && ` · ${t('detail.pageOf', { current: currentPage + 1, total: totalPages })}`} +
+
+ +
+ {showPagination && ( +
+ + +
+ )} + {bodyTable && } + + )} + {trailingDialogs} + + ); + } + return ( ({ @@ -233,6 +233,70 @@ describe('the enlarged state is the active tab (objectstack#27524, Target 9)', ( expect(activeTab()).toBe('quote_line_item.quote'); }); + it('a link that opens another tab still names the 动态 count, counted from the feed the host holds (objectstack#27524)', async () => { + // ③'s preview gate: opened on 任务, neither the 动态 panel nor its tab's + // stream is drawn, and the stubbed stream reports nothing — the count + // comes from the feed itself. + const items = [ + { id: 'c1', type: 'comment', actor: 'Ada', body: 'hi', createdAt: '2026-10-30T10:00:00Z' }, + { id: 'c2', type: 'comment', actor: 'Ada', body: 'again', createdAt: '2026-10-30T11:00:00Z' }, + { id: 'a1', type: 'activity', actor: 'Ada', body: 'called', createdAt: '2026-10-29T09:00:00Z' }, + ]; + const { container } = render( + + + , + ); + expect(activeTab()).toBe('task.lead_id'); + expect(container.querySelector('[data-node="record:activity"]')).toBeNull(); + await waitFor(() => expect(tabCount(RECORD_PAGE_TIMELINE_PANEL)).toBe('3')); + }); + + it('control: a feed still loading, or no feed held, names no 动态 count on another tab', async () => { + render( + + + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(tabCount(RECORD_PAGE_TIMELINE_PANEL)).toBeNull(); + cleanup(); + render( + , + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(tabCount(RECORD_PAGE_TIMELINE_PANEL)).toBeNull(); + }); + + it('the right column switched off names the 动态 count too', async () => { + const items = [{ id: 'c1', type: 'comment', actor: 'Ada', body: 'hi', createdAt: '2026-10-30T10:00:00Z' }]; + const { container } = render( + + + , + ); + expect(container.querySelector('[data-record-column="right"]')).toBeNull(); + await waitFor(() => expect(tabCount(RECORD_PAGE_TIMELINE_PANEL)).toBe('1')); + }); + it('Esc returns to 概览', () => { const onClose = vi.fn(); render( diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx index 04bcb6e25af..7b39bcfd4cf 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedCardStack-27055.test.tsx @@ -22,7 +22,7 @@ import * as React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; -import { RelatedCardChromeContext, RelatedListCompactContext } from '../relatedListCompact'; +import { RelatedCardChromeContext, RelatedListCompactContext, RelatedListEnlargedContext } from '../relatedListCompact'; vi.mock('@object-ui/react', async (importOriginal) => ({ ...(await importOriginal>()), @@ -32,9 +32,10 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ function StubNode({ schema }: { schema: any }) { const chrome = React.useContext(RelatedCardChromeContext); const compact = React.useContext(RelatedListCompactContext); + const enlarged = React.useContext(RelatedListEnlargedContext); const p = schema?.properties ?? {}; return ( -
+
{chrome?.onEnlarge && } {p.objectName ?? schema?.type} {chrome?.onMoveUp && } @@ -194,9 +195,12 @@ describe('the enlarged related list — ⤢ and ?related= (objectstack#27055, Ta const enlarged = container.querySelector('[data-related-enlarged="note.parent_id"]')!; expect(enlarged).toBeTruthy(); expect(container.querySelector('[data-record-columns]')).toBeNull(); - // The FULL face, not the compact card — and paged. + // The FULL face, not the compact card — and paged. Since objectstack#27524 + // it is drawn as the tab's face: the toolbar over the table, no card + // (`RelatedList.enlargedToolbar-27524`). const list = enlarged.querySelector('[data-node="record:related_list"]')!; expect(list.getAttribute('data-compact')).toBe('no'); + expect(list.getAttribute('data-enlarged')).toBe('yes'); expect(list.getAttribute('data-limit')).toBe('25'); // The header nodes are still drawn above it. expect(container.querySelector('[data-node="page:header"]')).not.toBeNull(); diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.enlargedToolbar-27524.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.enlargedToolbar-27524.test.tsx new file mode 100644 index 00000000000..52d4a10e12a --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.enlargedToolbar-27524.test.tsx @@ -0,0 +1,163 @@ +/** + * 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#27524 — the enlarged related list (the record page's active + * tab, `RelatedListEnlargedContext`), the ride-along ruled at ③'s preview + * gate: the board's toolbar over the table — the filter, 全部 / 我的 / 逾期, + * 新建, 导出 — with the count and page line between them, and no card header + * or card foot. Each chip is drawn only where the child object declares what + * it says (an `owner_id` column; a `dueLike` deadline with no `settledWhen`), + * and a chip narrows the list's own read; 导出 asks the server for the rows + * the face shows. + */ + +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { FilterScopeProvider } from '@object-ui/react'; +import { RelatedList } from '../RelatedList'; +import { RelatedListEnlargedContext } from '../relatedListCompact'; + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +const TASK_FIELDS = { + subject: { type: 'text', label: 'Subject' }, + lead: { type: 'lookup', reference: 'lead', label: 'Lead' }, + owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner' }, + due_date: { type: 'date', label: 'Due', dueLike: true }, +}; +const ROWS = [ + { id: 't1', subject: 'Send quote', lead: 'L-1', owner_id: 'u-1', due_date: '2026-10-30' }, + { id: 't2', subject: 'Call back', lead: 'L-1', owner_id: 'u-2', due_date: '2026-11-04' }, +]; + +function dataSourceFor(fields: Record = TASK_FIELDS) { + return { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + getObjectSchema: vi.fn(async () => ({ name: 'task', fields })), + exportDownload: vi.fn(async () => new Blob(['subject\n'], { type: 'text/csv' })), + }; +} + +function renderList({ + enlarged = true, + fields, + onNew = vi.fn(), +}: { enlarged?: boolean; fields?: Record; onNew?: () => void } = {}) { + const dataSource = dataSourceFor(fields); + const utils = render( + + + + + , + ); + return { ...utils, dataSource }; +} + +const toolbar = () => document.querySelector('[data-related-enlarged-toolbar]') as HTMLElement | null; +const lastFilter = (find: ReturnType) => JSON.stringify(find.mock.calls[find.mock.calls.length - 1][1].$filter); + +describe('the enlarged related list draws the board toolbar (objectstack#27524)', () => { + it('a toolbar over the table: the filter, 全部 N / 我的 / 逾期, 新建, 导出 — and no card header or foot', async () => { + const onNew = vi.fn(); + renderList({ onNew }); + await waitFor(() => expect(screen.getByText('Send quote')).toBeInTheDocument()); + const bar = toolbar()!; + expect(bar).not.toBeNull(); + expect(bar.getAttribute('role')).toBe('toolbar'); + expect(within(bar).getByRole('searchbox', { name: 'Filter Tasks…' })).toBeInTheDocument(); + const chips = Array.from(bar.querySelectorAll('[data-related-chip]')).map((el) => el.getAttribute('data-related-chip')); + expect(chips).toEqual(['all', 'mine', 'overdue']); + await waitFor(() => expect(bar.querySelector('[data-related-chip="all"]')?.textContent).toBe('All2')); + expect(bar.querySelector('[data-related-chip="all"]')?.getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(within(bar).getByTestId('related-list-new')); + expect(onNew).toHaveBeenCalledTimes(1); + expect(bar.querySelector('[data-related-enlarged-export]')).not.toBeNull(); + // The count and page line sits between the toolbar and the table. + expect(document.querySelector('[data-related-enlarged-status]')?.textContent).toBe('2 rows'); + // No card: no header title, no count badge, no View all. + expect(screen.queryByLabelText('2 records')).toBeNull(); + expect(document.querySelector('.rounded-xl, [class*="card"]')).toBeNull(); + expect(document.querySelector('thead')).not.toBeNull(); + }); + + it('我的 and 逾期 narrow the list\'s own read, beside the parent', async () => { + const { dataSource } = renderList(); + await waitFor(() => expect(screen.getByText('Send quote')).toBeInTheDocument()); + const before = lastFilter(dataSource.find); + expect(before).not.toContain('owner_id'); + + fireEvent.click(document.querySelector('[data-related-chip="mine"]')!); + await waitFor(() => expect(lastFilter(dataSource.find)).toContain('owner_id')); + expect(lastFilter(dataSource.find)).toContain('u-1'); + expect(lastFilter(dataSource.find)).toContain('L-1'); + expect(document.querySelector('[data-related-chip="mine"]')?.getAttribute('aria-pressed')).toBe('true'); + + fireEvent.click(document.querySelector('[data-related-chip="overdue"]')!); + await waitFor(() => expect(lastFilter(dataSource.find)).toContain('due_date')); + expect(lastFilter(dataSource.find)).not.toContain('owner_id'); + expect(lastFilter(dataSource.find)).toContain('<'); + }); + + it('a chip is drawn only where the object declares what it says', async () => { + const { dataSource } = renderList({ + fields: { + subject: { type: 'text', label: 'Subject' }, + lead: { type: 'lookup', reference: 'lead', label: 'Lead' }, + // A deadline with a `settledWhen`: no query can ask which are settled. + due_date: { type: 'date', label: 'Due', dueLike: true, settledWhen: 'record.done == true' }, + }, + }); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByText('Send quote')).toBeInTheDocument()); + // 全部 alone is no choice: no chip group at all. + expect(document.querySelector('[data-related-chip]')).toBeNull(); + expect(toolbar()).not.toBeNull(); + }); + + it('导出 asks the server for the rows the face shows: the parent and the chip', async () => { + const { dataSource } = renderList(); + await waitFor(() => expect(screen.getByText('Send quote')).toBeInTheDocument()); + const createObjectURL = vi.fn(() => 'blob:x'); + const revokeObjectURL = vi.fn(); + Object.assign(URL, { createObjectURL, revokeObjectURL }); + fireEvent.click(document.querySelector('[data-related-chip="mine"]')!); + await waitFor(() => expect(lastFilter(dataSource.find)).toContain('owner_id')); + fireEvent.click(document.querySelector('[data-related-enlarged-export]')!); + await waitFor(() => expect(dataSource.exportDownload).toHaveBeenCalledTimes(1)); + const [resource, request] = dataSource.exportDownload.mock.calls[0] as unknown as [string, { format: string; filter: unknown; fields: string[] }]; + expect(resource).toBe('task'); + expect(request.format).toBe('csv'); + expect(JSON.stringify(request.filter)).toContain('L-1'); + expect(JSON.stringify(request.filter)).toContain('u-1'); + expect(request.fields).toEqual(['subject', 'due_date']); + }); + + it('control: outside the active tab the list keeps its card face', async () => { + renderList({ enlarged: false }); + await waitFor(() => expect(screen.getByText('Send quote')).toBeInTheDocument()); + expect(toolbar()).toBeNull(); + expect(screen.getByLabelText('2 records')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/plugin-detail/src/relatedListCompact.ts b/ui/packages/plugin-detail/src/relatedListCompact.ts index 7d1ccf154a2..94ba10561a0 100644 --- a/ui/packages/plugin-detail/src/relatedListCompact.ts +++ b/ui/packages/plugin-detail/src/relatedListCompact.ts @@ -78,6 +78,14 @@ export const RecordBodyTablesContext = React.createContext>( /** Enlarge a related list in place — the host's `?related=` — for a body table. */ export const RelatedEnlargeContext = React.createContext<((id: string) => void) | null>(null); +/** + * The related list is drawn as the record page's active tab — its enlarged + * face (objectstack#27524, the ride-along ruled at ③'s preview gate): a + * toolbar (the filter, 全部 / 我的 / 逾期, 新建, 导出) over the table at the + * tab's full height, with no card header and no card foot. + */ +export const RelatedListEnlargedContext = React.createContext(false); + /** * The count of the compact card being drawn, counted up front by its stack * (objectstack#27056, Targets 1 and 2): the card draws its header from it, a diff --git a/ui/packages/plugin-detail/src/renderers/record-activity.tsx b/ui/packages/plugin-detail/src/renderers/record-activity.tsx index 70298b4fd2d..644fae7c3a5 100644 --- a/ui/packages/plugin-detail/src/renderers/record-activity.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-activity.tsx @@ -397,3 +397,57 @@ export const RecordActivityRenderer: React.FC = ({ }; export default RecordActivityRenderer; + +/** The first `record:activity` node among `nodes`, or inside their children. */ +function firstActivityNode(nodes: readonly unknown[]): Record | undefined { + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + const n = node as Record; + if (n.type === BLOCK_NAME) return n; + const children = Array.isArray(n.children) ? n.children : Array.isArray(n.items) ? n.items : undefined; + const inner = children ? firstActivityNode(children) : undefined; + if (inner) return inner; + } + return undefined; +} + +/** + * The 动态 stream's size while no face of it is drawn (objectstack#27524, the + * ride-along ruled at ③'s preview gate): a link that opens the record on + * another tab draws neither the 动态 panel nor the 动态 tab's stream, and the + * tab row still names the stream's count. It counts what the stream drawn by + * {@link RecordActivityRenderer} would hold — the same sources, the same merge + * with the field changes, the same `types` / `showCompleted` / + * `unifiedTimeline` — reports it through `onCount` (`null` while the feed + * loads, while a read is refused, or with no feed held by the host), and + * draws nothing. It reads only what the host already holds: it never fetches. + */ +export function RecordTimelineCountProbe({ + nodes, + onCount, +}: { + nodes: readonly unknown[]; + onCount: (count: number | null) => void; +}) { + const node = firstActivityNode(nodes); + const discussion = useDiscussionContext(); + const fieldChanges = React.useContext(RecordFieldChangesContext); + const bag = (node?.properties ?? {}) as Record; + const read = (key: string) => node?.[key] ?? bag[key]; + const hostItems = Array.isArray(read('items')) ? (read('items') as FeedItem[]) : undefined; + const loading: boolean = read('loading') ?? discussion?.loading ?? false; + const denied = hostItems !== undefined ? false : !!discussion?.activityDenied || !!discussion?.commentsDenied; + const sourceItems = hostItems ?? (discussion?.items as FeedItem[] | undefined); + const types = read('types'); + const showCompleted = read('showCompleted'); + const unifiedTimeline = read('unifiedTimeline'); + const count = React.useMemo(() => { + if (!node || !sourceItems || loading || denied) return null; + const stream = mergeTimelineStream(sourceItems, fieldChanges); + return applyFeedConfig(stream, { types, showCompleted, unifiedTimeline }, Number.MAX_SAFE_INTEGER, BLOCK_NAME).items.length; + }, [node, sourceItems, loading, denied, fieldChanges, types, showCompleted, unifiedTimeline]); + React.useEffect(() => { + onCount(count); + }, [onCount, count]); + return null; +} diff --git a/ui/packages/plugin-detail/src/useDetailTranslation.ts b/ui/packages/plugin-detail/src/useDetailTranslation.ts index 249cc77c7f6..f196c4e4ac0 100644 --- a/ui/packages/plugin-detail/src/useDetailTranslation.ts +++ b/ui/packages/plugin-detail/src/useDetailTranslation.ts @@ -120,6 +120,14 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.previousPage': 'Previous', 'detail.nextPage': 'Next', 'detail.pageOf': 'Page {{current}} of {{total}}', + // objectstack#27524 — the enlarged related list's toolbar. + 'detail.relatedFilter': 'Filter {{title}}…', + 'detail.relatedChips': 'Show', + 'detail.relatedChipAll': 'All', + 'detail.relatedChipMine': 'Mine', + 'detail.relatedChipOverdue': 'Overdue', + 'detail.relatedExport': 'Export', + 'detail.relatedRows': '{{count}} rows', 'detail.sortBy': 'Sort by', 'detail.filterPlaceholder': 'Filter…', 'detail.highlightFields': 'Key Fields',