Skip to content

Commit 25b8ef3

Browse files
feat(studio): the list-view panel edits a non-grid view's own settings (calendar, gallery, timeline, gantt, map, tree) (#27931)
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_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - 改了什么:Studio › 界面 › 列表视图面板,新增「本类型设置」区:日历、画廊、时间线、甘特、地图、树形视图自身的设置(如日历的开始日期字段与标题字段)现在可在面板里改,字段按名称(label)挑选。 - 为什么改:「添加视图」建出非表格视图后,Studio 只能改看板卡片,其他类型建好就再也改不了自身设置。 - 风险与回滚:只动 Studio 面板与两条 i18n;表格与看板面板不变;整个 PR 回滚即恢复原状,无数据迁移。 - 先看:`ListViewTypeSection.tsx`(按 spec 块的 JSONSchema 交给通用 SchemaForm 渲染)、`ObjectListViewInspector.tsx` 中挂载这一区的那几行、新测试文件。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/ObjectListViewInspector.typeSettings-27895.test.tsx`,预期 9 passed。 - 需要你做的:无,席位落地。 Fixes #27895 Clause-②: no Metadata-protocol: none ## What changes - **New section, one per view type that has a block of its own.** On a calendar, gallery, timeline, gantt, map or tree view, the list-view panel shows "TYPE settings" above the columns. The section is the list body's block of that type's name (`calendar`, `gallery`, …), rendered by the generic `SchemaForm` from the spec's own JSONSchema for that block (`getListVariantSchema().properties[type]`). Which settings a type has, which are required and which enum values are allowed all come from the spec; a key the spec adds to a block appears with no change here. There is no per-type field list. - **Fields are picked by label.** The form is given the panel's field catalog, so every `*Field` setting is the field picker (label beside machine name) and every `*Fields` setting the multi-field picker, by the form's existing name convention. - **Not in this section:** `grid` (no block), `kanban` (its card keeps `KanbanCardSection`), and `chart` (its block binds an ADR-0021 dataset and that dataset's measures, not the object's fields; see Acceptance notes). - **A field the block already names stays offered** in the panel's pickers, as the columns, filter, sort and kanban card already do (`fieldNamesHeldBy`). - **Copilot marks:** a copilot or MCP change under the block puts the draft chip on the section heading and counts toward the source line. - **i18n (en, zh):** `engine.studio.inspector.listView.typeSettings`, `…typeSettings.intro`, and the missing view type name `engine.inspector.view.type.tree`. ## Tests `ObjectListViewInspector.typeSettings-27895.test.tsx`: the real `useObjectListViewDraft` hook and the real panel over a real `MetadataClient`, whose transport parses every view PUT with the installed spec's `view` schema and answers 422 on refusal. - a calendar: start date and title picked by label; the saved draft holds `{ startDateField: 'start_date', titleField: 'summary' }`, the spec accepts it, and columns and type did not move; - a gallery's cover field and a timeline's start date field, the same way; - the section draws every key the spec's calendar block declares, and no other; - opening a view writes nothing (the buffer stays clean, and a save sends the block as stored, with no defaults filled in); - a read-only package draws the settings disabled; - controls: a grid has no section; a kanban keeps its card section and gets no second one; a chart gets no section. <details><summary>Ablation (one-time, not kept)</summary> `node scripts/ablation-replace.mjs` replaced the line that resolves the section's schema in `ObjectListViewInspector.tsx` with a constant `null`: anchor 1 to 0, blob `dbd6ee1d` to `b3327689`. The suite went **6 failed, 3 passed** (the three controls). Restored: blob equals HEAD `dbd6ee1d`, `git diff HEAD` empty. </details> ## Acceptance notes - **Chart is not covered.** A chart view's block is `{ chartType, dataset, values, dimensions }`: an ADR-0021 dataset name and that dataset's measure and dimension names. The generic form would draw them as free text, not pickers, so the section leaves chart out by name. The Create View dialog already has the dataset and measure pickers (`useDatasetCatalog`, `useDatasetSemantics`). Filed by the seat as #27933. - **Setting labels are the spec's.** A setting's label is the form's rendering of its key ("Start Date Field") and its help is the spec's `.describe()` text, in English in every locale, as in the Console's own view inspector, which renders the same blocks the same way. The section heading and intro are translated. - **A cleared required setting is held, not sent** (rework on `d3a3888a`). The field picker still offers "None". Clearing a required setting (a calendar's start date) keeps the block written, and `listViewHeldEdit` asks the spec's `ListViewSchema` (`objectHeldInput`). The list-view autosave then holds the edit, and the pillar shows the held line ("Start Date Field needs a value…"), whose "Show me" focuses that control. `confirmDropUnsavedWork` counts it before the pillar leaves the leaf. A finished but wrong block is still sent, and its refusal shows as before. Pinned by `StudioDesignSurface.listViewTypeHeld-27895.test.tsx` (3/3, two ablations). Side effect: an emptied block of a type with no required setting is written as `{}` instead of being dropped; the spec accepts it. - No new endpoint, client or metadata key: the section writes `config.TYPE` of the same view item through the panel's existing draft door. ## Verification Run on head `e8ddf333`; the rework's own readings (the new pin 3/3, 128 neighbouring files 1582 passed, type-check 0, dispatch-gates 34/34) are on `d3a3888a`: - `pnpm --dir ui exec vitest run` over the new file: 9 passed. - `pnpm --dir ui exec vitest run` over every `ObjectListViewInspector.*`, `InterfacesPillar.*`, `StudioDesignSurface.listViewInspector-11823`, `StudioDesignSurface.kanbanCardSelection-12725`, `StudioDesignSurface.leafColumnSelection-12725` test and `packages/i18n/`: 116 files, 1537 passed, 13 skipped. - `pnpm --filter @object-ui/app-shell --filter @object-ui/i18n run type-check`: exit 0. - `ui` i18n gates (`check-i18n-call-site-keys`, `check-i18n-dead-keys`, `check-i18n-en-drift`) and `pnpm --dir ui check:new-line-citations`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 34 derived commands, each run and reconciled with `--ran`; the per-command exits are in the report comment. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYotPfA6zhPC8hhqkF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 11137d4 commit 25b8ef3

8 files changed

Lines changed: 772 additions & 11 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): the list-view panel edits a calendar, gallery, timeline, gantt, map or tree view's own settings
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
In Studio › Interfaces, the list-view panel of an object now has a section for the settings of the view's own type, below the type it was created as: a calendar's start date, end date, title, color and all-day fields; a gallery's cover, title and card fields, cover fit and card size; a timeline's dates, title, grouping, color and scale; and the blocks of a gantt, a map and a tree. Before, only a kanban's card could be edited there, so a view made with "Add view" kept the fields it was created with.
12+
13+
The section is drawn from the spec's own schema for that type's block, so every setting the spec declares appears, with its required marks and allowed values, and a setting that names a field is picked from the object's fields by label. The draft it saves is the same view item, under `config.<type>`. Grid and kanban views are unchanged. A chart view's dataset binding is still edited in the Create View dialog only.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27895 — the settings of a list view's own type, in the
5+
* list-view panel: a calendar's start date and title fields, a gallery's
6+
* cover and card fields, a timeline's dates, scale and grouping, and the
7+
* blocks of a gantt, a map and a tree.
8+
*
9+
* Each type keeps its settings in the list body's block of the same name
10+
* (`calendar`, `gallery`, …, on the spec's `ListViewSchema`). The section is
11+
* that block, rendered from the spec's own JSONSchema for it
12+
* (`getListVariantSchema`) by the generic {@link SchemaForm}: which settings
13+
* a type has, which are required and which values an enum allows all come
14+
* from the spec, so a key the spec adds to a block shows up here with no
15+
* change. ⛔ No per-type field list. A setting that names a field (`*Field`,
16+
* `*Fields`) is a picker over the object's fields, by label, as the form
17+
* draws it wherever it is given a field catalog.
18+
*
19+
* Not here: `grid`, which has no block; `kanban`, whose card has its own
20+
* section (`KanbanCardSection`); and `chart`, whose block binds an ADR-0021
21+
* dataset and that dataset's measures, not the object's fields — the Create
22+
* View dialog's dataset pickers are the editor for it.
23+
*
24+
* A required setting can be cleared (the field picker offers "None"). That
25+
* edit is held, not sent, as the pillar holds every edit the spec refuses
26+
* only for an unset input (`listViewTypeHeldEdit`): the draft door would
27+
* refuse it with a 422, and every later save with it.
28+
*/
29+
30+
import * as React from 'react';
31+
import type { ListViewVisualization } from '@object-ui/core';
32+
import { ListViewSchema } from '@objectstack/spec/ui';
33+
import { SchemaForm } from '../metadata-admin/SchemaForm.js';
34+
import { getListVariantSchema } from '../metadata-admin/view-schema.js';
35+
import type { ObjectFieldOption } from '../metadata-admin/widgets.js';
36+
import type { LoadState } from '../metadata-admin/loadState.js';
37+
import { t, tFormat, translateSchemaFieldLabel } from '../metadata-admin/i18n.js';
38+
import { prettifyFieldName } from '../metadata-admin/field-source-label.js';
39+
import { DraftChip } from './StudioDraftMark.js';
40+
import { objectHeldInput, type StudioHeld } from './metadataError.js';
41+
42+
type Row = Record<string, unknown>;
43+
type JsonSchema = Record<string, any>;
44+
45+
function isPlainObject(v: unknown): v is Row {
46+
return !!v && typeof v === 'object' && !Array.isArray(v);
47+
}
48+
49+
/** The view types whose block is edited somewhere else, or not by object field (see the module docblock). */
50+
const EDITED_ELSEWHERE: ReadonlySet<string> = new Set(['kanban', 'chart']);
51+
52+
/**
53+
* The copy of each view type's name. A total record over the visualizations,
54+
* so a type the spec adds fails the build here until it has a name; every key
55+
* is spelled out, so the i18n gates can see it.
56+
*/
57+
const VIEW_TYPE_LABEL_KEYS: Record<ListViewVisualization, string> = {
58+
grid: 'engine.inspector.view.type.grid',
59+
kanban: 'engine.inspector.view.type.kanban',
60+
calendar: 'engine.inspector.view.type.calendar',
61+
gallery: 'engine.inspector.view.type.gallery',
62+
timeline: 'engine.inspector.view.type.timeline',
63+
gantt: 'engine.inspector.view.type.gantt',
64+
map: 'engine.inspector.view.type.map',
65+
chart: 'engine.inspector.view.type.chart',
66+
tree: 'engine.inspector.view.type.tree',
67+
};
68+
69+
/**
70+
* The spec's JSONSchema for the block a list body of `type` keeps its own
71+
* settings in, or `null` when this section does not edit that type: a value
72+
* the spec's `type` enum does not carry, a type with no block, or one edited
73+
* elsewhere.
74+
*/
75+
export function listViewTypeBlockSchema(type: unknown): JsonSchema | null {
76+
if (typeof type !== 'string' || EDITED_ELSEWHERE.has(type)) return null;
77+
const list = getListVariantSchema();
78+
const types: unknown = list?.properties?.type?.enum;
79+
if (!Array.isArray(types) || !types.includes(type)) return null;
80+
const block = list?.properties?.[type];
81+
return isPlainObject(block) && block.type === 'object' && isPlainObject(block.properties) ? block : null;
82+
}
83+
84+
/**
85+
* The field names a type's block holds, read by the form's own convention for
86+
* a field setting: a string under a `*Field` key, the strings under a
87+
* `*Fields` key.
88+
*/
89+
export function fieldNamesHeldByTypeBlock(block: unknown): string[] {
90+
if (!isPlainObject(block)) return [];
91+
const held: string[] = [];
92+
for (const [key, value] of Object.entries(block)) {
93+
if (/.Field$/.test(key) && typeof value === 'string' && value) held.push(value);
94+
else if (/Fields$/.test(key) && Array.isArray(value)) {
95+
for (const v of value) if (typeof v === 'string' && v) held.push(v);
96+
}
97+
}
98+
return held;
99+
}
100+
101+
function typeLabelOf(type: string, locale: string): string {
102+
const key = (VIEW_TYPE_LABEL_KEYS as Record<string, string | undefined>)[type];
103+
return key ? t(key, locale) : type;
104+
}
105+
106+
/**
107+
* The edit the pillar holds for a list body whose type's block the spec
108+
* refuses only because a required setting is unset, or `null`. Asked of the
109+
* spec's `ListViewSchema` (`objectHeldInput`): a block that is finished but
110+
* wrong is sent, and its refusal shows as before. `input` is the setting's
111+
* label as the section draws it, so "Show me" focuses that control.
112+
*/
113+
export function listViewTypeHeldEdit(body: Row, locale: string): StudioHeld<null> | null {
114+
const type = body.type;
115+
const schema = listViewTypeBlockSchema(type);
116+
if (!schema || typeof type !== 'string') return null;
117+
const key = objectHeldInput(body, ListViewSchema, type);
118+
if (key === null) return null;
119+
const input =
120+
translateSchemaFieldLabel(key, schema.properties[key]?.title as string | undefined, locale) || prettifyFieldName(key);
121+
return {
122+
clause: tFormat('engine.studio.held.listViewTypeNeedsInput', locale, { input, type: typeLabelOf(type, locale) }),
123+
target: null,
124+
input,
125+
};
126+
}
127+
128+
export function ListViewTypeSection({
129+
type,
130+
schema,
131+
body,
132+
writeBody,
133+
fieldCatalog,
134+
readOnly,
135+
locale,
136+
marked,
137+
}: {
138+
type: string;
139+
/** {@link listViewTypeBlockSchema} of `type`. */
140+
schema: JsonSchema;
141+
body: Row;
142+
writeBody: (next: Row) => void;
143+
fieldCatalog: LoadState<ObjectFieldOption[]>;
144+
readOnly: boolean;
145+
locale: string;
146+
/** objectui#12725 — the copilot drafted a setting of this block. */
147+
marked: boolean;
148+
}): React.ReactElement {
149+
const block = isPlainObject(body[type]) ? body[type] : undefined;
150+
const typeLabel = typeLabelOf(type, locale);
151+
return (
152+
<div data-testid="list-view-type-settings" data-view-type={type} className="space-y-3 rounded-md border p-2">
153+
<div>
154+
<p className="flex items-center gap-1.5 text-xs font-medium">
155+
{tFormat('engine.studio.inspector.listView.typeSettings', locale, { type: typeLabel })}
156+
{marked && <DraftChip locale={locale} of={type} />}
157+
</p>
158+
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">
159+
{t('engine.studio.inspector.listView.typeSettings.intro', locale)}
160+
</p>
161+
</div>
162+
<SchemaForm
163+
schema={schema}
164+
// A segment no field name can spell, so the ids cannot meet another form's.
165+
idPath={`studio-list-view-${type}`}
166+
value={block}
167+
readOnly={readOnly}
168+
widgetContext={{ conditionScope: 'none', objectFields: fieldCatalog }}
169+
// The block stays written when it is emptied: a required setting
170+
// cleared is then an unset input, which the pillar holds.
171+
onChange={(next) => writeBody({ ...body, [type]: next })}
172+
/>
173+
</div>
174+
);
175+
}

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

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,14 @@ import {
9898
type StudioCanvasNavEntry,
9999
} from './studio-canvas-preview.js';
100100
import type { Surface } from './navSurface.js';
101-
import { issueRefusal, plainRefusal } from './metadataError.js';
101+
import { issueRefusal, plainRefusal, type StudioHeld } from './metadataError.js';
102102
import { isStudioHiddenSystemField } from './studioHiddenSystemField.js';
103+
import {
104+
fieldNamesHeldByTypeBlock,
105+
ListViewTypeSection,
106+
listViewTypeBlockSchema,
107+
listViewTypeHeldEdit,
108+
} from './ListViewTypeSection.js';
103109
import {
104110
addedArrayElements,
105111
agentDraftMarkOf,
@@ -570,9 +576,10 @@ function sortEntriesOf(value: unknown): SortEntry[] {
570576

571577
/**
572578
* The field names a list body already references: its columns, its filter and
573-
* sort rules, and a kanban card's fields, title and group-by field
574-
* (objectui#13152). Read by the panel's field pickers so a reference the view
575-
* holds keeps drawing (see `ObjectListViewInspector`).
579+
* sort rules, a kanban card's fields, title and group-by field
580+
* (objectui#13152), and the field settings of the block its type keeps its
581+
* own settings in (objectstack#27895). Read by the panel's field pickers so a
582+
* reference the view holds keeps drawing (see `ObjectListViewInspector`).
576583
*/
577584
function fieldNamesHeldBy(body: Row): string[] {
578585
const held = new Set<string>();
@@ -587,6 +594,7 @@ function fieldNamesHeldBy(body: Row): string[] {
587594
add(body.kanban.titleField);
588595
add(body.kanban.groupByField);
589596
}
597+
if (typeof body.type === 'string') fieldNamesHeldByTypeBlock(body[body.type]).forEach(add);
590598
return [...held];
591599
}
592600

@@ -659,6 +667,18 @@ export function listViewIsKanban(listView: ObjectListViewDraft): boolean {
659667
return listBodyOf(row ?? newListViewItem(target, seedColumns)).type === 'kanban';
660668
}
661669

670+
/**
671+
* objectstack#27895 — the edit the pillar's list-view autosave holds, or
672+
* `null`: an edited view whose type's block lacks a required setting
673+
* (`listViewTypeHeldEdit`). An unedited view is never held: nothing would be
674+
* sent.
675+
*/
676+
export function listViewHeldEdit(listView: ObjectListViewDraft, locale: string): StudioHeld<null> | null {
677+
const { row, notList, dirty } = listView;
678+
if (!dirty || !row || notList) return null;
679+
return listViewTypeHeldEdit(listBodyOf(row), locale);
680+
}
681+
662682
const noop = (): void => {};
663683

664684
/**
@@ -902,7 +922,9 @@ function KanbanCardSection({
902922
* the designer's `filter-builder` widget, which writes the spec's
903923
* `ViewFilterRule[]`) and Sort (the runtime `SortBuilder`, in a popover the
904924
* width the builder needs, written as the spec's `{ field, order }[]`). On a
905-
* kanban view, the kanban card section comes first (`KanbanCardSection`).
925+
* kanban view, the kanban card section comes first (`KanbanCardSection`); on
926+
* a calendar, gallery, timeline, gantt, map or tree view, the section of that
927+
* type's own settings (`ListViewTypeSection`, objectstack#27895).
906928
*/
907929
export function ObjectListViewInspector({
908930
listView,
@@ -1058,12 +1080,16 @@ export function ObjectListViewInspector({
10581080
open: markOf('navigation', 'mode'),
10591081
groupBy: markOf('kanban', 'groupByField'),
10601082
};
1083+
// objectstack#27895 — the section of the view type's own settings, where it has one.
1084+
const typeBlockSchema = listViewTypeBlockSchema(body.type);
1085+
const typeBlockMark = typeBlockSchema ? markOf(String(body.type)) : null;
10611086
const columnsMark = markOf('columns');
10621087
const addedColumns = addedNames(columnsMark, columns, publishedBody?.columns);
10631088
const filterMark = markOf('filter');
10641089
const sortMark = markOf('sort');
10651090
const drawnMarks = [
10661091
...(body.type === 'kanban' ? [kanbanMarks.fields, kanbanMarks.title, kanbanMarks.open, kanbanMarks.groupBy] : []),
1092+
typeBlockMark,
10671093
columnsMark,
10681094
filterMark,
10691095
sortMark,
@@ -1194,6 +1220,18 @@ export function ObjectListViewInspector({
11941220
marks={kanbanMarks}
11951221
/>
11961222
)}
1223+
{typeBlockSchema && (
1224+
<ListViewTypeSection
1225+
type={String(body.type)}
1226+
schema={typeBlockSchema}
1227+
body={body}
1228+
writeBody={writeBody}
1229+
fieldCatalog={fieldCatalog}
1230+
readOnly={readOnly}
1231+
locale={locale}
1232+
marked={!!typeBlockMark}
1233+
/>
1234+
)}
11971235
<div className="space-y-1.5" data-testid="list-view-columns">
11981236
<FieldsListEditor
11991237
variantKey="config"

0 commit comments

Comments
 (0)