From b0657a05d5b2dc3369a372773b4f92dc08aa5f94 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:40:29 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20record=20pane=20v4=20in=20the?= =?UTF-8?q?=20console=20=E2=80=94=20split=20by=20default,=20maximize=20in?= =?UTF-8?q?=20place,=20full=20page=20only=20from=20the=20row=20and=20the?= =?UTF-8?q?=20header=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part B of objectstack#27545; part A (the mechanics) is on main. - app-shell: the record opens beside the list by default; the list is one element and keeps its scroll; a row click swaps the root; the open row is tinted and followed; ?recordId= changes re-render none of the list; the bar's maximize stays in place; the full page opens from the row's arrow and the header menu's last item; stepping keeps the previous record and warms both neighbours. - plugin-list: the Display menu's Full page segment through an optional selectPage (the setter keeps its two values); beside a pane the count bar prints no count. - plugin-grid: beside a pane the row-actions header draws no label. - i18n: list.recordOpenPage, list.recordOpenNote reworded. Claude-Session: https://claude.ai/code/session_01Utp6CY5hd7qEmdXd6Wg8Ap Co-authored-by: Claude --- .changeset/27545-record-pane-v4.md | 25 ++ content/docs/frontend/plugins/plugin-view.mdx | 24 ++ .../src/utils/recordOpenPreference.test.ts | 30 +- .../src/utils/recordOpenPreference.ts | 20 +- ...bjectView.recordOpenDisplay-26681.test.tsx | 34 +- ...ctView.recordOpenPreference-26677.test.tsx | 14 +- .../ObjectView.recordPaneV4-27545.test.tsx | 247 +++++++++++++++ .../app-shell/src/views/ObjectView.tsx | 207 ++++++------- ...rdDetailView.paneOpenAsPage-27545.test.tsx | 292 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 30 +- .../recordDrawerStack-26679.test.tsx | 121 ++++++-- .../recordDrawerStack.prefetch-27056.test.tsx | 37 ++- .../src/views/objectViewListRenderer.tsx | 21 +- .../src/views/objectViewOpenAsPage.tsx | 16 +- .../src/views/objectViewPluginSchema.ts | 13 +- .../src/views/objectViewRecordNavigation.ts | 109 ++++--- .../src/views/objectViewRecordOverlay.tsx | 67 +++- .../src/views/recordDrawerContext.ts | 17 + .../app-shell/src/views/recordDrawerStack.tsx | 172 ++++++----- ui/packages/i18n/src/locales/ar.ts | 3 +- ui/packages/i18n/src/locales/de.ts | 3 +- ui/packages/i18n/src/locales/en.ts | 3 +- ui/packages/i18n/src/locales/es.ts | 3 +- ui/packages/i18n/src/locales/fr.ts | 3 +- ui/packages/i18n/src/locales/ja.ts | 3 +- ui/packages/i18n/src/locales/ko.ts | 3 +- ui/packages/i18n/src/locales/pt.ts | 3 +- ui/packages/i18n/src/locales/ru.ts | 3 +- ui/packages/i18n/src/locales/zh.ts | 3 +- ui/packages/plugin-grid/src/ObjectGrid.tsx | 16 +- .../ObjectGrid.recordPaneFace-27545.test.tsx | 36 ++- ui/packages/plugin-list/src/ListView.tsx | 18 +- .../ListView.recordPaneCount-27545.test.tsx | 111 +++++++ ...wSettingsPopover.recordOpen-26681.test.tsx | 45 ++- .../src/components/ViewSettingsPopover.tsx | 65 ++-- 35 files changed, 1448 insertions(+), 369 deletions(-) create mode 100644 .changeset/27545-record-pane-v4.md create mode 100644 ui/packages/app-shell/src/views/ObjectView.recordPaneV4-27545.test.tsx create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.paneOpenAsPage-27545.test.tsx create mode 100644 ui/packages/plugin-list/src/__tests__/ListView.recordPaneCount-27545.test.tsx diff --git a/.changeset/27545-record-pane-v4.md b/.changeset/27545-record-pane-v4.md new file mode 100644 index 00000000000..88efe2a2d28 --- /dev/null +++ b/.changeset/27545-record-pane-v4.md @@ -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 Split | Drawer as before. `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. diff --git a/content/docs/frontend/plugins/plugin-view.mdx b/content/docs/frontend/plugins/plugin-view.mdx index 551d485814a..08973ed0269 100644 --- a/content/docs/frontend/plugins/plugin-view.mdx +++ b/content/docs/frontend/plugins/plugin-view.mdx @@ -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 diff --git a/ui/packages/app-shell/src/utils/recordOpenPreference.test.ts b/ui/packages/app-shell/src/utils/recordOpenPreference.test.ts index 9841de4b437..d82f6a65354 100644 --- a/ui/packages/app-shell/src/utils/recordOpenPreference.test.ts +++ b/ui/packages/app-shell/src/utils/recordOpenPreference.test.ts @@ -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'; @@ -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'); }); }); @@ -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', () => { diff --git a/ui/packages/app-shell/src/utils/recordOpenPreference.ts b/ui/packages/app-shell/src/utils/recordOpenPreference.ts index b8acc0db624..fd300936215 100644 --- a/ui/packages/app-shell/src/utils/recordOpenPreference.ts +++ b/ui/packages/app-shell/src/utils/recordOpenPreference.ts @@ -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 @@ -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; @@ -71,14 +75,14 @@ export const RECORD_OPEN_PREFERENCE = defineWorkspacePreference 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. */ diff --git a/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx b/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx index 6f397a431c2..ab975d5887f 100644 --- a/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx @@ -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, @@ -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' }); @@ -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' })); diff --git a/ui/packages/app-shell/src/views/ObjectView.recordOpenPreference-26677.test.tsx b/ui/packages/app-shell/src/views/ObjectView.recordOpenPreference-26677.test.tsx index 180dcdca291..eaabf9c6c87 100644 --- a/ui/packages/app-shell/src/views/ObjectView.recordOpenPreference-26677.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.recordOpenPreference-26677.test.tsx @@ -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. * @@ -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' })); diff --git a/ui/packages/app-shell/src/views/ObjectView.recordPaneV4-27545.test.tsx b/ui/packages/app-shell/src/views/ObjectView.recordPaneV4-27545.test.tsx new file mode 100644 index 00000000000..ef1cc22ff36 --- /dev/null +++ b/ui/packages/app-shell/src/views/ObjectView.recordPaneV4-27545.test.tsx @@ -0,0 +1,247 @@ +/** + * 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#27545 — the console object page's record pane v4, end to end + * through `ObjectView` (maintainer rulings of 2026-10-29): + * + * - Target 1: with no choice made the record opens BESIDE the list; the list + * stays clickable, a click on another row swaps the record, and the open + * row is marked and follows `↓` `↑`. + * - Target 3: `⤢` maximizes in place — same `?recordId=` URL, no history + * entry — and `Esc` restores. + * - Target 5: a `?recordId=` change (a row click, a step) does not re-render + * the list: `ListView` renders are counted across both. + * + * The record body is not what this file reads, so `RecordDetailView` is a + * null sink. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; + +const counter = vi.hoisted(() => ({ listViewRenders: 0 })); + +// The list the page draws, counted: every render of it is one more here. +vi.mock('@object-ui/plugin-list', async (importOriginal) => { + const actual = await importOriginal(); + const { createElement } = await import('react'); + const CountedListView = (props: Record) => { + counter.listViewRenders += 1; + return createElement(actual.ListView as never, props); + }; + return { ...actual, ListView: CountedListView }; +}); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities — `ListView` names `perms` in its fetch dependencies. + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +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('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +// The record body: not what this file reads. +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => ({ save: vi.fn(async () => ({})), get: vi.fn(async () => null) }), +})); + +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; +import { RECORD_OPEN_PREFERENCE_KEY } from '../utils/recordOpenPreference'; + +const OBJECT_NAME = 'duly_task'; +const VIEW_ID = `${OBJECT_NAME}.all`; +const ROWS = [ + { id: 'r1', name: 'Alpha' }, + { id: 'r2', name: 'Beta' }, + { id: 'r3', name: 'Gamma' }, +]; + +const objects = [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + }, + listViews: { + [VIEW_ID]: { name: VIEW_ID, label: 'Everything', type: 'grid', columns: ['name'] }, + }, + }, +]; + +/** Where the router is, and which history entry: a push or a replace moves `key`. */ +function LocationProbe() { + const location = useLocation(); + return ( + <> + {location.search} + {location.key} + + ); +} + +beforeEach(() => { + counter.listViewRenders = 0; + vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + window.localStorage.removeItem(RECORD_OPEN_PREFERENCE_KEY); + window.history.replaceState(null, '', '/'); +}); + +async function openPage(search = '?recordId=r1') { + const dataSource = { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: vi.fn(async (name: string) => objects.find((o) => o.name === name) ?? null), + } as never; + window.history.replaceState(null, '', `/apps/demo/${OBJECT_NAME}/view/${VIEW_ID}`); + render( + + + + + {}} />} + /> + + + + + , + ); + await screen.findByText('Gamma', undefined, { timeout: 5000 }); + await screen.findByTestId('record-split-pane', undefined, { timeout: 5000 }); + // The list reports its rows; the pane's ↑ ↓ step through them. + await waitFor(() => expect((screen.getByRole('button', { name: 'Next record' }) as HTMLButtonElement).disabled).toBe(false)); + await act(async () => { + await new Promise((settle) => setTimeout(settle, 50)); + }); +} + +const search = () => screen.getByTestId('location-search').textContent; +const historyKey = () => screen.getByTestId('location-key').textContent; +const rowOf = (name: string) => screen.getByText(name).closest('tr') as HTMLElement; +const isOpenRow = (name: string) => rowOf(name).hasAttribute('data-active-record'); + +describe('Target 1 — the record opens beside a readable, clickable list', () => { + it('with no choice made: the pane beside the list, the open row marked, no dialog', async () => { + await openPage(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(isOpenRow('Alpha')).toBe(true); + expect(isOpenRow('Beta')).toBe(false); + }); + + it('a click on another row swaps the record, and the mark moves with it', async () => { + await openPage(); + fireEvent.click(screen.getByText('Gamma')); + await waitFor(() => expect(search()).toBe('?recordId=r3')); + expect(isOpenRow('Gamma')).toBe(true); + expect(isOpenRow('Alpha')).toBe(false); + expect(screen.getByTestId('record-split-pane')).toBeTruthy(); + }); +}); + +describe('Target 5 — a `?recordId=` change does not re-render the list', () => { + it('a ↓ step and a row click leave the ListView render count where it was', async () => { + await openPage(); + const settled = counter.listViewRenders; + expect(settled).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole('button', { name: 'Next record' })); + await waitFor(() => expect(search()).toBe('?recordId=r2')); + await waitFor(() => expect(isOpenRow('Beta')).toBe(true)); + expect(isOpenRow('Alpha')).toBe(false); + + fireEvent.click(screen.getByText('Gamma')); + await waitFor(() => expect(search()).toBe('?recordId=r3')); + await act(async () => { + await new Promise((settle) => setTimeout(settle, 50)); + }); + expect(counter.listViewRenders).toBe(settled); + }); +}); + +describe('Target 3 — ⤢ maximizes in place', () => { + it('keeps the URL and the history entry, hides the list, and Esc restores', async () => { + await openPage(); + const keyBefore = historyKey(); + fireEvent.click(screen.getByRole('button', { name: 'Maximize' })); + const pane = screen.getByTestId('record-split-pane'); + expect(pane.hasAttribute('data-maximized')).toBe(true); + expect(screen.getByTestId('record-split-list').hasAttribute('inert')).toBe(true); + expect(search()).toBe('?recordId=r1'); + expect(historyKey()).toBe(keyBefore); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(pane.hasAttribute('data-maximized')).toBe(false); + expect(search()).toBe('?recordId=r1'); + expect(historyKey()).toBe(keyBefore); + }); +}); diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 42ba0675aa6..9f5711f8e0b 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -9,7 +9,7 @@ * - ListView delegation for non-grid view types (kanban, calendar, chart, etc.) */ -import { useMemo, useState, useCallback, useEffect, lazy, Suspense } from 'react'; +import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation, useHref } from 'react-router-dom'; import { resolveAffordance, type FilterTokenScope, type SchemaLike } from '@object-ui/core'; import { useObjectListPreferences } from '../context/ListPreferencesProvider.js'; @@ -20,9 +20,6 @@ import { ObjectView as PluginObjectView, ManageViewsDialog } from '@object-ui/pl // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx // uses ComponentRegistry.registerLazy so heavy plugins stay code-split). // Do NOT add eager `import '@object-ui/plugin-*'` side-effect imports here. -import { - NavigationOverlay, -} from '@object-ui/components'; import { Plus } from 'lucide-react'; import { useObjectListFavorite } from '../layout/SidebarItemMenu.js'; import { useMetaItemRefusal } from '../hooks/useMetaItemRefusal.js'; @@ -38,7 +35,6 @@ import { usePreviewDrafts, } from '../preview/PreviewModeContext.js'; import { isSetupAppSegment } from '../console/setupApp.js'; -import { RecordDetailView } from './RecordDetailView.js'; import { IDENTITY_IMPORT_WRITE_MODES } from './identityImport.js'; import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields } from './IdentityImportPanels.js'; import { ManagedUsersInviteLine } from './managedEnvironmentUsers.js'; @@ -75,7 +71,7 @@ import { useObjectViewTitleSwitcher } from './objectViewTitleSwitcher.js'; import { useObjectViewPluginSchema } from './objectViewPluginSchema.js'; import { useObjectViewHeaderChrome } from './objectViewHeaderChrome.js'; import { ObjectViewPageHeader } from './objectViewPageHeader.js'; -import { ObjectViewRecordOverlay } from './objectViewRecordOverlay.js'; +import { ObjectViewRecordOverlay, RecordPaneListCount } from './objectViewRecordOverlay.js'; import { useObjectViewListRenderer } from './objectViewListRenderer.js'; import { useObjectViewViewCrud } from './objectViewViewCrud.js'; @@ -181,7 +177,18 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // numbers (see the `ListView` below). Read off // the route's own `:appName`, the one signal the page header reads too. const inSetup = isSetupAppSegment(appName); - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams, setSearchParamsNow] = useSearchParams(); + // The router's setter is a new function whenever the search changes, and + // every list handler built on it (the URL state write-backs, the row + // click, the pane's step) would change with it: a `?recordId=` change — + // a row click, a step — would re-render the whole list (objectstack#27545). + // One setter for the page's life, calling the latest one. + const setSearchParamsRef = useRef(setSearchParamsNow); + setSearchParamsRef.current = setSearchParamsNow; + const setSearchParams = useCallback( + (next, options) => setSearchParamsRef.current(next, options), + [], + ); const location = useLocation(); const { showDebug } = useMetadataInspector(); const { t, language } = useObjectTranslation(); @@ -730,15 +737,15 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co useObjectViewLiveData({ dataSource, objectDef, refreshData, refreshKey, setRecordCount }); // objectstack#25992 slice 6: the record navigation overlay (drawer state in the - // URL, row click, expand to page, record links) moved to `./objectViewRecordNavigation.ts`. + // URL, row click, open as page, record links) moved to `./objectViewRecordNavigation.ts`. // objectstack#26677 — the side-by-side pane is a READER's preference, not a // `navigation.mode` (`split` left the spec). Live state: the Display menu's // Open records section writes it (objectstack#26681) and the next row click // opens the new way. It applies only to a record that opens in the drawer: // an object that declares `page` (or `new_window` / `none`) keeps its - // declaration. + // declaration. objectstack#27545 — the pane beside the list is the default. const [recordOpenPreference, setRecordOpenPreference] = useRecordOpenPreference(); - const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep } = useObjectViewRecordNavigation({ + const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleOpenAsPage, handleOpenRowAsPage, drawerRoot, drawerRestore, handleListRowsChange, handleRootStep } = useObjectViewRecordNavigation({ activeView, objectDef, objects, @@ -753,14 +760,25 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co viewLabel, unlabelledViewText, language, + recordOpenPreference, }); - // The split face draws the drawer's own record beside the list: same URL - // state (`?recordId=`), same close, a different shell. - const opensBesideList = recordOpenPreference === 'split' && navOverlay.mode === 'drawer'; + // A record that opens in the pane draws beside the list (`split`) unless + // the reader prefers the drawer over it: same URL state (`?recordId=`), + // same close, a different shell. A reader who prefers the page still gets + // the pane for a `?recordId=` link. Any other mode is the object's own. + const recordPaneMode = navOverlay.mode === 'drawer' + ? (recordOpenPreference === 'drawer' ? 'drawer' : 'split') + : navOverlay.mode; // objectstack#26681 — the Display menu's Open records section: the mode this // object and view resolved to, and the reader's preference with its setter. + // The console offers the full page as the third choice (objectstack#27545). const recordOpen = useMemo>( - () => ({ resolvedMode: navOverlay.mode, preference: recordOpenPreference, setPreference: setRecordOpenPreference }), + () => ({ + resolvedMode: navOverlay.mode, + preference: recordOpenPreference, + setPreference: setRecordOpenPreference, + selectPage: () => setRecordOpenPreference('page'), + }), [navOverlay.mode, recordOpenPreference, setRecordOpenPreference], ); // objectstack#26681 — the name cell's ↗, "open as full page", for a row that @@ -768,8 +786,10 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // already opens the page; a `new_window` / `none` one keeps its declaration. const openAsPageLabel = t('console.objectView.openAsPage', { defaultValue: 'Open as full page' }); const openAsPage = useMemo( - () => (navOverlay.mode === 'drawer' ? { objectName: objectDef.name, label: openAsPageLabel } : null), - [navOverlay.mode, objectDef.name, openAsPageLabel], + () => (navOverlay.mode === 'drawer' + ? { objectName: objectDef.name, label: openAsPageLabel, open: handleOpenRowAsPage } + : null), + [navOverlay.mode, objectDef.name, openAsPageLabel, handleOpenRowAsPage], ); // objectstack#25992 slice 8: the `filter[...]` URL equality filters and the `uf_*` @@ -836,7 +856,6 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co actions, handleRowClick, handleListRowsChange, - navOverlay, recordOpen, openAsPage, urlFilters, @@ -894,9 +913,40 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co viewAccess, }); - return ( - - + // The list itself: one element whether or not a record is open + // (objectstack#27545), so a `?recordId=` change — a row click, a step — + // hands React the same element and re-renders none of the list. Memoised + // for that cost only: a discarded cache re-renders it once, nothing more. + // The view handlers are rebuilt on every render of this page, so the list + // is handed ones that call the latest. + const listHandlers = useRef({ handleViewChange, handleCreateView, handleViewAction, onEdit }); + listHandlers.current = { handleViewChange, handleCreateView, handleViewAction, onEdit }; + const onListViewChange = useCallback((...args) => listHandlers.current.handleViewChange(...args), []); + const onListCreateView = useCallback((...args) => listHandlers.current.handleCreateView(...args), []); + const onListViewAction = useCallback((...args) => listHandlers.current.handleViewAction(...args), []); + const onListEdit = useCallback((record: any) => listHandlers.current.onEdit?.(record), []); + const listView = useMemo( + () => refusedViewFace ?? (viewAccess.checking ? null : ( + + )), + [refusedViewFace, viewAccess.checking, objectViewSchema, dataSource, mergedViews, activeViewId, onListViewChange, onListEdit, handleRowClick, renderListView, onListCreateView, onListViewAction], + ); + + // The page column — header, list, panels — that the record pane draws + // beside, at its full height, or over (objectstack#27545). + const pageColumn = (
{/* 1. Header with breadcrumb + description. The managed-by badge sits inline with the title so the @@ -1051,91 +1101,16 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co {/* 2. Content — Plugin ObjectView with ViewSwitcher + Filter + Sort */}
- {opensBesideList && navOverlay.isOpen ? ( - { if (!open) handleDrawerClose(); }} - title={objectLabel(objectDef)} - onExpand={handleExpandDrawer} - expandLabel={t('console.objectView.expandToPage', { defaultValue: 'Open as full page' })} - storageKey={`drawer-width:${objectDef.name}`} - mainContent={ -
-
-
- {refusedViewFace ?? (viewAccess.checking ? null : ( - onEdit?.(record)} - onRowClick={(record: any, event?: any) => { - handleRowClick(record, event); - }} - renderListView={renderListView} - onCreateView={handleCreateView} - hideNamedViewTabs - onViewAction={handleViewAction} - /> - ))} -
-
- {typeof recordCount === 'number' && ( -
- {/* One count family, like `ListView`'s record-count bar - (objectui#11445): i18next picks the CLDR slot. */} - {t('console.objectView.recordCount', { count: recordCount })} -
- )} -
- } - > - {(record: Record) => { - const recordId = (record.id || record._id) as string; - return ( - - ); - }} -
- ) : (
{/* objectui#13134 — `listFabClearance`: room at the end of the card list's scroller for the phone FAB. */}
- {refusedViewFace ?? (viewAccess.checking ? null : ( - onEdit?.(record)} - onRowClick={(record: any, event?: any) => { - handleRowClick(record, event); - }} - renderListView={renderListView} - onCreateView={handleCreateView} - hideNamedViewTabs - onViewAction={handleViewAction} - /> - ))} + {listView}
- {/* Record count footer removed — ListView already renders record-count-bar */} +
- )} {/* Metadata panel only shows for admin users */} handleViewCreate(cfg)} />
- - {/* Record Detail Overlay — the drawer, unless the reader prefers the pane beside the list */} - {!opensBesideList && ( - /* objectstack#25992 slice 10: the overlay moved to `./objectViewRecordOverlay.tsx`. */ - - )}
+ ); + + return ( + + + {/* objectstack#25992 slice 10: the record pane moved to `./objectViewRecordOverlay.tsx`. */} + {actionRuntime.dialogs} diff --git a/ui/packages/app-shell/src/views/RecordDetailView.paneOpenAsPage-27545.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.paneOpenAsPage-27545.test.tsx new file mode 100644 index 00000000000..661db7732d5 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.paneOpenAsPage-27545.test.tsx @@ -0,0 +1,292 @@ +/** + * 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#27545 — the record in a record pane (maintainer rulings of + * 2026-10-29): + * + * - Target 4: the full page opens from the row's ↗ and from the record + * header's `⋯`, whose last host item is 「Open as full page」 (the + * destructive band stays below it, objectui#12529). The bar's ⤢ maximizes + * in place; it routes nowhere. A record that is not in a pane — the page + * itself, or an embedded view no pane hosts — draws no such item. + * - Target 5: stepping never blanks. A step to the next record keeps the + * record on screen until the next one arrives; the page route, a real + * navigation, still resets (the control). + * + * Harness: the `RecordDetailView.headerMenuIcons-12529` mount (the real record + * view under a real `I18nProvider`, a metadata context and the real + * `FavoritesProvider`), embedded the way the pane draws it. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Routes, Route, useNavigate } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: 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('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { I18nProvider } from '@object-ui/i18n'; +import { MetadataCtx } from '@object-ui/react'; +import { RecordDetailView } from './RecordDetailView'; +import { RecordPaneContext, type RecordPaneApi } from './recordDrawerContext'; +import { FavoritesProvider } from '../context/FavoritesProvider'; + +const OBJECT_NAME = 'crm_lead'; +const NAMES: Record = { 'L-1': 'Ada Lovelace', 'L-2': 'Grace Hopper' }; + +const objects = [ + { + name: OBJECT_NAME, + label: 'Lead', + // The permissive bucket: edit + delete both default open. + managedBy: 'platform', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + }, + }, +]; + +const metadata = { + objects, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +} as any; + +/** A data source whose reads for `held` wait until released. */ +function dataSourceHolding(held?: string) { + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const findOne = vi.fn(async (_object: string, id: string) => { + if (id === held) await gate; + return { id, name: NAMES[id] }; + }); + const dataSource = { + find: vi.fn(async () => ({ data: [] })), + findOne, + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; + return { dataSource, findOne, release: () => release() }; +} + +function Providers({ children, initial = '/apps/crm/crm_lead' }: { children: React.ReactNode; initial?: string }) { + return ( + + + + {children} + + + + ); +} + +function Embedded({ recordId, dataSource, pane }: { recordId: string; dataSource: unknown; pane: RecordPaneApi | null }) { + return ( + + {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={recordId} + embedded + /> + + ); +} + +const toolbar = () => document.querySelector('[role="toolbar"]'); + +async function openOverflow(): Promise { + const trigger = screen.getByRole('button', { name: 'More actions' }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()); + return screen.getByRole('menu'); +} + +/** The menu's rows and separators, in document order. */ +function menuRows(menu: HTMLElement): string[] { + return Array.from(menu.querySelectorAll('[role="menuitem"], [role="separator"]')).map((el) => + el.getAttribute('role') === 'separator' ? '---' : (el.textContent ?? '').trim(), + ); +} + +beforeEach(() => { + cleanup(); + localStorage.clear(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('Target 4 — the header ⋯ ends with 「Open as full page」 in a record pane', () => { + it('is the last host item, above the destructive band, and hands the pane the gesture', async () => { + const { dataSource } = dataSourceHolding(); + const openAsPage = vi.fn(); + render( + + + , + ); + await waitFor(() => expect(toolbar()).toBeTruthy()); + const rows = menuRows(await openOverflow()); + expect(rows.slice(-3)).toEqual(['Open as full page', '---', 'Delete']); + fireEvent.click(screen.getByRole('menuitem', { name: 'Open as full page' })); + expect(openAsPage).toHaveBeenCalledTimes(1); + }); + + it('CONTROL: an embedded record no pane hosts draws no such item', async () => { + const { dataSource } = dataSourceHolding(); + render( + + + , + ); + await waitFor(() => expect(toolbar()).toBeTruthy()); + expect(menuRows(await openOverflow())).not.toContain('Open as full page'); + }); +}); + +/** + * Holds the callbacks the view queues with `queueMicrotask` while `run` runs, + * so a reset that would flash for one frame stays on screen long enough to be + * read. React captured its own `queueMicrotask` when it loaded; only call-time + * lookups — the view's reset among them — are held. + */ +async function holdingMicrotasks(run: () => void | Promise) { + const held: Array<() => void> = []; + const spy = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((callback) => { + held.push(callback); + }); + try { + await run(); + } finally { + spy.mockRestore(); + } + return async () => { + await act(async () => { + held.splice(0).forEach((callback) => callback()); + }); + }; +} + +describe('Target 5 — a step keeps the record on screen until the next one arrives', () => { + it('embedded: the previous record stays, never a skeleton, then the next one replaces it', async () => { + const { dataSource, release } = dataSourceHolding('L-2'); + const view = render( + + {} }} /> + , + ); + await screen.findAllByText(NAMES['L-1']); + await waitFor(() => expect(toolbar()).toBeTruthy()); + + const flush = await holdingMicrotasks(() => { + view.rerender( + + {} }} /> + , + ); + }); + // The next record has not arrived: the one on screen is still drawn. + expect(toolbar()).toBeTruthy(); + expect(screen.getAllByText(NAMES['L-1']).length).toBeGreaterThan(0); + await flush(); + + await act(async () => { + release(); + for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); + }); + await screen.findAllByText(NAMES['L-2']); + }); + + it('CONTROL: the record page resets on a route change to another record', async () => { + const { dataSource } = dataSourceHolding(); + function Nav() { + const navigate = useNavigate(); + return ( + + ); + } + render( + +