Skip to content

Commit 42afac7

Browse files
feat(studio): pick an object list view from the Interfaces header title, and keep it in the address (#30175)
Part of #30046 — this PR ships Scope 1–5. Scope 6–8 (fold the `surface=view:` page into this one, the 不在导航中 → 视图 filter, readable form-view names and their 视图类型) are left for a decision: see the size reading in Acceptance notes. Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio「界面」里对象入口的标题变成「对象 / 视图 ▾」,用运行态列表同一个下拉切换视图、搜索、新建视图;右侧面板的视图选择器、「添加视图」和「返回…」去掉了;当前视图写进地址栏 `?view=`。 - 为什么改:维护者要求「在预览区域顶部下拉选择视图。在对象的位置」,与前端运行态一致;原来切换视图后标题、地址都不变,刷新就回到默认视图,无法分享链接。 - 风险与回滚:只动 Studio 界面支柱(`ui/packages/app-shell`)与 studio 文案;不改 spec、不改服务端、不改元数据。运行态列表的下拉只多了「📌 可省略」一项,运行态照传,行为不变。回滚即 revert 本 PR。 - 先看:`ObjectListViewTitle.tsx`(新标题与新建视图)、`ObjectListViewInspector.tsx` 中 `useObjectListViewDraft` 的 `initialView` 一段(链接带来的视图何时生效、何时失效)、`StudioItemHeader.tsx` 的 `readableTitleWidth`(组合标题声明可读宽度)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.listViewSwitch-27896.test.tsx`,预期 10 passed;或在 Studio 打开 CRM 的「商机」,标题下拉选「商机看板」,地址出现 `&view=crm_opportunity.pipeline`,刷新后仍是看板。 - 需要你做的:无,席位落地。 ## What changes - **The view switcher is the header's title.** An Interfaces `object` entry's header reads object / view ▾. The view part is the running list's own `ObjectViewTitleSwitcher`, fed the object's list views (the merged `listViews` plus this package's drafts, as the panel picker listed them): rows by label, a search box, and New view, which opens the existing Create View dialog. Studio hands it no ☆, no 📌, no recent views and no Save as my view. It shows when the object has one list view too, so New view stays reachable. On a read-only package it switches views and offers no New view (`canCreate = !readOnly`, as the panel's Add view was gated). New file `ObjectListViewTitle.tsx`; the create path (`createView`, the taken-name toast) moved there unchanged. - **The panel's picker, Add view and "Back to …" are gone.** The panel keeps the open view's settings. Its title is the view's name exactly as the switcher gives it (`listViewNameOf`: the served `listViews` label first, else the item's own), so the CRM kanban's panel reads 商机看板 like the switcher, not the raw `Pipeline (Kanban)`. A label-less view is "Default view" or its key; the object's name moved to the header's prefix. - **The open view is in the address.** `?surface=object:OBJECT&nav=NAV&view=OBJECT.KEY`, written only while a view other than the entry's own is open. `useSurfaceDeepLink` is split into its capture and mirror halves (the composed hook is unchanged for the other pillars), so the Interfaces pillar writes the leaf and its view in one replace. `useObjectListViewDraft` takes `initialView`: shown once the linked leaf opens, dropped as soon as another leaf opens or the author picks a view, and taken only for a view of the open object. A pending edit is still sent before the switch (`switchListView`, unchanged), and a held edit still asks. - **`ObjectViewTitleSwitcher`: the pin is optional.** `pinnedViewId`, `onPinnedViewChange` and `recentViewIds` may be left out; without `onPinnedViewChange` no row draws 📌 and no Pinned section shows. The running list passes all three, so its behaviour is unchanged (its existing pins stay green, and a new pin holds both directions). - **The title keeps its view name on a narrow row** (measured, see Verification). A composed title shrinks inside its box instead of overflowing, so #29693's fold never saw it as cut: at 1280 on the read-only CRM it showed `Op… / Pipeline (K…` with room to spare in the labelled actions. The title now declares its whole width (`data-studio-title-readable`, read by `readableTitleWidth`) and carries a zero-height sizer whose text is a pseudo-element (no reader or tooltip reads it twice), and "Open in Data" folds to its glyph like the other labelled actions. Order on a narrow row: chips and tools fold, then the object prefix truncates (down to 1.5em), then the view name, with its whole text in the trigger's tooltip. - **Strings (`@object-ui/i18n` studio namespace):** adds `engine.studio.inspector.listView.defaultView`; removes `defaultTitle`, `namedTitle`, `addView`, `backToEntry`, `switchView`, `switchViewDraft`, which only the removed panel controls read. The switcher's own words (搜索视图, 新建视图) are the console's existing `view.*` keys. ## Tests All on head `ba9af0941`. <details><summary>Affected suites: 140 files, 1843 passed</summary> `pnpm --dir ui exec vitest run --maxWorkers=2` over every studio-design test that reads the Interfaces header, the list-view panel or the deep link (40 files), the switcher's own tests and the running list's switcher pins (`objectViewTitleSwitcher.test.tsx`, `objectViewTitleSwitcher.phoneWidth-29074`, `ObjectView.titleViewSwitcher-27594`, `ObjectView.starredView-27594`, `ObjectView.personalViews-29635`) and `packages/i18n/`: `Test Files 140 passed (140) · Tests 1843 passed | 13 skipped`. Typecheck: `pnpm --filter @object-ui/app-shell type-check` exit 0 (both programs). </details> <details><summary>New and rewritten pins</summary> - `StudioDesignSurface.listViewSwitch-27896.test.tsx` (rewritten onto the header switcher, 10 tests): the switcher lists all three views by label, the panel title is the same name, no ☆ / 📌 / recent / Save as my view, New view and search present; the panel holds no picker, Add view or Back; a drafted view's edit saves to that view; a pending edit is sent before the switch with no prompt; a held edit asks, declined stays, accepted switches; one list view still shows the switcher with New view; a read-only package switches and offers no New view; a pick writes `?view=` and the entry's view writes none; a link carrying `view` opens that view (a drafted one); CONTROL: a link naming another object's view is not taken. - `ObjectListViewInspector.addView-27836.test.tsx`: New view from the switcher creates the kanban and the calendar, refuses a taken name; the switcher lists the new view and picking the entry's view shows it again. - `objectViewTitleSwitcher.test.tsx`: new top-level describe — without the pin door no row draws 📌 and a handed pinned id draws no Pinned section; a row still switches. - `useSurfaceDeepLink.test.ts`: `view` parses beside `surface` and `nav`, back-compat without it, `view` alone names nothing. - `ObjectListViewInspector.displayTitle-13151`, `StudioDesignSurface.listViewInspector-11823`, `InterfacesPillar.customize-28562`: expectations moved to the view-only title and to the switcher's New view. </details> <details><summary>Ablations (one per changed behaviour, restored and proven by blob hash)</summary> Through `scripts/ablation-replace.mjs`; the suites import `src` directly, so no build sits in the path. - Mirror passes `null` for the view: `a pick writes ?view=` and `a link carrying the view` go red (2 failed / 8 passed); restored blob `b26a265eff49` == HEAD, `git diff HEAD` empty, 10/10 again. - `initialView: null`: `a link carrying the view` goes red (1 failed / 9 passed); restored == HEAD. - Pin always drawn: `no row draws 📌` goes red (1 failed / 14 passed); restored == HEAD. The first run of the mirror ablation turned only one pin red: the link pin read the address before the mirror's write landed. The pin now waits for the writes to land, and the re-run turned both red. </details> ## Acceptance notes - **Scope 6–8 are not in this PR.** The size reading: this PR is 1059 changed lines (712 + / 347 −, 16 files) for Scope 1–5. Scope 6 means folding 条件格式化 (with its CEL verdict wired into the list buffer's autosave hold) and the spec-form sections into the list panel, and sending `surface=view:` for a list view to the object page, including for an object no nav entry opens. Scope 7 is the 不在导航中 filter (a draft header carries no body, so the view's kind and object are known only from published rows or the name). Scope 8 is readable form-view names, a heading that says what remains, and the form 视图类型. Together they are an estimated 600–800 more lines with their pins. Also, the 视图类型 fix lives in `metadata-admin/inspectors/ViewVariantInspector.tsx` (its type options come from the list-variant enum, so a form's `simple` reads 未找到), which is outside this claim's file surface. - **The header-width table on the card does not hold on the read-only CRM.** With Customize, 只读 and Open in Data in the row, the title's room at 1280 is about 208 px, not ≥ 222 px. Before the fold fix the title clipped at 1280 too. After it, 1280 shows both names whole, and 1024 folds the tools and keeps the view name (`Pipeline (Kanban)` / 商机看板) whole with the object prefix cut. - **Seen, not fixed (pre-existing, not changed here):** on a draft-only object, an entry with no `viewName` opens `OBJECT.default` even when the package drafts an `isDefault` view, because the entry's view is resolved from the shell's merged `listViews` only. The switcher therefore lists "Default view" beside the drafted default, and Create View seeds no columns there. Both behaved the same with the old panel picker. Reported to the seat. - No spec, server or metadata change, and no new metadata key. ## Verification Browser, latest `main` + this branch, isolated stack (CRM `--fresh` backend on its own port, Console dev server pointed at it, Chromium from `/opt/pw-browsers`). The screenshots are in the session's scratchpad. They are not attached here because the relay carries no uploads. <details><summary>Read-only package (com.example.crm), en and zh, 1280 and 1024</summary> - 1280, 商机: title `商机 / 全部商机 ▾`; the dropdown lists 全部商机, 商机看板; picking 商机看板 writes `&view=crm_opportunity.pipeline`; reload keeps it; the panel title reads 商机看板. - 1280, en: `Opportunities / Pipeline (Kanban) ▾`, both whole (title 315 px). - 1024, en: tools folded to glyphs, `Op… / Pipeline (Kanban) ▾`, view name whole (label 151 px, not cut). - 1024, zh: `商机 / 商机看板 ▾`, both whole. - No New view in the dropdown; the panel's controls stay disabled. </details> <details><summary>Writable package (a fresh package with one object entry)</summary> - New view from the dropdown, named "Open opportunities this quarter": saved to the package draft, the URL gains `&view=…open_opportunities_this_quarter`, the panel and the title show it. - Switch to another view and back: the dropdown lists the new view. - Reload: the same view opens. A shared link opened in a second tab at 1024 opens the same view: chips folded, the long name truncated with its whole text in the trigger's tooltip. - The pending-edit-before-switch path was not driven in the browser (the drafted object's new view had no column to remove). It is pinned by `a pending edit is sent to its own view before the switch, and nothing asks`. </details> <details><summary>Local gates (on ba9af09)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 families; each was run and exited 0, `pnpm check:type-check-debt` included (`0 raw tsc error(s) total`) and `pnpm --dir ui exec vitest run scripts/` (172 files, 4985 passed). `--ran` reconciliation: `35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN`. `node scripts/check-sdui-manifest.mjs` exit 0; `pnpm --dir ui run check:new-line-citations`: `0 new citation(s)`; `node scripts/check-platform-checklist.mjs --changed-from origin/main` exit 0; eslint over the changed files: 0 errors. Repo-wide scans and the full UI test battery are CI's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYnE9YNkSZen7sijLA3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e9fa4b commit 42afac7

17 files changed

Lines changed: 716 additions & 347 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(app-shell): Studio Interfaces picks an object's list view from the header title, object / view ▾, the way the running list does, and the open view stays in the address
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **The view switcher is in the header's title.** An `object` entry's header reads object / view ▾. The switcher is the running list's own dropdown (`ObjectViewTitleSwitcher`): the object's list views by label, a view only drafted in this package included, a search box, and New view, which opens the Create View dialog. Studio does not offer the reader's own features there: no ☆, no 📌, no recent views, no Save as my view. It shows when the object has one list view too, so New view is always one click away. A read-only package switches views and offers no New view.
12+
- **The Properties panel no longer holds a view picker, Add view or "Back to …".** It keeps the open view's settings, and its title is the view's name as the switcher gives it (the label the object's served `listViews` carry). A view with no label is named "Default view" or by its key; the header names the object before it.
13+
- **The open view is in the address.** Switching writes `?view=<object>.<key>` beside `?surface=` and `?nav=`; the entry's own view writes none. A reload or a shared link opens that view again, a view only drafted in this package included. A link naming a view of another object is not taken. A pending edit is still sent before a switch, and an edit that cannot be sent still asks first.
14+
- **`ObjectViewTitleSwitcher`: the pin is optional.** `onPinnedViewChange`, `pinnedViewId` and `recentViewIds` may be left out; without `onPinnedViewChange` no row draws 📌 and no Pinned section is listed. The running list passes all three, so it is unchanged.
15+
- **`@object-ui/i18n` (studio namespace):** adds `engine.studio.inspector.listView.defaultView`; removes `defaultTitle`, `namedTitle`, `addView`, `backToEntry`, `switchView` and `switchViewDraft`, which only the removed panel controls read.

‎ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ const OBJECTS_READERS: Record<string, { kind: Kind; why: string }> = {
115115
'packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx': { kind: 'off', why: 'the metadata admin' },
116116
'packages/app-shell/src/views/metadata-admin/previews/ScreenPreview.tsx': { kind: 'off', why: "the metadata admin's form-screen preview" },
117117
'packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx': { kind: 'off', why: 'Studio' },
118+
'packages/app-shell/src/views/studio-design/ObjectListViewTitle.tsx': {
119+
kind: 'off',
120+
why: "Studio: the object's view labels New view refuses to reuse, the read the list-view panel made before the switcher moved into the header (objectstack#30046)",
121+
},
118122
'packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx': { kind: 'off', why: 'Studio' },
119123
'packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx': {
120124
kind: 'off',

‎ui/packages/app-shell/src/views/objectViewTitleSwitcher.test.tsx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,36 @@ describe('objectstack#27594 — the title switcher dropdown', () => {
190190
expect(within(menu).queryByTestId('list-view-switcher-manage')).toBeNull();
191191
});
192192
});
193+
194+
describe('objectstack#30046 — a host with no reader of its own hands no pin and no recent views', () => {
195+
function mountStudio(props: Partial<ObjectViewTitleSwitcherProps> = {}) {
196+
const onViewChange = vi.fn();
197+
render(
198+
<ObjectViewTitleSwitcher
199+
views={VIEWS}
200+
activeViewId="all"
201+
activeLabel="All Leads"
202+
favorites={null}
203+
onViewChange={onViewChange}
204+
t={t}
205+
{...props}
206+
/>,
207+
);
208+
fireEvent.click(screen.getByTestId('list-view-switcher'));
209+
return { menu: screen.getByTestId('list-view-switcher-menu'), onViewChange };
210+
}
211+
212+
it('no row draws 📌, and a pinned id handed without the pin door draws no Pinned section', () => {
213+
const { menu } = mountStudio({ pinnedViewId: 'mine' });
214+
expect(within(menu).queryAllByTestId('view-switcher-pin')).toHaveLength(0);
215+
expect(within(menu).queryByTestId('list-view-switcher-pinned')).toBeNull();
216+
expect(within(menu).queryByTestId('list-view-switcher-recent')).toBeNull();
217+
expect(sectionIds('list-view-switcher-all')).toEqual(['all', 'board', 'mine', 'saved_a', 'saved_b']);
218+
});
219+
220+
it('a row still switches the view', () => {
221+
const { menu, onViewChange } = mountStudio();
222+
fireEvent.click(within(within(menu).getByTestId('view-switcher-row-board')).getAllByRole('button')[0]);
223+
expect(onViewChange).toHaveBeenCalledWith('board');
224+
});
225+
});

‎ui/packages/app-shell/src/views/objectViewTitleSwitcher.tsx‎

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,15 @@ export interface ObjectViewTitleSwitcherProps {
7777
description?: string;
7878
onViewChange: (viewId: string) => void;
7979
/** The reader's pinned view for this object, if any. */
80-
pinnedViewId: string | null;
81-
/** Pin a view (it opens by default), or `null` to unpin. */
82-
onPinnedViewChange: (viewId: string | null) => void;
83-
/** The views the reader opened lately, most recent first. */
84-
recentViewIds: readonly string[];
80+
pinnedViewId?: string | null;
81+
/**
82+
* Pin a view (it opens by default), or `null` to unpin. Absent ⇒ no row
83+
* offers 📌 and no Pinned section is listed: a host with no reader of its
84+
* own (the Studio, objectstack#30046) has no default to pin.
85+
*/
86+
onPinnedViewChange?: (viewId: string | null) => void;
87+
/** The views the reader opened lately, most recent first; absent ⇒ no Recent section. */
88+
recentViewIds?: readonly string[];
8589
/** The ☆ of each row; absent off an app route, where no row offers one. */
8690
favorites: ListViewFavorites | null;
8791
viewTypeIcons?: Record<string, ComponentType<{ className?: string }>>;
@@ -123,9 +127,9 @@ export function ObjectViewTitleSwitcher({
123127
count,
124128
description,
125129
onViewChange,
126-
pinnedViewId,
130+
pinnedViewId = null,
127131
onPinnedViewChange,
128-
recentViewIds,
132+
recentViewIds = [],
129133
favorites,
130134
viewTypeIcons = VIEW_TYPE_ICONS,
131135
onAddView,
@@ -144,7 +148,7 @@ export function ObjectViewTitleSwitcher({
144148
const all = useMemo(() => authoredFirst(views), [views]);
145149
const shared = all.filter((v) => !v.personal);
146150
const mine = all.filter((v) => v.personal);
147-
const pinned = pinnedViewId ? byId.get(pinnedViewId) : undefined;
151+
const pinned = pinnedViewId && onPinnedViewChange ? byId.get(pinnedViewId) : undefined;
148152
const recent = recentViewIds
149153
.filter((id) => id !== pinned?.id)
150154
.map((id) => byId.get(id))
@@ -227,21 +231,23 @@ export function ObjectViewTitleSwitcher({
227231
<Star className={cn('h-3.5 w-3.5', starred && 'fill-current text-amber-500')} />
228232
</button>
229233
)}
230-
<button
231-
type="button"
232-
data-testid="view-switcher-pin"
233-
aria-label={pinLabel}
234-
title={pinLabel}
235-
aria-pressed={isPinned}
236-
onClick={() => onPinnedViewChange(isPinned ? null : view.id)}
237-
className={cn(
238-
'flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground max-sm:h-11 max-sm:w-11',
239-
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
240-
isPinned ? 'text-foreground' : reveal,
241-
)}
242-
>
243-
<Pin className={cn('h-3.5 w-3.5', isPinned && 'fill-current')} />
244-
</button>
234+
{onPinnedViewChange && (
235+
<button
236+
type="button"
237+
data-testid="view-switcher-pin"
238+
aria-label={pinLabel}
239+
title={pinLabel}
240+
aria-pressed={isPinned}
241+
onClick={() => onPinnedViewChange(isPinned ? null : view.id)}
242+
className={cn(
243+
'flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground max-sm:h-11 max-sm:w-11',
244+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
245+
isPinned ? 'text-foreground' : reveal,
246+
)}
247+
>
248+
<Pin className={cn('h-3.5 w-3.5', isPinned && 'fill-current')} />
249+
</button>
250+
)}
245251
{view.personal && onRenamePersonalView && (
246252
<button
247253
type="button"

‎ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -568,15 +568,25 @@ async function openOpportunities(): Promise<void> {
568568
await waitFor(() => expect(setting('startDateField')).toHaveTextContent('Close date'), { timeout: 8000 });
569569
}
570570

571+
/** Whether the header's view switcher (the title's ▾) offers New view; closes it again. */
572+
async function newViewOffered(): Promise<boolean> {
573+
fireEvent.click(within(header()).getByTestId('list-view-switcher'));
574+
const menu = await screen.findByTestId('list-view-switcher-menu');
575+
const offered = within(menu).queryByTestId('list-view-switcher-new') !== null;
576+
fireEvent.keyDown(menu, { key: 'Escape' });
577+
await waitFor(() => expect(screen.queryByTestId('list-view-switcher-menu')).toBeNull());
578+
return offered;
579+
}
580+
571581
const canvasSchema = () => JSON.parse(screen.getByTestId('object-view-schema').textContent ?? '{}') as Record<string, unknown>;
572582

573583
describe('Customize an object leaf’s list view into the pair (objectstack#28562)', () => {
574584
it('the panel is read-only until Customize; the first edit writes the view’s draft into the pair, declaring the override', async () => {
575585
mount();
576586
await openOpportunities();
577587
expect(setting('startDateField')).toBeDisabled();
578-
// A new view would be a new item of the base: not offered.
579-
expect(screen.getByRole('button', { name: t('engine.studio.inspector.listView.addView', 'en') })).toBeDisabled();
588+
// A new view would be a new item of the base: the header's view switcher offers none.
589+
expect(await newViewOffered()).toBe(false);
580590

581591
await customize();
582592
expect(srv.ensure).toEqual([]);
@@ -591,7 +601,7 @@ describe('Customize an object leaf’s list view into the pair (objectstack#2856
591601
expect(saved!.opts).toMatchObject({ ifNoneMatch: '*' });
592602
expect(srv.base.get('view/opportunity.default')).toStrictEqual(BASE_ROWS['view/opportunity.default']);
593603
await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 });
594-
expect(screen.getByRole('button', { name: t('engine.studio.inspector.listView.addView', 'en') })).toBeDisabled();
604+
expect(await newViewOffered()).toBe(false);
595605
});
596606

597607
it('Restore brings back the base’s columns', async () => {

‎ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.addView-27836.test.tsx‎

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* objectstack#27836 — "Add view" in an `object` leaf's list-view panel: the
5-
* type first, then the fields that type requires, picked by label; the view is
6-
* created in the package draft and the panel shows it.
4+
* objectstack#27836 — New view, from an `object` leaf's view switcher (the
5+
* header's title, objectstack#30046): the type first, then the fields that
6+
* type requires, picked by label; the view is created in the package draft and
7+
* the panel shows it.
78
*
8-
* The REAL hook (`useObjectListViewDraft`), the REAL panel and the REAL Create
9-
* View dialog over a REAL `MetadataClient`, whose transport judges every view
10-
* PUT with the installed spec's `view` schema and answers the write door's 422
11-
* on refusal.
9+
* The REAL hook (`useObjectListViewDraft`), the REAL title switcher
10+
* (`ObjectListViewTitle`), the REAL panel and the REAL Create View dialog over
11+
* a REAL `MetadataClient`, whose transport judges every view PUT with the
12+
* installed spec's `view` schema and answers the write door's 422 on refusal.
1213
*
1314
* Pinned:
1415
* - a kanban and a calendar are created, each under its own name, with the
1516
* picked fields, in the package draft (`If-None-Match: *`), and the spec
1617
* accepts them; the panel then edits the new view, and the default grid is
1718
* not written;
18-
* - "Back to …" shows the view the nav entry opens again;
19+
* - the switcher lists the new view, and picking the nav entry's view there
20+
* shows that one again;
1921
* - a name a view already holds is refused: nothing is written, the dialog
2022
* stays open, and the panel says why.
2123
*/
@@ -121,6 +123,7 @@ vi.mock('sonner', async (importOriginal) => {
121123
});
122124

123125
import { ObjectListViewInspector, useObjectListViewDraft } from './ObjectListViewInspector';
126+
import { ObjectListViewTitle } from './ObjectListViewTitle';
124127
import type { Surface } from './navSurface';
125128
import { tFormat } from '../metadata-admin/i18n';
126129

@@ -143,7 +146,14 @@ const LEAF: Surface = { type: 'object', name: 'acme_task', label: 'Tasks' };
143146

144147
function Host() {
145148
const listView = useObjectListViewDraft({ client, packageId: PKG, leaf: LEAF, publishNonce: 0 });
146-
return <ObjectListViewInspector listView={listView} readOnly={false} locale="en" />;
149+
return (
150+
<>
151+
<h1>
152+
<ObjectListViewTitle listView={listView} objectText="Tasks" locale="en" canCreate />
153+
</h1>
154+
<ObjectListViewInspector listView={listView} readOnly={false} locale="en" />
155+
</>
156+
);
147157
}
148158

149159
async function settle() {
@@ -159,7 +169,15 @@ async function openPanel(): Promise<void> {
159169
</MetadataCtx.Provider>,
160170
);
161171
await settle();
162-
await waitFor(() => expect(screen.getByTestId('list-view-add-view')).toBeEnabled());
172+
}
173+
174+
/** Opens the switcher (the title's ▾) and reads its rows, by label. */
175+
async function openSwitcher(): Promise<string[]> {
176+
if (!screen.queryByTestId('list-view-switcher-menu')) fireEvent.click(screen.getByTestId('list-view-switcher'));
177+
const menu = await screen.findByTestId('list-view-switcher-menu');
178+
return Array.from(menu.querySelectorAll('[data-testid^="view-switcher-row-"]')).map(
179+
(row) => row.querySelector('button')?.textContent ?? '',
180+
);
163181
}
164182

165183
/** The view item name the panel shows beneath its title. */
@@ -174,7 +192,8 @@ const submitButton = () => screen.getByTestId('create-view-submit') as HTMLButto
174192

175193
/** "Add view", pick `type`, pick each required field by its label, name the view `label`, and press Create. */
176194
async function addView(type: string, picks: Array<[key: string, label: string]>, label: string) {
177-
fireEvent.click(screen.getByTestId('list-view-add-view'));
195+
await openSwitcher();
196+
fireEvent.click(await screen.findByTestId('list-view-switcher-new'));
178197
await waitFor(() => expect(typeCard(type)?.disabled).toBe(false));
179198
fireEvent.click(typeCard(type)!);
180199
for (const [key, option] of picks) {
@@ -221,7 +240,7 @@ describe('ObjectListViewInspector — "Add view" creates a view of the chosen ty
221240
await waitFor(() => expect(screen.queryByTestId('create-view-submit')).toBeNull());
222241
await waitFor(() => expect(shownView()).toBe('acme_task.pipeline_board'));
223242
expect(screen.getByTestId('list-view-kanban-card')).toBeInTheDocument();
224-
expect(screen.getByTestId('list-view-back-to-entry')).toBeInTheDocument();
243+
expect(screen.getByTestId('list-view-switcher')).toHaveTextContent('Pipeline board');
225244
});
226245

227246
it('a calendar: born with its start-date and title fields, picked by label', async () => {
@@ -236,15 +255,17 @@ describe('ObjectListViewInspector — "Add view" creates a view of the chosen ty
236255
await waitFor(() => expect(shownView()).toBe('acme_task.due_dates'));
237256
});
238257

239-
it('"Back to …" shows the view the nav entry opens again', async () => {
258+
it('the switcher lists the new view, and picking the entry\'s view there shows that one again', async () => {
240259
await openPanel();
241260
await addView('kanban', [['groupByField', 'Status']], 'Pipeline board');
242261
await waitFor(() => expect(shownView()).toBe('acme_task.pipeline_board'));
243262

244-
fireEvent.click(screen.getByTestId('list-view-back-to-entry'));
263+
const rows = await openSwitcher();
264+
expect(rows).toContain('Pipeline board');
265+
expect(rows).toContain('Default view');
266+
fireEvent.click(screen.getByTestId(`view-switcher-row-${DEFAULT_VIEW}`).querySelector('button')!);
245267
await settle();
246268
expect(shownView()).toBe(DEFAULT_VIEW);
247-
expect(screen.queryByTestId('list-view-back-to-entry')).toBeNull();
248269
});
249270

250271
it('a name a view already holds: nothing is written, the dialog stays open, and the panel says why', async () => {

0 commit comments

Comments
 (0)