Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/30046-studio-off-nav-list-views.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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<typeof import('../useMetadata')>();
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<string, unknown>) {
render(
<ViewDefaultInspector
type="view"
name={`crm_lead.${viewKind}`}
draft={{ name: `crm_lead.${viewKind}`, object: 'crm_lead', viewKind, config }}
onPatch={vi.fn()}
onSelectionChange={vi.fn()}
readOnly={false}
locale={locale as never}
/>,
);
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<string[]> {
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));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string> = {
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;

Expand Down Expand Up @@ -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({
Expand All @@ -205,9 +237,12 @@ export function ViewVariantInspector({
const variant = (draft[variantKey] as Record<string, unknown> | 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`
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,48 @@ export function navOpenedItems(nodes: readonly NavNode[], locale: string): Set<s
return opened;
}

/**
* objectstack#30046 — whether the 视图 group leaves `item` out: a list view
* whose object a nav entry opens (`opened`, {@link navOpenedItems}), which the
* entry's header dropdown reaches. A form view, a list view of an object no
* entry opens, and a view whose family or object is unknown stay listed, as
* does a view a read-only base's pair adds (`addedIn`), which opens at that
* pair.
*/
export function offNavViewReachedByEntry(
item: Pick<PackageSurface, 'object' | 'viewKind'> & { addedIn?: string },
opened: ReadonlySet<string>,
): 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<Pick<PackageSurface, 'viewKind'>>, 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,
Expand All @@ -446,11 +488,22 @@ export function navOpenedItems(nodes: readonly NavNode[], locale: string): Set<s
export function OffNavSections({
sections,
active,
activeViewId = null,
onPick,
onlyCustomized = false,
}: {
sections: ReadonlyArray<{ type: OffNavType; items: ReadonlyArray<PackageSurface & { addedIn?: string }> }>;
/**
* 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<PackageSurface & { addedIn?: string }> }>;
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;
Expand All @@ -464,7 +517,15 @@ export function OffNavSections({
<p className="px-2 pb-1 text-[11px] text-muted-foreground">{t('engine.studio.if.offNav.heading', locale)}</p>
)}
{shown.map((s) => (
<OffNavSection key={s.type} type={s.type} items={s.items} active={active} onPick={onPick} />
<OffNavSection
key={s.type}
type={s.type}
label={s.label}
items={s.items}
active={active}
activeViewId={activeViewId}
onPick={onPick}
/>
))}
</div>
);
Expand All @@ -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<PackageSurface & { addedIn?: string }>;
active: Surface | null;
activeViewId: string | null;
onPick: (s: Surface) => void;
}): React.ReactElement {
const locale = useMetadataLocale();
Expand All @@ -489,7 +554,7 @@ function OffNavSection({
return (
<StudioRailGroup
testId={`if-off-nav-${type}`}
label={t(`engine.studio.if.offNav.${type}`, locale)}
label={label ?? t(`engine.studio.if.offNav.${type}`, locale)}
count={items.length}
collapsed={!open}
onToggle={() => setOpen((v) => !v)}
Expand All @@ -506,7 +571,7 @@ function OffNavSection({
<StudioRailRow
key={item.name}
onClick={() => 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'] } } },
]);
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading