Skip to content

Commit bee5d6f

Browse files
feat(app-shell): an object leaf's Studio panel edits its list view (objectui#11823, step 1) (#11868)
Refs #11823 (item 1) Clause-②: no ## What this does An `object` leaf's Properties panel in Studio's Interfaces pillar now edits the list view its canvas shows: **Columns** (add, remove, drag, or select a row and move it up or down), **Filter** and **Sort**. Edits autosave to the package draft and show on the canvas at once: ADR-0084's "source + preview", with preview = runtime. Steps 2 (dashboard and report create flow) and 3 (page create flow) of objectui#11823 are not in this PR, and the card stays open for them. - **Which view.** The one the canvas opens, read off the nav entry as the runtime reads it (`navEntryListTarget`). An entry that lands on a named view edits that view (matched with `resolveViewId`; a name that matches nothing is `OBJECT.NAME`, created by the first edit). Any other entry edits the object's default list view: the merged view flagged `isDefault`, else `OBJECT.default`, the name `expandViewContainer` gives a container's default `list`. - **What is written, and through what.** A `view` item in its record shape, `{ name, object, viewKind: 'list', isDefault?, config: { type, data, columns, filter?, sort? } }`, sent by `client.save('view', name, body, { mode: 'draft', packageId })`: the pillar's own `MetadataClient`, behind its own `useDraftSaveGuard` (objectui#11773), debounced by the pillar's `useDraftAutoSave`. Both are called as they are. No new endpoint and no new client. - **A view that does not exist yet.** The panel says so, and shows the columns the running app shows such an object (`defaultListColumnsFromObject`, imported from `views/ObjectView.tsx`). The canvas shows those columns too. The first edit creates the view from them. - **Read-only package.** Only the read-only note shows, every control is disabled, the autosave is blocked on the same `readOnly`, and nothing is written. - **Not a list view.** An item at the view's name that is not a list view (a form view, or a body with no `columns`) is shown as such, and is never edited or saved over. - **A refused save** reads as the pillar reads one: `issueRefusal` from `metadataError.ts` (imported, not edited) gives the author sentence, with the raw refusal under Details. - **Control.** A studio-canvas leaf of any other type keeps `studioCanvasNoBlocks`. ## Measurements (H1 to H4) Taken against a published `@objectstack/*` 17.7.0 server (`objectstack dev --fresh`, the release this repo resolves), with a writable Studio package (`POST /api/v1/packages`) and a code package, and this branch's console under Vite, driven by Playwright and Chromium. - **H1: confirmed, with one more finding.** On `59ce048` the panel of an object leaf showed only `studioCanvasNoBlocks` (browser reading). For a plain entry the canvas rendered `{ type: 'object-view', objectName }` with no `listViews`. That is a grid of the object's fields (Title, Amount, Stage, Due, Owner), not the package's default list view. On the same data, the running app (`/apps/pst_app/pst_order`) rendered the published default view: Title and Amount, filtered and sorted. For an object with no view, the canvas also showed Owner, which the runtime's fallback list leaves out. For a `viewName` entry, the canvas rendered the cache's named view. The panel now edits the entry's named view, or else the default view. The canvas shows the panel's view in both cases. - **H2: measured; the record shape is written.** The runtime serves an object's list views as `view` records (`GET /meta/view`). A package's `defineView` container arrives expanded: `OBJ.default` with `isDefault: true`, and `OBJ.KEY` for each named view. `GET /meta/object/NAME` carries no `listViews`; the console merges `view` items into `objectDef.listViews` (`mergeViewsIntoObjects`). The draft door accepted both a record and a container body (`PUT /meta/view/NAME?mode=draft&package=…`, 200). But `?preview=draft` listed the record and not the container, because containers are never listed. `publish-drafts` published the record, and `GET /meta/view` then served it. The door refused a sort entry spelled `direction` (422). It accepted a draft for a code package's view as a customization overlay, so the read-only gate is Studio's own `readOnly`, as for every other inspector. The object's embedded `listViews` (`ObjectSchema.listViews`, meant for system objects) is not written: it belongs to the object definition and the Data pillar. - **H3: confirmed, and resolved inside the canvas body.** The canvas read only the metadata cache (`useMetadata`, published rows), and only for a `viewName` entry, so a draft never showed there. `StudioObjectRecordsCanvas`'s body now also reads a Studio-internal context, `StudioCanvasListViewContext`. It sits beside the published props, like `StudioCanvasNavEntryContext`, and holds the panel's buffer. The function's signature and `StudioCanvasPreviewProps` are unchanged, and nothing is added to `index.ts`. - **H4: measured.** The runtime grid's column picker lives inside `plugin-list`'s `ListView` (`ViewSettingsPopover`, not exported), so it cannot be imported. Three editors are reused by import, unedited: - **Columns:** the Studio's `FieldsListEditor`. - **Filter:** the runtime `FilterBuilder`, through the designer's exported `filter-builder` widget, which writes the spec's `ViewFilterRule[]`. - **Sort:** the runtime `SortBuilder` from `@object-ui/components`, inside a popover. Placed inline in the 18rem rail, its fixed label and direction widths leave the field select about 36px. It has no disabled prop, so the read-only state disables its trigger instead. Its row `id` is stripped on write, because the spec refuses `id` on a sort entry. ## The saved shape (browser reading, writable package) After removing one seeded column of an object that had no view: ```text PUT /api/v1/meta/view/pst_item.default?mode=draft&package=com.probe.studio {"name":"pst_item.default","object":"pst_item","viewKind":"list","isDefault":true,"config":{"type":"grid","data":{"provider":"object","object":"pst_item"},"columns":["name","qty"]}} ``` Every saved body in the pins parses with the spec's `ViewItemSchema` (installed `@objectstack/spec` 17.7.0). ## Real-browser reading (dev-only harness, not committed) Console from this branch, backend `@objectstack/*` 17.7.0, Chromium (`/opt/pw-browsers/chromium`): - Orders, which has a published default view: the panel showed `All Orders` with columns `title`, `amount`, filter Stage, and sort "Amount descending". The canvas showed Title and Amount, rows Charlie 20 and Alpha 10, which is the runtime's own rendering. - Adding the Stage column put Stage on the canvas at once, and an autosave `PUT …/view/pst_order.default?mode=draft&package=com.probe.studio` followed. The header's Changes count went to 1. - Switching the sort to ascending reordered the canvas to Alpha 10, Charlie 20, and the PUT carried `"order":"asc"`. - Changing the filter value to `won` changed the canvas to Bravo 30 and Delta 5, and the PUT carried `{"field":"stage","operator":"equals","value":"won"}`. - Items, which has no view: the panel said no list view exists yet and showed `name`, `qty`, `color`. The canvas showed Name, Quantity and Color, as the running app does (before this change, the canvas also showed Owner). Removing `color` created the view (the PUT above), and the canvas dropped the column. After the package's drafts were published, the running app listed Name and Quantity. - A code package (`writable: false`): the panel was read-only, with no drag handles, no remove buttons, no Add field button, and disabled Filter and Sort triggers. No PUT was sent. - Opening the Filter and Sort popovers wrote nothing. ## File surface Every file is on the claim's surface: - `studio-design/ObjectListViewInspector.tsx` (new): the panel, the buffer hook (load, the version guard, save) and the pure helpers. - `studio-design/StudioDesignSurface.tsx`: - the inspector body's studio-canvas branch; - in `InterfacesPillar`, the list view's draft write path, which the claim asked to be named before editing: one `useObjectListViewDraft` call and one `useDraftAutoSave` call, placed right after the leaf autosave; - the provider around the canvas; - the conflict dialog's render; - an import. None of seat 3's objectui#11786 regions is edited: the `useDraftAutoSave` definition, the Data pillar's `doSave`, the Automations save and `doPublish`. - `studio-design/studio-canvas-preview.tsx`: the new context, `listViewsOf` exported at module level (not from `index.ts`), and the body of `StudioObjectRecordsCanvas`. - `metadata-admin/i18n.ts`: new `engine.studio.inspector.listView.*` rows, en and zh. - Tests beside these, and `.changeset/11823-list-view-inspector.md` (`@object-ui/app-shell` patch). Two existing pins changed meaning, because the object leaf's rail is no longer the no-blocks statement: - `StudioDesignSurface.studioCanvasLeaf.test.tsx` now takes the no-blocks pins on a stub `report` studio canvas, and adds an object-leaf pin for the new panel. - `StudioDesignSurface.selectionLeafScope.test.tsx` now waits for the panel instead of the statement. Its assertions are unchanged. ## Tests and gates (local `git rev-parse --short HEAD` = `f5610fa`, which includes `main` at `5aa7f55`) | Command | Exit | Verdict | |---|---|---| | `pnpm --workspace-concurrency=2 --filter "@object-ui/app-shell^..." build` (via os-verify-lock) | 0 | `VERDICT command-exit 0`, 29 of 47 projects | | `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) | 0 | `tsc -p tsconfig.test.json --listFiles` lists all 5 touched test files | | `pnpm exec vitest run` on `StudioDesignSurface*`, `studio-canvas-preview*` and `ObjectListViewInspector*` | 0 | `Test Files 54 passed (54)`, `Tests 341 passed (341)` | | `pnpm exec vitest run` on the other 44 `studio-design` test files, plus the 5 tests that read the designer i18n tables | 0 | `Test Files 49 passed (49)`, `Tests 381 passed (381)` | | `pnpm check:control-bytes` | 0 | `check-control-bytes: OK` | | `pnpm check:new-line-citations` | 0 | `VERDICT new-cross-file-line-citations: 0 new citation(s)` | | `pnpm check:changeset-claims` | 0 | `No pending changeset names a file this change touches.` | | `pnpm check:pending-changeset-literals` | 0 | `No test source names a pending changeset.` | | `pnpm check:i18n-designer-parity` | 0 | `Every en row has a zh row, and every shared row carries the same placeholders.` | | `pnpm check:spec-symbols` | 0 | OK | | `pnpm check:i18n-keys` / `check:i18n-drift` | 0 / 0 | keys resolve / `No designer-table en value changed in this range.` | | `pnpm check:vi-mock-specifiers` / `-inherit` / `-override-shape` | 0 / 0 / 0 | OK | | `pnpm check:test-path-roots` / `check:metadata-write-doors` / `check:unreferenced-sources` | 0 / 0 / 0 | OK | | `node scripts/check-changeset-presence.mjs` / `check-changeset-no-major.mjs` | 0 / 0 | `9 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)` / no `major` | **Lint (narrowed, measured).** `eslint --format json` ran over the 9 touched TS files, as the package script runs it (`eslint .` from `packages/app-shell`): 9 files, 0 errors, 26 warnings, all of them `react-refresh/only-export-components` or `react-hooks/*` warnings, of the kinds the sibling studio-design files already carry. The root `eslint.config.js` sets no type-aware parser options (`project` / `projectService`), so this diff cannot move a verdict on a file it does not touch. The repo-wide `pnpm lint` and the full test farm are CI's. **Reverse check.** With the fix committed, `StudioDesignSurface.tsx` was set back to `main`'s blob (`git checkout 5aa7f55 -- …`, under a trap restoring from `HEAD`). The new pin file then went red in the predicted direction: `Tests 6 failed | 1 passed (7)`. Every panel pin failed with `Unable to find an element by: [data-testid="studio-list-view-inspector"]`, and the rail rendered the no-blocks statement. The CONTROL pin stayed green. After restoring, the file's blob equals `HEAD`'s (`9cb25ec…`) and `git diff HEAD` is empty. ## Acceptance notes - A `viewName` entry naming a view that does not exist yet: the panel shows the seed columns and the not-created note, while the canvas keeps its existing fallback (the plain grid) until the first edit. The runtime's landing for an unresolved `/view/NAME` was not measured. - `FieldsListEditor` is reused as it is. A row shows a column's own label, else its field name. The add-field popover still offers a field that is already a column (badged "Added") and system fields. Adding one twice writes a duplicate column, which the draft door accepts. Carrier: none. - A draft read carries `_packageId`, a spec-declared protection field, and saves send it back, as the pillar's leaf drafts do. - A list-view edit left within the 1.5 s debounce is dropped, as the leaf draft's is. Only nav editing mirrors dirty state up to the header's leave guard. - An object of another package, opened from this package's nav, is edited as a draft under this package. The door accepts that as a customization overlay; the round trip was not measured. - No message locator maps a refused view issue to its control yet, so a refusal reads as "the server refused this draft", with the issues under Details. Session: `https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z` --- _Generated by [Claude Code](https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d33270a commit bee5d6f

