Skip to content

Commit bc8945f

Browse files
feat(app-shell): Studio opens an off-nav list view on its object's page, lists only views no dropdown reaches, and reads form views as forms (#30401)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgXkTWXsjBzzss3KguAuRzTF)_ domain:ui#2 · os-bill · session_01Utp6Cb5R6JDdfyvtj3dPR2 ## 维护者速读 - 改了什么:Studio 界面里,从「不在导航中 → 视图」打开列表视图,现在落到对象页(标题 对象 / 视图 ▾),条件格式化和视图表单分节一并搬到右侧面板;有导航入口的对象,其列表视图不再重复列在该分组;表单视图显示为「线索表单」这类可读名字,分组标题按内容写「表单视图 / 视图 / 表单视图与列表视图」,表单视图的「视图类型」读出 简单 / 标签页 / 向导,不再是「simple(未找到)」。 - 为什么改:#30046 Scope 6–8(裁定 A,6651738887)。同一个列表视图以前有两个页面、两套面板,导航里已能到达的视图又在「不在导航中」重复出现,表单视图名字是 `crm_lead.form` 这类内部名。 - 风险与回滚:只动 Studio 前端(app-shell 与 studio 语言包三个新键),不动元数据、spec 或服务端。共用的视图检查器只在表单视图上换类型清单,列表视图那一侧不变并有测试钉住。回滚即 revert 本 PR。 - 先看:`StudioDesignSurface.tsx` 的 `pickOffNav` / `listViewDoorOf` 与离线分组过滤,`ListViewMoreSection.tsx`(新),`ViewVariantInspector.tsx` 的 `useTypeOptions`。 - 两分钟验证:打开示例 CRM 的 Studio → 界面:「不在导航中」只剩「表单视图」4 项;点「线索表单」,视图类型显示「简单」;访问 `?surface=view:crm_lead.pipeline` 落到「线索 / 线索看板」。 - 需要你做的:本 PR 新增三个 i18n 键(Clause-② widening),席位需安排 TIER_DEFAULT 评审;其余无。 Fixes #30046 Clause-②: yes (widening) — three new published `@object-ui/i18n` Studio keys: `engine.studio.if.offNav.viewForms`, `engine.studio.if.offNav.viewMixed`, `engine.studio.if.offNav.formName` (en + zh). No accept set narrowed. Metadata-protocol: none — Studio UI only: no metadata key, spec schema, server route or stored shape is added or read differently; the view items are read through the existing `/meta/view` list (drafts overlaid) and written through the existing list-view draft path. ## What changes Scope 6–8 of #30046, ruled as option A (6651738887). Scope 1–5 landed in #30175 and are untouched. - **Scope 6 — a list view opens on its object's page from every door.** Picking a list view under 不在导航中 → 视图, or following a link to the view itself (`?surface=view:OBJECT.KEY`), now lands on the object page with its header dropdown (object / view ▾) on that view: the object's nav entry when it has one, else the object itself with no entry (`?surface=object:OBJECT&view=…`, which a reload or a shared link opens again). The panel there gains `ListViewMoreSection` (new): the view's 条件格式化 (`ConditionalFormattingEditor`, whose CEL verdicts now hold the list-view autosave the way they held the view page's Save) and the spec authoring form's sections for a view, pruned of every key the panel already edits, so no setting has two controls. A form view, and a view a read-only base's pair adds (`addedIn`), still open as themselves. #30039 owns translating those sections. - **Scope 7 — the 视图 group lists what the dropdowns do not reach.** A list view whose object has a nav entry is no longer listed, a view only drafted in the package included (`withDraftViewBindings` reads the drafted rows' `object`/`viewKind` from the draft-overlaid list, fail-soft). The list views of an object with no entry stay listed. The only-customized filter is unchanged. - **Scope 8 — form views read as forms.** A form view is listed by its label, else as the object's name plus 表单 ("Lead form", 「线索表单」, with ` · key` for a non-default form); the group's heading says what it holds (表单视图, 视图, or 表单视图与列表视图). In `ViewVariantInspector`, a form view's 视图类型 reads the form-variant schema's `type` enum (simple / tabbed / wizard, labelled with the existing `engine.inspector.pageBlock.option.formType.*` keys) and defaults to `simple`; a list view's picker is byte-for-byte the same roster as before. - `ObjectListViewInspector.tsx`: the hook gains `openView(surface, viewId)`; the panel appends `ListViewMoreSection` as an independent block after the sort block, with no existing section reordered (seat 4's #30089 adds a section in the same render region). #30315's `listViewTargetOf` hunks are not touched. `StudioDesignSurface.tsx`'s `restoreOriginal` / `publishNonce` region (#30238) is not touched. ## Tests - New `InterfacesPillar.offNavListViews-30046.test.tsx` (13 cases): Scope 7 filtering with controls (an object with no entry stays listed, a drafted view included), Scope 8 names and the three headings, Scope 6 object page landing, the panel's 条件格式化 and spec sections, a second pick on the same page, `?surface=object:…&view=` and `?surface=view:…` links with and without an entry, CONTROL that a form view still opens as itself, and the CEL hold (autosave blocked on a bad rule, saved once fixed). - New `ViewVariantInspector.formViewType-30046.test.tsx` (8 cases, en + zh): a form view reads its layout and offers only the form roster; a form view with no type reads Simple; CONTROL a list view still offers the list types; CONTROL an off-enum list type is still flagged. - `packageSurfaces.test.ts`: 3 cases for `viewBindingOf` / `withDraftViewBindings`. - `InterfacesPillar.offNavItems-26889.test.tsx` and `InterfacesPillar.offNavDeepLink-28561.test.tsx`: their off-nav view fixture is now a form view (`web_form`), since a list view of an object with an entry is no longer listed there; what they assert is unchanged. - Red first: with `origin/main`'s five source files checked out, 17 of the new/changed cases fail (5 files); all pass on this branch. - Ablations (`scripts/ablation-replace.mjs`, each restored with blob == HEAD and an empty `git diff HEAD`): Scope 7 filter → 3 red; the list-view door → 5 red; the form-type roster pick → 2 red (both locales); the CEL hold term → 1 red. ## Acceptance notes - Card Acceptance 5, browser checks on both package kinds (branch head before the main merge, which brought no ui change), screenshots in the project files under `ui-seat2/card-30046/screenshots/`: - Read-only example CRM (`examples/app-crm`): 不在导航中 lists only 表单视图 (4): 活动表单, 线索表单 · web_to_lead, 线索表单, 商机表单; the default landing panel carries 条件格式化 and 基础信息 below the sort block; `?surface=view:crm_lead.pipeline` lands on 线索 / 线索看板 at `surface=object:crm_lead&nav=nav_leads&view=crm_lead.pipeline`; 线索表单 opens as a form view whose 视图类型 reads 简单. - Writable throwaway package (one object with a nav entry, one without, all views drafted): the heading reads 表单视图与列表视图 with Case表单 and Open cases only; the entry object's drafted All tasks / My tasks are absent from the group and present in its header dropdown; Open cases lands on Case / Open cases at `surface=object:ws_case&view=ws_case.open`, survives a reload, and its panel shows the view's CEL rule (CEL 有效). - Observed, not in this card's scope: the form view inspector's header still reads the item name (`crm_lead.form`), and its spec sections (Layout, Columns, Title) are English in zh, which is #30039's. - Line budget: 1153 insertions, 41 deletions (1194 changed lines, about half of them tests), under the ~1200 stop. ## Verification On the final tree (head `9eff7192`, `origin/main` `870ebce3` merged in), every exit code recorded to a file before any pipe: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack <14 changed paths>`: 35 commands, all exit 0 (`pnpm --dir ui exec vitest run scripts/`: Test Files 170 passed | 2 skipped, Tests 4985 passed | 2 skipped). `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. - `node scripts/check-sdui-manifest.mjs`: exit 0 (and `--self-test` exit 0). - `pnpm --filter @object-ui/app-shell --filter @object-ui/i18n lint`: exit 0, 0 errors (warnings only, existing patterns). - `pnpm --dir ui run check:new-line-citations`: exit 0. `pnpm check:nul-bytes`: exit 0. - `pnpm exec turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n --concurrency=2` (under `os-verify-lock`): 69/69 tasks, exit 0. - Targeted re-run after the merge: the five changed/new test files, Test Files 5 passed, Tests 50 passed. - Before the merge, every test file importing a changed module plus every `InterfacesPillar.*` and `StudioDesignSurface.*` test (167 files): 1220/1221, the one red being the zh lazy-catalogue race in the new inspector test, since fixed (top-level `await loadStudioCatalogue('zh')`) and green three runs in a row. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXDf4Yt5PtKZFbtLVD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f6caa12 commit bc8945f

