Skip to content

Commit 7414fc8

Browse files
objectstack-fleet[bot]hotlongclaude
authored
feat(app-shell,plugin-list,plugin-grid,i18n): the Display menu's Open records section writes the reader's drawer | split preference; the name cell's ↗ opens the record as the full page (#27031)
Fixes #26681 Clause-②: no Metadata-protocol: none Part of the record-page-v2 epic #26676 (child 4 of 4). Implements the maintainer's ruling 1 of 2026-10-27, quoted on the card: 「……split / popover 变成用户个人偏好……」 — the side-by-side pane is each reader's own choice, not metadata. ## What changes - **Display menu, 「打开记录」 section** (`ViewSettingsPopover`, after Layout, before Group). For a row that resolves to `drawer`: two segments 抽屉 | 分屏 writing the reader's preference (per browser, across objects, `os:record-open-mode`), and the note 「个人偏好,不进对象声明;两种都能展开为整页」. For an object or view that resolves to `page`: the read-only line 「此对象声明为整页打开」, no segments. For `new_window` / `none`: no section (see Acceptance notes). A host that passes no `recordOpen` (every host but the console) draws no section. - **The preference is live** (`app-shell/src/utils/recordOpenPreference.ts`): the write side `writeRecordOpenPreference` (its storage type is `Storage` narrowed to `setItem`; no storage or a refused write throws nothing and the choice still holds on the page) and `useRecordOpenPreference`, a `useSyncExternalStore` pair that hears this page's writes and another tab's `storage` event. `ObjectView` replaces its read-once `useState(readRecordOpenPreference)` with it, so choosing 分屏 makes the next row click open the split face with no reload or remount. - **The name cell's ↗** (`app-shell/src/views/objectViewOpenAsPage.tsx`): beside the row's record link, hover-revealed by opacity (the `RowActionMenu` class set, so it keeps its Tab stop; a coarse pointer always shows it), `aria-label` and `title` 「整页打开」 (`console.objectView.openAsPage`). It is a real anchor at the host's `recordHref`; a plain click goes through the host's `openRecord` — the record route with the list as origin, the same destination as the drawer's own expand — and stops before the row's click, so neither the drawer nor the pane opens and the preference is untouched. Drawn only for a drawer-mode row: a `page` row already opens the page, and `none` opens nothing. - **Grid host channel `recordLinkAdornment`** (`ObjectGrid` → `LinkCell`, relayed by `ListView`): a host node after the record link, never inside it, beside the first column the view links (`link: true`), else beside the auto-linked primary field. Absent, the link cell is byte-identical to before (the `ObjectGrid.linkCellAnchor` markup pin stays green). - **i18n**: five `list.*` keys and `console.objectView.openAsPage` in all ten packs, plus the `ListView` defaults-map rows the `defaults-maps-mirror-en-pack` test holds to the en pack. No metadata is added and `navigation` reading is unchanged; `packages/spec/**` is not touched, nor any file of #26679 (`navigation-overlay.tsx`, `objectViewRecordNavigation.ts`, `RelatedRecordActionsBridge.tsx`, `RecordDetailView.tsx`). ## Route taken against the dispatch's mechanism hypotheses - **The props do not travel through plugin-view's `PluginObjectView`.** `ObjectView`'s `renderListView` callback (`objectViewListRenderer.tsx`) renders `ListView` itself, so `recordOpen` goes ObjectView → `renderListView` → `ListView` → `ViewSettingsPopover`, one hop shorter; `PluginObjectView` is unchanged. Both new props are React host props (function values / host state), never node keys: nothing enters `GRID_QUERY_INPUTS` or the SDUI manifest. - **The ↗ first went through the grid's existing `rowAdornment` channel and was moved off it.** Measured on HotCRM contacts: `rowAdornment` sits beside the row's TITLE (the declared name field's column when shown, else the first shown column), and contact's `nameField: 'full_name'` is not a column, so the arrow landed in the avatar column, away from 「名」 where the row opens. Hence the new `recordLinkAdornment` channel at the link cell (screenshot below). ## Screenshots (HotCRM `1f3f8880` on a fresh DB, console served from this branch's `ui/` src) Evidence lives on a never-merged branch, commit `964025cf3f3f7a2ecfd4a743d250699f2ec552ec`. Leads (the lead view declares `navigation.mode: 'page'`) — the read-only line: ![leads Display menu](https://github.com/objectstack-ai/objectstack/blob/964025cf3f3f7a2ecfd4a743d250699f2ec552ec/evidence/issue-26681-2026-10-28/lead-display.png?raw=true) Opportunities (declares `drawer`) — the two segments and the note: ![opportunities Display menu](https://github.com/objectstack-ai/objectstack/blob/964025cf3f3f7a2ecfd4a743d250699f2ec552ec/evidence/issue-26681-2026-10-28/opportunity-display.png?raw=true) Contacts, first row hovered — the ↗ beside 「John」, the record link: ![contacts row hover](https://github.com/objectstack-ai/objectstack/blob/964025cf3f3f7a2ecfd4a743d250699f2ec552ec/evidence/issue-26681-2026-10-28/contact-row-hover.png?raw=true) ## Tests Measured on head `3d7fb4c462` (branch tip after merging `origin/main`), from `ui/`: - `pnpm exec vitest run --maxWorkers=3 packages/app-shell/ packages/plugin-grid/ packages/plugin-list/ packages/i18n/` — `Test Files 2307 passed | 1 skipped (2308)`, `Tests 21776 passed | 22 skipped (21798)`, exit 0. - New suites: `plugin-list/src/__tests__/ViewSettingsPopover.recordOpen-26681.test.tsx` (drawer segments and note, Split writes `split`, the pressed segment writes nothing, `page` read-only line, no section for `new_window` / `none` / no prop, zh pack); `plugin-grid/src/__tests__/ObjectGrid.recordLinkAdornment-26681.test.tsx` (node beside the authored link as the anchor's sibling, one per row, none on the auto-linked first column, absent prop leaves no wrapper, click never reaches the row); `app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx` (choosing 分屏 keeps `split` and the next row click draws the split face; no choice opens the drawer; a `page` view shows the read-only line and no ↗; the ↗ is an anchor at the record route beside the link, and a click lands on the record route with no drawer and no preference written); `app-shell/src/utils/recordOpenPreference.test.ts` gains the write side. - `pnpm --filter @object-ui/i18n --filter @object-ui/plugin-grid --filter @object-ui/plugin-list --filter @object-ui/app-shell run type-check` (after building the three libraries' `dist`) — exit 0, each `type-check` script echoed. - Reverse verification of the cross-package type (`ListViewProps['recordOpen']`, read by app-shell from plugin-list's rebuilt `.d.ts`), via `scripts/ablation-replace.mjs` with a trap restore, committed state first: replacing `resolvedMode: navOverlay.mode` with `resolvedMode: 'split' as const` turns app-shell's `tsc --noEmit` red — `TS2322: Type '"split"' is not assignable to type '"none" | "drawer" | "page" | "new_window"'`, exit 2; restored, blob back to `43072da53b2a`, `git diff HEAD` empty. A first attempt that added an extra key instead stayed green (exit 0): a contextually typed arrow's return gets no excess-property check, so that leg proved nothing and was replaced, not counted. ## Acceptance notes - `new_window` / `none` draw no Open records section: the preference changes nothing there and the ruling gives those declarations no line of their own. A reader of such an object sees no setting rather than a control that does nothing. - The section is drawn in the Display popover only; the wide toolbar (`compactToolbar: false`) has no twin. No host that passes `recordOpen` draws the wide form — the console never relays a view's `compactToolbar` to `ListView` — so the popover's "change one, change both" note now names this exception. - The ↗'s zh tooltip 「整页打开」 follows the dispatch's ruling text; the drawer's own expand control reads `console.objectView.expandToPage` 「以完整页面打开」 for the same English sentence. Two zh spellings of one action on one page — noted, not changed here (the drawer's controls belong to #26679). - The ↗ is drawn for the grid only (`recordLinkAdornment` reaches `ObjectGrid`); kanban, gallery and the other visualizations open records from cards with no name cell. ## Verification Gate families from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (derived from the merge base with `origin/main`, at `3d7fb4c462`; 35 commands), each run on its own with the exit code captured before any pipe: - `node scripts/check-adr-0087-registration.mjs --base origin/main` — exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` — exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` — exit 0 - `node scripts/check-closing-keyword-parity.mjs` — exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` — exit 0 - `node scripts/check-comment-mask-corpus.mjs` — exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` — exit 0 - `node scripts/check-empty-changeset.mjs --self-test` — exit 0 - `node scripts/check-sdui-manifest.mjs` — exit 0 - `node scripts/check-sdui-manifest.mjs --self-test` — exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` — exit 0 - `node scripts/release-pending-publish.mjs --self-test` — exit 0 - `pnpm --dir ui exec vitest run scripts/` — exit 1 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` — exit 0 - `pnpm check:changeset-gate-self-tests` — exit 0 - `pnpm check:closed-ceremony-text` — exit 0 - `pnpm check:cross-package-test-inputs` — exit 0 - `pnpm check:dispatcher-error-vocabulary` — exit 0 - `pnpm check:driver-memory-census` — exit 0 - `pnpm check:engine-double-contract` — exit 0 - `pnpm check:error-status-conformance` — exit 0 - `pnpm check:gitlink-declared` — exit 0 - `pnpm check:nul-bytes` — exit 0 - `pnpm check:pm-changeset-deadline-census` — exit 0 - `pnpm check:published-files` — exit 0 - `pnpm check:query-options-erasure` — exit 0 - `pnpm check:refd-timer-probe` — exit 0 - `pnpm check:skill-identifier-liveness` — exit 0 - `pnpm check:test-source-alias` — exit 0 - `pnpm check:tier-file-adoption` — exit 0 - `pnpm check:type-check-coverage` — exit 0 - `pnpm check:type-check-debt` — exit 0 - `pnpm check:undeclared-dep-imports` — exit 0 - `pnpm check:watch-hint-literal` — exit 0 - `pnpm check:where-matcher` — exit 0 The one red is the `ui/scripts` vitest battery, which ran beside the four-package suite on a box at load average 14: 14 files, 22 tests. `select-affected-tests-wiring.test.ts` fails on this host by construction (`shard.sh: line 2: mapfile: command not found`, exit 127 — macOS's bash 3 has no `mapfile`; CI runs Linux). The other failures are tests timing out at roughly 20 s each; none of their files reads a path this diff changes. They are rerunning in isolation now; that verdict goes in the `os-dev-report` comment on #26681, and CI's own run is the authority. **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the commands above were run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran. `test:repo` (the root vitest `repo` project) is CI's, not run here: the diff touches no file of that project. --- _Generated by [Claude Code](https://claude.ai/code/session_cdb7a717-def7-4e71-9988-f089fbaa18ff)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5cba8fc commit 7414fc8

