Skip to content

Commit 47ee57f

Browse files
fix(app-shell): the metadata editor page tells its scoped inspector why it is read-only (#29203)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb2ejsWw32Bacu7MBXXjd2HN)_ Fixes #29079 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:元数据编辑页(Setup 的条目页,以及 Studio「全部元数据」下的 `/studio/包/metadata/flow/名称`)现在会把「为什么只读」告诉右侧的节点属性栏。属性栏显示的原因,就是编辑器上方提示条对当前原因说的那句话:只读软件包、锁定、缺少 `manage_metadata` 能力、代码包提供的条目、运行时只读的类型,以及安全目录的隔离墙。没有新增文案。 - **为什么改**:#28985 之后,Studio 自动化页的流程节点属性栏会解释灰掉的控件,但同一个属性栏放在元数据编辑页上时,宿主不传原因,控件仍然灰着却没有任何说明。 - **风险与回滚**:低。只多传一个可选属性 `readOnlyReason`,且只传给按选中项打开的属性栏;可写时不传,渲染与之前相同。未选中时的默认属性栏不变。回滚即还原本 PR。 - **先看**:`ResourceEditPage.tsx` 中 `formReadOnly` 下方新增的 `inspectorReadOnlyReason`(原因的先后顺序与上方提示条一致),以及 `InspectorComponent` 调用处新增的一行。 - **两分钟验证**:在 `ui/` 下运行 `pnpm exec vitest run packages/app-shell/src/views/metadata-admin/ResourceEditPage.inspectorReadOnlyReason-29079.test.tsx`,预期 6 个测试全绿;去掉调用处那一行后 5 个变红。 - **需要你做的**:无,席位落地。 ## What changes - **`ResourceEditPage` derives `inspectorReadOnlyReason`** next to `formReadOnly` and passes it to the scoped inspector (`InspectorComponent`) as `readOnlyReason`, the prop #28985 added to `MetadataInspectorProps`. The sentence is the one the page's own notice gives for the cause in force, in the notices' order: 1. the walled security catalog: `engine.catalog.readOnly.walled`; 2. a read-only package (Studio's "All metadata" area): `engine.studio.pkg.readonlyHint`, the sentence Studio's Automations pillar passes; 3. a lock: the lock banner's title, then the `lockReason` the server serves; 4. a declaration-only catalog: its `engine.catalog.declaredOnly.hint` sentence; 5. a caller without `manage_metadata`: `engine.edit.capabilityReadOnly`; 6. an item a code package ships: `engine.edit.artifactLockedBanner`; 7. a type that is read-only at runtime: `engine.edit.readOnlyTypeBanner`. An editable page passes none. - **No string is new**, so no i18n pack changes: every sentence is an existing key the page already shows above the editor. - **The no-selection default inspector is unchanged.** `MetadataDefaultInspectorProps` declares no `readOnlyReason`, and widening it is outside this card. ## Deviations The card asks for three causes on the node inspector: no write access, a pending walled decision, and view mode pointing at Edit. Measured on `main` `87d6baef`, only the first can reach the flow node inspector on this page: - **Walled** applies only to the security catalog types (`position`, `permission`, `capability`), and none of them registers a scoped inspector. The page still names the wall first, so an inspector a host registers for one of those types gets the right sentence and not the capability one. The test pins that through a host inspector registered for `position`. - **View mode does not reach a scoped inspector.** The inspector renders only beside a designer canvas. A designer type enters editing as soon as the item can be written (the designer auto-on effect), stays in editing after save and reset, and has no Edit button (the Edit button renders only when there is no preview). A sentence pointing at Edit would point at nothing, so view mode passes no reason. The seat accepted this shape before code. ## Tests - **New: `ResourceEditPage.inspectorReadOnlyReason-29079.test.tsx`, 6 cases.** The real page, gates and `FlowInspector` → `FlowNodeInspector`; only the canvases (to emit a selection) and the data reads are stubbed. Each case opens the item, selects on the canvas and reads the accessible description of the node inspector's reason group: - a writable flow: no reason group, and Remove node is enabled; - a read-only package's area, a caller without `manage_metadata`, a locked item with a served reason, and a flow a code package ships: each shows its own sentence; - the walled catalog: a host inspector registered for `position` receives `readOnly` and the wall's sentence. - **Red on main:** the same file on `87d6baef` reads 5 failed, 1 passed (only the writable control passes). - **Ablation:** deleting the `readOnlyReason={inspectorReadOnlyReason}` line through `scripts/ablation-replace.mjs` reads 5 failed, 1 passed; restored, blob equals `HEAD` and `git diff HEAD` is empty. ## Verification All readings on head `162f85c0`, base `87d6baef`. <details><summary>Tests</summary> - `pnpm exec vitest run` (from `ui/`) on `ResourceEditPage.inspectorReadOnlyReason-29079.test.tsx`: 6 of 6 passed. The same file on `87d6baef`: 5 failed, 1 passed. - Ablation of the `readOnlyReason={inspectorReadOnlyReason}` line (`scripts/ablation-replace.mjs`, anchor 1 to 0, blob `9e74e863` to `5d2abee0`): 5 failed, 1 passed; restored to the `HEAD` blob, `git diff HEAD` empty. - Sweep of the 120 ui test files that `git grep` finds naming `ResourceEditPage`, `MetadataResourceEditPage` or `readOnlyReason`, in three locked batches at `--maxWorkers=2`: 40/40 files (364 tests), 40/40 (204), 40/40 (547). Narrowed on purpose: CI's Test UI runs the rest of app-shell. - `pnpm exec turbo run type-check --filter=@object-ui/app-shell`: 65 of 65 tasks successful. - `eslint` on the two changed ui files: 0 errors; 51 warnings, the same count `ResourceEditPage.tsx` carries on `main`, none in the new test. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands` derived 34 families; all 34 ran with exit 0: the 33 unlocked ones with this body as the PR event (`check-adr-0087-registration`, `check-changeset-no-major`, `check-closing-keyword-parity` and `check-sdui-manifest` among them), and `pnpm --dir ui exec vitest run scripts/` locked: 169 files passed, 2 skipped (4,900 tests). - `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. - `check:new-line-citations`: 0 new citations. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUgMp6a6PrErVUDx8h)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e66e309 commit 47ee57f

3 files changed

Lines changed: 281 additions & 0 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): the metadata editor page tells its scoped inspector why it is read-only, so a flow's node inspector explains its disabled controls there too
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The metadata editor page (Setup's item page, and Studio's "All metadata" area at `/studio/PKG/metadata/flow/NAME`) folds every read-only cause into one flag, and it passed that flag to the scoped inspector with no reason. A flow's node inspector therefore showed disabled controls with nothing to say why, while the same inspector on Studio's Automations pillar explained them.
11+
12+
The page now hands the scoped inspector the reason (`MetadataInspectorProps.readOnlyReason`) as the sentence its own notice gives for the cause in force, in the notices' order: the walled security catalog; a read-only package; a lock, with the reason the server serves for it; a declaration-only catalog; a caller without the `manage_metadata` capability; an item a code package ships; and a type that is read-only at runtime. An editable page passes none. No string is new.
13+
14+
View mode owes no sentence: the scoped inspector exists only beside a designer canvas, and a designer type enters editing whenever it can be written and has no Edit button. The no-selection default inspector (`MetadataDefaultInspectorProps`) is unchanged.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29079 — the metadata editor page tells a read-only scoped
5+
* inspector WHY it is read-only (`MetadataInspectorProps.readOnlyReason`), with
6+
* the sentence the page's own notice gives for the cause in force.
7+
*
8+
* The page folds its causes into one `formReadOnly`; the inspector cannot tell
9+
* them apart, so the reason is the host's. Each case opens a flow on this page,
10+
* selects a node on the canvas and reads the node inspector's described group
11+
* (`FlowNodeInspector`, which draws it only when a reason arrives). The walled
12+
* catalog applies to the security catalog types alone, none of which ships a
13+
* scoped inspector, so its case registers one for `position` through the
14+
* public registry, as a host would.
15+
*
16+
* Stubbed: the flow and position canvases (only to emit a selection, which is
17+
* the canvas's own concern) and the data reads. The page, its gates and the
18+
* node inspector are real.
19+
*/
20+
21+
import '@testing-library/jest-dom/vitest';
22+
import * as React from 'react';
23+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
24+
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
25+
import { MemoryRouter } from 'react-router-dom';
26+
import { MetadataAreaHostContext, type MetadataAreaHost } from './metadataAreaHost';
27+
28+
const FLOW = {
29+
name: 'close_case',
30+
label: 'Close case',
31+
type: 'autolaunched',
32+
nodes: [
33+
{ id: 'start', type: 'start', label: 'Start' },
34+
{ id: 'set_status', type: 'update_record', label: 'Set status', config: { objectName: 'task', fields: { status: 'done' } } },
35+
{ id: 'end', type: 'end', label: 'End' },
36+
],
37+
edges: [
38+
{ id: 'e1', source: 'start', target: 'set_status' },
39+
{ id: 'e2', source: 'set_status', target: 'end' },
40+
],
41+
};
42+
43+
const POSITION = { name: 'sales_rep', label: 'Sales rep' };
44+
45+
/** The layered envelope each case serves; reset per test. */
46+
let layered: Record<string, unknown> = {};
47+
let canAuthor = true;
48+
let walled = false;
49+
50+
const client = {
51+
list: vi.fn(async () => []),
52+
listDrafts: vi.fn(async () => []),
53+
get: vi.fn(async () => null),
54+
getDraft: vi.fn(async () => null),
55+
references: vi.fn(async () => []),
56+
layered: vi.fn(async () => layered),
57+
saveDraft: vi.fn(async () => ({})),
58+
save: vi.fn(async () => ({})),
59+
};
60+
61+
vi.mock('./useMetadata', async (importOriginal) => ({
62+
...(await importOriginal<Record<string, unknown>>()),
63+
useMetadataClient: () => client,
64+
// The registry rows as the spec declares them (`DEFAULT_METADATA_TYPE_REGISTRY`).
65+
useMetadataTypes: () => ({
66+
loading: false,
67+
error: null,
68+
entries: [
69+
{ type: 'flow', label: 'Flow', domain: 'automation', allowEnvironmentOverlay: false, allowRuntimeCreate: true },
70+
{ type: 'position', label: 'Position', domain: 'security', allowEnvironmentOverlay: false, allowRuntimeCreate: true },
71+
],
72+
}),
73+
}));
74+
vi.mock('./i18n', async (importOriginal) => ({
75+
...(await importOriginal<Record<string, unknown>>()),
76+
useMetadataLocale: () => 'en-US',
77+
}));
78+
vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({
79+
...(await importOriginal<Record<string, unknown>>()),
80+
useCanAuthorMetadata: () => canAuthor,
81+
useCanReadAuthoringTrail: () => false,
82+
}));
83+
vi.mock('./catalog-scope', async (importOriginal) => ({
84+
...(await importOriginal<Record<string, unknown>>()),
85+
useWalledCatalogReadOnly: (type: string) => walled && type === 'position',
86+
}));
87+
vi.mock('./previews/useFlowNodePalette', () => ({
88+
useActionConfigSchemas: () => ({}),
89+
useFlowNodePalette: () => [],
90+
}));
91+
vi.mock('./previews/useObjectFields', () => ({
92+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
93+
}));
94+
95+
vi.stubGlobal(
96+
'fetch',
97+
vi.fn(async () => new Response('not found', { status: 404 })),
98+
);
99+
100+
import { MetadataResourceEditPage } from './ResourceEditPage';
101+
import { registerBuiltinInspectors } from './inspectors';
102+
import { registerMetadataPreview, getMetadataPreview, type MetadataSelection } from './preview-registry';
103+
import {
104+
registerMetadataInspector,
105+
getMetadataInspector,
106+
type MetadataInspectorProps,
107+
} from './inspector-registry';
108+
import { t, tFormat } from './i18n';
109+
110+
registerBuiltinInspectors();
111+
112+
/** Canvas stand-in: emits the selection the real canvas emits on a click. */
113+
function stubCanvas(selection: MetadataSelection) {
114+
return function StubCanvas({ onSelectionChange }: { onSelectionChange?: (next: MetadataSelection | null) => void }) {
115+
return (
116+
<button type="button" onClick={() => onSelectionChange?.(selection)}>
117+
select on canvas
118+
</button>
119+
);
120+
};
121+
}
122+
123+
/** A host's inspector for a type that ships none: it draws what it is handed. */
124+
function HostPositionInspector({ readOnly, readOnlyReason }: MetadataInspectorProps) {
125+
return (
126+
<div data-testid="host-position-inspector" data-readonly={String(readOnly)}>
127+
{readOnlyReason ?? ''}
128+
</div>
129+
);
130+
}
131+
132+
const realFlowPreview = getMetadataPreview('flow');
133+
const realPositionPreview = getMetadataPreview('position');
134+
const realPositionInspector = getMetadataInspector('position');
135+
136+
beforeEach(() => {
137+
layered = { effective: FLOW, code: null, overlay: null, editable: true, deletable: true };
138+
canAuthor = true;
139+
walled = false;
140+
registerMetadataPreview('flow', stubCanvas({ kind: 'node', id: 'set_status', label: 'Set status' }) as never);
141+
registerMetadataPreview('position', stubCanvas({ kind: 'node', id: 'sales_rep', label: 'Sales rep' }) as never);
142+
registerMetadataInspector('position', HostPositionInspector);
143+
});
144+
145+
afterEach(() => {
146+
cleanup();
147+
vi.clearAllMocks();
148+
if (realFlowPreview) registerMetadataPreview('flow', realFlowPreview);
149+
if (realPositionPreview) registerMetadataPreview('position', realPositionPreview);
150+
if (realPositionInspector) registerMetadataInspector('position', realPositionInspector);
151+
});
152+
153+
const READ_ONLY_HOST: MetadataAreaHost = {
154+
packageId: 'com.vendor.hr',
155+
readOnly: true,
156+
basePath: '/studio/com.vendor.hr/metadata',
157+
};
158+
159+
/** Open the item, then select on the canvas so the scoped inspector mounts. */
160+
async function openAndSelect(type: string, name: string, host: MetadataAreaHost | null = null) {
161+
const url = host ? `${host.basePath}/${type}/${name}` : `/apps/setup/metadata/${type}/${name}`;
162+
const tree = (
163+
<MemoryRouter initialEntries={[url]}>
164+
<MetadataResourceEditPage type={type} name={name} />
165+
</MemoryRouter>
166+
);
167+
render(host ? <MetadataAreaHostContext.Provider value={host}>{tree}</MetadataAreaHostContext.Provider> : tree);
168+
fireEvent.click(await screen.findByRole('button', { name: 'select on canvas' }));
169+
}
170+
171+
/** The node inspector's reason, as its accessible description resolves. */
172+
async function nodeInspectorReason(): Promise<string | null> {
173+
// The inspector is mounted once its Remove-node control is.
174+
await screen.findByRole('button', { name: t('engine.inspector.flowNode.remove', 'en-US') });
175+
const group = document.querySelector<HTMLElement>('[data-testid="flow-node-readonly-reason"]');
176+
if (!group) return null;
177+
const id = group.getAttribute('aria-describedby');
178+
return (id && document.getElementById(id)?.textContent) || '';
179+
}
180+
181+
describe('the metadata editor page names the read-only cause to the scoped inspector (objectstack#29079)', () => {
182+
it('a writable flow: the node inspector is editable and carries no reason', async () => {
183+
await openAndSelect('flow', 'close_case');
184+
expect(await nodeInspectorReason()).toBeNull();
185+
expect(screen.getByRole('button', { name: t('engine.inspector.flowNode.remove', 'en-US') })).toBeEnabled();
186+
});
187+
188+
it("a read-only package's area: the package is the reason, as Studio's Automations pillar says it", async () => {
189+
await openAndSelect('flow', 'close_case', READ_ONLY_HOST);
190+
expect(await nodeInspectorReason()).toBe(t('engine.studio.pkg.readonlyHint', 'en-US'));
191+
});
192+
193+
it('a caller without manage_metadata: the capability is the reason', async () => {
194+
canAuthor = false;
195+
await openAndSelect('flow', 'close_case');
196+
expect(await nodeInspectorReason()).toBe(t('engine.edit.capabilityReadOnly', 'en-US'));
197+
});
198+
199+
it('a locked item: the lock and its served reason are the reason', async () => {
200+
layered = {
201+
effective: FLOW,
202+
code: null,
203+
overlay: null,
204+
editable: false,
205+
deletable: false,
206+
lock: 'full',
207+
lockReason: 'Managed by the release pipeline.',
208+
};
209+
await openAndSelect('flow', 'close_case');
210+
expect(await nodeInspectorReason()).toBe(
211+
`${t('engine.edit.lockFull', 'en-US')} Managed by the release pipeline.`,
212+
);
213+
});
214+
215+
it('a flow a code package ships: the package is the reason, with the way to author one', async () => {
216+
layered = {
217+
effective: FLOW,
218+
code: { ...FLOW, _packageId: 'com.vendor.hr' },
219+
overlay: null,
220+
provenance: 'package',
221+
packageId: 'com.vendor.hr',
222+
editable: true,
223+
deletable: false,
224+
};
225+
await openAndSelect('flow', 'close_case');
226+
expect(await nodeInspectorReason()).toBe(
227+
tFormat('engine.edit.artifactLockedBanner', 'en-US', { type: 'flow' }),
228+
);
229+
});
230+
231+
it("the walled security catalog: the wall is the reason, handed to a host's inspector", async () => {
232+
walled = true;
233+
layered = { effective: POSITION, code: null, overlay: null, editable: true, deletable: true };
234+
await openAndSelect('position', 'sales_rep');
235+
const inspector = await screen.findByTestId('host-position-inspector');
236+
expect(inspector).toHaveAttribute('data-readonly', 'true');
237+
await waitFor(() =>
238+
expect(inspector).toHaveTextContent(
239+
tFormat('engine.catalog.readOnly.walled', 'en-US', { type: 'Position' }),
240+
),
241+
);
242+
});
243+
});

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2570,6 +2570,29 @@ function MetadataResourceEditPageImpl({
25702570
// so its fields are not either.
25712571
const formReadOnly = readOnly || walledDeciding || (!editing && !createMode);
25722572

2573+
// objectstack#29079 — why the scoped inspector is read-only, as the sentence
2574+
// the notice above the editor gives for the cause in force, in the notices'
2575+
// order. The inspector is shared by hosts whose causes differ, so the host
2576+
// names the cause (`MetadataInspectorProps.readOnlyReason`). View mode owes
2577+
// no sentence: the inspector exists only beside a designer canvas, and a
2578+
// designer type enters editing whenever it can be written (the auto-on
2579+
// effect above) and offers no Edit button to point at.
2580+
const inspectorReadOnlyReason = ((): string | undefined => {
2581+
if (!formReadOnly) return undefined;
2582+
if (walledDeciding) {
2583+
return tFormat('engine.catalog.readOnly.walled', locale, {
2584+
type: translateMetadataType(type, locale, entry?.label ?? type),
2585+
});
2586+
}
2587+
if (!readOnly) return undefined;
2588+
if (hostReadOnly) return t('engine.studio.pkg.readonlyHint', locale);
2589+
if (isLocked) return [lockBannerTitle(layered?.lock, locale), lockReason].filter(Boolean).join(' ');
2590+
if (declarationOnly) return t(`engine.catalog.declaredOnly.hint.${type}`, locale);
2591+
if (!canAuthor) return t('engine.edit.capabilityReadOnly', locale);
2592+
if (showArtifactLockedBanner) return tFormat('engine.edit.artifactLockedBanner', locale, { type });
2593+
return tFormat('engine.edit.readOnlyTypeBanner', locale, { flag: 'OS_METADATA_WRITABLE', type });
2594+
})();
2595+
25732596
// Note: URL `?tab=` deep-links were repurposed to open side-panel
25742597
// sheets (Layers / References / Related). Anything else is ignored —
25752598
// the main work area is always the form+preview.
@@ -3490,6 +3513,7 @@ function MetadataResourceEditPageImpl({
34903513
setBlockingReport({ key: selectionKey, count })
34913514
}
34923515
readOnly={formReadOnly}
3516+
readOnlyReason={inspectorReadOnlyReason}
34933517
locale={locale}
34943518
/>
34953519
) : !selection && DefaultInspectorComponent ? (

0 commit comments

Comments
 (0)