14 files changed

Lines changed: 1153 additions & 41 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(app-shell): Studio Interfaces opens a list view from 不在导航中 on its object's page; the 视图 group lists only what no header dropdown reaches, and names a form view and its own type
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **A list view opens on its object's page from every door.** A list view picked under 不在导航中 → 视图, and a link to the view itself (`?surface=view:OBJECT.KEY`, the All metadata door), now land on the object's page with its header dropdown (object / view ▾), on that view: the object's nav entry when it has one, else the object itself, with no entry (`?surface=object:OBJECT`, which a reload or a shared link opens again, `?view=` included). The panel there now also carries what only the view's own page offered: its conditional formatting (条件格式化), whose CEL verdicts hold the view's autosave as they held the view page's Save, and the sections of the spec's authoring form for a view, without the keys the panel already edits. A form view, and a view a read-only base's pair adds, still open as themselves.
12+
- **The 视图 group lists what the dropdowns do not reach.** A list view whose object has a nav entry is no longer listed there, a view only drafted in the package included; the list views of an object with no entry stay listed.
13+
- **Form views read as forms.** A form view is listed by its label, else as the object's name and the word for a form ("Lead form", 「线索表单」), never its item name (`crm_lead.form`); the group's heading says what it holds (表单视图, 视图, or 表单视图与列表视图). In the view inspector, a form view's 视图类型 offers the form layouts of the spec's `FormViewSchema.type` (simple, tabbed, wizard) and reads its own, where it read 「simple(未找到)」; a list view's picker is unchanged.
14+
- **`@object-ui/i18n`: three Studio keys,** `engine.studio.if.offNav.viewForms`, `engine.studio.if.offNav.viewMixed` and `engine.studio.if.offNav.formName`, in en and zh.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#30046 (Scope 8) — a form view's 视图类型 reads a real type.
5+
*
6+
* The view inspector's type picker offered the LIST-view types alone (grid,
7+
* kanban, calendar, …), read off the spec's list-variant schema, for every
8+
* view it was shown on. A form view's `type` is a form layout (`simple`,
9+
* `tabbed`, `wizard`, `FormViewSchema.type`), which that roster does not
10+
* carry, so every form view read 「simple(未找到)」: its own, valid type
11+
* flagged as not found, beside list types it can never take.
12+
*
13+
* The picker now reads the roster of the view's own family: a form view's
14+
* from the spec's form-variant schema, a list view's unchanged. Both sides
15+
* are pinned, through the rendered home panel, in both designer locales.
16+
*/
17+
18+
import * as React from 'react';
19+
import { describe, it, expect, vi, afterEach } from 'vitest';
20+
import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react';
21+
22+
const state = vi.hoisted(() => {
23+
const metadataClient: Record<string, unknown> = {
24+
get: vi.fn(async () => undefined),
25+
list: vi.fn(async (type: string) => (type === 'object' ? [{ name: 'crm_lead', label: 'Lead' }] : ([] as unknown[]))),
26+
};
27+
// The bound object's field read goes through the draft-overlaid door.
28+
metadataClient.withPreviewDrafts = () => metadataClient;
29+
return { metadataClient };
30+
});
31+
vi.mock('../useMetadata', async (importOriginal) => {
32+
const mod = await importOriginal<typeof import('../useMetadata')>();
33+
return { ...mod, useMetadataClient: () => state.metadataClient };
34+
});
35+
36+
import { ViewDefaultInspector } from './ViewInspector';
37+
import { t } from '../i18n';
38+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
39+
40+
// The zh designer strings are a lazy namespace: loaded before any case reads them.
41+
await loadStudioCatalogue('zh');
42+
43+
afterEach(cleanup);
44+
45+
type Locale = 'en-US' | 'zh-CN';
46+
47+
/** A view item as the server serves it: `{ name, object, viewKind, config }` (ADR-0017). */
48+
function renderView(locale: Locale, viewKind: 'form' | 'list', config: Record<string, unknown>) {
49+
render(
50+
<ViewDefaultInspector
51+
type="view"
52+
name={`crm_lead.${viewKind}`}
53+
draft={{ name: `crm_lead.${viewKind}`, object: 'crm_lead', viewKind, config }}
54+
onPatch={vi.fn()}
55+
onSelectionChange={vi.fn()}
56+
readOnly={false}
57+
locale={locale as never}
58+
/>,
59+
);
60+
return screen.getByRole('combobox', { name: t('engine.inspector.view.type', locale) });
61+
}
62+
63+
/**
64+
* The options the open picker offers, by their text: read in the listbox this
65+
* trigger controls, so a list another case left in the document is never read.
66+
*/
67+
async function optionsOf(trigger: HTMLElement): Promise<string[]> {
68+
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
69+
const listbox = await waitFor(() => {
70+
const id = trigger.getAttribute('aria-controls');
71+
const el = id ? document.getElementById(id) : null;
72+
expect(el).not.toBeNull();
73+
expect(within(el!).queryAllByRole('option').length).toBeGreaterThan(0);
74+
return el!;
75+
});
76+
return within(listbox).getAllByRole('option').map((o) => (o.textContent ?? '').trim());
77+
}
78+
79+
const formType = (value: string, locale: Locale) => t(`engine.inspector.pageBlock.option.formType.${value}`, locale);
80+
const listType = (value: string, locale: Locale) => t(`engine.inspector.view.type.${value}`, locale);
81+
82+
describe('a form view’s 视图类型 reads a real type (objectstack#30046)', () => {
83+
it.each(['en-US', 'zh-CN'] as const)('%s — a form view reads its layout, never flagged as not found', async (locale) => {
84+
const trigger = renderView(locale, 'form', { type: 'simple', sections: [] });
85+
expect(trigger.textContent).toContain(formType('simple', locale));
86+
expect(trigger.textContent).not.toContain(t('engine.form.notFound', locale));
87+
// The form family's own roster, and none of the list types.
88+
expect(await optionsOf(trigger)).toEqual([formType('simple', locale), formType('tabbed', locale), formType('wizard', locale)]);
89+
});
90+
91+
it.each(['en-US', 'zh-CN'] as const)('%s — a form view with no type reads the spec’s default layout', (locale) => {
92+
const trigger = renderView(locale, 'form', { sections: [] });
93+
expect(trigger.textContent).toContain(formType('simple', locale));
94+
expect(trigger.textContent).not.toContain(listType('grid', locale));
95+
});
96+
97+
// The list-view side, unchanged: the same inspector, the list roster.
98+
it.each(['en-US', 'zh-CN'] as const)('%s — CONTROL: a list view still offers the list types', async (locale) => {
99+
const trigger = renderView(locale, 'list', { type: 'kanban', columns: ['name'] });
100+
expect(trigger.textContent).toContain(listType('kanban', locale));
101+
const offered = await optionsOf(trigger);
102+
expect(offered).toContain(listType('grid', locale));
103+
expect(offered).toContain(listType('kanban', locale));
104+
expect(offered).not.toContain(formType('simple', locale));
105+
});
106+
107+
it.each(['en-US', 'zh-CN'] as const)('%s — CONTROL: a list type the spec does not carry is still flagged', (locale) => {
108+
const trigger = renderView(locale, 'list', { type: 'simple', columns: ['name'] });
109+
expect(trigger.textContent).toContain(t('engine.form.notFound', locale));
110+
});
111+
});

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx‎

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
*
1919
* The inspector keeps a thin curated layer for two cross-cutting concerns
2020
* the spec form can't express well on its own:
21-
* 1. the VIEW TYPE picker (options sourced from the spec `type` enum), and
21+
* 1. the VIEW TYPE picker (options sourced from the spec `type` enum of the
22+
* view's own family: list types for a list view, form layouts for a
23+
* form view), and
2224
* 2. the bound OBJECT (stored at `data.object`, drives field loading).
2325
*
2426
* Column add / reorder / select lives in the live column canvas above the
@@ -140,6 +142,27 @@ export interface ViewVariantInspectorProps extends MetadataDefaultInspectorProps
140142
objectFieldsOverride?: ObjectFieldInfo[];
141143
}
142144