24 files changed

Lines changed: 999 additions & 18 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
'@object-ui/plugin-grid': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
feat(plugin-list,plugin-grid,i18n): the Display menu's Open records section — the reader's drawer | split preference, or the object's declared full page — and a host node beside each row's record link (#26681)
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **`@object-ui/plugin-list`**: `ListView` takes a `recordOpen` host prop and hands it to the Display menu (`ViewSettingsPopover`), which draws an Open records section after Layout. For a drawer-mode object it offers two segments, Drawer | Split, writing the reader's own preference through the host's `setPreference`, with a note that the choice is the reader's and that both expand to the full page. For an object or view that declares `navigation.mode: 'page'` it draws one read-only line instead; for `new_window` and `none`, nothing. A host that passes no `recordOpen` draws no section, so every other host is unchanged. It is a host prop, not a view key: no metadata is added, and the section neither counts toward the Display trigger's changed-settings count nor returns under "Reset to view default".
13+
- **`@object-ui/plugin-list`** and **`@object-ui/plugin-grid`**: `ListView` and `ObjectGrid` take a `recordLinkAdornment` host prop — a node drawn beside each row's record link, after the anchor and never inside it: beside the first column the view links (`link: true`), else beside the auto-linked primary field. `rowAdornment` is unchanged: it sits beside the row's title, which need not be where the record opens from. The console passes an "open as full page" arrow through it for a row that opens the drawer. Absent, the link cell is exactly the anchor it was.
14+
- **`@object-ui/i18n`**: the section's five `list.*` strings (`recordOpen`, `recordOpenDrawer`, `recordOpenSplit`, `recordOpenNote`, `recordOpenDeclaredPage`) and `console.objectView.openAsPage`, the tooltip of the console list's open-as-full-page arrow, in all ten packs.

‎ui/packages/app-shell/src/utils/recordOpenPreference.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
DEFAULT_RECORD_OPEN_PREFERENCE,
2121
RECORD_OPEN_PREFERENCE_KEY,
2222
readRecordOpenPreference,
23+
writeRecordOpenPreference,
2324
} from './recordOpenPreference';
2425

