From 8c8d96773cef7b829e23f44216b62627c9520c94 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:03:47 +0000 Subject: [PATCH 1/5] fix(app-shell): Studio's action inspector keeps a new field-value row and stores a script action's type Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QsR2jVG55iUTTWRd5tR6Di --- .../inspectors/ActionDefaultInspector.tsx | 79 +++++++++++++++---- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx index 88fe5441ea9..4f9e24b1fa5 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx @@ -28,6 +28,11 @@ * `aiDescription` keys, which the spec refuses by name, so one tick refused * every later save of the object (objectstack#26728). The two flat keys are * cleared by the AI control's next write, and never read. + * + * The Type control shows the `type` the draft holds, and every write that + * authors a script writes `type: 'script'` with it (objectstack#26734). The + * field-value editor holds a row that names no field yet, and writes it to + * `patch` once it does (objectstack#26735). */ import * as React from 'react'; @@ -95,6 +100,16 @@ const ACTION_TYPES: KeyedOption[] = [ { value: 'url', labelKey: 'engine.inspector.action.type.url' }, ]; +/** + * objectstack#26734 — the route `@objectstack/spec` gives an action that names + * no `type` (`ActionSchema.type` defaults to it). The Behavior panels follow it, + * so a draft without `type` still shows the editor for what it will run. It is + * never shown in the Type control as if stored: the console reads the stored + * row, not its parse, and runs nothing for an action without `type`. The first + * script write stores it instead. + */ +const DEFAULT_ACTION_TYPE = 'script'; + const VARIANT_OPTS: KeyedOption[] = [ { value: 'primary', labelKey: 'engine.inspector.action.variant.primary' }, { value: 'secondary', labelKey: 'engine.inspector.action.variant.secondary' }, @@ -402,6 +417,19 @@ const BODY_SHAPE_OPTS: KeyedOption[] = [ ]; /** What a script action DOES — `operation` is authored beside `type`, not in it. */ +/** One field-value row of an update action: the field, and the value written to it. */ +type PatchRow = [string, unknown]; + +/** The rows `patch` stores: those that name a field, under the trimmed name. */ +function namedPatchRows(rows: ReadonlyArray): PatchRow[] { + return rows.filter(([key]) => key.trim() !== '').map(([key, value]) => [key.trim(), value]); +} + +function samePatchRows(a: ReadonlyArray, b: ReadonlyArray): boolean { + return a.length === b.length + && a.every(([key, value], i) => key === b[i][0] && JSON.stringify(value) === JSON.stringify(b[i][1])); +} + const SCRIPT_OPERATIONS: KeyedOption[] = [ { value: '', labelKey: 'engine.inspector.action.scriptOp.run' }, { value: 'update', labelKey: 'engine.inspector.action.scriptOp.update' }, @@ -626,7 +654,13 @@ export function ActionDefaultInspector({ const iconFieldId = React.useId(); const str = (k: string): string => (typeof draft[k] === 'string' ? (draft[k] as string) : ''); - const type = str('type') || 'script'; + // objectstack#26734 — `storedType` is what the Type control shows ("—" when + // the draft holds none); `type` is the route the panels below follow. + const storedType = str('type'); + const type = storedType || DEFAULT_ACTION_TYPE; + /** A script write, with the `type` a draft that holds none is stored with. */ + const withScriptType = (change: Record): Record => + storedType === '' ? { type: 'script', ...change } : change; const objectName = str('objectName'); const body = (draft.body && typeof draft.body === 'object' ? draft.body : {}) as Record; const params: ActionParam[] = Array.isArray(draft.params) ? (draft.params as ActionParam[]) : []; @@ -674,15 +708,30 @@ export function ActionDefaultInspector({ onPatch(cleared); return; } - const out: Record = { operation: undefined, patch: undefined }; + const out: Record = withScriptType({ operation: undefined, patch: undefined }); if (!str('target')) out.body = writeScriptBody(body, {}); onPatch(out); }; - const commitPatch = (rows: Array<[string, unknown]>) => { - const next: Record = {}; - for (const [key, value] of rows) if (key.trim() !== '') next[key.trim()] = value; - onPatch({ patch: next }); + /* ─── objectstack#26735 — a field-value row waits here for its field ────── + * + * `patch` is a record, so a row that names no field has no key to be stored + * under. "Add field value" used to commit `['', '']`, the write dropped the + * blank key, and no row ever appeared. The rows on screen are now this list: + * a row is written to `patch` once it names a field, and one that names none + * (just added, or its field cleared) is held here, so a blank row never + * reaches the draft. Stamped with the action, like the Wrap choice below, and + * read only while its named rows are exactly the draft's `patch`: any other + * write to `patch` shows the draft's rows instead. */ + const [heldRows, setHeldRows] = React.useState<{ action: string; rows: PatchRow[] } | null>(null); + const fieldValueRows: PatchRow[] = + heldRows && heldRows.action === str('name') && samePatchRows(namedPatchRows(heldRows.rows), patchRows) + ? heldRows.rows + : patchRows; + const commitFieldValueRows = (rows: PatchRow[]) => { + setHeldRows({ action: str('name'), rows }); + const named = namedPatchRows(rows); + if (!samePatchRows(named, patchRows)) onPatch(withScriptType({ patch: Object.fromEntries(named) })); }; /* ─── objectui#7234 — the OTHER way a correctly-placed action never appears ── @@ -818,7 +867,7 @@ export function ActionDefaultInspector({ }, [blockingIssues]); // objectui#11921 — every body write goes through the one writer. - const patchBody = (p: Record) => onPatch({ body: writeScriptBody(body, p) }); + const patchBody = (p: Record) => onPatch(withScriptType({ body: writeScriptBody(body, p) })); const patchParam = (i: number, p: Partial) => onPatch({ params: params.map((it, j) => (j === i ? { ...it, ...p } : it)) }); @@ -869,7 +918,7 @@ export function ActionDefaultInspector({ {/* 2 ─ Behavior (type-first) */}
- + {isUpdateOperation && (
{withSlots(tr('engine.inspector.action.pinnedScript'), { script: script })} @@ -888,18 +937,20 @@ export function ActionDefaultInspector({ {isUpdateOperation ? (
- {patchRows.length === 0 ? ( + {fieldValueRows.length === 0 ? (

{tr('engine.inspector.action.fieldValuesEmpty')}

) : ( - patchRows.map(([key, value], i) => ( + // Keyed by position, so a held row that gains its field keeps + // its inputs (and the focus) as it is written. + fieldValueRows.map(([key, value], i) => (
commitPatch(patchRows.map((row, j) => (j === i ? [v, row[1]] : row)))} + onCommit={(v) => commitFieldValueRows(fieldValueRows.map((row, j) => (j === i ? [v, row[1]] : row)))} placeholder="status" disabled={readOnly} mono @@ -909,7 +960,7 @@ export function ActionDefaultInspector({ commitPatch(patchRows.map((row, j) => (j === i ? [row[0], v] : row)))} + onCommit={(v) => commitFieldValueRows(fieldValueRows.map((row, j) => (j === i ? [row[0], v] : row)))} placeholder="done" disabled={readOnly} mono @@ -922,7 +973,7 @@ export function ActionDefaultInspector({ className="h-8 w-8 shrink-0" disabled={readOnly} aria-label={tr('engine.inspector.action.removeFieldValue')} - onClick={() => commitPatch(patchRows.filter((_, j) => j !== i))} + onClick={() => commitFieldValueRows(fieldValueRows.filter((_, j) => j !== i))} > @@ -935,7 +986,7 @@ export function ActionDefaultInspector({ size="sm" className="h-7 text-xs" disabled={readOnly} - onClick={() => commitPatch([...patchRows, ['', '']])} + onClick={() => commitFieldValueRows([...fieldValueRows, ['', '']])} > {tr('engine.inspector.action.addFieldValue')} From 70cf28c7913e2891add741f32ffc3817836a67da Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:04:52 +0000 Subject: [PATCH 2/5] test(app-shell): pin the held field-value row and the stored script type Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QsR2jVG55iUTTWRd5tR6Di --- ...ultInspector.fieldValueRows-26735.test.tsx | 155 +++++++++++++++++ ...DefaultInspector.scriptType-26734.test.tsx | 156 ++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.fieldValueRows-26735.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.scriptType-26734.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.fieldValueRows-26735.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.fieldValueRows-26735.test.tsx new file mode 100644 index 00000000000..1230be909f3 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.fieldValueRows-26735.test.tsx @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26735 — "Add field value" adds a row the author can fill, and + * the row reaches the update action's `patch` once it names a field. + * + * Measured before the fix: the button committed `['', '']`, the write dropped + * the blank key, so no row appeared and `patch` stayed `{}` — an update action + * made in Studio could never be given a field to set. + * + * The pins drive the real inspector in a host that merges each patch + * shallowly, the way the action hosts do, and parse the sent draft with the + * spec's `ActionSchema`. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { ActionSchema } from '@objectstack/spec/ui'; + +const state = vi.hoisted(() => ({ + metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]), withPreviewDrafts() { return this; } }, +})); +vi.mock('../useMetadata', () => ({ useMetadataClient: () => state.metadataClient })); + +import { ActionDefaultInspector } from './ActionDefaultInspector'; + +afterEach(cleanup); + +type Draft = Record; + +function mountHost(initial: Draft) { + const seen: { draft: Draft; writes: Draft[]; setDraft: (d: Draft) => void } = { + draft: initial, + writes: [], + setDraft: () => {}, + }; + function Host() { + const [draft, setDraft] = React.useState(initial); + seen.draft = draft; + seen.setDraft = (d) => setDraft(d); + return ( + { + seen.writes.push(patch); + setDraft((d) => ({ ...d, ...patch })); + }} + /> + ); + } + render(); + return seen; +} + +const sent = (draft: Draft): Draft => JSON.parse(JSON.stringify(draft)); + +function refusals(draft: Draft): string[] { + const parsed = ActionSchema.safeParse(sent(draft)); + return parsed.success ? [] : parsed.error.issues.map((i) => `${i.path.join('.')}:${i.code}`); +} + +/** The text inputs under every visible label `label`, in row order. */ +function inputsUnder(label: string): HTMLInputElement[] { + return screen + .queryAllByText(label, { selector: 'label' }) + .map((lab) => lab.parentElement!.querySelector('input') as HTMLInputElement); +} + +const addRow = () => fireEvent.click(screen.getByRole('button', { name: 'Add field value' })); + +/** The skeleton Studio's New starts from (objectui#11820): `type` left to the spec. */ +const NEW_UPDATE_ACTION: Draft = { + name: 'invoice_action_2', + label: 'New action', + objectName: 'invoice', + locations: ['record_header'], + operation: 'update', + patch: {}, +}; + +describe('fieldValueRowsPin — a new field-value row is held until it names a field (objectstack#26735)', () => { + it('add row → field → value: the row shows at once, and `patch` holds it once it names a field', () => { + const seen = mountHost(NEW_UPDATE_ACTION); + expect(inputsUnder('Field')).toHaveLength(0); + + addRow(); + // The row is on screen, and nothing was written: a blank row never + // reaches the draft. + expect(inputsUnder('Field')).toHaveLength(1); + expect(seen.writes).toStrictEqual([]); + expect(sent(seen.draft).patch).toStrictEqual({}); + + const field = inputsUnder('Field')[0]; + fireEvent.change(field, { target: { value: 'status' } }); + expect(sent(seen.draft).patch).toStrictEqual({ status: '' }); + // The row was written in place: the same input, so the focus stays. + expect(inputsUnder('Field')[0]).toBe(field); + expect(field).toHaveValue('status'); + + fireEvent.change(inputsUnder('Value')[0], { target: { value: 'done' } }); + expect(sent(seen.draft).patch).toStrictEqual({ status: 'done' }); + expect(refusals(seen.draft)).toEqual([]); + }); + + it('a row whose field is cleared stays on screen, held, and leaves `patch`', () => { + const seen = mountHost({ ...NEW_UPDATE_ACTION, type: 'script', patch: { status: 'done', priority: 'high' } }); + expect(inputsUnder('Field').map((i) => i.value)).toEqual(['status', 'priority']); + + fireEvent.change(inputsUnder('Field')[0], { target: { value: '' } }); + expect(sent(seen.draft).patch).toStrictEqual({ priority: 'high' }); + expect(inputsUnder('Field').map((i) => i.value)).toEqual(['', 'priority']); + expect(inputsUnder('Value').map((i) => i.value)).toEqual(['done', 'high']); + + fireEvent.change(inputsUnder('Field')[0], { target: { value: 'stage' } }); + expect(sent(seen.draft).patch).toStrictEqual({ priority: 'high', stage: 'done' }); + expect(refusals(seen.draft)).toEqual([]); + }); + + it('removing a held row writes nothing; removing a written one takes it out of `patch`', () => { + const seen = mountHost({ ...NEW_UPDATE_ACTION, type: 'script', patch: { status: 'done' } }); + addRow(); + expect(inputsUnder('Field')).toHaveLength(2); + + const removes = () => screen.getAllByRole('button', { name: 'Remove field value' }); + fireEvent.click(removes()[1]); + expect(seen.writes).toStrictEqual([]); + expect(inputsUnder('Field')).toHaveLength(1); + + fireEvent.click(removes()[0]); + expect(seen.writes).toStrictEqual([{ patch: {} }]); + expect(inputsUnder('Field')).toHaveLength(0); + }); + + it('control: another write to `patch` shows the draft\'s rows, not the held ones', () => { + const seen = mountHost({ ...NEW_UPDATE_ACTION, type: 'script' }); + addRow(); + expect(inputsUnder('Field')).toHaveLength(1); + + React.act(() => seen.setDraft({ ...seen.draft, patch: { owner: 'me' } })); + expect(inputsUnder('Field').map((i) => i.value)).toEqual(['owner']); + }); + + it('control: an action that holds a `type` is written with `patch` alone', () => { + const seen = mountHost({ ...NEW_UPDATE_ACTION, type: 'script', patch: { status: 'done' } }); + addRow(); + fireEvent.change(inputsUnder('Field')[1], { target: { value: 'stage' } }); + expect(seen.writes).toStrictEqual([{ patch: { status: 'done', stage: '' } }]); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.scriptType-26734.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.scriptType-26734.test.tsx new file mode 100644 index 00000000000..98a6118300c --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.scriptType-26734.test.tsx @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26734 — a script action authored in Studio is stored with + * `type: 'script'`, and the Type control shows only a `type` the draft holds. + * + * Measured before the fix: New → "Run a script" → a body left the draft as + * `{ name, body }` with no `type`, while the Type control read Script. The + * console reads the stored row, finds no `type`, and runs nothing ("is not + * executable: no handler is registered under that name …"); the same action + * with `type: 'script'` added through the API ran. + * + * The pins drive the real inspector in a host that merges each patch + * shallowly, and parse the sent draft with the spec's `ActionSchema`. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { ActionSchema } from '@objectstack/spec/ui'; + +const state = vi.hoisted(() => ({ + metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]), withPreviewDrafts() { return this; } }, +})); +vi.mock('../useMetadata', () => ({ useMetadataClient: () => state.metadataClient })); + +import { ActionDefaultInspector } from './ActionDefaultInspector'; + +afterEach(cleanup); + +type Draft = Record; + +function mountHost(initial: Draft) { + const seen: { draft: Draft; writes: Draft[] } = { draft: initial, writes: [] }; + function Host() { + const [draft, setDraft] = React.useState(initial); + seen.draft = draft; + return ( + { + seen.writes.push(patch); + setDraft((d) => ({ ...d, ...patch })); + }} + /> + ); + } + render(); + return seen; +} + +const sent = (draft: Draft): Draft => JSON.parse(JSON.stringify(draft)); + +function refusals(draft: Draft): string[] { + const parsed = ActionSchema.safeParse(sent(draft)); + return parsed.success ? [] : parsed.error.issues.map((i) => `${i.path.join('.')}:${i.code}`); +} + +async function pick(label: string, option: string) { + fireEvent.keyDown(screen.getByRole('combobox', { name: label }), { key: 'ArrowDown' }); + await waitFor(() => expect(screen.queryAllByRole('option').length).toBeGreaterThan(0)); + fireEvent.click(screen.getByRole('option', { name: option })); +} + +const typeControl = () => screen.getByRole('combobox', { name: 'Type' }); +const scriptBody = () => screen.getByText('Script body', { selector: 'label' }).parentElement!.querySelector('textarea')!; + +const SCRIPT = 'Script — run an expression / sandboxed JS'; +const RUN_A_SCRIPT = 'Run a script — sandboxed JS / expression body'; + +/** The skeleton Studio's New starts from (objectui#11820): `type` left to the spec. */ +const NEW_UPDATE_ACTION: Draft = { + name: 'invoice_action_2', + label: 'New action', + objectName: 'invoice', + locations: ['record_header'], + operation: 'update', + patch: {}, +}; + +describe('scriptTypePin — a script authored here is stored with its type (objectstack#26734)', () => { + it('New → "Run a script" → a body: the draft carries `type: \'script\'` and parses', async () => { + const seen = mountHost(NEW_UPDATE_ACTION); + // The draft holds no `type`, so the Type control shows none. + expect(typeControl()).not.toHaveTextContent(SCRIPT); + + await pick('What it does', RUN_A_SCRIPT); + expect(sent(seen.draft).type).toBe('script'); + expect(typeControl()).toHaveTextContent(SCRIPT); + + fireEvent.change(scriptBody(), { target: { value: 'true' } }); + const stored = sent(seen.draft); + expect(stored).toMatchObject({ type: 'script', body: { language: 'expression', source: 'true' } }); + expect(stored).not.toHaveProperty('operation'); + expect(stored).not.toHaveProperty('patch'); + expect(refusals(seen.draft)).toEqual([]); + }); + + it('a stored script with no `type` shows none in Type, and its first body edit stores it', () => { + const seen = mountHost({ + name: 'run_script', + label: 'Run', + objectName: 'invoice', + locations: ['record_more'], + body: { language: 'expression', source: 'true' }, + }); + expect(typeControl()).not.toHaveTextContent(SCRIPT); + // The editor for what it will run stays on screen. + expect(scriptBody()).toHaveValue('true'); + + fireEvent.change(scriptBody(), { target: { value: 'false' } }); + expect(seen.writes).toStrictEqual([{ type: 'script', body: { language: 'expression', source: 'false' } }]); + expect(typeControl()).toHaveTextContent(SCRIPT); + expect(refusals(seen.draft)).toEqual([]); + }); + + it('the first field value of an update action from New stores `type` too', () => { + const seen = mountHost(NEW_UPDATE_ACTION); + fireEvent.click(screen.getByRole('button', { name: 'Add field value' })); + const field = screen.getByText('Field', { selector: 'label' }).parentElement!.querySelector('input')!; + fireEvent.change(field, { target: { value: 'status' } }); + expect(seen.writes).toStrictEqual([{ type: 'script', patch: { status: '' } }]); + expect(refusals(seen.draft)).toEqual([]); + }); + + it('why the panels still show the script editor: the spec gives a draft with no `type` the script route', () => { + const parsed = ActionSchema.safeParse({ name: 'run_script', label: 'Run', body: { language: 'expression', source: 'true' } }); + expect(parsed.success && parsed.data.type).toBe('script'); + }); + + it('control: an action that holds a `type` is written without one', async () => { + const seen = mountHost({ + name: 'invoice_sync', + label: 'Sync', + objectName: 'invoice', + locations: ['record_more'], + type: 'script', + body: { language: 'expression', source: 'true' }, + }); + expect(typeControl()).toHaveTextContent(SCRIPT); + fireEvent.change(scriptBody(), { target: { value: 'false' } }); + await pick('Script language', 'Sandboxed JS (L2)'); + for (const write of seen.writes) expect(write).not.toHaveProperty('type'); + expect(refusals(seen.draft)).toEqual([]); + }); + + it('control: an api action keeps its type, and Type shows it', () => { + mountHost({ name: 'invoice_api', label: 'Call', type: 'api', target: '/api/v1/sync' }); + expect(typeControl()).toHaveTextContent('API — call an endpoint'); + }); +}); From f206488d2429bf61949114c547c0e8433e41ded6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:05:15 +0000 Subject: [PATCH 3/5] chore(changeset): app-shell patch for the action editor's field values and script type Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QsR2jVG55iUTTWRd5tR6Di --- .../26735-26734-action-field-values-and-type.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/26735-26734-action-field-values-and-type.md diff --git a/.changeset/26735-26734-action-field-values-and-type.md b/.changeset/26735-26734-action-field-values-and-type.md new file mode 100644 index 00000000000..0e6c8804072 --- /dev/null +++ b/.changeset/26735-26734-action-field-values-and-type.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): Studio's action editor keeps a new field-value row, and stores a script action's type + +Clause-②: no +Metadata-protocol: none + +Two gaps in Studio's action editor (Data › an object › Actions) left a published action that does nothing: + +- **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. +- **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. From a9ea11053b5dd2b4dd63d7d5671d4fe0de37fb1f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:16:12 +0000 Subject: [PATCH 4/5] test(app-shell): the switch to Run a script now stores type on a skeleton that holds none Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QsR2jVG55iUTTWRd5tR6Di --- .../ActionDefaultInspector.scriptBodyLanguage-11921.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.scriptBodyLanguage-11921.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.scriptBodyLanguage-11921.test.tsx index 2a46ef8562b..db340c0f3d4 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.scriptBodyLanguage-11921.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.scriptBodyLanguage-11921.test.tsx @@ -131,7 +131,9 @@ describe('scriptBodyPin — the switch to "Run a script" seeds the language (obj await pick('What it does', RUN_A_SCRIPT); + // `type` is stored with the switch: the skeleton holds none (objectstack#26734). expect(seen.writes.at(-1)).toStrictEqual({ + type: 'script', operation: undefined, patch: undefined, body: { language: 'expression' }, @@ -293,7 +295,7 @@ describe('scriptBodyPin — controls: the other writes (objectui#11921)', () => const seen = mountHost({ ...NEW_ACTION, target: 'stamp_invoice' }); await pick('What it does', RUN_A_SCRIPT); - expect(seen.writes.at(-1)).toStrictEqual({ operation: undefined, patch: undefined }); + expect(seen.writes.at(-1)).toStrictEqual({ type: 'script', operation: undefined, patch: undefined }); expect(refusals(seen.draft)).toEqual([]); }); }); From 5c79e064404a25c16e63e2de8a83db0152a357b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:43:39 +0000 Subject: [PATCH 5/5] refactor(app-shell): keep the operation doc comment on SCRIPT_OPERATIONS Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QsR2jVG55iUTTWRd5tR6Di --- .../views/metadata-admin/inspectors/ActionDefaultInspector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx index 4f9e24b1fa5..5b370e89caf 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx @@ -416,7 +416,6 @@ const BODY_SHAPE_OPTS: KeyedOption[] = [ { value: 'wrap', labelKey: 'engine.inspector.action.bodyShape.wrap' }, ]; -/** What a script action DOES — `operation` is authored beside `type`, not in it. */ /** One field-value row of an update action: the field, and the value written to it. */ type PatchRow = [string, unknown]; @@ -430,6 +429,7 @@ function samePatchRows(a: ReadonlyArray, b: ReadonlyArray): && a.every(([key, value], i) => key === b[i][0] && JSON.stringify(value) === JSON.stringify(b[i][1])); } +/** What a script action DOES — `operation` is authored beside `type`, not in it. */ const SCRIPT_OPERATIONS: KeyedOption[] = [ { value: '', labelKey: 'engine.inspector.action.scriptOp.run' }, { value: 'update', labelKey: 'engine.inspector.action.scriptOp.update' },