145+
/**
146+
* objectstack#30046 — English fallback labels for a FORM view's `type`, the
147+
* form layouts of the spec's `FormViewSchema.type` enum.
148+
*/
149+
const FORM_TYPE_LABELS: Record<string, string> = {
150+
simple: 'Simple',
151+
tabbed: 'Tabbed',
152+
wizard: 'Wizard',
153+
};
154+
155+
/**
156+
* objectstack#30046 — localized label for a form view's layout: the words the
157+
* page form block already gives the same enum (`FormViewSchema.type`), else
158+
* the English map, else the raw value.
159+
*/
160+
function formTypeLabel(value: string, locale?: string): string {
161+
const key = `engine.inspector.pageBlock.option.formType.${value}`;
162+
const translated = t(key, locale);
163+
return translated === key ? (FORM_TYPE_LABELS[value] ?? value) : translated;
164+
}
165+
143166
/** Keys re-pinned from the live draft after every spec-form edit. */
144167
const PRESERVED_KEYS = ['columns', 'data', 'name'] as const;
145168

@@ -169,23 +192,32 @@ function readObjectBinding(
169192
return { value: '', path: 'top' };
170193
}
171194

172-
/** Build the View-type picker's options (`InspectorSelectField`) from the spec `type` enum. */
173-
function useTypeOptions(locale?: string) {
195+
/**
196+
* Build the View-type picker's options (`InspectorSelectField`) from the spec
197+
* `type` enum of the view's own family. objectstack#30046 — a form view's
198+
* `type` is a form layout (`FormViewSchema.type`), which the list-variant
199+
* enum does not carry: read from that roster, every form view's valid type
200+
* read as not found. A list view reads the list-variant enum, as before.
201+
*/
202+
function useTypeOptions(locale: string | undefined, formFamily: boolean) {
174203
return React.useMemo(() => {
175-
const schema = getListVariantSchema();
204+
const schema = formFamily ? getFormVariantSchema() : getListVariantSchema();
176205
const rawEnum = schema?.properties?.type?.enum;
177206
const values: string[] =
178207
Array.isArray(rawEnum) && rawEnum.length
179208
? rawEnum.filter((v: unknown): v is string => typeof v === 'string')
180-
: ['grid', 'kanban', 'calendar', 'gallery', 'gantt', 'timeline'];
181-
const opts = values.map((v) => ({ value: v, label: typeLabel(v, locale) }));
209+
: formFamily
210+
? Object.keys(FORM_TYPE_LABELS)
211+
: ['grid', 'kanban', 'calendar', 'gallery', 'gantt', 'timeline'];
212+
const label = formFamily ? formTypeLabel : typeLabel;
213+
const opts = values.map((v) => ({ value: v, label: label(v, locale) }));
182214
// objectui#8488 — a `type` the spec enum does not carry used to be appended
183215
// here UNFLAGGED, so an off-spec view type read exactly like an offered
184216
// one. `InspectorSelectField` synthesises and flags it now; `typeLabel`
185217
// still gets its say through `unknownValueLabel` at the call site, since a
186218
// retired type can still have a human name in `viewTypeLabels.ts`.
187219
return opts;
188-
}, [locale]);
220+
}, [locale, formFamily]);
189221
}
190222

