diff --git a/.changeset/30046-studio-off-nav-list-views.md b/.changeset/30046-studio-off-nav-list-views.md new file mode 100644 index 00000000000..94515f23fb4 --- /dev/null +++ b/.changeset/30046-studio-off-nav-list-views.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +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 + +Clause-②: yes (widening) +Metadata-protocol: none + +- **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. +- **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. +- **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. +- **`@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. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.formViewType-30046.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.formViewType-30046.test.tsx new file mode 100644 index 00000000000..1da6c2c8eea --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.formViewType-30046.test.tsx @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30046 (Scope 8) — a form view's 视图类型 reads a real type. + * + * The view inspector's type picker offered the LIST-view types alone (grid, + * kanban, calendar, …), read off the spec's list-variant schema, for every + * view it was shown on. A form view's `type` is a form layout (`simple`, + * `tabbed`, `wizard`, `FormViewSchema.type`), which that roster does not + * carry, so every form view read 「simple(未找到)」: its own, valid type + * flagged as not found, beside list types it can never take. + * + * The picker now reads the roster of the view's own family: a form view's + * from the spec's form-variant schema, a list view's unchanged. Both sides + * are pinned, through the rendered home panel, in both designer locales. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; + +const state = vi.hoisted(() => { + const metadataClient: Record = { + get: vi.fn(async () => undefined), + list: vi.fn(async (type: string) => (type === 'object' ? [{ name: 'crm_lead', label: 'Lead' }] : ([] as unknown[]))), + }; + // The bound object's field read goes through the draft-overlaid door. + metadataClient.withPreviewDrafts = () => metadataClient; + return { metadataClient }; +}); +vi.mock('../useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => state.metadataClient }; +}); + +import { ViewDefaultInspector } from './ViewInspector'; +import { t } from '../i18n'; +import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; + +// The zh designer strings are a lazy namespace: loaded before any case reads them. +await loadStudioCatalogue('zh'); + +afterEach(cleanup); + +type Locale = 'en-US' | 'zh-CN'; + +/** A view item as the server serves it: `{ name, object, viewKind, config }` (ADR-0017). */ +function renderView(locale: Locale, viewKind: 'form' | 'list', config: Record) { + render( + , + ); + return screen.getByRole('combobox', { name: t('engine.inspector.view.type', locale) }); +} + +/** + * The options the open picker offers, by their text: read in the listbox this + * trigger controls, so a list another case left in the document is never read. + */ +async function optionsOf(trigger: HTMLElement): Promise { + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + const listbox = await waitFor(() => { + const id = trigger.getAttribute('aria-controls'); + const el = id ? document.getElementById(id) : null; + expect(el).not.toBeNull(); + expect(within(el!).queryAllByRole('option').length).toBeGreaterThan(0); + return el!; + }); + return within(listbox).getAllByRole('option').map((o) => (o.textContent ?? '').trim()); +} + +const formType = (value: string, locale: Locale) => t(`engine.inspector.pageBlock.option.formType.${value}`, locale); +const listType = (value: string, locale: Locale) => t(`engine.inspector.view.type.${value}`, locale); + +describe('a form view’s 视图类型 reads a real type (objectstack#30046)', () => { + it.each(['en-US', 'zh-CN'] as const)('%s — a form view reads its layout, never flagged as not found', async (locale) => { + const trigger = renderView(locale, 'form', { type: 'simple', sections: [] }); + expect(trigger.textContent).toContain(formType('simple', locale)); + expect(trigger.textContent).not.toContain(t('engine.form.notFound', locale)); + // The form family's own roster, and none of the list types. + expect(await optionsOf(trigger)).toEqual([formType('simple', locale), formType('tabbed', locale), formType('wizard', locale)]); + }); + + it.each(['en-US', 'zh-CN'] as const)('%s — a form view with no type reads the spec’s default layout', (locale) => { + const trigger = renderView(locale, 'form', { sections: [] }); + expect(trigger.textContent).toContain(formType('simple', locale)); + expect(trigger.textContent).not.toContain(listType('grid', locale)); + }); + + // The list-view side, unchanged: the same inspector, the list roster. + it.each(['en-US', 'zh-CN'] as const)('%s — CONTROL: a list view still offers the list types', async (locale) => { + const trigger = renderView(locale, 'list', { type: 'kanban', columns: ['name'] }); + expect(trigger.textContent).toContain(listType('kanban', locale)); + const offered = await optionsOf(trigger); + expect(offered).toContain(listType('grid', locale)); + expect(offered).toContain(listType('kanban', locale)); + expect(offered).not.toContain(formType('simple', locale)); + }); + + it.each(['en-US', 'zh-CN'] as const)('%s — CONTROL: a list type the spec does not carry is still flagged', (locale) => { + const trigger = renderView(locale, 'list', { type: 'simple', columns: ['name'] }); + expect(trigger.textContent).toContain(t('engine.form.notFound', locale)); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx index b63f88a58fa..e1fac5a5226 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx @@ -18,7 +18,9 @@ * * The inspector keeps a thin curated layer for two cross-cutting concerns * the spec form can't express well on its own: - * 1. the VIEW TYPE picker (options sourced from the spec `type` enum), and + * 1. the VIEW TYPE picker (options sourced from the spec `type` enum of the + * view's own family: list types for a list view, form layouts for a + * form view), and * 2. the bound OBJECT (stored at `data.object`, drives field loading). * * Column add / reorder / select lives in the live column canvas above the @@ -140,6 +142,27 @@ export interface ViewVariantInspectorProps extends MetadataDefaultInspectorProps objectFieldsOverride?: ObjectFieldInfo[]; } +/** + * objectstack#30046 — English fallback labels for a FORM view's `type`, the + * form layouts of the spec's `FormViewSchema.type` enum. + */ +const FORM_TYPE_LABELS: Record = { + simple: 'Simple', + tabbed: 'Tabbed', + wizard: 'Wizard', +}; + +/** + * objectstack#30046 — localized label for a form view's layout: the words the + * page form block already gives the same enum (`FormViewSchema.type`), else + * the English map, else the raw value. + */ +function formTypeLabel(value: string, locale?: string): string { + const key = `engine.inspector.pageBlock.option.formType.${value}`; + const translated = t(key, locale); + return translated === key ? (FORM_TYPE_LABELS[value] ?? value) : translated; +} + /** Keys re-pinned from the live draft after every spec-form edit. */ const PRESERVED_KEYS = ['columns', 'data', 'name'] as const; @@ -169,23 +192,32 @@ function readObjectBinding( return { value: '', path: 'top' }; } -/** Build the View-type picker's options (`InspectorSelectField`) from the spec `type` enum. */ -function useTypeOptions(locale?: string) { +/** + * Build the View-type picker's options (`InspectorSelectField`) from the spec + * `type` enum of the view's own family. objectstack#30046 — a form view's + * `type` is a form layout (`FormViewSchema.type`), which the list-variant + * enum does not carry: read from that roster, every form view's valid type + * read as not found. A list view reads the list-variant enum, as before. + */ +function useTypeOptions(locale: string | undefined, formFamily: boolean) { return React.useMemo(() => { - const schema = getListVariantSchema(); + const schema = formFamily ? getFormVariantSchema() : getListVariantSchema(); const rawEnum = schema?.properties?.type?.enum; const values: string[] = Array.isArray(rawEnum) && rawEnum.length ? rawEnum.filter((v: unknown): v is string => typeof v === 'string') - : ['grid', 'kanban', 'calendar', 'gallery', 'gantt', 'timeline']; - const opts = values.map((v) => ({ value: v, label: typeLabel(v, locale) })); + : formFamily + ? Object.keys(FORM_TYPE_LABELS) + : ['grid', 'kanban', 'calendar', 'gallery', 'gantt', 'timeline']; + const label = formFamily ? formTypeLabel : typeLabel; + const opts = values.map((v) => ({ value: v, label: label(v, locale) })); // objectui#8488 — a `type` the spec enum does not carry used to be appended // here UNFLAGGED, so an off-spec view type read exactly like an offered // one. `InspectorSelectField` synthesises and flags it now; `typeLabel` // still gets its say through `unknownValueLabel` at the call site, since a // retired type can still have a human name in `viewTypeLabels.ts`. return opts; - }, [locale]); + }, [locale, formFamily]); } export function ViewVariantInspector({ @@ -205,9 +237,12 @@ export function ViewVariantInspector({ const variant = (draft[variantKey] as Record | undefined) ?? {}; const isFormFamily = isFormFamilyKey(familyKey ?? variantKey); + // objectstack#30046 — a form view without a `type` is the spec's default + // layout (`FormViewSchema.type` defaults to `simple`), not a grid. const viewType = - typeof variant.type === 'string' ? (variant.type as string) : 'grid'; - const typeOptions = useTypeOptions(locale); + typeof variant.type === 'string' ? (variant.type as string) : isFormFamily ? 'simple' : 'grid'; + const typeOptions = useTypeOptions(locale, isFormFamily); + const viewTypeLabel = isFormFamily ? formTypeLabel : typeLabel; const binding = readObjectBinding(variant, draft); // Canonical label lives at the top level (`draft.label`); a list `config` @@ -411,7 +446,7 @@ export function ViewVariantInspector({ value={viewType} options={typeOptions} unknownValueLabel={(v) => - flagUnknownValue(typeLabel(v, locale), t('engine.form.notFound', locale), locale) + flagUnknownValue(viewTypeLabel(v, locale), t('engine.form.notFound', locale), locale) } onCommit={(v) => writeVariant({ type: v })} disabled={readOnly} diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx index 342a99cbb6e..c4eb8f0520a 100644 --- a/ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx @@ -435,6 +435,48 @@ export function navOpenedItems(nodes: readonly NavNode[], locale: string): Set & { addedIn?: string }, + opened: ReadonlySet, +): boolean { + return item.viewKind === 'list' && !!item.object && !item.addedIn && opened.has(itemKeyOf('object', item.object)); +} + +/** + * objectstack#30046 — the 视图 group's heading, saying what it holds once + * the list views the dropdowns reach are gone: form views, list views (of + * objects no entry opens), or both. + */ +export function offNavViewHeading(items: ReadonlyArray>, locale: string): string { + const forms = items.filter((item) => item.viewKind === 'form').length; + if (forms === 0) return t('engine.studio.if.offNav.view', locale); + return t(forms === items.length ? 'engine.studio.if.offNav.viewForms' : 'engine.studio.if.offNav.viewMixed', locale); +} + +/** + * objectstack#30046 — what a 视图 row reads: the view's label, else, for a + * form view, its object's name and the word for a form ("Lead form"; a named + * form adds its key), never the item name (`crm_lead.form`). `objectLabel` is + * the object's name as the Studio shows it. A list view without a label keeps + * its item name, as before. + */ +export function offNavViewLabel(item: PackageSurface, objectLabel: string, locale: string): string { + const own = item.label && item.label !== item.name ? item.label : ''; + if (own || item.viewKind !== 'form') return own || item.name; + const form = tFormat('engine.studio.if.offNav.formName', locale, { object: objectLabel }); + const prefix = item.object ? `${item.object}.` : ''; + const key = prefix && item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name; + return key === 'form' ? form : `${form} · ${key}`; +} + /** * objectstack#26889 — the package's pages, dashboards, reports and views that * no nav entry opens (`navOpenedItems`), one collapsible section per type, @@ -446,11 +488,22 @@ export function navOpenedItems(nodes: readonly NavNode[], locale: string): Set }>; + /** + * objectstack#30046 — a section may name itself (`label`): the 视图 group + * says what it holds. Without one, the type's own heading. + */ + sections: ReadonlyArray<{ type: OffNavType; label?: string; items: ReadonlyArray }>; active: Surface | null; + /** + * objectstack#30046 — the list view the open object page shows, when that + * page was opened from here (an object no entry opens): its row is the + * active one, as a tree row is for its entry. + */ + activeViewId?: string | null; onPick: (s: Surface) => void; /** objectstack#28572 — "only customizations" is on: the sections list the marked items, and head nothing. */ onlyCustomized?: boolean; @@ -464,7 +517,15 @@ export function OffNavSections({

{t('engine.studio.if.offNav.heading', locale)}

)} {shown.map((s) => ( - + ))} ); @@ -473,13 +534,17 @@ export function OffNavSections({ /** One type's section: its rows, open until the author closes it. */ function OffNavSection({ type, + label, items, active, + activeViewId, onPick, }: { type: OffNavType; + label?: string; items: ReadonlyArray; active: Surface | null; + activeViewId: string | null; onPick: (s: Surface) => void; }): React.ReactElement { const locale = useMetadataLocale(); @@ -489,7 +554,7 @@ function OffNavSection({ return ( setOpen((v) => !v)} @@ -506,7 +571,7 @@ function OffNavSection({ onPick(surface)} - active={!!active && isSameSurface(active, surface)} + active={(!!active && isSameSurface(active, surface)) || (type === 'view' && item.name === activeViewId)} indent // The label is what the row says; the machine name rides on the // tooltip with the type, as on a tree row (objectui#11862). diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavDeepLink-28561.test.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavDeepLink-28561.test.tsx index 443ad1124e9..bfd2397fd14 100644 --- a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavDeepLink-28561.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavDeepLink-28561.test.tsx @@ -96,6 +96,7 @@ function seedWithApp() { server.published.set('view', [ { name: 'crm_lead.mine', label: 'Mine', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, { name: 'crm_lead.hot', label: 'Hot Leads', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, + { name: 'crm_lead.web_form', label: 'Web Form', object: 'crm_lead', viewKind: 'form', config: { type: 'simple', sections: [] } }, // The container the server keeps beside the expanded items: no item of its own. { name: 'crm_lead', object: 'crm_lead', listViews: { hot: { columns: ['name'] } } }, ]); @@ -134,11 +135,13 @@ const surfaceParam = () => new URLSearchParams(screen.getByTestId('where').textC const canvas = () => screen.findByTestId('stub-canvas', undefined, { timeout: 8000 }); describe('objectstack#28561 — Interfaces opens an off-nav item by `?surface=`', () => { + // objectstack#30046 — a form view: a link to a LIST view lands on its + // object's page instead (`InterfacesPillar.offNavListViews-30046`). it('a view no nav entry opens: the view, not the first leaf', async () => { seedWithApp(); - renderPillar('view:crm_lead.hot'); - expect(await canvas()).toHaveTextContent('view:crm_lead.hot'); - expect(surfaceParam()).toBe('view:crm_lead.hot'); + renderPillar('view:crm_lead.web_form'); + expect(await canvas()).toHaveTextContent('view:crm_lead.web_form'); + expect(surfaceParam()).toBe('view:crm_lead.web_form'); }); it('a report and a record page no entry opens', async () => { diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx index 1ea12bdc6a7..e4376ddb276 100644 --- a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx @@ -104,7 +104,10 @@ function seedWithApp() { server.published.set('report', [{ name: 'pipeline', label: 'Pipeline' }]); server.published.set('view', [ { name: 'crm_lead.mine', label: 'Mine', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, - { name: 'crm_lead.hot', label: 'Hot Leads', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, + // objectstack#30046 — a form view: a list view of `crm_lead`, which an + // entry opens, is reached by that entry's header dropdown and not listed + // here (`InterfacesPillar.offNavListViews-30046`). + { name: 'crm_lead.web_form', label: 'Web Form', object: 'crm_lead', viewKind: 'form', config: { type: 'simple', sections: [] } }, // The container the server keeps beside the expanded items: no item of its own. { name: 'crm_lead', object: 'crm_lead', listViews: { hot: { columns: ['name'] } } }, ]); @@ -156,7 +159,7 @@ describe('objectstack#26889 — Interfaces lists what no navigation entry names' 'page · welcome', 'dashboard · sales_overview', 'report · pipeline', - 'view · crm_lead.hot', + 'view · crm_lead.web_form', ]); // A draft-only page reads as the label its `_drafts` row carries. expect(within(sections).getByRole('button', { name: 'Welcome' })).toHaveAttribute('title', 'page · welcome'); @@ -188,7 +191,7 @@ describe('objectstack#26889 — Interfaces lists what no navigation entry names' renderPillar(); const sections = await offNav(); for (const [label, opened] of [ - ['Hot Leads', 'view:crm_lead.hot'], + ['Web Form', 'view:crm_lead.web_form'], ['Lead Detail', 'page:lead_detail'], ['Sales Overview', 'dashboard:sales_overview'], ] as const) { diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavListViews-30046.test.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavListViews-30046.test.tsx new file mode 100644 index 00000000000..2eea4088132 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavListViews-30046.test.tsx @@ -0,0 +1,452 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30046 (Scope 6–8) — what the Interfaces rail's 不在导航中 → 视图 + * group lists, and where a list view in it opens. + * + * Scope 1–5 put an object's list views in the header's title (object / view ▾). + * The group under the navigation tree still listed every view no entry named: + * a list view whose object has a navigation entry was listed there although + * the dropdown reaches it, a form view read as its machine name + * (`crm_lead.form`) under a heading that said nothing of what was left, and a + * list view picked there opened a page of its own (`surface=view:`), not the + * object's page with its dropdown. + * + * Pinned, through the mounted pillar, its real list-view buffer and the real + * panel (the client is a recorder): + * - Scope 7: a list view of an object with a nav entry is not listed, a view + * only drafted here included; CONTROL: the list views of an object with no + * entry stay listed; + * - Scope 8: a form view is listed under a readable name (its label, else the + * object's name and the word for a form), and the group's heading says + * what it holds (forms, list views, or both); + * - Scope 6: a list view picked there opens on the object's page, title + * object / view ▾, with that view in the panel and the address; the + * panel keeps what the view page offered (条件格式化 and the spec form's + * sections); a link to the view (`surface=view:`) lands on the same page, + * on the object's nav entry when it has one; CONTROL: a form view still + * opens as itself. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; + +const PKG = 'com.acme.crm'; + +const NAV = [{ id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'crm_lead' }]; + +const grid = (object: string, extra: Record = {}) => ({ + type: 'grid', + data: { provider: 'object', object }, + columns: ['name'], + ...extra, +}); +const listView = (object: string, key: string, label: string, extra: Record = {}, body: Record = {}) => ({ + name: `${object}.${key}`, + object, + viewKind: 'list', + label, + ...extra, + config: grid(object, body), +}); +const formView = (object: string, key: string, label?: string, extra: Record = {}) => ({ + name: `${object}.${key}`, + object, + viewKind: 'form', + ...(label ? { label } : {}), + ...extra, + config: { type: 'simple', sections: [{ label: 'Main', fields: ['name'] }] }, +}); + +/** The views the package publishes, by name. */ +let published: Record> = {}; +/** The views only drafted in this package, by name. */ +let drafts: Record> = {}; + +function seed() { + published = Object.fromEntries( + [ + listView('crm_lead', 'all', 'All leads', { isDefault: true }), + listView('crm_lead', 'hot', 'Hot leads'), + formView('crm_lead', 'form', undefined, { isDefault: true }), + formView('crm_lead', 'contact_us', 'Contact us'), + listView('crm_case', 'open', 'Open cases', { isDefault: true }), + listView('crm_case', 'mine', 'My cases', {}, { + conditionalFormatting: [{ condition: "record.status == 'urgent'", style: { color: 'red' } }], + }), + ].map((row) => [row.name, row]), + ); + drafts = Object.fromEntries([listView('crm_lead', 'drafted', 'Drafted leads')].map((row) => [row.name, row])); +} + +const OBJECTS = [ + { name: 'crm_lead', label: 'Lead' }, + { name: 'crm_case', label: 'Case' }, +]; + +/** An object as the shell's merged metadata holds it: its list views keyed by item name. */ +function objectDef(name: string) { + const own = Object.values(published).filter((row) => row.object === name && row.viewKind === 'list'); + return { + name, + label: OBJECTS.find((o) => o.name === name)?.label ?? name, + fields: { name: { name: 'name', label: 'Name', type: 'text' }, status: { name: 'status', label: 'Status', type: 'text' } }, + listViews: Object.fromEntries( + own.map((row) => [row.name, { ...row.config, name: row.name, label: row.label, ...(row.isDefault ? { isDefault: true } : {}) }]), + ), + }; +} + +const mockClient: Record = { + save: vi.fn(async (..._args: unknown[]) => ({ success: true, version: 'v1', state: 'draft' })), + list: vi.fn(async (type: string) => { + if (type === 'app') return [{ name: 'crm_app', label: 'CRM', navigation: NAV }]; + if (type === 'object') return OBJECTS; + if (type === 'view') return Object.values(published); + return []; + }), + listDrafts: vi.fn(async (opts?: { type?: string }) => + opts?.type === 'view' ? Object.values(drafts).map((d) => ({ name: d.name, label: d.label, packageId: PKG })) : [], + ), + listTypes: vi.fn(async () => ({ entries: [] })), + references: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + if (type === 'app') return { effective: { name: 'crm_app', label: 'CRM', navigation: NAV } }; + if (type === 'object') return { effective: objectDef(name), code: objectDef(name) }; + if (type === 'view') { + const row = published[name] ?? null; + return { code: null, overlay: row, overlayScope: row ? 'env' : null, effective: row, editable: true }; + } + return { effective: { name } }; + }), + getDraft: vi.fn(async (type: string, name: string) => { + const row = type === 'view' ? drafts[name] : undefined; + if (row) return { type, name, item: { ...row, _draft: true }, version: 'v1' }; + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + get: vi.fn(async (type: string, name: string) => (type === 'object' ? objectDef(name) : undefined)), + publish: vi.fn(async () => ({ success: true })), +}; +// The draft-overlaid reads: the package's view drafts are tagged `_draft`, as the server serves them. +const previewClient: Record = Object.assign(Object.create(mockClient), { + list: vi.fn(async (type: string, opts?: unknown) => + type === 'view' + ? [...Object.values(published), ...Object.values(drafts).map((d) => ({ ...d, _draft: true, _packageId: PKG }))] + : mockClient.list(type, opts), + ), +}); +mockClient.withPreviewDrafts = () => previewClient; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() } })); + +// The `view` resource's list filter (the aggregated container is no item) is +// registered here, as the console registers it. +import '../../services/builtinComponents'; +import { InterfacesPillar } from './StudioDesignSurface'; +import { registerMetadataPreview, type MetadataPreviewProps } from '../metadata-admin/preview-registry'; +import { t, tFormat } from '../metadata-admin/i18n'; +import { STUDIO_RAIL_ROW_ACTIVE } from './studio-tokens'; +import { __setCelFormulaLoader } from '../metadata-admin/celAuthoring'; + +/** A formula engine whose one verdict is: an expression that ends after an operator does not parse. */ +function stubCelEngine() { + __setCelFormulaLoader(() => + Promise.resolve({ + validateExpression: (_role: string, input: unknown) => { + const src = typeof input === 'string' ? input : String((input as { source?: string })?.source ?? ''); + return /[*+\-/&|=<>]\s*$/.test(src) + ? { ok: false, errors: [{ message: 'Parse error: expression ends after an operator' }], warnings: [] } + : { ok: true, errors: [], warnings: [] }; + }, + introspectScope: () => ({ fields: ['name', 'status'], roots: ['record'], functions: ['has'] }), + inferExpressionType: () => 'boolean' as const, + }) as never, + ); +} + +/** A view page's canvas that says what it was opened on. */ +function StubPreview({ type, name }: MetadataPreviewProps) { + return
{`${type}:${name}`}
; +} +registerMetadataPreview('view', StubPreview); + +/** Stands in for `plugin-view`'s `object-view` on an object page's canvas. */ +function ObjectViewRecorder({ schema }: { schema: Record }) { + return
{JSON.stringify(schema)}
; +} + +function metadata(): MetadataContextValue { + return { + apps: [], + objects: OBJECTS.map((o) => objectDef(o.name)), + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + }; +} + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +beforeAll(() => { + ComponentRegistry.register('object-view', ObjectViewRecorder as never, { namespace: 'plugin-view' }); +}); +beforeEach(() => { + seed(); + for (const fn of [mockClient.save, mockClient.list, mockClient.listDrafts, previewClient.list]) fn.mockClear(); + window.confirm = vi.fn(() => false) as unknown as typeof window.confirm; +}); +afterEach(() => { + cleanup(); + __setCelFormulaLoader(undefined); +}); + +/** The address the pillar writes, as a reload or a shared link reads it. */ +let address = new URLSearchParams(); +function AddressProbe() { + const { search } = useLocation(); + React.useEffect(() => { + address = new URLSearchParams(search); + }, [search]); + return null; +} + +function mountPillar(search = '') { + render( + + + + + + , + ); +} + +/** The 视图 group under the tree, once the package's views are read. */ +async function viewGroup(): Promise { + return screen.findByTestId('if-off-nav-view', undefined, { timeout: 8000 }); +} + +/** The group's rows, as `type · name` (their tooltip), in rail order. */ +function rowNames(group: HTMLElement): string[] { + return within(group) + .getAllByRole('button') + .map((b) => b.getAttribute('title') ?? '') + .filter((title) => title.startsWith('view · ')); +} + +/** The group's rows, as an author reads them. */ +function rowTexts(group: HTMLElement): string[] { + return within(group) + .getAllByRole('button') + .filter((b) => (b.getAttribute('title') ?? '').startsWith('view · ')) + .map((b) => b.textContent ?? ''); +} + +/** The view item name the object page's panel shows beneath its title. */ +const shownView = () => screen.queryByTestId('list-view-name')?.textContent ?? null; + +/** Every view row's read settled: the drafts headers and the draft-overlaid list. */ +async function viewsRead() { + await waitFor(() => expect(mockClient.listDrafts).toHaveBeenCalledWith({ packageId: PKG, type: 'view' })); + await waitFor(() => expect(previewClient.list).toHaveBeenCalledWith('view', { packageId: PKG })); + await new Promise((r) => setTimeout(r, 50)); +} + +describe('不在导航中 → 视图 lists what the dropdown does not reach (objectstack#30046 Scope 7)', () => { + it('a list view of an object with a nav entry is not listed, a drafted one included', async () => { + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + const names = rowNames(group); + expect(names).not.toContain('view · crm_lead.all'); + expect(names).not.toContain('view · crm_lead.hot'); + expect(names).not.toContain('view · crm_lead.drafted'); + }); + + it('what is listed: the form views, and the list views of an object no entry opens', async () => { + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + expect(rowNames(group).sort()).toEqual( + ['view · crm_case.mine', 'view · crm_case.open', 'view · crm_lead.contact_us', 'view · crm_lead.form'].sort(), + ); + }); + + it('CONTROL: the list views of an object with no entry stay listed', async () => { + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + expect(rowNames(group)).toEqual(expect.arrayContaining(['view · crm_case.mine', 'view · crm_case.open'])); + }); +}); + +describe('the group names what it holds, and a form view by a readable name (objectstack#30046 Scope 8)', () => { + it('a form view reads its label, else the object’s name and the word for a form; never its machine name', async () => { + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + const texts = rowTexts(group); + expect(texts).toContain(tFormat('engine.studio.if.offNav.formName', 'en', { object: 'Lead' })); + expect(texts).toContain('Contact us'); + expect(texts).not.toContain('crm_lead.form'); + }); + + it('the heading says it holds form and list views; with only forms left, form views', async () => { + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + expect(group).toHaveTextContent(t('engine.studio.if.offNav.viewMixed', 'en')); + + cleanup(); + for (const name of Object.keys(published)) if (name.startsWith('crm_case.')) delete published[name]; + mountPillar(); + const forms = await viewGroup(); + await viewsRead(); + expect(rowNames(forms).sort()).toEqual(['view · crm_lead.contact_us', 'view · crm_lead.form']); + expect(forms).toHaveTextContent(t('engine.studio.if.offNav.viewForms', 'en')); + expect(forms).not.toHaveTextContent(t('engine.studio.if.offNav.viewMixed', 'en')); + }); + + it('CONTROL: with no form view, the heading stays 视图', async () => { + for (const name of Object.keys(published)) if (published[name]!.viewKind === 'form') delete published[name]; + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + expect(rowNames(group)).toEqual(expect.arrayContaining(['view · crm_case.mine', 'view · crm_case.open'])); + expect(group).toHaveTextContent(t('engine.studio.if.offNav.view', 'en')); + expect(group).not.toHaveTextContent(t('engine.studio.if.offNav.viewForms', 'en')); + }); +}); + +describe('a list view opened from 不在导航中 lands on its object’s page (objectstack#30046 Scope 6)', () => { + it('opens on the object page: title object / view ▾, the view in the panel and the address, its row active', async () => { + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + fireEvent.click(within(group).getByRole('button', { name: 'My cases' })); + + await screen.findByTestId('studio-list-view-inspector', undefined, { timeout: 8000 }); + await waitFor(() => expect(shownView()).toBe('crm_case.mine')); + expect(screen.getByTestId('if-list-view-title-object')).toHaveTextContent('Case'); + expect(within(screen.getByTestId('if-item-header')).getByTestId('list-view-switcher')).toHaveTextContent('My cases'); + // No view page: its canvas is not mounted. + expect(screen.queryByTestId('stub-canvas')).toBeNull(); + await waitFor(() => expect(address.get('surface')).toBe('object:crm_case')); + expect(address.get('view')).toBe('crm_case.mine'); + // The row it was opened from is the active one, and only it. + const active = within(group).getAllByRole('button').filter((b) => b.className.includes(STUDIO_RAIL_ROW_ACTIVE)); + expect(active.map((b) => b.textContent)).toEqual(['My cases']); + }); + + it('the panel keeps what the view page offered: 条件格式化 with the view’s rules, and the spec form’s sections', async () => { + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + fireEvent.click(within(group).getByRole('button', { name: 'My cases' })); + await waitFor(() => expect(shownView()).toBe('crm_case.mine'), { timeout: 8000 }); + + const more = await screen.findByTestId('list-view-more', undefined, { timeout: 8000 }); + expect(within(more).getByTestId('list-view-conditional-formatting')).toBeInTheDocument(); + expect(within(more).getByDisplayValue("record.status == 'urgent'")).toBeInTheDocument(); + expect(within(more).getByTestId('list-view-spec-form')).toBeInTheDocument(); + }); + + it('a second view of the same object is one more pick in the rail, on the same page', async () => { + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + fireEvent.click(within(group).getByRole('button', { name: 'My cases' })); + await waitFor(() => expect(shownView()).toBe('crm_case.mine'), { timeout: 8000 }); + fireEvent.click(within(group).getByRole('button', { name: 'Open cases' })); + await waitFor(() => expect(shownView()).toBe('crm_case.open')); + expect(screen.getByTestId('if-list-view-title-object')).toHaveTextContent('Case'); + // The object's default view: the address names the object alone. + await waitFor(() => expect(address.has('view')).toBe(false)); + expect(address.get('surface')).toBe('object:crm_case'); + }); + + it('a link to an object with no entry (a reload of that page) opens it on the view it names', async () => { + mountPillar('?surface=object%3Acrm_case&view=crm_case.mine'); + await screen.findByTestId('studio-list-view-inspector', undefined, { timeout: 8000 }); + await waitFor(() => expect(shownView()).toBe('crm_case.mine')); + expect(screen.getByTestId('if-list-view-title-object')).toHaveTextContent('Case'); + }); + + it('a link to the view itself (`surface=view:`) lands on the same page; with a nav entry, on the entry', async () => { + mountPillar('?surface=view%3Acrm_case.mine'); + await screen.findByTestId('studio-list-view-inspector', undefined, { timeout: 8000 }); + await waitFor(() => expect(shownView()).toBe('crm_case.mine')); + expect(screen.queryByTestId('stub-canvas')).toBeNull(); + + cleanup(); + mountPillar('?surface=view%3Acrm_lead.hot'); + await screen.findByTestId('studio-list-view-inspector', undefined, { timeout: 8000 }); + await waitFor(() => expect(shownView()).toBe('crm_lead.hot')); + expect(screen.getByTestId('if-list-view-title-object')).toHaveTextContent('Leads'); + await waitFor(() => expect(address.get('nav')).toBe('nav_leads')); + expect(address.get('view')).toBe('crm_lead.hot'); + }); + + it('CONTROL: a form view still opens as itself', async () => { + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + fireEvent.click(within(group).getByRole('button', { name: 'Contact us' })); + expect(await screen.findByText('view:crm_lead.contact_us', undefined, { timeout: 8000 })).toBeInTheDocument(); + expect(screen.queryByTestId('studio-list-view-inspector')).toBeNull(); + }); + + it('a formatting rule whose CEL does not parse holds the view’s autosave, as the view page held Save', async () => { + stubCelEngine(); + mountPillar(); + const group = await viewGroup(); + await viewsRead(); + fireEvent.click(within(group).getByRole('button', { name: 'My cases' })); + await waitFor(() => expect(shownView()).toBe('crm_case.mine'), { timeout: 8000 }); + const rule = () => screen.getByTestId('cf-rule-0').querySelector('[role="combobox"]') as HTMLTextAreaElement; + await waitFor(() => expect(rule()).toBeInTheDocument(), { timeout: 8000 }); + + fireEvent.change(rule(), { target: { value: 'record.status ==' } }); + await screen.findByTestId('studio-held', undefined, { timeout: 4000 }); + // Past the autosave's 1.5 s debounce: nothing was sent. + await new Promise((r) => setTimeout(r, 2000)); + expect(mockClient.save.mock.calls.filter((c: unknown[]) => c[0] === 'view')).toEqual([]); + + // Fixed, it is sent to the view it was made on. + fireEvent.change(rule(), { target: { value: "record.status == 'open'" } }); + await waitFor(() => expect(mockClient.save.mock.calls.some((c: unknown[]) => c[0] === 'view')).toBe(true), { timeout: 8000 }); + const [, name, body] = mockClient.save.mock.calls.find((c: unknown[]) => c[0] === 'view') as [string, string, Record]; + expect(name).toBe('crm_case.mine'); + expect(body.config.conditionalFormatting[0].condition).toBe("record.status == 'open'"); + }, 20000); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/ListViewMoreSection.tsx b/ui/packages/app-shell/src/views/studio-design/ListViewMoreSection.tsx new file mode 100644 index 00000000000..71b49a69430 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/ListViewMoreSection.tsx @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30046 (Scope 6) — the list-view settings the object page's panel + * did not offer, which only the view's own page (`surface=view:`, the view + * inspector `ViewVariantInspector`) did: the view's conditional formatting + * (条件格式化) and the sections of the spec's authoring form for a view + * (`view.form.ts`: description, quick search, table options, pagination, + * end-user controls, navigation and sharing, and each type's own block). + * + * A list view now opens on its object's page from every door, so these come + * with it, below the panel's own sections, and nothing that page offered is + * dropped. Both are the inspector's own parts, fed the same way: + * + * - `ConditionalFormattingEditor`, over the body's `conditionalFormatting`; + * its CEL verdicts are reported to the host (`onBlockingIssuesChange`), + * which holds the list-view autosave on them, as the view page held its + * Save (objectui#4527); + * - the generic `SchemaForm`, over the spec's list-variant JSONSchema and + * the localized authoring form (`getViewForm`), with every key the panel + * already edits pruned from it, so no setting has two controls: the + * columns, the filter, the sort, the chart binding, and the blocks + * `ListViewTypeSection` draws. The kanban block stays: the card section + * edits its card, not its lanes or its summary field. + * + * ⛔ No per-key field list of what is shown: what the spec form declares flows + * through. The section titles are the spec form's, localized where the locale + * overlay carries them; #30039 owns translating the rest. + */ + +import * as React from 'react'; +import { ConditionalFormattingEditor } from '../metadata-admin/ConditionalFormattingEditor.js'; +import { SchemaForm } from '../metadata-admin/SchemaForm.js'; +import type { FormViewSpec } from '../metadata-admin/SchemaForm.js'; +import { getListVariantSchema, getViewForm } from '../metadata-admin/view-schema.js'; +import type { ObjectFieldOption } from '../metadata-admin/widgets.js'; +import type { LoadState } from '../metadata-admin/loadState.js'; +import { t } from '../metadata-admin/i18n.js'; + +type Row = Record; + +/** + * The body keys the list-view panel edits with its own sections, pruned from + * the spec form here: the columns (and the canvas), the filter, the sort, the + * conditional formatting above, the chart binding (`ListViewChartSection`) + * and the type blocks `ListViewTypeSection` draws. `data` and `name` the spec + * form's own pruning already drops (`getViewForm`). + */ +const EDITED_BY_THE_PANEL: ReadonlySet = new Set([ + 'columns', + 'filter', + 'sort', + 'conditionalFormatting', + 'chart', + 'calendar', + 'gallery', + 'timeline', + 'gantt', + 'map', + 'tree', +]); + +/** The fields the curated controls own, hidden from the form as the view inspector hides them. */ +const HIDDEN_FIELDS = ['type', 'object', 'label']; + +/** The localized spec form, without the keys the panel edits, and without a section left empty. */ +function panelViewForm(locale: string): FormViewSpec | undefined { + const form = getViewForm(locale); + if (!form) return undefined; + const fieldName = (f: unknown): unknown => (typeof f === 'string' ? f : (f as { field?: unknown } | null)?.field); + const sections = (form.sections ?? []) + .map((section) => ({ + ...section, + fields: (section.fields ?? []).filter((f: unknown) => !EDITED_BY_THE_PANEL.has(String(fieldName(f)))), + })) + .filter((section) => section.fields.length > 0); + return { ...form, sections }; +} + +/** `next` with every key the form does not edit as `body` holds it (present or absent). */ +function repinned(next: Row, body: Row): Row { + const merged: Row = { ...next }; + for (const key of [...EDITED_BY_THE_PANEL, 'data', 'name']) { + if (key in body) merged[key] = body[key]; + else delete merged[key]; + } + return merged; +} + +export function ListViewMoreSection({ + body, + writeBody, + objectName, + fieldNames, + fieldCatalog, + readOnly, + locale, + onBlockingIssuesChange, +}: { + /** The view's list body (`config` of a view item). */ + body: Row; + writeBody: (next: Row) => void; + objectName: string | undefined; + /** The object's field names, for the formatting rules' autocomplete. */ + fieldNames: string[]; + fieldCatalog: LoadState; + readOnly: boolean; + locale: string; + /** How many formatting rules' CEL does not parse: the host holds the autosave while it is not 0. */ + onBlockingIssuesChange?: (count: number) => void; +}): React.ReactElement { + const tLocal = React.useCallback((key: string) => t(key, locale), [locale]); + const rules = Array.isArray(body.conditionalFormatting) ? (body.conditionalFormatting as unknown[]) : []; + const form = React.useMemo(() => panelViewForm(locale), [locale]); + const schema = getListVariantSchema(); + return ( +
+
+ { + const { conditionalFormatting: _dropped, ...rest } = body; + writeBody(next.length > 0 ? { ...rest, conditionalFormatting: next } : rest); + }} + onBlockingIssuesChange={onBlockingIssuesChange} + /> +
+ {schema && form && ( +
+ writeBody(repinned(next, body))} + /> +
+ )} +
+ ); +} diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx index ac0866a43f4..e423a2b0124 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx @@ -130,6 +130,7 @@ import { listViewTypeHeldEdit, } from './ListViewTypeSection.js'; import { ListViewChartSection } from './ListViewChartSection.js'; +import { ListViewMoreSection } from './ListViewMoreSection.js'; import { ListViewBulkActionsSection, listViewHasSelectionBar } from './ListViewBulkActionsSection.js'; import { addedArrayElements, @@ -395,6 +396,11 @@ export interface ObjectListViewDraft { * a pending edit is the caller's to send first. */ showView: (viewId: string) => void; + /** + * objectstack#30046 — open the leaf `surface` (the host opens it) on its + * view `viewId`, as a link naming that view would (`ListViewLink`). + */ + openView: (surface: SurfaceIdentity, viewId: string) => void; /** Send the buffer to the package draft — the autosave's `save`. */ save: (sent: DraftSendClaim) => Promise; /** The version-conflict dialog of this buffer's guard; render it once. */ @@ -737,6 +743,14 @@ export function useObjectListViewDraft({ }, [entryViewId, entryKey], ); + // objectstack#30046 — a door outside the header (a list view picked under + // 不在导航中, a link to the view itself) opens the leaf `surface` on its + // view `viewId`: the link's own rule, so it shows once that leaf is open + // and expires as a link does. + const openView = React.useCallback((surface: SurfaceIdentity, next: string) => { + setPicked(null); + setLink({ surface, viewId: next }); + }, []); const row = buffer.row; const notList = !!row && !isListViewRow(row); @@ -815,6 +829,7 @@ export function useObjectListViewDraft({ edit, createView, showView, + openView, save, conflictDialog, canvas, @@ -1205,7 +1220,10 @@ function KanbanCardSection({ * type's own settings (`ListViewTypeSection`, objectstack#27895); on a chart * view, its dataset binding (`ListViewChartSection`, objectstack#27933). The * object's other list views are one pick away in the header's title - * (`ObjectListViewTitle`, objectstack#30046), not here. + * (`ObjectListViewTitle`, objectstack#30046), not here. Below them, what only + * the view's own page offered before a list view opened here from every door: + * its conditional formatting and the spec form's sections + * (`ListViewMoreSection`, objectstack#30046). */ export function ObjectListViewInspector({ listView, @@ -1213,6 +1231,7 @@ export function ObjectListViewInspector({ locale, selection, onSelectionChange, + onBlockingIssuesChange, }: { listView: ObjectListViewDraft; readOnly: boolean; @@ -1224,6 +1243,12 @@ export function ObjectListViewInspector({ */ selection?: ListViewColumnSelection | null; onSelectionChange?: (selection: ListViewColumnSelection | null) => void; + /** + * objectstack#30046 — how many conditional-formatting rules' CEL does not + * parse (`ListViewMoreSection`): the host holds the view's autosave while + * it is not 0. + */ + onBlockingIssuesChange?: (count: number) => void; }): React.ReactElement | null { const { target, row, notList, seedColumns, failure, saving, hasDraft, edit, loadedFor, targetKey, draftChanges, published } = listView; @@ -1542,6 +1567,17 @@ export function ObjectListViewInspector({ locale={locale} /> )} + {/* objectstack#30046 — what only the view's own page offered: 条件格式化 and the spec form's sections. */} + f.name)} + fieldCatalog={fieldCatalog} + readOnly={readOnly} + locale={locale} + onBlockingIssuesChange={onBlockingIssuesChange} + /> {latestMark && ( // objectui#12725 — the 来源 line: who drafted the marked settings, and when. diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index bb0d342598c..9e7305aa34c 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -106,6 +106,9 @@ import { navIcon, navKindName, navOpenedItems, + offNavViewHeading, + offNavViewLabel, + offNavViewReachedByEntry, type OffNavType, } from './InterfacesNavRail.js'; import { @@ -240,6 +243,7 @@ import { listViewColumnIndexOf, listViewColumnSelection, listViewHeldEdit, + listViewNameOf, listViewIsKanban, ObjectListViewInspector, useObjectListViewDraft, @@ -312,7 +316,12 @@ import { type StudioRefusal, } from './metadataError.js'; import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; -import { loadPackageLessSurfaces, loadPackageSurfaces, type PackageSurface } from './packageSurfaces.js'; +import { + loadPackageLessSurfaces, + loadPackageSurfaces, + withDraftViewBindings, + type PackageSurface, +} from './packageSurfaces.js'; import { STUDIO_ORG_SCOPE_PILLAR, isOrgScopeDraft, @@ -326,7 +335,7 @@ import { useWalledCatalogReadOnly } from '../metadata-admin/catalog-scope.js'; import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; import { StudioMetadataArea } from './StudioMetadataArea.js'; import { useMetadataRefreshNonce } from './useMetadataRefreshNonce.js'; -import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js'; +import { resolveSurface, findSurfaceInTree, isSameSurface, type NavNode, type Surface } from './navSurface.js'; import { servedNavTextOf, displayNavTree, displayAppLabel, type ServedNavText } from './servedNavText.js'; import { ServedWidgetTextContext, servedWidgetTextOf } from './servedDashboardText.js'; import { @@ -2299,9 +2308,22 @@ export function InterfacesPillar({ // default in `offNavFallbackRef`, and the effect after the package-items read // opens the item from those lists, or that default when the package has no // such item. Decided once, for the link this pillar mounted with. + // objectstack#30046 — so does a link to an object no entry opens (the object + // page a list view under 不在导航中 opens, reloaded or shared): the views + // the package lists say which object that is. const offNavDeepLinkRef = React.useRef<'pending' | 'done'>( - initialSurface && (OFF_NAV_TYPES as readonly string[]).includes(initialSurface.type) ? 'pending' : 'done', + initialSurface && ([...OFF_NAV_TYPES, 'object'] as readonly string[]).includes(initialSurface.type) + ? 'pending' + : 'done', ); + // objectstack#30046 — the doors that open a list view on its object's page + // (`listViewDoorOf`, `openListViewDoor`, below), read by the deep-link + // effect, which runs before they are defined in this render. + const listViewDoorRef = React.useRef<{ + of: (item: PackageSurface & { addedIn?: string }) => { surface: Surface; viewId: string } | null; + objectSurface: (objectName: string) => Surface; + open: (door: { surface: Surface; viewId: string }) => void; + } | null>(null); const offNavFallbackRef = React.useRef(null); const offNavUpgrade = useDeepLinkFallback(); const currentRef = React.useRef(current); @@ -2531,9 +2553,11 @@ export function InterfacesPillar({ const read = (at: string) => Promise.all( OFF_NAV_TYPES.map((type) => - loadPackageSurfaces(client, type, at, locale, getMetadataResource(type)?.listFilter).catch( - () => [] as PackageSurface[], - ), + loadPackageSurfaces(client, type, at, locale, getMetadataResource(type)?.listFilter) + // objectstack#30046 — a view row says its object and family, a + // draft-only one too: the 视图 group and its doors read them. + .then((items) => (type === 'view' ? withDraftViewBindings(client, at, items) : items)) + .catch(() => [] as PackageSurface[]), ), ); (async () => { @@ -2557,22 +2581,42 @@ export function InterfacesPillar({ React.useEffect(() => { if (!initialSurface) return; if (appStatus === 'loading' || packageItems?.packageId !== packageId) return; + // objectstack#30046 — a link to an object no entry opens: a list view of + // it is among the package's views, and the object's page opens (on the + // view the link names, `initialView` of the list-view buffer). + const objectViews = + initialSurface.type === 'object' + ? (packageItems.byType.view ?? []).some((i) => i.viewKind === 'list' && i.object === initialSurface.name) + : false; const item = (packageItems.byType[initialSurface.type as OffNavType] ?? []).find( (i) => i.name === initialSurface.name, ); - const linked: Surface | null = item - ? { type: item.type, name: item.name, label: item.label, ...(item.addedIn ? { addedIn: item.addedIn } : {}) } - : null; + // objectstack#30046 — a link to a list view itself (`surface=view:`, the + // All metadata door) lands on its object's page, as a row under 不在导航中 + // does. + const door = item && initialSurface.type === 'view' ? (listViewDoorRef.current?.of(item) ?? null) : null; + const linked: Surface | null = door + ? door.surface + : objectViews + ? (listViewDoorRef.current?.objectSurface(initialSurface.name) ?? null) + : item + ? { type: item.type, name: item.name, label: item.label, ...(item.addedIn ? { addedIn: item.addedIn } : {}) } + : null; // objectstack#28572 — an item the pair adds is listed once the pair is // known, after the package list answers: a link the first lists could not // resolve opens it then, while the default it opened is still open. if (offNavDeepLinkRef.current === 'done') { if (linked && offNavUpgrade.take(currentRef.current ? leafKeyOf(currentRef.current) : null, leafKeyOf(linked))) { - setCurrent(linked); + if (door) listViewDoorRef.current?.open(door); + else setCurrent(linked); } return; } offNavDeepLinkRef.current = 'done'; + if (door && currentRef.current === null) { + listViewDoorRef.current?.open(door); + return; + } const target = linked ?? offNavFallbackRef.current; if (!linked && target && currentRef.current === null) offNavUpgrade.note(leafKeyOf(target)); setCurrent((cur) => cur ?? target); @@ -3281,6 +3325,38 @@ export function InterfacesPillar({ // `?surface=` mirror (see useSurfaceDeepLink), with the view an `object` // leaf shows when it is not the one its entry opens (objectstack#30046). useSurfaceDeepLinkMirror(current, current?.type === 'object' && listView.entryTarget ? (listView.target?.viewId ?? null) : null); + // objectstack#30046 — an object's name as the Studio shows it: the label the + // shell's metadata serves in this locale, else this package's own row, else + // the name. Names the page of an object no entry opens, and its forms. + const objectLabelOf = (objectName: string): string => + resolveI18nLabel((findByName(metadata.objects, objectName) as { label?: I18nLabel } | undefined)?.label, locale)?.trim() || + pkgObjects.find((o) => o.name === objectName)?.label || + objectName; + // objectstack#30046 (Scope 6) — a list view opened from a door outside the + // header (a row under 不在导航中, a link to the view itself) lands on its + // object's page, the one page a list view is edited on: the object's nav + // entry when it has one (the first, in tree order, as a link to the object + // finds it), else the object itself, with no entry. A form view, a view + // whose family or object is unknown, and one a read-only base's pair adds + // (read and saved at that pair) open as themselves, as before. + const objectSurfaceOf = (objectName: string): Surface => + findSurfaceInTree(navTree, { type: 'object', name: objectName }, locale, targetLabel) ?? { + type: 'object', + name: objectName, + label: objectLabelOf(objectName), + }; + const listViewDoorOf = (item: PackageSurface & { addedIn?: string }): { surface: Surface; viewId: string } | null => + item.type === 'view' && item.viewKind === 'list' && item.object && !item.addedIn + ? { surface: objectSurfaceOf(item.object), viewId: item.name } + : null; + const openListView = listView.openView; + const openListViewDoor = (door: { surface: Surface; viewId: string }) => { + openListView(door.surface, door.viewId); + setCurrent(door.surface); + }; + React.useLayoutEffect(() => { + listViewDoorRef.current = { of: listViewDoorOf, objectSurface: objectSurfaceOf, open: openListViewDoor }; + }); const listViewTargetId = listView.target?.viewId ?? null; const listViewCustomizable = customizing && listViewTargetId !== null; const listViewServed = useServedOverride( @@ -3306,7 +3382,27 @@ export function InterfacesPillar({ // objectstack#27895 — an edit that clears a required setting of the view // type's own block (a calendar's start date) is held, not sent, as a // leaf's is: asked of the spec's list-view schema (`listViewHeldEdit`). - const listViewHeld = listViewHeldEdit(listView, locale); + // objectstack#30046 — so is an edit while a conditional-formatting rule's + // CEL does not parse (the panel's `ListViewMoreSection`), as the view's own + // page held its Save on the same verdicts. + const listViewTargetKey = listView.targetKey; + const [listViewCel, setListViewCel] = React.useState<{ key: string; count: number }>({ key: '', count: 0 }); + const reportListViewCel = React.useCallback( + (count: number) => + setListViewCel((prev) => (prev.key === listViewTargetKey && prev.count === count ? prev : { key: listViewTargetKey, count })), + [listViewTargetKey], + ); + const listViewCelBlocking = listViewCel.key === listViewTargetKey ? listViewCel.count : 0; + const listViewHeld = + listViewHeldEdit(listView, locale) ?? + (listViewCelBlocking > 0 && listView.dirty && listView.target + ? { + clause: tFormat('engine.studio.held.inspectorInvalid', locale, { + item: listViewNameOf(listView, listView.target, listView.row, locale), + }), + target: null, + } + : null); const { flush: flushListView } = useDraftAutoSave({ target: listView.targetKey, loadedFor: listView.loadedFor, @@ -3886,7 +3982,12 @@ export function InterfacesPillar({ return null; }; const railEntry = current.navId ? find(railTree) : null; - const text = railEntry ? navEntryLabelText(railEntry as NavEntryLike, locale, targetLabel) : current.label; + // objectstack#30046 — an object page no entry opens is named by its object. + const text = railEntry + ? navEntryLabelText(railEntry as NavEntryLike, locale, targetLabel) + : current.type === 'object' && !current.navId + ? objectLabelOf(current.name) + : current.label; return text || current.name; })(); // objectui#12455 — an object entry's structure is the Data pillar's: the @@ -4551,6 +4652,8 @@ export function InterfacesPillar({ // objectui#12725 — the pillar's selection, which a canvas header click also makes. selection={selection} onSelectionChange={setSelection} + // objectstack#30046 — a formatting rule's CEL that does not parse holds the autosave. + onBlockingIssuesChange={reportListViewCel} /> ) : ( @@ -4725,6 +4828,24 @@ export function InterfacesPillar({ setCurrent(s); if (isMobile) setRailOpen(false); }; + // objectstack#30046 (Scope 6) — a row under 不在导航中 opens its item; a list + // view's is its object's page (`listViewDoorOf`). On that page already, the + // pick is the header dropdown's: a pending edit is sent first, or asked about. + const pickOffNav = (s: Surface) => { + const item = s.type === 'view' ? (packageItems?.byType.view ?? []).find((i) => i.name === s.name) : undefined; + const door = item && !s.addedIn ? listViewDoorOf(item) : null; + if (!door) { + pickSurface(s); + return; + } + if (current?.type === 'object' && isSameSurface(current, door.surface)) { + switchListView(door.viewId); + } else { + if (leafKeyOf(door.surface) !== leafKey && !confirmDropUnsavedWork(leafUnsent, locale)) return; + openListViewDoor(door); + } + if (isMobile) setRailOpen(false); + }; // objectstack#26889 — what the sections under the tree list: the package's // items no entry of the nav buffer opens, an unsent nav edit included. None // while the app is still resolving, so an item its entry will claim is never @@ -4763,14 +4884,27 @@ export function InterfacesPillar({ hasDraft: navHasDraft, } : null; - const offNavSections = OFF_NAV_TYPES.map((type) => ({ - type, - items: listOnlyCustomized + // objectstack#30046 — under 视图: a list view an entry's header dropdown + // reaches is not listed again (Scope 7); a form view reads a readable name, + // and the heading says what the group holds (Scope 8). + const offNavSections = OFF_NAV_TYPES.map((type) => { + const items = listOnlyCustomized ? markedOf(type) : itemsRead - ? (packageItems?.byType[type] ?? []).filter((item) => !navOpened.has(itemKeyOf(type, item.name))) - : [], - })); + ? (packageItems?.byType[type] ?? []).filter( + (item) => + !navOpened.has(itemKeyOf(type, item.name)) && !(type === 'view' && offNavViewReachedByEntry(item, navOpened)), + ) + : []; + if (type !== 'view') return { type, items }; + return { + type, + label: offNavViewHeading(items, locale), + items: items.map((item) => ({ ...item, label: offNavViewLabel(item, objectLabelOf(item.object ?? ''), locale) })), + }; + }); + // objectstack#30046 — the view an object page opened from 不在导航中 shows: its row is the active one. + const offNavActiveView = current?.type === 'object' && !current.navId ? (listView.target?.viewId ?? null) : null; return ( @@ -5068,7 +5202,8 @@ export function InterfacesPillar({ )} diff --git a/ui/packages/app-shell/src/views/studio-design/packageSurfaces.test.ts b/ui/packages/app-shell/src/views/studio-design/packageSurfaces.test.ts index 76be25ecd1b..7804a9a6e90 100644 --- a/ui/packages/app-shell/src/views/studio-design/packageSurfaces.test.ts +++ b/ui/packages/app-shell/src/views/studio-design/packageSurfaces.test.ts @@ -13,7 +13,12 @@ * item still appears in its rail. */ import { describe, expect, it, vi } from 'vitest'; -import { loadPackageLessSurfaces, loadPackageSurfaces, type PackageSurfaceClient } from './packageSurfaces'; +import { + loadPackageLessSurfaces, + loadPackageSurfaces, + withDraftViewBindings, + type PackageSurfaceClient, +} from './packageSurfaces'; function makeClient( published: Array>, @@ -209,3 +214,43 @@ describe("a draft-only item reads as its `_drafts` row's label (objectui#11862)" ]); }); }); + +/** + * objectstack#30046 — a `view` row carries its bound object and family, so the + * Interfaces rail can tell a list view (reached by its object's dropdown, and + * opened on its object's page) from a form view. A draft-only view's family is + * read from the draft-overlaid list, since its `_drafts` header has no body. + */ +describe('view rows say their object and family (objectstack#30046)', () => { + it('a published view row carries `object` and `viewKind`; another type carries neither', async () => { + const views = makeClient([{ name: 'crm_lead.all', label: 'All', object: 'crm_lead', viewKind: 'list' }], []); + expect(await loadPackageSurfaces(views, 'view', 'pkg')).toEqual([ + { type: 'view', name: 'crm_lead.all', label: 'All', object: 'crm_lead', viewKind: 'list' }, + ]); + const pages = makeClient([{ name: 'home', label: 'Home', object: 'crm_lead' }], []); + expect(await loadPackageSurfaces(pages, 'page', 'pkg')).toEqual([{ type: 'page', name: 'home', label: 'Home' }]); + }); + + it('a draft-only view takes its object and family from the draft-overlaid list', async () => { + const list = vi.fn(async () => [ + { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list' }, + { name: 'crm_lead.mine', object: 'crm_lead', viewKind: 'list', _draft: true }, + ]); + const items = await withDraftViewBindings({ withPreviewDrafts: () => ({ list }) }, 'pkg', [ + { type: 'view', name: 'crm_lead.all', label: 'All', object: 'crm_lead', viewKind: 'list' }, + { type: 'view', name: 'crm_lead.mine', label: 'Mine' }, + ]); + expect(list).toHaveBeenCalledWith('view', { packageId: 'pkg' }); + expect(items[1]).toEqual({ type: 'view', name: 'crm_lead.mine', label: 'Mine', object: 'crm_lead', viewKind: 'list' }); + }); + + it('asks nothing when every row already says it, and keeps the rows when the read fails', async () => { + const list = vi.fn(async () => []); + const known = [{ type: 'view', name: 'crm_lead.all', label: 'All', object: 'crm_lead', viewKind: 'list' }]; + expect(await withDraftViewBindings({ withPreviewDrafts: () => ({ list }) }, 'pkg', known)).toBe(known); + expect(list).not.toHaveBeenCalled(); + const unknown = [{ type: 'view', name: 'crm_lead.mine', label: 'Mine' }]; + const failing = { withPreviewDrafts: () => ({ list: vi.fn(async () => Promise.reject(new Error('403'))) }) }; + expect(await withDraftViewBindings(failing, 'pkg', unknown)).toEqual(unknown); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/packageSurfaces.ts b/ui/packages/app-shell/src/views/studio-design/packageSurfaces.ts index 17ad2f81065..f05c3c44a15 100644 --- a/ui/packages/app-shell/src/views/studio-design/packageSurfaces.ts +++ b/ui/packages/app-shell/src/views/studio-design/packageSurfaces.ts @@ -49,6 +49,14 @@ export interface PackageSurface { type: string; name: string; label: string; + /** + * objectstack#30046 — a `view` row's bound object and family (`viewKind`, + * `list` | `form`), as the published item declares them (ADR-0017's + * `{ name, object, viewKind }`). Absent on every other type, and on a + * draft-only view: its `_drafts` header carries no body. + */ + object?: string; + viewKind?: string; } /** @@ -127,6 +135,7 @@ function mergeSurfaces( type, name: String(o.name ?? ''), label: String(o.label ?? o.name ?? ''), + ...(type === 'view' ? viewBindingOf(o) : {}), })) .filter((o) => o.name); @@ -142,3 +151,49 @@ function mergeSurfaces( return items; } + +/** + * objectstack#30046 — a view item's bound object and family, where the item + * declares them as strings: the members {@link PackageSurface} carries for a + * `view` row. Used for a published row and for a draft-overlaid one alike. + */ +export function viewBindingOf(item: Record): Pick { + return { + ...(typeof item.object === 'string' && item.object ? { object: item.object } : {}), + ...(typeof item.viewKind === 'string' && item.viewKind ? { viewKind: item.viewKind } : {}), + }; +} + +/** The draft-overlaid read {@link withDraftViewBindings} asks: the metadata client's preview door. */ +export interface DraftViewBindingClient { + withPreviewDrafts(on: boolean): { list(type: string, options?: { packageId?: string }): Promise }; +} + +/** + * objectstack#30046 — the `view` rows of {@link loadPackageSurfaces} with the + * bound object and family of each draft-only row filled in. A `_drafts` + * header carries no body, so a view only drafted here (one made with New view) + * is read from the draft-overlaid list (`withPreviewDrafts`), which serves + * each pending draft's body. Asked only when some row lacks them; a failed or + * refused read returns the rows as they were, so nothing listed is lost. + */ +export async function withDraftViewBindings( + client: DraftViewBindingClient, + packageId: string, + items: PackageSurface[], +): Promise { + if (items.every((item) => item.viewKind)) return items; + let rows: unknown; + try { + rows = await client.withPreviewDrafts(true).list('view', { packageId }); + } catch { + return items; + } + const byName = new Map>(); + for (const row of Array.isArray(rows) ? rows : []) { + if (row && typeof row === 'object' && typeof (row as { name?: unknown }).name === 'string') { + byName.set((row as { name: string }).name, viewBindingOf(row as Record)); + } + } + return items.map((item) => (item.viewKind ? item : { ...item, ...byName.get(item.name) })); +} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 2be1e24fc88..83e288c60f3 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -2413,6 +2413,9 @@ const STUDIO_EN: Record = { 'engine.studio.if.offNav.dashboard': 'Dashboards', 'engine.studio.if.offNav.report': 'Reports', 'engine.studio.if.offNav.view': 'Views', + 'engine.studio.if.offNav.viewForms': 'Form views', + 'engine.studio.if.offNav.viewMixed': 'Form and list views', + 'engine.studio.if.offNav.formName': '{object} form', 'engine.studio.if.openInData': 'Open in Data', // objectui#12725 — the status line above the canvas, and the item's ⋯ menu. 'engine.studio.if.status.design': 'Design', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index a254e8aba12..c5dd51349e1 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2164,6 +2164,9 @@ const STUDIO_ZH: Record = { 'engine.studio.if.offNav.dashboard': '仪表盘', 'engine.studio.if.offNav.report': '报表', 'engine.studio.if.offNav.view': '视图', + 'engine.studio.if.offNav.viewForms': '表单视图', + 'engine.studio.if.offNav.viewMixed': '表单视图与列表视图', + 'engine.studio.if.offNav.formName': '{object}表单', 'engine.studio.if.openInData': '在「数据」中打开', // objectui#12725 — 画布上方的状态行,以及条目的 ⋯ 菜单。 'engine.studio.if.status.design': '设计态',