10 files changed

Lines changed: 1260 additions & 30 deletions
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+
---
4+
5+
Studio's Interfaces pillar edits an object's list view (objectui#11823, step 1). The Properties panel of an `object` leaf used to say that nothing is edited there. It now edits the list view the canvas beside it shows: the columns (add, remove, drag or move up and down), the filter and the sort.
6+
7+
- **Which view.** The view the canvas opens, read from the nav entry the way the running app reads it. An entry that names a view (`viewName`) edits that view. Any other entry edits the object's default list view: the view flagged `isDefault`, else `OBJECT.default`, the name the platform gives a container's default `list`.
8+
- **Where it is saved.** In the package draft, as a `view` metadata item (`{ name, object, viewKind: 'list', config }`), through the metadata draft write the other Studio editors use, with their version guard and autosave. Publishing the package publishes it with the rest of its drafts.
9+
- **A view that does not exist yet** is created by the first edit, seeded with the columns the running app shows for an object with no view.
10+
- **The canvas shows the edit at once.** It renders the panel's view, unsaved changes included, as the running app renders that list. An object entry now previews the list the running app opens: the object's default list view or, for an object with no list view yet, the default columns the running app shows. Before, it drew a plain grid of the object's fields.
11+
- **A read-only package** shows the panel with every control disabled, and nothing is saved.
12+
13+
A studio-canvas leaf of any other type keeps its "nothing is edited from this panel" statement. Nothing is added to the package entry: no export, prop, type member or language-pack key. The panel's copy lives in the designer's own string tables (en and zh).

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3118,6 +3118,19 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
31183118
// to wait for — `StudioCanvasPreviewProps` carries no selection by contract.
31193119
'engine.studio.inspector.studioCanvasNoBlocks':
31203120
'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.',
3121+
// objectui#11823 — an `object` leaf's panel: the list view its canvas shows.
3122+
'engine.studio.inspector.listView.kind': 'List view',
3123+
'engine.studio.inspector.listView.intro':
3124+
'The list on the canvas. Its columns, filter and sort save to this package\'s draft and show on the canvas at once.',
3125+
'engine.studio.inspector.listView.notCreated':
3126+
'There is no list view {view} yet. Your first change here creates it in this package\'s draft.',
3127+
'engine.studio.inspector.listView.readOnly': 'This package is read-only: the list view is shown here, not edited.',
3128+
'engine.studio.inspector.listView.notList': '{view} is not a list view, so it is not edited here.',
3129+
'engine.studio.inspector.listView.filter': 'Filter',
3130+
'engine.studio.inspector.listView.sort': 'Sort',
3131+
'engine.studio.inspector.listView.addSort': '+ Add sort…',
3132+
'engine.studio.inspector.listView.sortAsc': '{field} ascending',
3133+
'engine.studio.inspector.listView.sortDesc': '{field} descending',
31213134
'engine.studio.inspector.designersMissing':
31223135
'No metadata designers are registered in this session, so there is nothing to edit here.',
31233136
'engine.studio.inspector.noPageSchema': 'Page settings are unavailable — the page schema could not be loaded.',
@@ -6099,6 +6112,16 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
60996112
'engine.studio.inspector.emptyLine2': '它的属性会在这里直接编辑。',
61006113
'engine.studio.inspector.studioCanvasNoBlocks':
61016114
'此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。',
6115+
'engine.studio.inspector.listView.kind': '列表视图',
6116+
'engine.studio.inspector.listView.intro': '画布上的就是这个列表。它的列、筛选与排序保存到本包的草稿,并立即显示在画布上。',
6117+
'engine.studio.inspector.listView.notCreated': '还没有列表视图 {view}。在这里做的第一次修改会在本包的草稿中创建它。',
6118+
'engine.studio.inspector.listView.readOnly': '本包只读:这里展示列表视图,不能编辑。',
6119+
'engine.studio.inspector.listView.notList': '{view} 不是列表视图,因此不在这里编辑。',
6120+
'engine.studio.inspector.listView.filter': '筛选',
6121+
'engine.studio.inspector.listView.sort': '排序',
6122+
'engine.studio.inspector.listView.addSort': '+ 添加排序…',
6123+
'engine.studio.inspector.listView.sortAsc': '{field} 升序',
6124+
'engine.studio.inspector.listView.sortDesc': '{field} 降序',
61026125
'engine.studio.inspector.designersMissing': '本次会话没有注册任何元数据设计器,这里没有可编辑的内容。',
61036126
'engine.studio.inspector.noPageSchema': '页面设置不可用——无法加载页面 schema。',
61046127
'engine.studio.inspector.sourcePageLine1': '这个页面是 {kind} 源码,不是积木树 ——',
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11823 — which list view an `object` leaf's panel edits, and the
5+
* shapes it writes and hands the canvas. The pillar-level pins are in
6+
* `StudioDesignSurface.listViewInspector-11823.test.tsx`.
7+
*/
8+
9+
import { describe, expect, it } from 'vitest';
10+
import { ViewItemSchema } from '@objectstack/spec/ui';
11+
import { isListViewRow, listViewTargetOf, namedListViewOf, newListViewItem } from './ObjectListViewInspector';
12+
13+
const VIEWS = {
14+
'showcase_task.default': { name: 'showcase_task.default', type: 'grid', columns: [], isDefault: true },
15+
'showcase_task.tabular': { name: 'showcase_task.tabular', type: 'grid', columns: [], isDefault: false },
16+
};
17+
18+
describe('listViewTargetOf — the view the canvas opens (objectui#11823)', () => {
19+
it('an entry that lands on a named view edits that view, matched as ObjectView matches it', () => {
20+
expect(listViewTargetOf('showcase_task', 'tabular', VIEWS)).toEqual({
21+
objectName: 'showcase_task',
22+
viewId: 'showcase_task.tabular',
23+
isDefault: false,
24+
});
25+
expect(listViewTargetOf('showcase_task', 'showcase_task.tabular', VIEWS).viewId).toBe('showcase_task.tabular');
26+
});
27+
28+
it('a named view the object does not have yet is the one the entry opens once it exists', () => {
29+
expect(listViewTargetOf('showcase_task', 'urgent', VIEWS)).toEqual({
30+
objectName: 'showcase_task',
31+
viewId: 'showcase_task.urgent',
32+
isDefault: false,
33+
});
34+
});
35+
36+
it('any other entry edits the default list view: the one flagged, else `<object>.default`', () => {
37+
const flagged = { ...VIEWS, 'showcase_task.default': { ...VIEWS['showcase_task.default'], isDefault: false }, 'showcase_task.tabular': { ...VIEWS['showcase_task.tabular'], isDefault: true } };
38+
expect(listViewTargetOf('showcase_task', undefined, flagged)).toEqual({
39+
objectName: 'showcase_task',
40+
viewId: 'showcase_task.tabular',
41+
isDefault: true,
42+
});
43+
expect(listViewTargetOf('showcase_task', undefined, {})).toEqual({
44+
objectName: 'showcase_task',
45+
viewId: 'showcase_task.default',
46+
isDefault: true,
47+
});
48+
});
49+
});
50+
51+
describe('newListViewItem — a view created by the first edit (objectui#11823)', () => {
52+
it('is a spec-valid ViewItem record, flagged default only as the default list view', () => {
53+
const created = newListViewItem({ objectName: 'showcase_task', viewId: 'showcase_task.default', isDefault: true }, ['title']);
54+
expect(ViewItemSchema.safeParse(created).success).toBe(true);
55+
expect(created).toMatchObject({ name: 'showcase_task.default', object: 'showcase_task', viewKind: 'list', isDefault: true });
56+
57+
const named = newListViewItem({ objectName: 'showcase_task', viewId: 'showcase_task.urgent', isDefault: false }, []);
58+
expect(ViewItemSchema.safeParse(named).success).toBe(true);
59+
expect(named).not.toHaveProperty('isDefault');
60+
});
61+
});
62+
63+
describe('namedListViewOf — the canvas copy of a view item (objectui#11823)', () => {
64+
it('is the record\'s list body with the item label', () => {
65+
expect(
66+
namedListViewOf({ name: 'a.b', object: 'a', viewKind: 'list', label: 'All', config: { type: 'grid', columns: ['x'] } }),
67+
).toEqual({ type: 'grid', columns: ['x'], label: 'All' });
68+
});
69+
70+
it('folds a stored `options.KIND` bag as the Studio view preview folds it', () => {
71+
expect(
72+
namedListViewOf({ name: 'a.b', config: { type: 'kanban', columns: [], options: { kanban: { groupByField: 's' } } } }),
73+
).toEqual({ type: 'kanban', columns: [], kanban: { groupByField: 's' } });
74+
});
75+
});
76+
77+
describe('isListViewRow — only a list view is edited (objectui#11823)', () => {
78+
it('reads the spec\'s family discriminant, and a list body\'s required `columns` on a row with none', () => {
79+
expect(isListViewRow({ name: 'a.b', object: 'a', viewKind: 'list', config: { type: 'grid', columns: [] } })).toBe(true);
80+
expect(isListViewRow({ name: 'a.b', object: 'a', type: 'grid', columns: ['x'] })).toBe(true);
81+
});
82+
83+
it('a form view, or a body that is no list, is never taken for one', () => {
84+
expect(isListViewRow({ name: 'a.b', object: 'a', viewKind: 'form', config: { type: 'simple', sections: [] } })).toBe(false);
85+
expect(isListViewRow({ name: 'a.b', label: 'a.b' })).toBe(false);
86+
});
87+
});

0 commit comments

Comments
 (0)