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/27545-record-pane-v4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/plugin-grid': patch
'@object-ui/plugin-list': patch
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(plugin-grid,plugin-list,app-shell,i18n): record pane v4 in the console — a record opens beside the list by default, ⤢ maximizes it in place on the same URL, and the full page opens only from the row's ↗ and the record header's ⋯ (#27545)

Clause-②: yes (widening)
Metadata-protocol: none

No metadata key is added, removed or read differently: every change is in how the console opens a record from a list, and in the reader's own preference. The mechanics (the split face, the non-modal drawer, maximize, the list's narrowed faces) shipped in the change before this one.

- **`@object-ui/app-shell`**
- The object page opens a record beside the list unless the reader prefers the drawer (Display menu); a reader who prefers the full page gets it from a row click. Below a 1000 px content area the pane is a non-modal drawer over the list.
- The list is one element whatever the pane does, so it keeps its scroll. A click on another row swaps the pane's root record. The open row is tinted, follows ↑ ↓ and scrolls into view.
- A `?recordId=` change (a row click, a step) re-renders none of the list: the router's setter and the list's handlers no longer change identity with the URL.
- The bar's ⤢ maximizes the pane in place and no longer routes; nested levels share it. The full page opens from the row's ↗ and from the record header's `⋯`, whose last host item is 「Open as full page」. 「Collapse to drawer」 is drawn only on a page the ↗ opened.
- A step keeps the record on screen until the next one arrives, and ↑ ↓ warm the related counts of both neighbours.
- **`@object-ui/plugin-list`**
- The Display menu's Open records section draws a third segment, Full page, when the host passes the new optional `recordOpen.selectPage`; a host that passes none sees two segments, as before, but now in the order Split | Drawer (it was Drawer | Split), since Split is the default. `recordOpen.preference`, an input the section only reads, also accepts `'page'`. `setPreference` still takes `'split' | 'drawer'`, so a host's setter keeps its type. The note says a record's ↗ always opens the full page.
- Beside a record pane the record-count bar no longer prints the count (the console prints it under the list); the rest of the row stays.
- **`@object-ui/plugin-grid`**: narrowed beside a pane, the row-actions column keeps its header's accessible name but draws no label, so it is only as wide as its ⋮.
- **`@object-ui/i18n`**: `list.recordOpenPage` in all ten packs, and `list.recordOpenNote` reworded in all ten.
24 changes: 24 additions & 0 deletions content/docs/frontend/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,30 @@ record as a full page in a new browser tab. Inert rows stay inert: `mode: 'none'
`preventNavigation`, and `operations.read: false` without a `navigation` config
ignore a modifier click too.

#### The Console's record pane

In the Console's object pages, a record that opens in the drawer opens
**beside the list** by default. The pane is 720 px wide, and the reader can
drag its divider from 360 px to 60% of the viewport. The list next to it stays
readable and clickable:

- it shows its first three non-avatar columns, or one card row per record when
it is narrower than 440 px;
- the open record's row is highlighted and follows the pane's ↑ ↓;
- clicking another row swaps the record.

On a content area narrower than 1000 px, the pane becomes the drawer over the
list. The drawer is non-modal too. Readers who prefer the drawer, or the full
page, choose it in the list's Display menu (Open records). That choice is the
reader's own, stored in the browser, never metadata.

The pane's ⤢ maximizes it in place, up to the sidebar. The URL and history
stay as they are, and ⤡ or `Esc` restores it. The full page opens from a row's
↗, or from 「Open as full page」, the last host item of the record header's
`⋯`. 「Collapse to drawer」 on that page returns to the list only when the ↗
opened it. None of this is declared in metadata: `navigation.mode` still
decides only whether a record opens in the drawer at all.

## CRUD Operations

All four operations are built in and run against the `dataSource` prop. You do
Expand Down
30 changes: 17 additions & 13 deletions ui/packages/app-shell/src/utils/recordOpenPreference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
*
* `split` left `NavigationModeSchema`: the side-by-side pane is a reader's own
* choice, kept per browser under `os:record-open-mode`. These rows pin what
* the console reads: the two values it knows, and the drawer for everything
* the console reads: the values it knows, and the default for everything
* else — no value, an unknown one, no storage, a storage that throws.
* objectstack#27545: the default is the split pane, and `page` (the Display
* menu's 「Full page」) is a value it knows.
*/

import { describe, it, expect } from 'vitest';
Expand All @@ -28,32 +30,32 @@ const storageWith = (value: string | null) => ({ getItem: (key: string) => (key
describe('readRecordOpenPreference (objectstack#26677)', () => {
it('the key and the default are the ones the Display-menu control will write against', () => {
expect(RECORD_OPEN_PREFERENCE_KEY).toBe('os:record-open-mode');
expect(DEFAULT_RECORD_OPEN_PREFERENCE).toBe('drawer');
expect(DEFAULT_RECORD_OPEN_PREFERENCE).toBe('split');
});

it('reads the two values it knows', () => {
it('reads the three values it knows', () => {
expect(readRecordOpenPreference(storageWith('split'))).toBe('split');
expect(readRecordOpenPreference(storageWith('drawer'))).toBe('drawer');
expect(readRecordOpenPreference(storageWith('page'))).toBe('page');
});

it('answers the drawer for no value, an unknown value, or a retired mode name', () => {
expect(readRecordOpenPreference(storageWith(null))).toBe('drawer');
expect(readRecordOpenPreference(storageWith('page'))).toBe('drawer');
expect(readRecordOpenPreference(storageWith('modal'))).toBe('drawer');
expect(readRecordOpenPreference(storageWith('SPLIT'))).toBe('drawer');
it('answers the split pane for no value, an unknown value, or a retired mode name', () => {
expect(readRecordOpenPreference(storageWith(null))).toBe('split');
expect(readRecordOpenPreference(storageWith('modal'))).toBe('split');
expect(readRecordOpenPreference(storageWith('SPLIT'))).toBe('split');
});

it('answers the drawer with no storage, and with a storage that throws', () => {
expect(readRecordOpenPreference(null)).toBe('drawer');
it('answers the split pane with no storage, and with a storage that throws', () => {
expect(readRecordOpenPreference(null)).toBe('split');
const throwing = { getItem: () => { throw new Error('SecurityError'); } };
expect(readRecordOpenPreference(throwing)).toBe('drawer');
expect(readRecordOpenPreference(throwing)).toBe('split');
});

it('with no `window` at all (SSR — this file runs in the node project), the default storage is absent: the drawer', () => {
it('with no `window` at all (SSR — this file runs in the node project), the default storage is absent: the split pane', () => {
// The browser's own `localStorage` is read by the console's object page;
// `ObjectView.recordOpenPreference-26677.test.tsx` drives that path.
expect(typeof window).toBe('undefined');
expect(readRecordOpenPreference()).toBe('drawer');
expect(readRecordOpenPreference()).toBe('split');
});
});

Expand All @@ -74,6 +76,8 @@ describe('writeRecordOpenPreference (objectstack#26681)', () => {
expect(readRecordOpenPreference(storage)).toBe('split');
writeRecordOpenPreference('drawer', storage);
expect(readRecordOpenPreference(storage)).toBe('drawer');
writeRecordOpenPreference('page', storage);
expect(readRecordOpenPreference(storage)).toBe('page');
});

it('writes nothing and throws nothing with no storage, or with a storage that refuses the write', () => {
Expand Down
20 changes: 12 additions & 8 deletions ui/packages/app-shell/src/utils/recordOpenPreference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ import {
* or the full page where the object declares `navigation.mode: 'page'` — and
* the side-by-side pane is each reader's own choice. So `split` left
* `NavigationModeSchema` (an author can no longer fix it for every reader) and
* lives here instead: `drawer` draws the record in the drawer over the list,
* `split` draws the same record page beside the list.
* lives here instead: `split` draws the record page beside the list, `drawer`
* draws it in the drawer over the list, `page` opens the full page.
*
* Since objectstack#27545 (maintainer rulings of 2026-10-29) the pane beside
* the list is the default, and `page` is the third choice, for a reader who
* wants a row click to open the record page itself.
*
* Both sides live here: {@link readRecordOpenPreference} and
* {@link writeRecordOpenPreference}, and {@link useRecordOpenPreference}, the
Expand All @@ -44,17 +48,17 @@ import {
* A thin alias over the console's one preference store,
* `workspacePreference.ts` (objectstack#27052): the preference is
* {@link RECORD_OPEN_PREFERENCE} there, in the `user` scope, and it keeps the
* spelling it was first stored with — a bare `drawer` / `split` under
* spelling it was first stored with — a bare `split` / `drawer` / `page` under
* {@link RECORD_OPEN_PREFERENCE_KEY} — so a reader's earlier choice reads
* back unchanged.
*/
export type RecordOpenPreference = 'drawer' | 'split';
export type RecordOpenPreference = 'split' | 'drawer' | 'page';

/** The `localStorage` key the preference is kept under. */
export const RECORD_OPEN_PREFERENCE_KEY = 'os:record-open-mode';

/** What a reader who never chose gets: the drawer. */
export const DEFAULT_RECORD_OPEN_PREFERENCE: RecordOpenPreference = 'drawer';
/** What a reader who never chose gets: the pane beside the list. */
export const DEFAULT_RECORD_OPEN_PREFERENCE: RecordOpenPreference = 'split';

/** The storage surface this module reads — `localStorage`, or a test double. */
export type RecordOpenPreferenceStorage = Pick<Storage, 'getItem'>;
Expand All @@ -71,14 +75,14 @@ export const RECORD_OPEN_PREFERENCE = defineWorkspacePreference<RecordOpenPrefer
name: 'record.open-mode',
storageKey: () => RECORD_OPEN_PREFERENCE_KEY,
codec: {
decode: (stored) => (stored === 'split' || stored === 'drawer' ? stored : undefined),
decode: (stored) => (stored === 'split' || stored === 'drawer' || stored === 'page' ? stored : undefined),
encode: (value) => value,
},
});

/**
* The reader's record-open preference, or {@link DEFAULT_RECORD_OPEN_PREFERENCE}
* when none is stored, the stored value is not one of the two, or there is no
* when none is stored, the stored value is not one of the three, or there is no
* readable storage. `storage` defaults to the browser's `localStorage`; `null`
* says there is none.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
* End to end through `ObjectView`, the host that resolves the mode and keeps
* the preference (`utils/recordOpenPreference.ts`):
*
* - a drawer object: the Open records section draws Drawer | Split; choosing
* Split keeps `split` under `os:record-open-mode`, and the next row click
* draws the record BESIDE the list (the split face: the list's record-count
* footer and the pane's close control) — no reload, no remount;
* - a drawer object: the Open records section draws Split | Drawer | Full
* page (objectstack#27545); choosing Split keeps `split` under
* `os:record-open-mode`, and the next row click draws the record BESIDE
* the list (the split face: the list's record-count footer and the pane's
* close control) — no reload, no remount. With no choice made the row
* opens beside the list too (the default since objectstack#27545), and
* choosing Full page makes the next row click land on the record page;
* - a view that declares `page`: the read-only line, no segments, and no ↗
* (its rows already open the page);
* - the ↗ beside a drawer row's record link is a real anchor to the record route,
Expand Down Expand Up @@ -171,6 +174,7 @@ function openDisplay() {

describe('objectstack#26681 — Open records in the Display menu', () => {
it('a drawer object: choosing Split keeps the preference, and the next row click opens the record beside the list', async () => {
window.localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'drawer');
await openList(objectsWith());
openDisplay();
const drawerSegment = await screen.findByRole('radio', { name: 'Drawer' });
Expand All @@ -190,13 +194,33 @@ describe('objectstack#26681 — Open records in the Display menu', () => {
expect(screen.queryByRole('dialog')).toBeNull();
});

it('a drawer object with no choice made: the row opens the drawer', async () => {
it('objectstack#27545 — a drawer object with no choice made: the row opens the record beside the list', async () => {
await openList(objectsWith());
fireEvent.click(screen.getByTestId('primary-field-link'));
expect(await screen.findByTestId('record-split-pane', undefined, { timeout: 5000 })).toBeTruthy();
expect(screen.getByTestId('record-count-footer')).toBeTruthy();
expect(screen.queryByRole('dialog')).toBeNull();
});

it('objectstack#27545 — a reader who prefers the drawer: the row opens the drawer', async () => {
window.localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'drawer');
await openList(objectsWith());
fireEvent.click(screen.getByTestId('primary-field-link'));
expect(await screen.findByRole('dialog', undefined, { timeout: 5000 })).toBeTruthy();
expect(screen.queryByTestId('record-count-footer')).toBeNull();
});

it('objectstack#27545 — choosing Full page: the next row click lands on the record page', async () => {
await openList(objectsWith());
openDisplay();
fireEvent.click(await screen.findByRole('radio', { name: 'Full page' }));
expect(window.localStorage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe('page');
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
fireEvent.click(screen.getByTestId('primary-field-link'));
expect(await screen.findByTestId('record-page-route', undefined, { timeout: 5000 })).toBeTruthy();
expect(screen.queryByRole('dialog')).toBeNull();
});

it('a view that declares `page`: the read-only line, no segments, and no ↗', async () => {
window.localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'split');
await openList(objectsWith({ mode: 'page' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
*
* - preference `split` → the split face: the list in the left pane with its
* record-count footer (drawn only there) and the pane's own close control;
* - no preference → the drawer: a dialog-role sheet, no footer, no pane;
* - preference `drawer` → the drawer: a dialog-role sheet, no footer, no pane;
* - no preference → the split face: the default since objectstack#27545
* (maintainer ruling 2026-10-29, Target 1);
* - a view that DECLARES `page` → the declaration wins over the preference:
* neither the pane nor the drawer is drawn on the list.
*
Expand Down Expand Up @@ -165,13 +167,21 @@ describe('objectstack#26677 — the split pane is the reader\'s preference', ()
expect(screen.getByRole('button', { name: 'Close panel' })).toBeTruthy();
});

it('no preference: the record opens in the drawer, and nothing is drawn beside the list', async () => {
it('preference `drawer`: the record opens in the drawer, and nothing is drawn beside the list', async () => {
window.localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'drawer');
await openRecord(objectsWith());
expect(await screen.findByRole('dialog', undefined, { timeout: 5000 })).toBeTruthy();
expect(screen.queryByTestId('record-count-footer')).toBeNull();
expect(screen.queryByRole('button', { name: 'Close panel' })).toBeNull();
});

it('objectstack#27545 — no preference: the record opens beside the list', async () => {
await openRecord(objectsWith());
expect(await screen.findByTestId('record-split-pane', undefined, { timeout: 5000 })).toBeTruthy();
expect(screen.getByTestId('record-count-footer')).toBeTruthy();
expect(screen.queryByRole('dialog')).toBeNull();
});

it('a view that declares `page` keeps its declaration: the preference draws no pane', async () => {
window.localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'split');
await openRecord(objectsWith({ mode: 'page' }));
Expand Down
Loading
Loading