Skip to content

Commit f558e14

Browse files
fix(app-shell): a locked metadata item's designer canvas is read-only (#27035)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8TMV42aHkwWJ3enBd3HQn32)_ Fixes #26888 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Setup 元数据编辑页对"不可写"的条目(被包锁定,或调用者无权编写)不再把 onPatch 交给设计器画布,画布随之只读。 - 为什么改:锁定对象的画布仍可加字段、移动/删除分区并显示"Unsaved",但页面没有保存按钮,离开即静默丢失。 - 风险与回滚:只改一个 prop;可写条目行为不变。回滚即还原该 prop,无数据或协议影响。 - 先看:ResourceEditPage.tsx 中预览组件的 onPatch 三元表达式;其次新测试文件 lockedCanvas-26888。 - 两分钟验证:pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/ResourceEditPage.lockedCanvas-26888.test.tsx,预期 2 passed。 - 需要你做的:无,席位落地。 ## What changes `MetadataResourceEditPage` passes the registered designer preview `onPatch={readOnly ? undefined : …}` instead of an unconditional `onPatch`. `readOnly` is the page's existing `!canWrite && !createMode`, so it covers a locked item (`layered.editable === false`), a caller without `manage_metadata`, and a walled catalog. Per the preview contract (`MetadataPreviewProps.onPatch`), a canvas with no `onPatch` is read-only; `ObjectFormCanvas` derives `readOnly = !onPatch`, which hides Add field, Add section, Add field to this section, Move section up/down and Remove section. This mirrors Studio's read-only package handling (objectui#11136, `onPatch={readOnly ? undefined : onPatch}` in `StudioDesignSurface`). The fix is at the page, so every Setup designer type goes read-only on a locked item, not only Object; `ObjectPreview.tsx` needed no change. The Description box (`ObjectDefaultInspector`) was already `disabled={readOnly}` through the page's `formReadOnly`: measured, already done. ## Tests New `ResourceEditPage.lockedCanvas-26888.test.tsx` mounts the real page with the built-in designers registered: - control: a writable object shows Add field, Add field to this section, Move section up/down, Remove section; - a locked object (`editable: false`, `lock: 'full'`) shows the lock banner, none of those controls, and no "Unsaved". Ablation (restore the unconditional `onPatch` via `scripts/ablation-replace.mjs`, anchor hit 1 → 0, blob 6cd1fb3 → 256b0123): the locked case goes red (the found Move/Remove BUTTON list did not deeply equal `[]`), control stays green; restore verified blob == HEAD and `git diff HEAD` empty. ## Acceptance notes - None. ## Verification All local readings on head `a512197f` (base `7eb613c3`). - Closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` under the lock: exit 0 (6m16s). - New test: 2/2 passed. Nearby suites `ResourceEditPage*` + `ObjectFormCanvas`: 44 files, 224 tests passed. - `pnpm --filter @object-ui/app-shell run type-check`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 34 derived families, all exit 0; `--ran` reconciliation: 34 run, 0 NOT-MEASURED, 0 UNRUN. Includes `node scripts/check-sdui-manifest.mjs` and `pnpm --dir ui exec vitest run scripts/` (176 files passed, 2 skipped). - Also run: `pnpm check:changeset-fixed` (roster under `.changeset`) exit 0; `pnpm --dir ui run check:new-line-citations` exit 0. - Type-check lanes, artifact rosters and wide-population families: left to this PR's CI check-runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbajUhBue1th6Leba9E)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5866877 commit f558e14

3 files changed

