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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/27524-record-top-bar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/components': patch
'@object-ui/app-shell': patch
'@object-ui/plugin-detail': patch
'@object-ui/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.
7 changes: 7 additions & 0 deletions ui/packages/app-shell/src/urlParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 2 additions & 1 deletion ui/packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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'));
Expand Down
Loading
Loading