191223
export function ViewVariantInspector({
@@ -205,9 +237,12 @@ export function ViewVariantInspector({
205237
const variant = (draft[variantKey] as Record<string, unknown> | undefined) ?? {};
206238

207239
const isFormFamily = isFormFamilyKey(familyKey ?? variantKey);
240+
// objectstack#30046 — a form view without a `type` is the spec's default
241+
// layout (`FormViewSchema.type` defaults to `simple`), not a grid.
208242
const viewType =
209-
typeof variant.type === 'string' ? (variant.type as string) : 'grid';
210-
const typeOptions = useTypeOptions(locale);
243+
typeof variant.type === 'string' ? (variant.type as string) : isFormFamily ? 'simple' : 'grid';
244+
const typeOptions = useTypeOptions(locale, isFormFamily);
245+
const viewTypeLabel = isFormFamily ? formTypeLabel : typeLabel;
211246
const binding = readObjectBinding(variant, draft);
212247

213248
// Canonical label lives at the top level (`draft.label`); a list `config`
@@ -411,7 +446,7 @@ export function ViewVariantInspector({
411446
value={viewType}
412447
options={typeOptions}
413448
unknownValueLabel={(v) =>
414-
flagUnknownValue(typeLabel(v, locale), t('engine.form.notFound', locale), locale)
449+
flagUnknownValue(viewTypeLabel(v, locale), t('engine.form.notFound', locale), locale)
415450
}
416451
onCommit={(v) => writeVariant({ type: v })}
417452
disabled={readOnly}

‎ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx‎

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,48 @@ export function navOpenedItems(nodes: readonly NavNode[], locale: string): Set<s
435435
return opened;
436436
}
437437

438+
/**
439+
* objectstack#30046 — whether the 视图 group leaves `item` out: a list view
440+
* whose object a nav entry opens (`opened`, {@link navOpenedItems}), which the
441+
* entry's header dropdown reaches. A form view, a list view of an object no
442+
* entry opens, and a view whose family or object is unknown stay listed, as
443+
* does a view a read-only base's pair adds (`addedIn`), which opens at that
444+
* pair.
445+
*/
446+
export function offNavViewReachedByEntry(
447+
item: Pick<PackageSurface, 'object' | 'viewKind'> & { addedIn?: string },
448+
opened: ReadonlySet<string>,
449+
): boolean {
450+
return item.viewKind === 'list' && !!item.object && !item.addedIn && opened.has(itemKeyOf('object', item.object));
451+
}
452+
453+
/**
454+
* objectstack#30046 — the 视图 group's heading, saying what it holds once
455+
* the list views the dropdowns reach are gone: form views, list views (of
456+
* objects no entry opens), or both.
457+
*/
458+
export function offNavViewHeading(items: ReadonlyArray<Pick<PackageSurface, 'viewKind'>>, locale: string): string {
459+
const forms = items.filter((item) => item.viewKind === 'form').length;
460+
if (forms === 0) return t('engine.studio.if.offNav.view', locale);
461+
return t(forms === items.length ? 'engine.studio.if.offNav.viewForms' : 'engine.studio.if.offNav.viewMixed', locale);
462+
}
463+
464+
/**
465+
* objectstack#30046 — what a 视图 row reads: the view's label, else, for a
466+
* form view, its object's name and the word for a form ("Lead form"; a named
467+
* form adds its key), never the item name (`crm_lead.form`). `objectLabel` is
468+
* the object's name as the Studio shows it. A list view without a label keeps
469+
* its item name, as before.
470+
*/
471+
export function offNavViewLabel(item: PackageSurface, objectLabel: string, locale: string): string {
472+
const own = item.label && item.label !== item.name ? item.label : '';
473+
if (own || item.viewKind !== 'form') return own || item.name;
474+
const form = tFormat('engine.studio.if.offNav.formName', locale, { object: objectLabel });
475+
const prefix = item.object ? `${item.object}.` : '';
476+
const key = prefix && item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name;
477+
return key === 'form' ? form : `${form} · ${key}`;
478+
}
479+
438480
/**
439481
* objectstack#26889 — the package's pages, dashboards, reports and views that
440482
* no nav entry opens (`navOpenedItems`), one collapsible section per type,
@@ -446,11 +488,22 @@ export function navOpenedItems(nodes: readonly NavNode[], locale: string): Set<s
446488
export function OffNavSections({
447489
sections,
448490
active,
491+
activeViewId = null,
449492
onPick,
450493
onlyCustomized = false,
451494
}: {
452-
sections: ReadonlyArray<{ type: OffNavType; items: ReadonlyArray<PackageSurface & { addedIn?: string }> }>;
495+
/**
496+
* objectstack#30046 — a section may name itself (`label`): the 视图 group
497+
* says what it holds. Without one, the type's own heading.
498+
*/
499+
sections: ReadonlyArray<{ type: OffNavType; label?: string; items: ReadonlyArray<PackageSurface & { addedIn?: string }> }>;
453500
active: Surface | null;
501+
/**
502+
* objectstack#30046 — the list view the open object page shows, when that
503+
* page was opened from here (an object no entry opens): its row is the
504+
* active one, as a tree row is for its entry.
505+
*/
506+
activeViewId?: string | null;
454507
onPick: (s: Surface) => void;
455508
/** objectstack#28572 — "only customizations" is on: the sections list the marked items, and head nothing. */
456509
onlyCustomized?: boolean;
@@ -464,7 +517,15 @@ export function OffNavSections({
464517
<p className="px-2 pb-1 text-[11px] text-muted-foreground">{t('engine.studio.if.offNav.heading', locale)}</p>
465518
)}
466519
{shown.map((s) => (
467-
<OffNavSection key={s.type} type={s.type} items={s.items} active={active} onPick={onPick} />
520+
<OffNavSection
521+
key={s.type}
522+
type={s.type}
523+
label={s.label}
524+
items={s.items}
525+
active={active}
526+
activeViewId={activeViewId}
527+
onPick={onPick}
528+
/>
468529
))}
469530
</div>
470531
);
@@ -473,13 +534,17 @@ export function OffNavSections({
473534
/** One type's section: its rows, open until the author closes it. */
474535
function OffNavSection({
475536
type,
537+
label,
476538
items,
477539
active,
540+
activeViewId,
478541
onPick,
479542
}: {
480543
type: OffNavType;
544+
label?: string;
481545
items: ReadonlyArray<PackageSurface & { addedIn?: string }>;
482546
active: Surface | null;
547+
activeViewId: string | null;
483548
onPick: (s: Surface) => void;
484549
}): React.ReactElement {
485550
const locale = useMetadataLocale();
@@ -489,7 +554,7 @@ function OffNavSection({
489554
return (
490555
<StudioRailGroup
491556
testId={`if-off-nav-${type}`}
492-
label={t(`engine.studio.if.offNav.${type}`, locale)}
557+
label={label ?? t(`engine.studio.if.offNav.${type}`, locale)}
493558
count={items.length}
494559
collapsed={!open}
495560
onToggle={() => setOpen((v) => !v)}
@@ -506,7 +571,7 @@ function OffNavSection({
506571
<StudioRailRow
507572
key={item.name}
508573
onClick={() => onPick(surface)}
509-
active={!!active && isSameSurface(active, surface)}
574+
active={(!!active && isSameSurface(active, surface)) || (type === 'view' && item.name === activeViewId)}
510575
indent
511576
// The label is what the row says; the machine name rides on the
512577
// tooltip with the type, as on a tree row (objectui#11862).

‎ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavDeepLink-28561.test.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ function seedWithApp() {
9696
server.published.set('view', [
9797
{ name: 'crm_lead.mine', label: 'Mine', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } },
9898
{ name: 'crm_lead.hot', label: 'Hot Leads', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } },
99+
{ name: 'crm_lead.web_form', label: 'Web Form', object: 'crm_lead', viewKind: 'form', config: { type: 'simple', sections: [] } },
99100
// The container the server keeps beside the expanded items: no item of its own.
100101
{ name: 'crm_lead', object: 'crm_lead', listViews: { hot: { columns: ['name'] } } },
101102
]);
@@ -134,11 +135,13 @@ const surfaceParam = () => new URLSearchParams(screen.getByTestId('where').textC
134135
const canvas = () => screen.findByTestId('stub-canvas', undefined, { timeout: 8000 });
135136

136137
describe('objectstack#28561 — Interfaces opens an off-nav item by `?surface=`', () => {
138+
// objectstack#30046 — a form view: a link to a LIST view lands on its
139+
// object's page instead (`InterfacesPillar.offNavListViews-30046`).
137140
it('a view no nav entry opens: the view, not the first leaf', async () => {
138141
seedWithApp();
139-
renderPillar('view:crm_lead.hot');
140-
expect(await canvas()).toHaveTextContent('view:crm_lead.hot');
141-
expect(surfaceParam()).toBe('view:crm_lead.hot');
142+
renderPillar('view:crm_lead.web_form');
143+
expect(await canvas()).toHaveTextContent('view:crm_lead.web_form');
144+
expect(surfaceParam()).toBe('view:crm_lead.web_form');
142145
});
143146

144147
it('a report and a record page no entry opens', async () => {

0 commit comments

Comments
 (0)