Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/26735-26734-action-field-values-and-type.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<Draft>(initial);
seen.draft = draft;
seen.setDraft = (d) => setDraft(d);
return (
<ActionDefaultInspector
type="action"
name={String(initial.name)}
locale={'en-US' as never}
readOnly={false}
draft={draft}
onPatch={(patch) => {
seen.writes.push(patch);
setDraft((d) => ({ ...d, ...patch }));
}}
/>
);
}
render(<Host />);
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: '' } }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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([]);
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

function mountHost(initial: Draft) {
const seen: { draft: Draft; writes: Draft[] } = { draft: initial, writes: [] };
function Host() {
const [draft, setDraft] = React.useState<Draft>(initial);
seen.draft = draft;
return (
<ActionDefaultInspector
type="action"
name={String(initial.name)}
locale={'en-US' as never}
readOnly={false}
draft={draft}
onPatch={(patch) => {
seen.writes.push(patch);
setDraft((d) => ({ ...d, ...patch }));
}}
/>
);
}
render(<Host />);
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');
});
});
Loading
Loading