Skip to content

Commit 2720874

Browse files
fix(app-shell): Studio's action editor keeps a new field-value row, and stores a script action's type (#26877)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbBTimVx1S4qDzt6oB6ihKTY)_ Fixes #26735 Fixes #26734 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 的动作编辑器(数据 › 对象 › 操作)两处修正。「更新字段」动作点「添加字段值」现在立刻出现一行,在填上字段名之前只留在编辑器里,填上后才写进 `patch`;「运行脚本」动作现在在作者切换到运行脚本、编辑脚本体或语言、填字段值时写入 `type: 'script'`,「类型」下拉只显示动作真正存有的类型,没有时显示「—」。 - **为什么改**:以前「添加字段值」写进一个空键的行,空键被丢掉,行不出现,`patch` 永远是 `{}`;「运行脚本」动作存下来没有 `type`,下拉却显示 Script,控制台读到无类型的动作什么都不执行。两种动作在 Studio 里做出来发布后都点了没反应。 - **风险与回滚**:低。不改规范、不改服务端、不加文案;只改设计器的写法。已有 `type` 的动作写法与以前完全一样。回滚即 revert 本 PR。 - **先看**:`ActionDefaultInspector.tsx` 里的 `withScriptType` 与 `commitFieldValueRows`;`ActionDefaultInspector.scriptBodyLanguage-11921.test.tsx` 里改成带 `type` 的两条期望。 - **两分钟验证**:在 Studio 某对象的「操作」里新建动作,点「添加字段值」,预期立刻出现一行;填字段 `status`、值 `done`,预期草稿的 `patch` 为 `{ status: 'done' }` 且带 `type: 'script'`。 - **需要你做的**:无,席位落地。 ## What changes `ActionDefaultInspector` (Studio › Data › an object › Actions), two writes: - **Field values (#26735).** "Add field value" committed `['', '']`, and the patch writer drops every blank key, so no row appeared and an update action's `patch` stayed `{}`. The rows on screen are now a list held in the inspector: a row is written to `patch` once it names a field, and a row that names none (just added, or its field cleared) is held there and never written. The held list is stamped with the action, like the Body Shape Wrap choice, and is read only while its named rows equal the draft's `patch`; any other write to `patch` shows the draft's rows. Rows stay keyed by position, so a held row that gains its field keeps its inputs and the focus. - **Script type (#26734).** The Type control showed `str('type') || 'script'`, so a draft with no `type` read Script while nothing committed it. A "Run a script" action made from New (whose skeleton leaves `type` to the spec) was stored without one, and the console runs nothing for an action without `type`. The Type control now shows only a stored `type` ("—" when there is none), and `withScriptType` adds `type: 'script'` to every write that authors a script on a draft that holds no `type`: the "What it does" switch in both directions (the update direction already wrote it), each body edit, a language change and a field-value write. The Behavior panels still follow the spec's default route (`ActionSchema.type` defaults to `script`), so the editor for what the action runs stays on screen. A draft that already holds a `type` is written exactly as before. No `packages/spec` change, no server change, no new string, no runtime fallback for an action without `type`. ## Tests - `ActionDefaultInspector.fieldValueRows-26735.test.tsx` (new, 5 cases): from the New skeleton, Add shows a row and writes nothing; Field `status` writes `patch: { status: '' }` with the same input element; Value `done` writes `{ status: 'done' }` and the draft parses with `ActionSchema`. A cleared field keeps the row on screen and takes it out of `patch`; removing a held row writes nothing; another write to `patch` shows the draft's rows; a typed action is written with `patch` alone. - `ActionDefaultInspector.scriptType-26734.test.tsx` (new, 6 cases): New → "Run a script" → a body stores `type: 'script'` and parses; a stored script with no `type` shows none in Type and its first body edit stores it; the first field value from New stores it; the spec gives a draft with no `type` the `script` route; controls: a typed script action's body and language writes carry no `type`, and an api action shows API. - `ActionDefaultInspector.scriptBodyLanguage-11921.test.tsx`: the two pins of the "Run a script" switch from the typeless New skeleton now expect `type: 'script'` in the write. They pinned exactly the write #26734 reports. - Main-tree check (one-off, not kept): with the fix committed, the inspector was replaced by its `origin/main` blob (`commitFieldValueRows` count 1 → 0 on disk) and the two new files ran red, Tests 8 failed, 3 passed (11): every case except the two typed-action controls and the spec reading. Restored with `git checkout HEAD`, `git diff HEAD` empty. ## Acceptance notes - **"—" in the pinned Type control on the New skeleton.** New's update skeleton (`ObjectActionsPanel`, outside this card's file surface) leaves `type` off on purpose, and its pin `newActionPin` asserts that. Until the author's first edit, the pinned Type control on that skeleton reads "—" beside the "Pinned to script" hint. The first field value or "What it does" switch stores `type`. Seeding `type: 'script'` in the skeleton would close that window; it is a change to `ObjectActionsPanel` and its pin, so it is left as an open question in the report. - **Stored rows without `type`.** The spec declares `type` with a `script` default, and the console reads the stored row, not its parse, so it treats any stored action without `type` as not executable. This PR stops Studio from writing new ones; it does not change the runtime, by the card's order. ## Verification All readings at head `a9ea1105` (base `e9e344a9`), under `scripts/pm/os-verify-lock.sh`. <details><summary>Touched package: @object-ui/app-shell</summary> - Dependency closure: `pnpm exec turbo run build --filter='@object-ui/app-shell^...'` plus formula, sdui-parser, lint and client, 63 tasks successful, VERDICT command-exit 0. - Type-check: `pnpm exec turbo run type-check --filter=@object-ui/app-shell --concurrency=2`, 64 tasks successful (app-shell's own task a cache miss, executed: `tsc --noEmit` and `tsc -p tsconfig.test.json`), VERDICT command-exit 0. - The two new test files: Test Files 2 passed (2), Tests 11 passed (11). - Narrowed app-shell run: every test file naming `ActionDefaultInspector` or `ObjectActionsPanel` (26 files). First run: Tests 2 failed, 224 passed (226), the two `scriptBodyLanguage-11921` pins of the typeless switch's write, updated above. That file re-run: Tests 13 passed (13). The rest of app-shell is left to CI's Test UI shards. - ESLint with `ui/eslint.config.js` on the four touched files: exit 0, no output. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands for the 5 changed paths; all 34 exit 0, each code captured before any pipe, among them `check:nul-bytes`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:type-check-coverage`, `check:type-check-debt`, `check-sdui-manifest`, `check-empty-changeset`, `check-changeset-no-major` and `check-closing-keyword-parity` (with this body as the event). `pnpm --dir ui exec vitest run scripts/`: Test Files 178 passed, 2 skipped (180), Tests 5065 passed, 2 skipped. - `--ran` reconciled: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED". - `pnpm --dir ui run check:new-line-citations`: 0 new cross-file line-address citations. - Families CI runs over the whole tree (type-check lanes, lint, artifact rosters) are CI's; the PR's check-runs are the reading. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (lint is CI's) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (two cards on one inspector, bundled by the seat's claim) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jVG55iUTTWRd5tR6Di --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jVG55iUTTWRd5tR6Di)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31a6ca7 commit 2720874

5 files changed

Lines changed: 392 additions & 15 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+
fix(app-shell): Studio's action editor keeps a new field-value row, and stores a script action's type
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Two gaps in Studio's action editor (Data › an object › Actions) left a published action that does nothing:
11+
12+
- **Add field value** on an "Update fields on this record" action added no row, so `patch` stayed `{}` and the action could not be given a field to set. The row now appears at once and is held in the editor until it names a field; from then on it is written to `patch`. A row whose field is cleared stays on screen, held, and leaves `patch`. A row that names no field is never written.
13+
- **Run a script** left the action without `type`, while the Type control read Script. The console runs nothing for an action without `type`, so the button sent no request. Every edit that authors a script (the "What it does" switch, the body, its language, a field value) now stores `type: 'script'` on an action that has none, and the Type control shows only a type the action holds: "—" until then. An action that already has a `type` is written exactly as before.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26735 — "Add field value" adds a row the author can fill, and
5+
* the row reaches the update action's `patch` once it names a field.
6+
*
7+
* Measured before the fix: the button committed `['', '']`, the write dropped
8+
* the blank key, so no row appeared and `patch` stayed `{}` — an update action
9+
* made in Studio could never be given a field to set.
10+
*
11+
* The pins drive the real inspector in a host that merges each patch
12+
* shallowly, the way the action hosts do, and parse the sent draft with the
13+
* spec's `ActionSchema`.
14+
*/
15+
16+
import '@testing-library/jest-dom/vitest';
17+
import * as React from 'react';
18+
import { describe, it, expect, vi, afterEach } from 'vitest';
19+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
20+
import { ActionSchema } from '@objectstack/spec/ui';
21+
22+
const state = vi.hoisted(() => ({
23+
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]), withPreviewDrafts() { return this; } },
24+
}));
25+
vi.mock('../useMetadata', () => ({ useMetadataClient: () => state.metadataClient }));
26+
27+
import { ActionDefaultInspector } from './ActionDefaultInspector';
28+
29+
afterEach(cleanup);
30+
31+
type Draft = Record<string, unknown>;
32+
33+
function mountHost(initial: Draft) {
34+
const seen: { draft: Draft; writes: Draft[]; setDraft: (d: Draft) => void } = {
35+
draft: initial,
36+
writes: [],
37+
setDraft: () => {},
38+
};
39+
function Host() {
40+
const [draft, setDraft] = React.useState<Draft>(initial);
41+
seen.draft = draft;
42+
seen.setDraft = (d) => setDraft(d);
43+
return (
44+
<ActionDefaultInspector
45+
type="action"
46+
name={String(initial.name)}
47+
locale={'en-US' as never}
48+
readOnly={false}
49+
draft={draft}
50+
onPatch={(patch) => {
51+
seen.writes.push(patch);
52+
setDraft((d) => ({ ...d, ...patch }));
53+
}}
54+
/>
55+
);
56+
}
57+
render(<Host />);
58+
return seen;
59+
}
60+
61+
const sent = (draft: Draft): Draft => JSON.parse(JSON.stringify(draft));
62+
63+
function refusals(draft: Draft): string[] {
64+
const parsed = ActionSchema.safeParse(sent(draft));
65+
return parsed.success ? [] : parsed.error.issues.map((i) => `${i.path.join('.')}:${i.code}`);
66+
}
67+
68+
/** The text inputs under every visible label `label`, in row order. */
69+
function inputsUnder(label: string): HTMLInputElement[] {
70+
return screen
71+
.queryAllByText(label, { selector: 'label' })
72+
.map((lab) => lab.parentElement!.querySelector('input') as HTMLInputElement);
73+
}
74+
75+
const addRow = () => fireEvent.click(screen.getByRole('button', { name: 'Add field value' }));
76+
77+
/** The skeleton Studio's New starts from (objectui#11820): `type` left to the spec. */
78+
const NEW_UPDATE_ACTION: Draft = {
79+
name: 'invoice_action_2',
80+
label: 'New action',
81+
objectName: 'invoice',
82+
locations: ['record_header'],
83+
operation: 'update',
84+
patch: {},
85+
};
86+
87+
describe('fieldValueRowsPin — a new field-value row is held until it names a field (objectstack#26735)', () => {
88+
it('add row → field → value: the row shows at once, and `patch` holds it once it names a field', () => {
89+
const seen = mountHost(NEW_UPDATE_ACTION);
90+
expect(inputsUnder('Field')).toHaveLength(0);
91+
92+
addRow();
93+
// The row is on screen, and nothing was written: a blank row never
94+
// reaches the draft.
95+
expect(inputsUnder('Field')).toHaveLength(1);
96+
expect(seen.writes).toStrictEqual([]);
97+
expect(sent(seen.draft).patch).toStrictEqual({});
98+
99+
const field = inputsUnder('Field')[0];
100+
fireEvent.change(field, { target: { value: 'status' } });
101+
expect(sent(seen.draft).patch).toStrictEqual({ status: '' });
102+
// The row was written in place: the same input, so the focus stays.
103+
expect(inputsUnder('Field')[0]).toBe(field);
104+
expect(field).toHaveValue('status');
105+
106+
fireEvent.change(inputsUnder('Value')[0], { target: { value: 'done' } });
107+
expect(sent(seen.draft).patch).toStrictEqual({ status: 'done' });
108+
expect(refusals(seen.draft)).toEqual([]);
109+
});
110+
111+
it('a row whose field is cleared stays on screen, held, and leaves `patch`', () => {
112+
const seen = mountHost({ ...NEW_UPDATE_ACTION, type: 'script', patch: { status: 'done', priority: 'high' } });
113+
expect(inputsUnder('Field').map((i) => i.value)).toEqual(['status', 'priority']);
114+
115+
fireEvent.change(inputsUnder('Field')[0], { target: { value: '' } });
116+
expect(sent(seen.draft).patch).toStrictEqual({ priority: 'high' });
117+
expect(inputsUnder('Field').map((i) => i.value)).toEqual(['', 'priority']);
118+
expect(inputsUnder('Value').map((i) => i.value)).toEqual(['done', 'high']);
119+
120+
fireEvent.change(inputsUnder('Field')[0], { target: { value: 'stage' } });
121+
expect(sent(seen.draft).patch).toStrictEqual({ priority: 'high', stage: 'done' });
122+
expect(refusals(seen.draft)).toEqual([]);
123+
});
124+
125+
it('removing a held row writes nothing; removing a written one takes it out of `patch`', () => {
126+
const seen = mountHost({ ...NEW_UPDATE_ACTION, type: 'script', patch: { status: 'done' } });
127+
addRow();
128+
expect(inputsUnder('Field')).toHaveLength(2);
129+
130+
const removes = () => screen.getAllByRole('button', { name: 'Remove field value' });
131+
fireEvent.click(removes()[1]);
132+
expect(seen.writes).toStrictEqual([]);
133+
expect(inputsUnder('Field')).toHaveLength(1);
134+
135+
fireEvent.click(removes()[0]);
136+
expect(seen.writes).toStrictEqual([{ patch: {} }]);
137+
expect(inputsUnder('Field')).toHaveLength(0);
138+
});
139+
140+
it('control: another write to `patch` shows the draft\'s rows, not the held ones', () => {
141+
const seen = mountHost({ ...NEW_UPDATE_ACTION, type: 'script' });
142+
addRow();
143+
expect(inputsUnder('Field')).toHaveLength(1);
144+
145+
React.act(() => seen.setDraft({ ...seen.draft, patch: { owner: 'me' } }));
146+
expect(inputsUnder('Field').map((i) => i.value)).toEqual(['owner']);
147+
});
148+
149+
it('control: an action that holds a `type` is written with `patch` alone', () => {
150+
const seen = mountHost({ ...NEW_UPDATE_ACTION, type: 'script', patch: { status: 'done' } });
151+
addRow();
152+
fireEvent.change(inputsUnder('Field')[1], { target: { value: 'stage' } });
153+
expect(seen.writes).toStrictEqual([{ patch: { status: 'done', stage: '' } }]);
154+
});
155+
});

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.scriptBodyLanguage-11921.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ describe('scriptBodyPin — the switch to "Run a script" seeds the language (obj
131131

132132
await pick('What it does', RUN_A_SCRIPT);
133133

134+
// `type` is stored with the switch: the skeleton holds none (objectstack#26734).
134135
expect(seen.writes.at(-1)).toStrictEqual({
136+
type: 'script',
135137
operation: undefined,
136138
patch: undefined,
137139
body: { language: 'expression' },
@@ -293,7 +295,7 @@ describe('scriptBodyPin — controls: the other writes (objectui#11921)', () =>
293295
const seen = mountHost({ ...NEW_ACTION, target: 'stamp_invoice' });
294296

295297
await pick('What it does', RUN_A_SCRIPT);
296-
expect(seen.writes.at(-1)).toStrictEqual({ operation: undefined, patch: undefined });
298+
expect(seen.writes.at(-1)).toStrictEqual({ type: 'script', operation: undefined, patch: undefined });
297299
expect(refusals(seen.draft)).toEqual([]);
298300
});
299301
});
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26734 — a script action authored in Studio is stored with
5+
* `type: 'script'`, and the Type control shows only a `type` the draft holds.
6+
*
7+
* Measured before the fix: New → "Run a script" → a body left the draft as
8+
* `{ name, body }` with no `type`, while the Type control read Script. The
9+
* console reads the stored row, finds no `type`, and runs nothing ("is not
10+
* executable: no handler is registered under that name …"); the same action
11+
* with `type: 'script'` added through the API ran.
12+
*
13+
* The pins drive the real inspector in a host that merges each patch
14+
* shallowly, and parse the sent draft with the spec's `ActionSchema`.
15+
*/
16+
17+
import '@testing-library/jest-dom/vitest';
18+
import * as React from 'react';
19+
import { describe, it, expect, vi, afterEach } from 'vitest';
20+
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
21+
import { ActionSchema } from '@objectstack/spec/ui';
22+
23+
const state = vi.hoisted(() => ({
24+
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]), withPreviewDrafts() { return this; } },
25+
}));
26+
vi.mock('../useMetadata', () => ({ useMetadataClient: () => state.metadataClient }));
27+
28+
import { ActionDefaultInspector } from './ActionDefaultInspector';
29+
30+
afterEach(cleanup);
31+
32+
type Draft = Record<string, unknown>;
33+
34+
function mountHost(initial: Draft) {
35+
const seen: { draft: Draft; writes: Draft[] } = { draft: initial, writes: [] };
36+
function Host() {
37+
const [draft, setDraft] = React.useState<Draft>(initial);
38+
seen.draft = draft;
39+
return (
40+
<ActionDefaultInspector
41+
type="action"
42+
name={String(initial.name)}
43+
locale={'en-US' as never}
44+
readOnly={false}
45+
draft={draft}
46+
onPatch={(patch) => {
47+
seen.writes.push(patch);
48+
setDraft((d) => ({ ...d, ...patch }));
49+
}}
50+
/>
51+
);
52+
}
53+
render(<Host />);
54+
return seen;
55+
}
56+
57+
const sent = (draft: Draft): Draft => JSON.parse(JSON.stringify(draft));
58+
59+
function refusals(draft: Draft): string[] {
60+
const parsed = ActionSchema.safeParse(sent(draft));
61+
return parsed.success ? [] : parsed.error.issues.map((i) => `${i.path.join('.')}:${i.code}`);
62+
}
63+
64+
async function pick(label: string, option: string) {
65+
fireEvent.keyDown(screen.getByRole('combobox', { name: label }), { key: 'ArrowDown' });
66+
await waitFor(() => expect(screen.queryAllByRole('option').length).toBeGreaterThan(0));
67+
fireEvent.click(screen.getByRole('option', { name: option }));
68+
}
69+
70+
const typeControl = () => screen.getByRole('combobox', { name: 'Type' });
71+
const scriptBody = () => screen.getByText('Script body', { selector: 'label' }).parentElement!.querySelector('textarea')!;
72+
73+
const SCRIPT = 'Script — run an expression / sandboxed JS';
74+
const RUN_A_SCRIPT = 'Run a script — sandboxed JS / expression body';
75+
76+
/** The skeleton Studio's New starts from (objectui#11820): `type` left to the spec. */
77+
const NEW_UPDATE_ACTION: Draft = {
78+
name: 'invoice_action_2',
79+
label: 'New action',
80+
objectName: 'invoice',
81+
locations: ['record_header'],
82+
operation: 'update',
83+
patch: {},
84+
};
85+
86+
describe('scriptTypePin — a script authored here is stored with its type (objectstack#26734)', () => {
87+
it('New → "Run a script" → a body: the draft carries `type: \'script\'` and parses', async () => {
88+
const seen = mountHost(NEW_UPDATE_ACTION);
89+
// The draft holds no `type`, so the Type control shows none.
90+
expect(typeControl()).not.toHaveTextContent(SCRIPT);
91+
92+
await pick('What it does', RUN_A_SCRIPT);
93+
expect(sent(seen.draft).type).toBe('script');
94+
expect(typeControl()).toHaveTextContent(SCRIPT);
95+
96+
fireEvent.change(scriptBody(), { target: { value: 'true' } });
97+
const stored = sent(seen.draft);
98+
expect(stored).toMatchObject({ type: 'script', body: { language: 'expression', source: 'true' } });
99+
expect(stored).not.toHaveProperty('operation');
100+
expect(stored).not.toHaveProperty('patch');
101+
expect(refusals(seen.draft)).toEqual([]);
102+
});
103+
104+
it('a stored script with no `type` shows none in Type, and its first body edit stores it', () => {
105+
const seen = mountHost({
106+
name: 'run_script',
107+
label: 'Run',
108+
objectName: 'invoice',
109+
locations: ['record_more'],
110+
body: { language: 'expression', source: 'true' },
111+
});
112+
expect(typeControl()).not.toHaveTextContent(SCRIPT);
113+
// The editor for what it will run stays on screen.
114+
expect(scriptBody()).toHaveValue('true');
115+
116+
fireEvent.change(scriptBody(), { target: { value: 'false' } });
117+
expect(seen.writes).toStrictEqual([{ type: 'script', body: { language: 'expression', source: 'false' } }]);
118+
expect(typeControl()).toHaveTextContent(SCRIPT);
119+
expect(refusals(seen.draft)).toEqual([]);
120+
});
121+
122+
it('the first field value of an update action from New stores `type` too', () => {
123+
const seen = mountHost(NEW_UPDATE_ACTION);
124+
fireEvent.click(screen.getByRole('button', { name: 'Add field value' }));
125+
const field = screen.getByText('Field', { selector: 'label' }).parentElement!.querySelector('input')!;
126+
fireEvent.change(field, { target: { value: 'status' } });
127+
expect(seen.writes).toStrictEqual([{ type: 'script', patch: { status: '' } }]);
128+
expect(refusals(seen.draft)).toEqual([]);
129+
});
130+
131+
it('why the panels still show the script editor: the spec gives a draft with no `type` the script route', () => {
132+
const parsed = ActionSchema.safeParse({ name: 'run_script', label: 'Run', body: { language: 'expression', source: 'true' } });
133+
expect(parsed.success && parsed.data.type).toBe('script');
134+
});
135+
136+
it('control: an action that holds a `type` is written without one', async () => {
137+
const seen = mountHost({
138+
name: 'invoice_sync',
139+
label: 'Sync',
140+
objectName: 'invoice',
141+
locations: ['record_more'],
142+
type: 'script',
143+
body: { language: 'expression', source: 'true' },
144+
});
145+
expect(typeControl()).toHaveTextContent(SCRIPT);
146+
fireEvent.change(scriptBody(), { target: { value: 'false' } });
147+
await pick('Script language', 'Sandboxed JS (L2)');
148+
for (const write of seen.writes) expect(write).not.toHaveProperty('type');
149+
expect(refusals(seen.draft)).toEqual([]);
150+
});
151+
152+
it('control: an api action keeps its type, and Type shows it', () => {
153+
mountHost({ name: 'invoice_api', label: 'Call', type: 'api', target: '/api/v1/sync' });
154+
expect(typeControl()).toHaveTextContent('API — call an endpoint');
155+
});
156+
});

0 commit comments

Comments
 (0)