Lines changed: 163 additions & 2 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a locked metadata item's designer canvas is read-only
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
In Setup › All Metadata Types, a locked item (for example an object installed by a package, under the banner "This item is locked and cannot be edited or deleted.") still had a live designer canvas. On an object you could add a field, add a field to a section, move a section or remove one: the change showed in the canvas with an "Unsaved" chip, but the page offers no Save on a locked item, so the work was dropped without a word when you left.
11+
12+
What changes: when the page cannot write the item (it is locked, or you lack the capability to author metadata), the designer canvas gets no edit callback, so it renders read-only for every designer type, the way the Studio canvas already does for a read-only package. The Description box was already disabled on a locked item. Writable items are unchanged.
13+
14+
What to do: nothing.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26888 — a locked item's designer canvas is read-only.
5+
*
6+
* The page learns the lock from the layered read (`editable: false`) and keeps
7+
* Save, the form and the inspectors read-only on it, but it handed the
8+
* registered preview an `onPatch` unconditionally. The object canvas derives
9+
* its read-only state from `onPatch` alone, so on a locked object it offered
10+
* Add field, Add field to this section, Move section and Remove section: the
11+
* edit landed in the draft, the inspector said "Unsaved", and no Save existed
12+
* to keep it. The fix passes no `onPatch` when the page cannot write the item,
13+
* the preview contract Studio already follows for a read-only package
14+
* (objectui#11136).
15+
*
16+
* The unlocked case is the control: the same object, writable, still gets
17+
* every control, so a red locked case is the defect and not a harness that
18+
* never mounted the canvas.
19+
*/
20+
21+
import '@testing-library/jest-dom/vitest';
22+
import * as React from 'react';
23+
import { afterEach, describe, expect, it, vi } from 'vitest';
24+
import { cleanup, render, screen } from '@testing-library/react';
25+
import { MemoryRouter } from 'react-router-dom';
26+
27+
const OBJECT = {
28+
name: 'crm_account',
29+
label: 'Account',
30+
fields: {
31+
account_name: { type: 'text', label: 'Account Name', group: 'basics' },
32+
website: { type: 'url', label: 'Website', group: 'basics' },
33+
notes: { type: 'textarea', label: 'Notes', group: 'details' },
34+
},
35+
fieldGroups: [
36+
{ key: 'basics', label: 'Basics' },
37+
{ key: 'details', label: 'Details' },
38+
],
39+
};
40+
41+
const layered = vi.hoisted(() => ({ editable: true }));
42+
43+
const client = vi.hoisted(() => ({
44+
list: async () => [],
45+
listDrafts: async () => [],
46+
get: async () => null,
47+
getDraft: async () => null,
48+
references: async () => [],
49+
layered: async () => ({
50+
code: OBJECT,
51+
overlay: null,
52+
overlayScope: null,
53+
effective: OBJECT,
54+
lock: layered.editable ? 'none' : 'full',
55+
provenance: 'package',
56+
packageId: 'app.objectstack.hotcrm',
57+
editable: layered.editable,
58+
deletable: layered.editable,
59+
}),
60+
}));
61+
62+
vi.mock('./useMetadata', async (importOriginal) => {
63+
const mod = await importOriginal<typeof import('./useMetadata')>();
64+
return {
65+
...mod,
66+
useMetadataClient: () => client,
67+
useMetadataTypes: () => ({
68+
loading: false,
69+
error: null,
70+
entries: [
71+
{
72+
type: 'object',
73+
name: 'object',
74+
label: 'Object',
75+
allowEnvironmentOverlay: true,
76+
allowRuntimeCreate: true,
77+
schema: { type: 'object', properties: { name: { type: 'string' }, label: { type: 'string' } } },
78+
},
79+
],
80+
}),
81+
};
82+
});
83+
84+
vi.stubGlobal(
85+
'fetch',
86+
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
87+
);
88+
89+
import { MetadataResourceEditPage } from './ResourceEditPage';
90+
import './register-builtins';
91+
// The built-in designers (the Object canvas among them), which the package
92+
// entry registers from a chunk it loads with a dynamic `import()`: called at
93+
// module scope so they are registered before the first render.
94+
import { registerBuiltinDesigners } from './register-builtin-designers';
95+
registerBuiltinDesigners();
96+
97+
afterEach(() => {
98+
cleanup();
99+
layered.editable = true;
100+
});
101+
102+
async function openObject(editable: boolean) {
103+
layered.editable = editable;
104+
render(
105+
<MemoryRouter initialEntries={['/metadata/object/crm_account']}>
106+
<MetadataResourceEditPage type="object" name="crm_account" />
107+
</MemoryRouter>,
108+
);
109+
// The canvas is up once its first section header shows.
110+
expect(await screen.findByText('Basics', {}, { timeout: 8000 })).toBeInTheDocument();
111+
}
112+
113+
const CANVAS_WRITES = [
114+
'Add field to this section',
115+
'Move section up',
116+
'Move section down',
117+
'Remove section',
118+
];
119+
120+
describe('Setup object designer — a locked item gets a read-only canvas (objectstack#26888)', () => {
121+
it('control: a writable object offers the canvas add / move / remove controls', async () => {
122+
await openObject(true);
123+
for (const name of CANVAS_WRITES) {
124+
expect(screen.queryAllByRole('button', { name }).length).toBeGreaterThan(0);
125+
}
126+
expect(screen.getByRole('button', { name: 'Add field' })).toBeInTheDocument();
127+
});
128+
129+
it('a locked object offers none of them, and nothing reads "Unsaved"', async () => {
130+
await openObject(false);
131+
expect(await screen.findByTestId('lock-banner-title')).toBeInTheDocument();
132+
for (const name of CANVAS_WRITES) {
133+
expect(screen.queryAllByRole('button', { name })).toEqual([]);
134+
}
135+
expect(screen.queryByRole('button', { name: 'Add field' })).toBeNull();
136+
expect(screen.queryByText('Unsaved')).toBeNull();
137+
});
138+
});

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3081,8 +3081,17 @@ function MetadataResourceEditPageImpl({
30813081
onSelectionChange={setSelection}
30823082
locale={locale}
30833083
diagnostics={previewDiagnostics}
3084-
onPatch={(patch) =>
3085-
handleDraftChange((d) => ({ ...(d as Record<string, unknown>), ...patch }))
3084+
// objectstack#26888 — an item this page cannot write (a
3085+
// locked package item, a caller without the capability)
3086+
// gets no `onPatch`: per the preview contract the canvas
3087+
// is then read-only, as Studio does for a read-only
3088+
// package (objectui#11136). A live canvas here took
3089+
// edits the page offers no Save for, and dropped them.
3090+
onPatch={
3091+
readOnly
3092+
? undefined
3093+
: (patch) =>
3094+
handleDraftChange((d) => ({ ...(d as Record<string, unknown>), ...patch }))
30863095
}
30873096
/>
30883097
</div>

0 commit comments

Comments
 (0)