Skip to content

Commit ff784ef

Browse files
feat(studio): Add view in the Interfaces list-view panel creates a kanban, calendar or other list view (#27870)
domain:ui#3 · os-bill · session_01Utp6CaJoinvGQSbHeDaWBe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKZpitbtaBebZpZsTXoTeoa)_ ## 维护者速读 - 改了什么:Studio 界面支柱里,对象的列表视图属性面板多了「添加视图」按钮。点开就是控制台已有的新建视图对话框:先选视图类型(表格、看板、日历、画廊……,来自 spec 的列表视图类型),再按字段标签选该类型必填的字段(看板的分组字段、日历的开始日期和标题字段),再填名称和键。视图直接完整地存进本包草稿,随后属性面板和画布都切到这个新视图,面板上有「返回 …」回到导航入口原来打开的视图。 - 为什么改:以前面板只能编辑入口打开的那一个视图(通常是默认表格),Studio 里没有任何办法新建看板、日历等视图(#27521 第 31 行)。 - 风险与回滚:只改前端;新视图从不设为默认视图,导航入口不改,默认表格一个字节都不写;用的是控制台加视图入口同一个构建器(`buildNewViewSpec` + `viewEnvelope`),所以草稿里不会出现半成品视图;键已被占用时什么都不写、对话框不关、提示原因。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx`(`createView`、面板里的「添加视图」与「返回」)、`ObjectListViewInspector.addView-27836.test.tsx`、`StudioDesignSurface.tsx`(打开对话框前先发出面板里未保存的修改)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/ObjectListViewInspector.addView-27836.test.tsx`,预期 1 个文件 4 条全部通过(看板、日历、返回入口视图、键重名被拒)。 - 需要你做的:无,席位落地。 Fixes #27836 Clause-②: no Metadata-protocol: none ## What changes **Measured first, on `main` at `9ae6384e`.** The Interfaces pillar's list-view panel (`ObjectListViewInspector`) edits exactly one view: the one the open nav entry lands on (`listViewTargetOf`), usually the object's default grid. Nothing in Studio creates a view of another type. Once a view exists, the panel already edits any list view's columns, filter and sort, and a kanban's card section (`KanbanCardSection`); a calendar, gallery, timeline, gantt, map, chart or tree has no section of its own (see Acceptance notes). The canvas already renders whatever view the panel holds (`StudioCanvasListViewContext` feeds `object-view`'s `listViews`), so a kanban or calendar view in the panel shows as a board or calendar on the canvas. **"Add view"** is a button at the top of the panel. It opens the console's existing `CreateViewDialog`, unchanged: - the type grid is the dialog's total record over `ListViewVisualization`, which `@object-ui/core` derives from the spec's view type enum, so no list is kept here; - the dialog asks the chosen type's required fields first and keeps Create disabled until each is picked; its field pickers here read the same fields as the panel's own pickers (the object's fields minus the platform's hidden system columns), shown by label; - then a label and a key. On Create, the new hook method `useObjectListViewDraft().createView(payload)`: 1. builds the body the console's add-view door builds: `buildNewViewSpec` (with the panel's seed columns as the fallback, which mirrors them into `kanban.columns` as the spec requires) wrapped by `viewEnvelope` (`OBJECT.KEY`, `viewKind: 'list'`, the label, `config.data` bound to the object); 2. refuses a name a view already holds, published (`client.list('view')`) or drafted in any package (`client.listDrafts({ type: 'view' })`), writing nothing, as the pillar's New dashboard / report / page create does; 3. saves it to the package draft with `If-None-Match: *`, so the door also refuses one drafted between that read and the save; 4. then the panel and the canvas show it. The choice is stamped with the nav entry it was made on, so it expires by construction when another leaf opens. "Back to …" shows the entry's view again. The view is born complete, so the draft never holds a half-made view and the held-input rule has nothing to hold. It is never the object's default (`isDefault` is not written), and the nav entry is not changed: the running app lists every view of the object in its view switcher. A refusal (taken key, or the door's own refusal) keeps the dialog open with the author's input, as `CreateViewDialog`'s contract asks, and a toast says why in the designer locale. Opening the dialog first sends the panel's pending edit (the pillar's list-view autosave `flush`), because the view the panel shows changes once the new one is created and the autosave drops an edit begun on another target. ## Tests `ObjectListViewInspector.addView-27836.test.tsx`: the REAL hook, panel and Create View dialog over a REAL `MetadataClient`, whose transport judges every view PUT with the installed spec's `view` schema (422 on refusal): - a kanban: one PUT, to `acme_task.pipeline_board` in the package with `If-None-Match: *`, accepted by the spec, `kanban.groupByField: 'status'`, no `isDefault`, no write to the default view; the panel then shows the board with its card section; - a calendar: `calendar: { startDateField: 'due_date', titleField: 'title' }`, picked by label, accepted; the panel shows it; - "Back to …" returns the panel to `acme_task.default`; - a taken key: no PUT, the dialog stays open, the toast names the key, the panel stays on the default view. ## Acceptance notes - **Editing a non-grid view's own settings is not built here.** After creation, a kanban's card section is editable (it already was); a calendar's start-date and title fields, and the type-specific blocks of gallery, timeline, gantt, map, chart and tree, are set only by the dialog. The panel still edits their columns, filter and sort. Filed by the seat as #27895. - **A view created here is reopened in Studio only through a nav entry that names it.** The panel shows the view it created until another leaf opens; there is no view switcher inside the panel. In the running app the view is in the object's view switcher once published. Filed by the seat as #27896. - The dialog's own copy (type names, field prompts) comes from the console bundle (`console.objectView.*`, through `useObjectTranslation`), so it follows the app's language rather than the Studio's designer locale; the panel's new strings are in the studio bundle (en, zh). - Publishing: the view is an ordinary package draft (`client.save('view', …, { mode: 'draft', packageId })`), published with the package like every other Studio draft; not exercised in a browser here. - No docs page under `content/docs/` describes this panel; none was changed. ## Verification Readings for this head are in the report comment on #27836 (tests, ablation, type-check, the derived gate list reconciled with `--ran`). --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 784dc9a commit ff784ef

6 files changed

Lines changed: 443 additions & 9 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): "Add view" in the Interfaces list-view panel creates a kanban, calendar or other list view
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The list-view panel of an object in Studio's Interfaces pillar edited only the view its nav entry opens, usually the default grid, so a kanban, calendar or any other view type could not be created from Studio. The panel now has an "Add view" button. It opens the console's Create View dialog: the author picks the view type, then the fields that type requires by their labels (the group-by field for a kanban, the start date and title fields for a calendar), then a label and a key. The view is created complete in the package's draft, under a name no view holds yet, and the panel and the canvas then show it, with a link back to the view the nav entry opens. The new view is never the object's default, and the nav entry is not changed: the running app lists the view in the object's view switcher once the package is published.
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27836 — "Add view" in an `object` leaf's list-view panel: the
5+
* type first, then the fields that type requires, picked by label; the view is
6+
* created in the package draft and the panel shows it.
7+
*
8+
* The REAL hook (`useObjectListViewDraft`), the REAL panel and the REAL Create
9+
* View dialog over a REAL `MetadataClient`, whose transport judges every view
10+
* PUT with the installed spec's `view` schema and answers the write door's 422
11+
* on refusal.
12+
*
13+
* Pinned:
14+
* - a kanban and a calendar are created, each under its own name, with the
15+
* picked fields, in the package draft (`If-None-Match: *`), and the spec
16+
* accepts them; the panel then edits the new view, and the default grid is
17+
* not written;
18+
* - "Back to …" shows the view the nav entry opens again;
19+
* - a name a view already holds is refused: nothing is written, the dialog
20+
* stays open, and the panel says why.
21+
*/
22+
23+
import '@testing-library/jest-dom/vitest';
24+
import * as React from 'react';
25+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
26+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
27+
import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';
28+
import { MetadataClient } from '@object-ui/data-objectstack';
29+
import { getMetadataTypeSchema } from '@objectstack/spec/kernel';
30+
31+
const PKG = 'com.acme.app';
32+
const DEFAULT_VIEW = 'acme_task.default';
33+
34+
const OBJECT_DEF = {
35+
name: 'acme_task',
36+
label: 'Task',
37+
fields: {
38+
title: { name: 'title', label: 'Title', type: 'text' },
39+
status: {
40+
name: 'status',
41+
label: 'Status',
42+
type: 'select',
43+
options: [
44+
{ value: 'open', label: 'Open' },
45+
{ value: 'done', label: 'Done' },
46+
],
47+
},
48+
due_date: { name: 'due_date', label: 'Due date', type: 'date' },
49+
},
50+
};
51+
52+
const gridView = {
53+
name: DEFAULT_VIEW,
54+
object: 'acme_task',
55+
viewKind: 'list',
56+
isDefault: true,
57+
config: { type: 'grid', data: { provider: 'object', object: 'acme_task' }, columns: ['title', 'status'] },
58+
};
59+
60+
const server = {
61+
active: new Map<string, Record<string, unknown>>(),
62+
drafts: new Map<string, Record<string, unknown>>(),
63+
puts: [] as Array<{ name: string; pkg: string | null; ifNoneMatch: string | null; status: number; body: any }>,
64+
};
65+
66+
const json = (status: number, body: unknown) =>
67+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
68+
69+
async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
70+
const method = (init?.method ?? 'GET').toUpperCase();
71+
const url = new URL(String(input), 'http://studio.test');
72+
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
73+
if (!meta) return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
74+
const seg = meta[1].split('/').map(decodeURIComponent);
75+
const key = `${seg[0]}/${seg[1]}`;
76+
if (seg[0] === '_drafts') {
77+
return json(200, { drafts: [...server.drafts.keys()].map((k) => ({ type: k.split('/')[0], name: k.split('/')[1] })) });
78+
}
79+
if (method === 'PUT') {
80+
const body = JSON.parse(String(init?.body ?? '{}'));
81+
const verdict = getMetadataTypeSchema('view')!.safeParse(body);
82+
const ifNoneMatch = new Headers(init?.headers).get('If-None-Match');
83+
server.puts.push({ name: seg[1]!, pkg: url.searchParams.get('package'), ifNoneMatch, status: verdict.success ? 200 : 422, body });
84+
if (!verdict.success) {
85+
return json(422, {
86+
success: false,
87+
error: { code: 'INVALID_METADATA', message: 'view failed validation', details: { issues: verdict.error.issues } },
88+
});
89+
}
90+
server.drafts.set(key, body);
91+
return json(200, { success: true, version: 'v1', state: 'draft' });
92+
}
93+
if (seg[0] === 'object' && seg.length === 2) return json(200, { type: 'object', name: seg[1], item: OBJECT_DEF });
94+
if (seg[0] === 'view' && seg.length === 1) return json(200, [...server.active.values()]);
95+
if (seg.length === 3 && seg[2] === 'layers') {
96+
const row = server.active.get(key);
97+
return row
98+
? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row })
99+
: json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
100+
}
101+
if (seg.length === 2 && url.searchParams.get('state') === 'draft') {
102+
const draft = server.drafts.get(key);
103+
return draft
104+
? json(200, { type: seg[0], name: seg[1], item: { ...draft, _draft: true }, version: 'v1' })
105+
: json(404, { error: { code: 'NO_DRAFT', message: 'absent' } });
106+
}
107+
const row = server.active.get(key);
108+
return row ? json(200, { type: seg[0], name: seg[1], item: row }) : json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
109+
}
110+
111+
const client = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
112+
113+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
114+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
115+
return { ...mod, useMetadataClient: () => client };
116+
});
117+
const toastError = vi.fn();
118+
vi.mock('sonner', async (importOriginal) => {
119+
const mod = await importOriginal<typeof import('sonner')>();
120+
return { ...mod, toast: Object.assign(vi.fn(), mod.toast, { error: (...args: unknown[]) => toastError(...args) }) };
121+
});
122+
123+
import { ObjectListViewInspector, useObjectListViewDraft } from './ObjectListViewInspector';
124+
import type { Surface } from './navSurface';
125+
import { tFormat } from '../metadata-admin/i18n';
126+
127+
const METADATA: MetadataContextValue = {
128+
apps: [],
129+
objects: [OBJECT_DEF],
130+
dashboards: [],
131+
reports: [],
132+
pages: [],
133+
loading: false,
134+
error: null,
135+
refresh: async () => {},
136+
invalidate: () => {},
137+
ensureType: async () => [],
138+
getItem: async () => null,
139+
getItemsByType: () => [],
140+
};
141+
142+
const LEAF: Surface = { type: 'object', name: 'acme_task', label: 'Tasks' };
143+
144+
function Host() {
145+
const listView = useObjectListViewDraft({ client, packageId: PKG, leaf: LEAF, publishNonce: 0 });
146+
return <ObjectListViewInspector listView={listView} readOnly={false} locale="en" />;
147+
}
148+
149+
async function settle() {
150+
await act(async () => {
151+
for (let i = 0; i < 25; i++) await new Promise((r) => setTimeout(r, 0));
152+
});
153+
}
154+
155+
async function openPanel(): Promise<void> {
156+
render(
157+
<MetadataCtx.Provider value={METADATA}>
158+
<Host />
159+
</MetadataCtx.Provider>,
160+
);
161+
await settle();
162+
await waitFor(() => expect(screen.getByTestId('list-view-add-view')).toBeEnabled());
163+
}
164+
165+
/** The view item name the panel shows beneath its title. */
166+
const shownView = () => screen.getByTestId('list-view-name').textContent;
167+
168+
const typeCard = (type: string) =>
169+
Array.from(document.querySelectorAll<HTMLButtonElement>('button[data-testid^="create-view-type-"]')).find(
170+
(b) => b.getAttribute('data-testid') === `create-view-type-${type}`,
171+
);
172+
const picker = (key: string) => screen.getByTestId(`create-view-required-${key}`);
173+
const submitButton = () => screen.getByTestId('create-view-submit') as HTMLButtonElement;
174+
175+
/** "Add view", pick `type`, pick each required field by its label, name the view `label`, and press Create. */
176+
async function addView(type: string, picks: Array<[key: string, label: string]>, label: string) {
177+
fireEvent.click(screen.getByTestId('list-view-add-view'));
178+
await waitFor(() => expect(typeCard(type)?.disabled).toBe(false));
179+
fireEvent.click(typeCard(type)!);
180+
for (const [key, option] of picks) {
181+
await waitFor(() => expect(picker(key)).toBeEnabled());
182+
fireEvent.keyDown(picker(key), { key: 'ArrowDown' });
183+
const listbox = await screen.findByRole('listbox');
184+
const hit = within(listbox).getAllByRole('option').find((o) => o.textContent === option);
185+
if (!hit) throw new Error(`${type}: ${key} lists no "${option}"`);
186+
fireEvent.click(hit);
187+
await waitFor(() => expect(picker(key).textContent).toBe(option));
188+
}
189+
fireEvent.change(screen.getByTestId('create-view-name-input'), { target: { value: label } });
190+
await waitFor(() => expect(submitButton().disabled).toBe(false));
191+
fireEvent.click(submitButton());
192+
await settle();
193+
}
194+
195+
beforeEach(() => {
196+
server.active.clear();
197+
server.drafts.clear();
198+
server.puts = [];
199+
toastError.mockClear();
200+
server.active.set(`view/${DEFAULT_VIEW}`, gridView);
201+
});
202+
afterEach(cleanup);
203+
204+
describe('ObjectListViewInspector — "Add view" creates a view of the chosen type and opens it (objectstack#27836)', () => {
205+
it('a kanban: born with the picked group-by field in the package draft, accepted by the spec, and opened in the panel', async () => {
206+
await openPanel();
207+
expect(shownView()).toBe(DEFAULT_VIEW);
208+
await addView('kanban', [['groupByField', 'Status']], 'Pipeline board');
209+
210+
expect(server.puts).toHaveLength(1);
211+
const [put] = server.puts;
212+
expect(put).toMatchObject({ name: 'acme_task.pipeline_board', pkg: PKG, ifNoneMatch: '*', status: 200 });
213+
expect(put.body).toMatchObject({ name: 'acme_task.pipeline_board', object: 'acme_task', viewKind: 'list', label: 'Pipeline board' });
214+
expect(put.body.config.type).toBe('kanban');
215+
expect(put.body.config.kanban).toMatchObject({ groupByField: 'status' });
216+
// Never the object's default: the default grid stays the one it was.
217+
expect(put.body.isDefault).toBeUndefined();
218+
expect(server.puts.some((p) => p.name === DEFAULT_VIEW)).toBe(false);
219+
220+
// The dialog closed, and the panel now edits the board, card section included.
221+
await waitFor(() => expect(screen.queryByTestId('create-view-submit')).toBeNull());
222+
await waitFor(() => expect(shownView()).toBe('acme_task.pipeline_board'));
223+
expect(screen.getByTestId('list-view-kanban-card')).toBeInTheDocument();
224+
expect(screen.getByTestId('list-view-back-to-entry')).toBeInTheDocument();
225+
});
226+
227+
it('a calendar: born with its start-date and title fields, picked by label', async () => {
228+
await openPanel();
229+
await addView('calendar', [['startDateField', 'Due date'], ['titleField', 'Title']], 'Due dates');
230+
231+
expect(server.puts).toHaveLength(1);
232+
const [put] = server.puts;
233+
expect(put).toMatchObject({ name: 'acme_task.due_dates', pkg: PKG, ifNoneMatch: '*', status: 200 });
234+
expect(put.body.config.type).toBe('calendar');
235+
expect(put.body.config.calendar).toEqual({ startDateField: 'due_date', titleField: 'title' });
236+
await waitFor(() => expect(shownView()).toBe('acme_task.due_dates'));
237+
});
238+
239+
it('"Back to …" shows the view the nav entry opens again', async () => {
240+
await openPanel();
241+
await addView('kanban', [['groupByField', 'Status']], 'Pipeline board');
242+
await waitFor(() => expect(shownView()).toBe('acme_task.pipeline_board'));
243+
244+
fireEvent.click(screen.getByTestId('list-view-back-to-entry'));
245+
await settle();
246+
expect(shownView()).toBe(DEFAULT_VIEW);
247+
expect(screen.queryByTestId('list-view-back-to-entry')).toBeNull();
248+
});
249+
250+
it('a name a view already holds: nothing is written, the dialog stays open, and the panel says why', async () => {
251+
server.active.set('view/acme_task.pipeline_board', { ...gridView, name: 'acme_task.pipeline_board', isDefault: undefined });
252+
await openPanel();
253+
await addView('kanban', [['groupByField', 'Status']], 'Pipeline board');
254+
255+
expect(server.puts).toEqual([]);
256+
expect(toastError).toHaveBeenCalledWith(
257+
tFormat('engine.studio.inspector.listView.addViewTaken', 'en', { name: 'acme_task.pipeline_board' }),
258+
);
259+
expect(screen.getByTestId('create-view-submit')).toBeInTheDocument();
260+
expect(shownView()).toBe(DEFAULT_VIEW);
261+
});
262+
});

0 commit comments

Comments
 (0)