2526
const storageWith = (value: string | null) => ({ getItem: (key: string) => (key === RECORD_OPEN_PREFERENCE_KEY ? value : null) });
@@ -55,3 +56,29 @@ describe('readRecordOpenPreference (objectstack#26677)', () => {
5556
expect(readRecordOpenPreference()).toBe('drawer');
5657
});
5758
});
59+
60+
describe('writeRecordOpenPreference (objectstack#26681)', () => {
61+
/** A storage double that keeps what it is handed. */
62+
const memoryStorage = () => {
63+
const kept = new Map<string, string>();
64+
return {
65+
getItem: (key: string) => kept.get(key) ?? null,
66+
setItem: (key: string, value: string) => void kept.set(key, value),
67+
};
68+
};
69+
70+
it('keeps the choice under the key the read side reads', () => {
71+
const storage = memoryStorage();
72+
writeRecordOpenPreference('split', storage);
73+
expect(storage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe('split');
74+
expect(readRecordOpenPreference(storage)).toBe('split');
75+
writeRecordOpenPreference('drawer', storage);
76+
expect(readRecordOpenPreference(storage)).toBe('drawer');
77+
});
78+
79+
it('writes nothing and throws nothing with no storage, or with a storage that refuses the write', () => {
80+
expect(() => writeRecordOpenPreference('split', null)).not.toThrow();
81+
const refusing = { setItem: () => { throw new Error('QuotaExceededError'); } };
82+
expect(() => writeRecordOpenPreference('split', refusing)).not.toThrow();
83+
});
84+
});

‎ui/packages/app-shell/src/utils/recordOpenPreference.ts‎

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9+
import { useCallback, useSyncExternalStore } from 'react';
10+
911
/**
1012
* How the console's object page opens a record a reader clicks, when the
1113
* object opens its records in the drawer — a READER's own preference, not
@@ -19,9 +21,11 @@
1921
* lives here instead: `drawer` draws the record in the drawer over the list,
2022
* `split` draws the same record page beside the list.
2123
*
22-
* This is the READ side. The Display-menu control that writes it is
23-
* objectstack#26681; until it lands nothing in the console writes the key, so
24-
* every reader gets the default.
24+
* Both sides live here: {@link readRecordOpenPreference} and
25+
* {@link writeRecordOpenPreference}, and {@link useRecordOpenPreference}, the
26+
* live pair the object page reads — the Display menu's 「打开记录」 control
27+
* writes through it and the next row click opens the new way, with no reload
28+
* (objectstack#26681).
2529
*
2630
* Read per browser: the value lives in `localStorage` under
2731
* {@link RECORD_OPEN_PREFERENCE_KEY}, across objects. An absent value, an
@@ -42,7 +46,10 @@ export const DEFAULT_RECORD_OPEN_PREFERENCE: RecordOpenPreference = 'drawer';
4246
/** The storage surface this module reads — `localStorage`, or a test double. */
4347
export type RecordOpenPreferenceStorage = Pick<Storage, 'getItem'>;
4448

45-
function defaultStorage(): RecordOpenPreferenceStorage | null {
49+
/** The storage surface this module writes — `localStorage`, or a test double. */
50+
export type RecordOpenPreferenceWriteStorage = Pick<Storage, 'setItem'>;
51+
52+
function defaultStorage(): Storage | null {
4653
if (typeof window === 'undefined') return null;
4754
try {
4855
return window.localStorage;
@@ -69,3 +76,79 @@ export function readRecordOpenPreference(
6976
return DEFAULT_RECORD_OPEN_PREFERENCE;
7077
}
7178
}
79+
80+
/**
81+
* A choice storage would not keep, held for this page: a browser whose
82+
* storage refuses the write (private mode, a full quota, a blocked origin)
83+
* would otherwise have the reader pick 分屏 and keep reading 抽屉 back.
84+
* `undefined` while storage keeps the choice — storage is then the one answer
85+
* — and cleared by the next kept write and by another tab's write (see
86+
* `subscribe`).
87+
*/
88+
let pageChoice: RecordOpenPreference | undefined;
89+
90+
/** The live {@link useRecordOpenPreference} callers on this page. */
91+
const listeners = new Set<() => void>();
92+
93+
/**
94+
* Keep the reader's choice. `storage` defaults to the browser's
95+
* `localStorage`; `null` says there is none. With no storage, or one that
96+
* refuses the write, nothing is kept and nothing throws: the choice holds on
97+
* this page, through {@link useRecordOpenPreference}, and the next page load
98+
* reads the default.
99+
*
100+
* Every live {@link useRecordOpenPreference} on this page hears the write;
101+
* another tab of the same origin hears it through the browser's `storage`
102+
* event.
103+
*/
104+
export function writeRecordOpenPreference(
105+
next: RecordOpenPreference,
106+
storage: RecordOpenPreferenceWriteStorage | null = defaultStorage(),
107+
): void {
108+
pageChoice = next;
109+
if (storage) {
110+
try {
111+
storage.setItem(RECORD_OPEN_PREFERENCE_KEY, next);
112+
pageChoice = undefined;
113+
} catch {
114+
// Not kept past this page — see above.
115+
}
116+
}
117+
for (const listener of Array.from(listeners)) listener();
118+
}
119+
120+
function subscribe(onChange: () => void): () => void {
121+
listeners.add(onChange);
122+
const onStorage = (event: StorageEvent) => {
123+
// `key === null` is a `clear()` in another tab.
124+
if (event.key === RECORD_OPEN_PREFERENCE_KEY || event.key === null) {
125+
pageChoice = undefined;
126+
onChange();
127+
}
128+
};
129+
if (typeof window !== 'undefined') window.addEventListener('storage', onStorage);
130+
return () => {
131+
listeners.delete(onChange);
132+
if (typeof window !== 'undefined') window.removeEventListener('storage', onStorage);
133+
};
134+
}
135+
136+
function snapshot(): RecordOpenPreference {
137+
return pageChoice ?? readRecordOpenPreference();
138+
}
139+
140+
function serverSnapshot(): RecordOpenPreference {
141+
return DEFAULT_RECORD_OPEN_PREFERENCE;
142+
}
143+
144+
/**
145+
* The reader's record-open preference as live state, and the setter the
146+
* Display menu writes it with (objectstack#26681). A write here, in another
147+
* component of this page, or in another tab re-renders every caller, so the
148+
* object page decides its split face from the value the reader just chose.
149+
*/
150+
export function useRecordOpenPreference(): [RecordOpenPreference, (next: RecordOpenPreference) => void] {
151+
const preference = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
152+
const setPreference = useCallback((next: RecordOpenPreference) => writeRecordOpenPreference(next), []);
153+
return [preference, setPreference];
154+
}
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#26681 — the console object page's Display menu writes the
11+
* reader's record-open preference, and the name cell's ↗ opens the record as
12+
* the full page.
13+
*
14+
* End to end through `ObjectView`, the host that resolves the mode and keeps
15+
* the preference (`utils/recordOpenPreference.ts`):
16+
*
17+
* - a drawer object: the Open records section draws Drawer | Split; choosing
18+
* Split keeps `split` under `os:record-open-mode`, and the next row click
19+
* draws the record BESIDE the list (the split face: the list's record-count
20+
* footer and the pane's close control) — no reload, no remount;
21+
* - a view that declares `page`: the read-only line, no segments, and no ↗
22+
* (its rows already open the page);
23+
* - the ↗ beside a drawer row's record link is a real anchor to the record route,
24+
* and a plain click on it lands there, leaving the preference as it was.
25+
*
26+
* The record body is not what this file reads, so `RecordDetailView` is a
27+
* null sink.
28+
*/
29+
30+
import * as React from 'react';
31+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
32+
import { render, cleanup, screen, fireEvent, waitFor, within } from '@testing-library/react';
33+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
34+
import { createI18n, I18nProvider } from '@object-ui/i18n';
35+
36+
vi.mock('@object-ui/permissions', async (importOriginal) => {
37+
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
38+
// Stable identities — `ListView` names `perms` in its fetch dependencies.
39+
const perms = {
40+
check: () => ({ allowed: true }),
41+
checkField: () => true,
42+
getFieldPermissions: () => [],
43+
getRowFilter: () => undefined,
44+
getObjectApiOperations: () => undefined,
45+
roles: [],
46+
isLoaded: false,
47+
hasCapabilities: () => true,
48+
can: () => true,
49+
cannot: () => false,
50+
};
51+
const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] };
52+
return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms };
53+
});
54+
55+
vi.mock('@object-ui/auth', async (importOriginal) => ({
56+
...(await importOriginal<Record<string, unknown>>()),
57+
useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }),
58+
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
59+
createAuthenticatedFetch: () => vi.fn(),
60+
}));
61+
62+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
63+
...(await importOriginal<Record<string, unknown>>()),
64+
useRealtimeSubscription: () => ({ lastMessage: null }),
65+
useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }),
66+
}));
67+
68+
vi.mock('sonner', () => ({
69+
toast: Object.assign(vi.fn(), {
70+
success: vi.fn(), error: vi.fn(), info: vi.fn(),
71+
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
72+
}),
73+
}));
74+
75+
vi.mock('./MetadataInspector', () => ({
76+
MetadataPanel: () => null,
77+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
78+
}));
79+
// The record body: not what this file reads.
80+
vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));
81+
82+
vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({
83+
...(await importOriginal<Record<string, unknown>>()),
84+
useMetadataClient: () => ({ save: vi.fn(async () => ({})), get: vi.fn(async () => null) }),
85+
}));
86+
87+
import { ObjectView } from './ObjectView';
88+
import { ExpressionProvider } from '../providers/ExpressionProvider';
89+
import { RECORD_OPEN_PREFERENCE_KEY } from '../utils/recordOpenPreference';
90+
91+
const OBJECT_NAME = 'duly_task';
92+
const VIEW_ID = `${OBJECT_NAME}.all`;
93+
const ROWS = [{ id: 'r1', name: 'Alpha' }];
94+
95+
const objectsWith = (navigation?: Record<string, unknown>) => [
96+
{
97+
name: OBJECT_NAME,
98+
label: 'Task',
99+
fields: {
100+
id: { type: 'text', label: 'Id' },
101+
name: { type: 'text', label: 'Name' },
102+
},
103+
listViews: {
104+
[VIEW_ID]: {
105+
name: VIEW_ID,
106+
label: 'Everything',
107+
type: 'grid',
108+
columns: ['name'],
109+
...(navigation ? { navigation } : {}),
110+
},
111+
},
112+
},
113+
];
114+
115+
beforeEach(() => {
116+
vi.stubGlobal(
117+
'fetch',
118+
vi.fn(async () =>
119+
new Response(JSON.stringify({ data: [] }), {
120+
status: 200,
121+
headers: { 'content-type': 'application/json' },
122+
}),
123+
),
124+
);
125+
});
126+
127+
afterEach(() => {
128+
cleanup();
129+
vi.unstubAllGlobals();
130+
window.localStorage.removeItem(RECORD_OPEN_PREFERENCE_KEY);
131+
window.history.replaceState(null, '', '/');
132+
});
133+
134+
/** Mount the object list, its one row drawn. */
135+
async function openList(objects: ReturnType<typeof objectsWith>) {
136+
const dataSource = {
137+
find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
138+
findOne: vi.fn(async () => null),
139+
create: vi.fn(async () => ({})),
140+
update: vi.fn(async () => ({})),
141+
delete: vi.fn(async () => ({})),
142+
getObjectSchema: vi.fn(async (name: string) => objects.find((o) => o.name === name) ?? null),
143+
} as never;
144+
// The list's own record href is built from the DOCUMENT's path
145+
// (`listRecordDetailUrl`), which a `MemoryRouter` does not move.
146+
window.history.replaceState(null, '', `/apps/demo/${OBJECT_NAME}/view/${VIEW_ID}`);
147+
render(
148+
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })}>
149+
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'user' }}>
150+
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}/view/${VIEW_ID}`]}>
151+
<Routes>
152+
<Route
153+
path="/apps/:appName/:objectName/view/:viewId"
154+
element={<ObjectView dataSource={dataSource} objects={objects} onEdit={() => {}} />}
155+
/>
156+
<Route
157+
path="/apps/:appName/:objectName/record/:recordId"
158+
element={<output data-testid="record-page-route">record page</output>}
159+
/>
160+
</Routes>
161+
</MemoryRouter>
162+
</ExpressionProvider>
163+
</I18nProvider>,
164+
);
165+
await screen.findByTestId('primary-field-link', undefined, { timeout: 5000 });
166+
}
167+
168+
function openDisplay() {
169+
fireEvent.click(screen.getByTestId('toolbar-display-trigger'));
170+
}
171+
172+
describe('objectstack#26681 — Open records in the Display menu', () => {
173+
it('a drawer object: choosing Split keeps the preference, and the next row click opens the record beside the list', async () => {
174+
await openList(objectsWith());
175+
openDisplay();
176+
const drawerSegment = await screen.findByRole('radio', { name: 'Drawer' });
177+
expect(drawerSegment.getAttribute('aria-checked')).toBe('true');
178+
fireEvent.click(screen.getByRole('radio', { name: 'Split' }));
179+
180+
expect(window.localStorage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe('split');
181+
// The section reads the live value back: no reload, no remount.
182+
await waitFor(() =>
183+
expect(screen.getByRole('radio', { name: 'Split' }).getAttribute('aria-checked')).toBe('true'),
184+
);
185+
186+
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
187+
fireEvent.click(screen.getByTestId('primary-field-link'));
188+
expect(await screen.findByTestId('record-count-footer', undefined, { timeout: 5000 })).toBeTruthy();
189+
expect(screen.getByRole('button', { name: 'Close panel' })).toBeTruthy();
190+
expect(screen.queryByRole('dialog')).toBeNull();
191+
});
192+
193+
it('a drawer object with no choice made: the row opens the drawer', async () => {
194+
await openList(objectsWith());
195+
fireEvent.click(screen.getByTestId('primary-field-link'));
196+
expect(await screen.findByRole('dialog', undefined, { timeout: 5000 })).toBeTruthy();
197+
expect(screen.queryByTestId('record-count-footer')).toBeNull();
198+
});
199+
200+
it('a view that declares `page`: the read-only line, no segments, and no ↗', async () => {
201+
window.localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'split');
202+
await openList(objectsWith({ mode: 'page' }));
203+
expect(screen.queryByTestId('record-open-as-page')).toBeNull();
204+
openDisplay();
205+
expect(await screen.findByTestId('display-record-open-declared')).toBeTruthy();
206+
expect(within(screen.getByTestId('display-record-open')).queryByRole('radio')).toBeNull();
207+
});
208+
209+
it('the ↗ beside a drawer row is an anchor to the record route, and a click lands there without changing the preference', async () => {
210+
await openList(objectsWith());
211+
const arrow = await screen.findByTestId('record-open-as-page');
212+
expect(arrow.tagName).toBe('A');
213+
expect(arrow.getAttribute('href')).toBe(`/apps/demo/${OBJECT_NAME}/record/r1`);
214+
expect(arrow.getAttribute('aria-label')).toBe('Open as full page');
215+
expect(arrow.getAttribute('title')).toBe('Open as full page');
216+
// Beside the row's record link, never inside it.
217+
const pair = arrow.closest('[data-testid="link-cell-trailing"]') as HTMLElement;
218+
expect(pair.querySelector('[data-testid="primary-field-link"]')).not.toBeNull();
219+
expect(arrow.parentElement?.closest('a')).toBeNull();
220+
221+
fireEvent.click(arrow);
222+
expect(await screen.findByTestId('record-page-route')).toBeTruthy();
223+
// Neither the drawer nor the pane opened on the way, and nothing was kept.
224+
expect(screen.queryByRole('dialog')).toBeNull();
225+
expect(window.localStorage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBeNull();
226+
});
227+
});

0 commit comments

Comments
 (0)