Skip to content

Commit 4a9fe31

Browse files
fix(app-shell): Studio refusals read as an author sentence, with the raw text under Details (objectui#11785) (#11848)
Fixes #11785 Clause-②: no ## What this changes Studio's save strips printed `formatMetadataError(e)` verbatim. Authors read the object write guard's developer prose (a class name, tracker ids, wire paths) and the server's raw issue paths (`nodes.2.config.title — …`, `navigation.1.objectName — …`). Each strip on the Data, Automations and Interfaces pages now shows a `StudioRefusal` with three parts: - **One sentence** that names the input in the editor's own terms. On the Data page that is the field's label ("Changes not saved: the field “Status” needs at least one option. Add an option, or change the field to a type that is not a choice."). On the Automations page it is the step's label and the inspector input ("Changes not saved. Check Title on the step “Notify approver” — Required text value"). On the Interfaces page it is the navigation item and its input. - **A "Show me" button** that opens that field, step or navigation item, when the path maps to one. - **A closed "Details" disclosure** holding the raw text. That text is exactly `formatMetadataError(e)`, what the strip printed before, so nothing is lost. A path Studio cannot place on the open page keeps its line under Details, and the sentence says the draft was refused. The guard (`assertObjectMetadataWritable`) changes **message text only**. It no longer prints the door's label (`MetadataClient.save`, `MetadataService`, `importObjectDraft`), `@objectstack/spec` or tracker ids. Its meaning, the field and type it names, and the remedy are kept. Its signature, error type and exports are unchanged. The third parameter is now spelled `_writer`, because the root tsconfig has `noUnusedParameters`. The data-objectstack README's two `// throws:` quotes now begin with the new first line, "The object was not saved: the field". ## How Studio recognises the guard's refusal without parsing prose The guard throws a plain `Error` before any request, and a typed refusal would be a new export (a Clause-② stop). Instead, the message is now a function of the body alone (it names no door), and Studio keeps the body it sent. It re-runs the guard on that body. `e` counts as the guard's refusal only when it has no status, carries no issues, and its message is byte-identical to the guard's. The field is the first one the guard refuses on its own, checked by the same equality. Anything else falls back to the failure as it was. The client door test pins the premise: the door refuses in the guard's own words. ## Measured before acting (dispatch Zone 2) - **H1, readers of the guard's text.** These read it: the guard's own tests; `metadata-client.objectWriteGuard.test.ts`, whose door-name pin is inverted here; plugin-designer's `MetadataFieldsPage.storedChoiceWithoutOptions-11253` banner test, which matches the phrase `is a radio with no options`, a clause kept verbatim; and the data-objectstack README (see Acceptance notes). - **H2, does structure survive?** Yes. `MetadataError.issues` (path, message, code) reaches every Studio `catch`. It was flattened at `setError(formatMetadataError(e))`. The strips now hold the view model instead of a string. - **H3, what maps.** | Issue path | Mapped to | Opens | |---|---|---| | `fields.NAME…` (record, or array by index or name) | the field, by its label | the field inspector | | `nodes.N.config.KEY` / `nodes.N.KEY…` | step N by its label, plus the input label from the node's field table | the node inspector | | `navigation.N.KEY` (top level) | the navigation item, plus its Label / Link-to-object input | nav editing on that entry | The index used for `navigation.N` is the index in the list that was SENT, mapped back to the editor's entry by object or `id`. objectui#11776's save leaves unbound entries out, so the two indexes differ. Unmapped remainder, kept under Details with the generic sentence: - `edges.*`, `variables.*` and other flow-level keys; - nested `navigation.N.children…` (the Studio nav inspector edits top-level entries only); - object keys outside `fields` (`validations.*`, `name`, root); - every Interfaces leaf path (page blocks, dashboard widgets). ## Pins - `metadataError.authorRefusal-11785.test.ts` (view model): the guard's choice and relationship refusals become sentences. Its controls cover the same words with a server status, the guard's words about a body that was not sent, and a transport failure. It also covers `nodes.2.config.title` → node 2's Title, the unmapped path under Details, several issues, a nav sent-index differing from the editor index, nested nav, zh, and the CONTROL that `formatMetadataError` (every other caller) is unchanged. - `DataPillar.authorRefusal-11785.test.tsx` (real door, transport double): switching a field to Picklist shows the author sentence, with no `MetadataClient`, tracker id, backtick or API name in the visible message. The raw text sits in a closed `details` element. "Show me" reopens the field. - `AutomationsPillar.authorRefusal-11785.test.tsx` (real FlowPreview / FlowInspector): a 422 at `nodes.2.config.title` names "Title on the step “Notify approver”", with the raw line under Details. "Show me" opens that node, and an unmapped `variables.0.name` stays under Details. **Ablation (one run, committed implementation, restore proven):** `objectSaveRefusal(e, body, locale)` in the Data page's save catch was replaced with `plainRefusal(e)`, i.e. render `formatMetadataError(e)` verbatim again. `ablation-replace.mjs` reported anchor 1 → 0 and the blob changed. Both Data pins went red (2 failed / 2). The first failure: expected "Changes not saved: the field “New field” needs at least one option…", received "The object was not saved: the field `field_2` is a `select` with no options…". The restore left the blob equal to HEAD with an empty `git diff HEAD`. ## Tests and gates All runs are on the final commit `36165fc` (this branch merged with `main` at `455c646`, never rebased), under the container's shared verify lock, from the worktree root. - **Build:** `turbo run build --filter=@object-ui/app-shell^...` (28 of 28 tasks). Type-check: `pnpm --filter @object-ui/data-objectstack type-check` exits 0; `pnpm --filter @object-ui/app-shell type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`, which includes `src/**/*.test.ts(x)`) exits 0. - **Tests:** `pnpm exec vitest run` on the union below: **228 files, 2608 tests passed**, exit 0. - `packages/data-objectstack/` in full. - `packages/app-shell/src/views/studio-design/` in full. - Every app-shell and console test that imports `StudioDesignSurface`, `metadataError`, the guard, or reads the designer table (derived by `git grep`, 33 files). - The write-doors and i18n script tests. - All 11 plugin-designer `MetadataFieldsPage*` suites (the non-Studio `formatMetadataError` callers, as the control). - **Declared narrowing:** app-shell's full suite is not run locally and is left to CI. The derived set covers direct importers of the changed modules. It is blind to a test that reaches them only transitively. - **ESLint** on the 9 touched source and test files: 0 errors. `StudioDesignSurface.tsx` has 13 warnings against 14 on the base (`16cda0b`), so none is new. The full `pnpm lint` is CI's. - **Gates:** all exit 0. - control-bytes, test-path-roots, changeset-claims, pending-changeset-literals; - i18n-keys, i18n-drift, i18n-dead-keys, i18n-designer-parity ("Every en row has a zh row, and every shared row carries the same placeholders."); - metadata-write-doors ("3 reach assertObjectMetadataWritable"); - esm-specifiers, phantom-deps, vi-mock-inherit, vi-mock-override-shape; - spec-symbols: red once on a module-local `Doc` alias, renamed `SentBody`, now green; - new-line-citations, designer-field-key-parity, and `scripts/check-changeset-presence.mjs`. - **NOT MEASURED:** check:eager-closure, check:doc-examples and check:doc-snippets. All three report PREREQUISITE NOT MET because they need a built console or a doc build. As a stand-in for eager-closure, a static-import crawl of app-shell's `index.ts` on the base tree finds `flow-node-config.ts`, `navItemLabel.ts` and `i18n.ts` already IN the barrel's closure. Control: `console/home/QuickActions.tsx` reads OUT, confirmed by hand. So the new imports add no module to it. **Patch round on head `00304bc`** (README only; `main` still `455c646`, already merged), under the shared lock where noted: - `turbo run build $(node scripts/check-doc-snippet-types.mjs --build-filter) --concurrency=2`: exit 0 (locked). - `pnpm --filter @object-ui/data-objectstack type-check` (`tsc --noEmit`): exit 0 (locked). - `pnpm check:doc-snippets` (its corpus is every `README.md` under `packages/`): exit 0, "Every covered documentation snippet compiles against the built types." (locked). - `pnpm check:readme-exports`: exit 0, "check-readme-exports: OK (43 tracked README(s) under packages/ …)". It was run after that build; the run before the build was a PREREQUISITE failure (no `dist`), not a finding. - `pnpm check:changeset-claims` exit 0, `pnpm check:pending-changeset-literals` exit 0, `pnpm check:doc-types` exit 0, `pnpm check:doc-fences` exit 0, `pnpm check:control-bytes` exit 0, `node scripts/check-changeset-presence.mjs` exit 0. - The code is unchanged since `36165fc`, whose union is above. ## Acceptance notes - **README, done in this PR (claim amendment comment `6050143821`, answer A):** `packages/data-objectstack/README.md` is published (`files` lists it). Its two `// throws:` comment lines quoted the guard's old first line, "MetadataClient.save refused this object metadata write: the field". Both now read "The object was not saved: the field". That is the guard's first line as measured by running it on the README's two example bodies. The second line of each quote already matched and is unchanged, and no other README text changed (commit `00304bc`). - `ResourceEditPage` (metadata-admin) is not restructured. It already maps a 422's issues to labelled inline errors, and it shows the guard's refusal as `err.message`, which is now free of class names and tracker ids. - Publish failures stay toasts (`formatPublishFailures`). No strip renders them, and the card measured no publish case. Converting them is a separate surface. - Create dialogs (`CreateItemDialog`, off-surface) print `error.message` only. Creates keep the plain text there. - Inline errors inside the inspectors were not added. `ObjectFieldInspector.tsx` and the flow inspectors are another seat's files or off-surface, so the strip's sentence plus "Show me" is the attachment. - Observation, not filed: `translateValidationMessage` maps a bare `Required` to "A name is required." whatever the path. The sentence passes the server's message through it, as ResourceEditPage does, and inherits that. It was not reached at a public door in this run. - objectui#11786 (the autosave refusing intermediate states) is not addressed here and remains open. Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 59ce048 commit 4a9fe31

11 files changed

Lines changed: 1142 additions & 79 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/data-objectstack': patch
4+
---
5+
6+
Studio shows a refused save as a sentence the author can act on, with the technical detail behind a "Details" disclosure (objectui#11785).
7+
8+
- **Studio's save strips.** The Data, Automations and Interfaces pages used to print the refusal verbatim: the write guard's developer prose, or the server's raw issue paths such as `nodes.2.config.title — …`. Each strip now shows one sentence that names the input the refusal is about, in the terms the editor shows. On the Data page that is the field's label. On the Automations page it is the step's label and the inspector input, so a refusal at `nodes.2.config.title` reads "Check Title on the step “Notify approver” — …". On the Interfaces page it is the navigation item and its input. A "Show me" button opens that field, step or navigation item. The raw text stays under a closed "Details" disclosure, unchanged. A path Studio cannot place on the open page keeps its line there, and the sentence says the draft was refused.
9+
- **A Picklist with no options.** Switching a field to Picklist before it has an option now says "Changes not saved: the field “Status” needs at least one option…", instead of the guard's message.
10+
- **The object write guard's message names no code.** `assertObjectMetadataWritable` no longer prints the door's class name, a package name or tracker ids. Its message is the same at every door, so the other surfaces that show it as their banner read the field, its type and the remedy. Its signature, error type and exports are unchanged.
11+
12+
`formatMetadataError` is unchanged, so every other surface that calls it prints what it printed before. Nothing is added to either package entry: no export, prop, type member or language-pack key. The new sentences are rows in the metadata-admin designer's own string tables (en and zh).

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2758,6 +2758,23 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
27582758
'engine.studio.publish': 'Publish',
27592759
'engine.studio.loading': 'Loading…',
27602760
'engine.studio.loadFailed': 'Failed to load',
2761+
// objectui#11785 — a refused save, in the author's words. The strip shows one
2762+
// of these sentences; the raw refusal (field paths, codes) stays under
2763+
// `engine.studio.refusal.details`, and `show` opens the input it names.
2764+
'engine.studio.refusal.choiceWithoutOptions':
2765+
'Changes not saved: the field “{field}” needs at least one option. Add an option, or change the field to a type that is not a choice.',
2766+
'engine.studio.refusal.relationshipWithoutTarget':
2767+
'Changes not saved: the field “{field}” does not say which object it links to. Pick the object, or change the field to a type that is not a relationship.',
2768+
'engine.studio.refusal.issue': 'Changes not saved. Check {where} — {problem}',
2769+
'engine.studio.refusal.more': '({count} more under Details)',
2770+
'engine.studio.refusal.unlocated': 'Changes not saved: the server refused this draft. Details say what to change.',
2771+
'engine.studio.refusal.field': 'the field “{field}”',
2772+
'engine.studio.refusal.step': 'the step “{step}”',
2773+
'engine.studio.refusal.stepInput': '{input} on the step “{step}”',
2774+
'engine.studio.refusal.navItem': 'the navigation item “{item}”',
2775+
'engine.studio.refusal.navItemInput': '{input} on the navigation item “{item}”',
2776+
'engine.studio.refusal.details': 'Details',
2777+
'engine.studio.refusal.show': 'Show me',
27612778
'engine.studio.unpublishedDraft': 'Unpublished draft',
27622779
'engine.studio.unpublished': 'Unpublished',
27632780
'engine.studio.new': 'New',
@@ -5714,6 +5731,20 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
57145731
'engine.studio.publish': '发布',
57155732
'engine.studio.loading': '加载中…',
57165733
'engine.studio.loadFailed': '加载失败',
5734+
'engine.studio.refusal.choiceWithoutOptions':
5735+
'更改未保存:字段「{field}」至少需要一个选项。请添加选项,或将该字段改为非选项类型。',
5736+
'engine.studio.refusal.relationshipWithoutTarget':
5737+
'更改未保存:字段「{field}」未指定要关联的对象。请选择对象,或将该字段改为非关联类型。',
5738+
'engine.studio.refusal.issue': '更改未保存。请检查{where} —— {problem}',
5739+
'engine.studio.refusal.more': '(另有 {count} 处,见详情)',
5740+
'engine.studio.refusal.unlocated': '更改未保存:服务端拒绝了此草稿。详情中说明了需要修改的内容。',
5741+
'engine.studio.refusal.field': '字段「{field}」',
5742+
'engine.studio.refusal.step': '步骤「{step}」',
5743+
'engine.studio.refusal.stepInput': '步骤「{step}」的「{input}」',
5744+
'engine.studio.refusal.navItem': '导航项「{item}」',
5745+
'engine.studio.refusal.navItemInput': '导航项「{item}」的「{input}」',
5746+
'engine.studio.refusal.details': '详情',
5747+
'engine.studio.refusal.show': '定位',
57175748
'engine.studio.unpublishedDraft': '未发布草稿',
57185749
'engine.studio.unpublished': '未发布',
57195750
'engine.studio.new': '新建',
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11785 — on the Studio Automations page, a server refusal names the
5+
* step and the input it is about, instead of printing a raw issue path.
6+
*
7+
* The card's measured case: a 422 on `nodes.2.config.title` printed
8+
* "nodes.2.config.title — …" as the banner. The strip now says which step
9+
* (node 2, by its label) and which input (its Title, by the inspector's own
10+
* label), offers "Show me" to open that step, and keeps the raw line under a
11+
* closed "Details" disclosure. A path it cannot place stays under Details.
12+
*
13+
* The canvas and inspector are the real registered `FlowPreview` and
14+
* `FlowInspector`; the client is a server double whose `save` refuses, driven
15+
* through the header switch, which saves at once (as in
16+
* `StudioDesignSurface.automationsReadOnly-11124.test.tsx`).
17+
*/
18+
19+
import '@testing-library/jest-dom/vitest';
20+
import * as React from 'react';
21+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22+
import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
23+
import { MemoryRouter } from 'react-router-dom';
24+
25+
const PKG = 'com.acme.app';
26+
27+
const FLOW = {
28+
name: 'approval',
29+
label: 'Approval',
30+
type: 'autolaunched',
31+
status: 'active',
32+
nodes: [
33+
{ id: 'start', type: 'start', label: 'Start' },
34+
{ id: 'route', type: 'decision', label: 'Route' },
35+
{ id: 'tell', type: 'notify', label: 'Notify approver', config: { recipients: ['owner'] } },
36+
{ id: 'end', type: 'end', label: 'End' },
37+
],
38+
edges: [
39+
{ id: 'e1', source: 'start', target: 'route' },
40+
{ id: 'e2', source: 'route', target: 'tell' },
41+
{ id: 'e3', source: 'tell', target: 'end' },
42+
],
43+
};
44+
45+
const MISSING = 'Invalid input: expected string, received undefined';
46+
47+
const server = vi.hoisted(() => ({
48+
/** The issues the next draft save is refused with. */
49+
issues: [] as Array<{ path: string; message: string }>,
50+
}));
51+
52+
const mockClient = vi.hoisted(() => ({
53+
list: vi.fn(async () => [{ name: 'approval', label: 'Approval' }]),
54+
listDrafts: vi.fn(async () => []),
55+
listTypes: vi.fn(async () => ({ entries: [] })),
56+
get: vi.fn(async () => null),
57+
references: vi.fn(async () => []),
58+
layered: vi.fn(async () => ({
59+
code: null,
60+
overlay: null,
61+
overlayScope: null,
62+
effective: JSON.parse(JSON.stringify(FLOW)),
63+
editable: true,
64+
deletable: true,
65+
resettable: false,
66+
lock: 'none',
67+
})),
68+
getDraft: vi.fn(async (type: string, name: string) => {
69+
throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 });
70+
}),
71+
save: vi.fn(async () => {
72+
// The client's parsed `MetadataError`: a headline, a status, a code and
73+
// the structured issues the server returned.
74+
throw Object.assign(new Error(`flow/approval failed spec validation (${server.issues.length})`), {
75+
status: 422,
76+
code: 'INVALID_METADATA',
77+
issues: server.issues,
78+
});
79+
}),
80+
publish: vi.fn(async () => ({ success: true })),
81+
reset: vi.fn(async () => ({})),
82+
}));
83+
84+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
85+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
86+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
87+
});
88+
89+
vi.mock('./packages-io', async (importOriginal) => {
90+
const mod = await importOriginal<typeof import('./packages-io')>();
91+
return { ...mod, fetchPackages: vi.fn(async () => []) };
92+
});
93+
94+
vi.mock('@object-ui/react', async (importOriginal) => {
95+
const mod = await importOriginal<typeof import('@object-ui/react')>();
96+
return { ...mod, useAdapter: () => dataSource };
97+
});
98+
99+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
100+
101+
import { AutomationsPillar } from './StudioDesignSurface';
102+
import { createEmptyDataSource } from './__tests__/emptyDataSource';
103+
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
104+
import { registerMetadataInspector } from '../metadata-admin/inspector-registry';
105+
import { FlowPreview } from '../metadata-admin/previews/FlowPreview';
106+
import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector';
107+
import { t, tFormat } from '../metadata-admin/i18n';
108+
109+
const dataSource = createEmptyDataSource();
110+
111+
// The pillar's `/automation/_status` probe and the inspector's catalog reads go
112+
// through the global `fetch`; "absent" keeps each on its documented fallback.
113+
vi.stubGlobal(
114+
'fetch',
115+
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
116+
);
117+
118+
registerMetadataPreview('flow', FlowPreview);
119+
registerMetadataInspector('flow', FlowInspector);
120+
121+
beforeEach(() => {
122+
server.issues = [];
123+
for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
124+
});
125+
126+
afterEach(cleanup);
127+
128+
/** Open the flow, then flip the header switch: a save that the server refuses. */
129+
async function refusedSave(issues: Array<{ path: string; message: string }>): Promise<HTMLElement> {
130+
server.issues = issues;
131+
render(
132+
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
133+
<AutomationsPillar packageId={PKG} />
134+
</MemoryRouter>,
135+
);
136+
const toggle = await screen.findByRole('switch', undefined, { timeout: 8000 });
137+
await waitFor(() => expect(toggle).not.toBeDisabled());
138+
fireEvent.click(toggle);
139+
await waitFor(() => expect(mockClient.save).toHaveBeenCalledTimes(1));
140+
return screen.findByTestId('studio-refusal');
141+
}
142+
143+
describe('Automations page — a server refusal names the step and its input (objectui#11785)', () => {
144+
it('`nodes.2.config.title` names node 2\'s Title, with the raw path under Details', async () => {
145+
const strip = await refusedSave([{ path: 'nodes.2.config.title', message: MISSING }]);
146+
147+
const message = within(strip).getByTestId('studio-refusal-message');
148+
expect(message).toHaveTextContent(
149+
tFormat('engine.studio.refusal.issue', 'en', {
150+
where: tFormat('engine.studio.refusal.stepInput', 'en', { input: 'Title', step: 'Notify approver' }),
151+
problem: t('engine.validation.expectedStringUndefined', 'en'),
152+
}),
153+
);
154+
expect(message.textContent).not.toMatch(/nodes\.\d|config\.title/);
155+
156+
const details = within(strip).getByTestId('studio-refusal-detail') as HTMLDetailsElement;
157+
expect(details.open).toBe(false);
158+
expect(details.querySelector('pre')).toHaveTextContent(`• nodes.2.config.title — ${MISSING}`);
159+
});
160+
161+
it('"Show me" opens that step in the inspector', async () => {
162+
const strip = await refusedSave([{ path: 'nodes.2.config.title', message: MISSING }]);
163+
fireEvent.click(within(strip).getByRole('button', { name: t('engine.studio.refusal.show', 'en') }));
164+
165+
const rail = screen.getByRole('complementary');
166+
const label = (await within(rail).findByLabelText('Label', undefined, { timeout: 8000 })) as HTMLInputElement;
167+
expect(label.value).toBe('Notify approver');
168+
expect(within(rail).getByLabelText('ID')).toHaveValue('tell');
169+
});
170+
171+
it('a path it cannot place stays reachable under Details', async () => {
172+
const strip = await refusedSave([{ path: 'variables.0.name', message: 'Invalid input' }]);
173+
174+
expect(within(strip).getByTestId('studio-refusal-message')).toHaveTextContent(
175+
t('engine.studio.refusal.unlocated', 'en'),
176+
);
177+
expect(within(strip).queryByRole('button', { name: t('engine.studio.refusal.show', 'en') })).toBeNull();
178+
expect(within(strip).getByTestId('studio-refusal-detail').querySelector('pre')).toHaveTextContent(
179+
'• variables.0.name — Invalid input',
180+
);
181+
});
182+
});
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11785 — on the Studio data page, a save the write guard holds shows
5+
* the author a sentence, not the guard's developer text.
6+
*
7+
* The card's measured case: add a field, switch its type to Picklist, and the
8+
* draft autosave is held because a choice field has no options. The strip used
9+
* to print the guard's message verbatim (a class name and tracker ids). It now
10+
* shows a sentence naming the field by its label, a "Show me" button that
11+
* opens that field, and the raw text inside a closed "Details" disclosure.
12+
*
13+
* The client is a real `MetadataClient`, so the door that runs the guard is the
14+
* production one; only the transport under it is a double, as in
15+
* `DataPillar.choiceWithoutOptions-11253.test.tsx`.
16+
*/
17+
18+
import '@testing-library/jest-dom/vitest';
19+
import * as React from 'react';
20+
import { afterEach, describe, expect, it, vi } from 'vitest';
21+
import { render, screen, fireEvent, cleanup, act, within } from '@testing-library/react';
22+
import userEvent from '@testing-library/user-event';
23+
import { MemoryRouter } from 'react-router-dom';
24+
import { MetadataClient } from '@object-ui/data-objectstack';
25+
26+
const objectDef = {
27+
name: 'showcase_task',
28+
label: 'Task',
29+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
30+
};
31+
32+
const puts: unknown[] = [];
33+
34+
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
35+
if (init?.method === 'PUT') {
36+
puts.push(JSON.parse(String(init.body)));
37+
return new Response(JSON.stringify({ success: true }), {
38+
status: 200,
39+
headers: { 'content-type': 'application/json' },
40+
});
41+
}
42+
return new Response('null', { status: 404, headers: { 'content-type': 'application/json' } });
43+
});
44+
45+
const client = new MetadataClient({ baseUrl: 'http://test.local', fetch: fetchImpl as unknown as typeof fetch });
46+
Object.assign(client, {
47+
list: vi.fn(async () => [{ name: 'showcase_task', label: 'Task' }]),
48+
listDrafts: vi.fn(async () => []),
49+
layered: vi.fn(async () => ({ effective: objectDef, code: objectDef })),
50+
getDraft: vi.fn(async () => null),
51+
});
52+
53+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
54+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
55+
return { ...mod, useMetadataClient: () => client, useMetadataTypes: () => ({ entries: [] }) };
56+
});
57+
58+
vi.mock('./packages-io', async (importOriginal) => {
59+
const mod = await importOriginal<typeof import('./packages-io')>();
60+
return { ...mod, fetchPackages: vi.fn(async () => []) };
61+
});
62+
63+
vi.mock('@object-ui/react', async (importOriginal) => {
64+
const mod = await importOriginal<typeof import('@object-ui/react')>();
65+
return { ...mod, useAdapter: () => dataSource };
66+
});
67+
68+
import { DataPillar } from './StudioDesignSurface';
69+
import { createEmptyDataSource } from './__tests__/emptyDataSource';
70+
import { registerBuiltinInspectors } from '../metadata-admin/inspectors';
71+
import { t, tFormat } from '../metadata-admin/i18n';
72+
73+
const dataSource = createEmptyDataSource();
74+
registerBuiltinInspectors();
75+
76+
afterEach(() => {
77+
cleanup();
78+
puts.length = 0;
79+
fetchImpl.mockClear();
80+
});
81+
82+
/** The inspector control under a visible label. */
83+
function controlUnder(label: string): HTMLElement {
84+
const lab = screen.getByText(label, { selector: 'label' });
85+
return lab.parentElement!.querySelector('input, [role="combobox"]') as HTMLElement;
86+
}
87+
88+
describe('Studio data page — a held Picklist reads as a sentence (objectui#11785)', () => {
89+
it('names the field by its label, shows no developer text, and keeps the raw text under Details', async () => {
90+
render(
91+
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
92+
<DataPillar packageId="com.example.showcase" />
93+
</MemoryRouter>,
94+
);
95+
fireEvent.click(await screen.findByRole('button', { name: 'Form' }));
96+
fireEvent.click(await screen.findByTitle(/^Add a field/));
97+
await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 });
98+
const apiName = (controlUnder('API name') as HTMLInputElement).value;
99+
const label = (controlUnder('Label') as HTMLInputElement).value;
100+
expect(apiName, 'the new field has no API name: the harness is dead').not.toBe('');
101+
expect(label, 'the new field has no label: the harness is dead').not.toBe('');
102+
await userEvent.click(controlUnder('Type'));
103+
await userEvent.click(await screen.findByRole('option', { name: /· Picklist$/ }));
104+
105+
// Past the autosave's 1.5 s debounce: the guard holds the save.
106+
await act(async () => {
107+
await new Promise((r) => setTimeout(r, 2300));
108+
});
109+
expect(puts, 'the guard must hold this save').toEqual([]);
110+
111+
const strip = await screen.findByTestId('studio-refusal');
112+
const message = within(strip).getByTestId('studio-refusal-message');
113+
expect(message).toHaveTextContent(
114+
tFormat('engine.studio.refusal.choiceWithoutOptions', 'en', { field: label }),
115+
);
116+
expect(message.textContent).not.toMatch(/MetadataClient|objectstack#|objectui#|`/);
117+
// The API name is the guard's word for the field; the author reads its label.
118+
expect(label).not.toBe(apiName);
119+
expect(message.textContent).not.toContain(apiName);
120+
121+
// The raw text is still there, closed until the author asks for it.
122+
const details = within(strip).getByTestId('studio-refusal-detail') as HTMLDetailsElement;
123+
expect(details.open).toBe(false);
124+
expect(within(details).getByText(t('engine.studio.refusal.details', 'en'))).toBeVisible();
125+
const raw = details.querySelector('pre')!;
126+
expect(raw).not.toBeVisible();
127+
expect(raw).toHaveTextContent(new RegExp(`\`${apiName}\` is a \`select\` with no options`));
128+
fireEvent.click(within(details).getByText(t('engine.studio.refusal.details', 'en')));
129+
expect(details.open).toBe(true);
130+
});
131+
132+
it('"Show me" opens the named field\'s inspector after it was closed', async () => {
133+
render(
134+
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
135+
<DataPillar packageId="com.example.showcase" />
136+
</MemoryRouter>,
137+
);
138+
fireEvent.click(await screen.findByRole('button', { name: 'Form' }));
139+
fireEvent.click(await screen.findByTitle(/^Add a field/));
140+
await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 });
141+
const apiName = (controlUnder('API name') as HTMLInputElement).value;
142+
await userEvent.click(controlUnder('Type'));
143+
await userEvent.click(await screen.findByRole('option', { name: /· Picklist$/ }));
144+
await act(async () => {
145+
await new Promise((r) => setTimeout(r, 2300));
146+
});
147+
const strip = await screen.findByTestId('studio-refusal');
148+
149+
// The rail's own header close (the field inspector inside carries another).
150+
const railHeader = screen.getByText(t('engine.studio.data.fieldProps', 'en')).closest('header') as HTMLElement;
151+
fireEvent.click(within(railHeader).getByRole('button', { name: t('engine.studio.close', 'en') }));
152+
expect(screen.queryByText('API name', { selector: 'label' })).toBeNull();
153+
154+
fireEvent.click(within(strip).getByRole('button', { name: t('engine.studio.refusal.show', 'en') }));
155+
expect(await screen.findByText('API name', { selector: 'label' })).toBeInTheDocument();
156+
expect((controlUnder('API name') as HTMLInputElement).value).toBe(apiName);
157+
});
158+
});

0 commit comments

Comments
 (0)