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
14 changes: 14 additions & 0 deletions .changeset/28164-connector-input-value-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): Studio's `connector_action` form treats each `connectorConfig.input` value as a value slot

Clause-②: no
Metadata-protocol: none

The flow expression ledger roots `connector_action` `input.*` in the node's `connectorConfig` sibling block, and `registerFlow` refuses a `{…}` token there. Studio's readers ignored the entry's block, so the Input section offered no CEL-expression editor and showed no refusal: an author met the refusal only at publish.

- Each input value now has the text / CEL-expression toggle that other value slots have, in both the typed fields an action's `inputSchema` produces and the key/value repeater. An expression is stored as a CEL value envelope, `{ dialect: 'cel', source }`, at the same key.
- A `{…}` token typed into an input value shows the spec's refusal under the value as it is typed. The text is the same sentence the publish door gives.
- The Debug run fails a `connector_action` step whose input holds a `{…}` token, with the spec's refusal naming `connectorConfig.input.<key>`, because the executor refuses that input before it dispatches.
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ import { Button, Label, cn } from '@object-ui/components';
import { Code2 } from 'lucide-react';
import {
FLOW_NODE_EXPRESSION_PATHS,
FLOW_NODE_SIBLING_EXPRESSION_BLOCKS,
flowNodeConfigRefusals,
flowNodeValueTemplateRefusals,
isExpressionEnvelopeShaped,
resolveFlowNodeValueSlots,
valueSlotTemplateRefusals,
type FlowNodeExpressionBlock,
} from '@objectstack/spec/automation';
import { FlowKeyValueField, type FlowKeyValueFieldProps } from './FlowKeyValueField.js';
import { FlowExprIssue } from './FlowExprIssue.js';
Expand Down Expand Up @@ -264,16 +266,49 @@ function configOf(node: Record<string, unknown> | null | undefined): Record<stri
return cfg && typeof cfg === 'object' && !Array.isArray(cfg) ? (cfg as Record<string, unknown>) : {};
}

/** Whether `path` on `node` is one `value`-role slot of the spec's ledger (objectui#12372). */
/**
* `node` with `value` written at `path`, every object on the way copied: the
* node the spec's judges are asked about, as it would stand after the edit.
*/
function withValueAt(node: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
const [head, ...rest] = path;
if (head === undefined) return node;
const cur = node[head];
const inner = cur && typeof cur === 'object' && !Array.isArray(cur) ? (cur as Record<string, unknown>) : {};
return { ...node, [head]: rest.length === 0 ? value : withValueAt(inner, rest, value) };
}

/**
* The node block a field at `path` is rooted in, when the ledger can root a
* slot there: `config` (one key deep, a `['config', KEY]` field), or a declared
* sibling block (`FLOW_NODE_SIBLING_EXPRESSION_BLOCKS` — a `connector_action`
* node's `connectorConfig`, whose `input.<key>` fields the connector's own
* input schema types).
*/
function slotBlockOf(path: readonly string[]): FlowNodeExpressionBlock | null {
const [block] = path;
if (block === 'config') return path.length === 2 ? 'config' : null;
return path.length >= 2 && Object.prototype.hasOwnProperty.call(FLOW_NODE_SIBLING_EXPRESSION_BLOCKS, block)
? (block as FlowNodeExpressionBlock)
: null;
}

/**
* Whether `path` on `node` is one `value`-role slot of the spec's ledger
* (objectui#12372), in the block the ledger roots it in.
*/
function isScalarValueSlot(
node: Record<string, unknown> | null | undefined,
path: readonly string[],
): boolean {
const type = node?.type;
if (typeof type !== 'string' || path.length !== 2 || path[0] !== 'config') return false;
const key = path[1];
const probe = { ...configOf(node), [key]: 'probe' };
return resolveFlowNodeValueSlots(type, probe).some((found) => found.path === key && found.entry.eachElement !== true);
const block = slotBlockOf(path);
if (!node || typeof type !== 'string' || block === null) return false;
const slot = path.slice(1).join('.');
const probe = withValueAt(node, path, 'probe');
return resolveFlowNodeValueSlots(type, configOf(probe), probe).some(
(found) => found.block === block && found.path === slot && found.entry.eachElement !== true,
);
}

/**
Expand All @@ -298,10 +333,22 @@ function filterOperandRefusal(value: unknown): string[] | null {
return refusals.length > 0 ? refusals.map((r) => r.message) : null;
}

/** What the spec's config judges refuse at `key` of `config` on a node of `nodeType`. */
function valueSlotRefusals(nodeType: string, config: Record<string, unknown>, key: string): string[] {
const at = (path: string) => path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`);
const messages = [...flowNodeValueTemplateRefusals(nodeType, config), ...flowNodeConfigRefusals(nodeType, config)]
/**
* What the spec's judges refuse at the field `path` of `node` on a node of
* `nodeType`. The value-slot judge is handed the node's sibling blocks, so a
* slot rooted outside `config` (a `connector_action` node's
* `connectorConfig.input`) is judged there; the contract judge reads `config`
* only, so it answers for a `config` field alone.
*/
function valueSlotRefusals(nodeType: string, node: Record<string, unknown>, path: readonly string[]): string[] {
const [block, ...rest] = path;
const key = rest.join('.');
const at = (p: string) => p === key || p.startsWith(`${key}.`) || p.startsWith(`${key}[`);
const config = configOf(node);
const messages = [
...flowNodeValueTemplateRefusals(nodeType, config, node).filter((refusal) => refusal.block === block),
...(block === 'config' ? flowNodeConfigRefusals(nodeType, config) : []),
]
.filter((refusal) => at(refusal.path))
.map((refusal) => refusal.message);
return [...new Set(messages)];
Expand Down Expand Up @@ -599,7 +646,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
isScalarValueSlot(context?.node, field.path);
const valueSlotIssues =
valueSlot && typeof context?.node?.type === 'string' && !isUnsetFieldValue(value)
? valueSlotRefusals(context.node.type, { ...configOf(context.node), [field.path[1]]: value }, field.path[1])
? valueSlotRefusals(context.node.type, withValueAt(context.node, field.path, value), field.path)
: [];
// objectui#6226 — the row-based condition builder, on the fields that opted in
// AND at a site that declares the vocabulary. All three conjuncts are load
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* FlowNodeInspector — a `connector_action` node's input values are value slots
* (objectstack#28164).
*
* The spec's expression ledger roots `connector_action` `input.*` in the node's
* `connectorConfig` sibling block, and `registerFlow` refuses a `{…}` token
* there. The form gives each input value what every other value slot has: the
* text / CEL-expression toggle, and the spec's refusal beside the value as it
* is typed, read from the same judge the publish door calls. Driven through the
* real render path, for both editors the Input section uses: a typed field
* (the action declares an `inputSchema`) and the key/value repeater (it
* declares none).
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react';
import { flowNodeValueTemplateRefusals } from '@objectstack/spec/automation';

vi.mock('../previews/useFlowNodePalette', async (orig) => ({
...(await orig<Record<string, unknown>>()),
useActionConfigSchemas: () => ({}),
useFlowNodePalette: () => [],
}));
vi.mock('../previews/useObjectFields', () => ({
useObjectFields: () => ({ fields: [], loading: false, error: null }),
}));

import { FlowNodeInspector } from './FlowNodeInspector';
import type { MetadataSelection } from '../preview-registry';

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

const TOGGLE = 'Write as a CEL expression';

const SLACK_POST_MESSAGE = {
type: 'object',
required: ['channel'],
properties: {
channel: { type: 'string', description: 'Channel id, user id, or #name' },
text: { type: 'string', description: 'Message text' },
},
};

function mockRegistry(actions: unknown[]) {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(
JSON.stringify({
success: true,
data: { connectors: [{ name: 'slack', label: 'Slack', type: 'saas', origin: 'plugin', state: 'ready', actions }] },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
),
);
}

function connectorNode(input: Record<string, unknown>, actionId = 'chat.postMessage') {
return { id: 'post', type: 'connector_action', label: 'Post to Slack', connectorConfig: { connectorId: 'slack', actionId, input } };
}

const SELECTION: MetadataSelection = { kind: 'node', id: 'post' };

/** The inspector over a draft that takes each patch, as the designer host does. */
function Host({ initial, onPatch }: { initial: Record<string, unknown>; onPatch: (patch: Record<string, unknown>) => void }) {
const [draft, setDraft] = React.useState(initial);
return (
<FlowNodeInspector
type="flow"
name="notify_flow"
selection={SELECTION}
draft={draft}
onPatch={(patch: Record<string, unknown>) => {
onPatch(patch);
setDraft((d) => ({ ...d, ...patch }));
}}
onClearSelection={vi.fn()}
locale="en-US"
readOnly={false}
/>
);
}

function renderInspector(input: Record<string, unknown>, actionId?: string) {
const onPatch = vi.fn();
const initial = { nodes: [{ id: 'start', type: 'start' }, connectorNode(input, actionId)], edges: [{ source: 'start', target: 'post' }] };
render(<Host initial={initial} onPatch={onPatch} />);
return { onPatch };
}

/** The spec's refusal of `input` on the node, at `connectorConfig.input.KEY`. */
function specRefusal(input: Record<string, unknown>, key: string): string {
const node = connectorNode(input);
const found = flowNodeValueTemplateRefusals('connector_action', {}, node).find(
(r) => r.block === 'connectorConfig' && r.path === `input.${key}`,
);
expect(found, `the spec refuses connectorConfig.input.${key}`).toBeDefined();
return found!.message;
}

/** The control group a typed field's label heads. */
function typedField(labelText: string): HTMLElement {
const group = screen.getByText(labelText, { selector: 'label' }).parentElement;
if (!group) throw new Error(`no group under the "${labelText}" label`);
return group;
}

function lastInput(onPatch: ReturnType<typeof vi.fn>): unknown {
expect(onPatch).toHaveBeenCalled();
const patch = onPatch.mock.calls.at(-1)![0] as { nodes?: Array<Record<string, unknown>> };
const node = (patch.nodes ?? []).find((n) => n.id === 'post')!;
return (node.connectorConfig as Record<string, unknown>).input;
}

describe('#28164 — a typed connector input is a value slot', () => {
it('shows the spec’s refusal in the form when a `{…}` token is typed into the value', async () => {
mockRegistry([{ key: 'chat.postMessage', label: 'Post Message', inputSchema: SLACK_POST_MESSAGE }]);
const { onPatch } = renderInspector({ channel: 'C123' });

await screen.findByText('Text', { selector: 'label' });
const field = typedField('Text');
expect(within(field).queryByRole('alert')).toBeNull();
fireEvent.change(within(field).getByRole('textbox'), { target: { value: 'Hello {record.name}' } });

expect(lastInput(onPatch)).toEqual({ channel: 'C123', text: 'Hello {record.name}' });
const alert = await within(typedField('Text')).findByRole('alert');
expect(alert.textContent).toContain(specRefusal({ channel: 'C123', text: 'Hello {record.name}' }, 'text'));
});

it('shows the refusal for a stored token, and none for a plain literal', async () => {
mockRegistry([{ key: 'chat.postMessage', label: 'Post Message', inputSchema: SLACK_POST_MESSAGE }]);
const stored = { channel: 'C123', text: 'Hi {name}' };
renderInspector(stored);

await screen.findByText('Text', { selector: 'label' });
expect(within(typedField('Text')).getByRole('alert').textContent).toContain(specRefusal(stored, 'text'));
expect(within(typedField('Channel')).queryByRole('alert')).toBeNull();
});

it('offers the expression toggle, which stores a CEL value envelope at the same key', async () => {
mockRegistry([{ key: 'chat.postMessage', label: 'Post Message', inputSchema: SLACK_POST_MESSAGE }]);
const { onPatch } = renderInspector({ channel: 'C123' });

await screen.findByText('Text', { selector: 'label' });
const field = typedField('Text');
fireEvent.change(within(field).getByRole('textbox'), { target: { value: "'Hello ' + record.name" } });
fireEvent.click(within(field).getByRole('button', { name: TOGGLE }));

expect(lastInput(onPatch)).toEqual({ channel: 'C123', text: { dialect: 'cel', source: "'Hello ' + record.name" } });
expect(within(typedField('Text')).queryByRole('alert')).toBeNull();
});
});

describe('#28164 — the Input repeater’s values are value slots', () => {
it('shows the spec’s refusal under a repeater value holding a `{…}` token, and offers the toggle', async () => {
mockRegistry([{ key: 'plain', label: 'Plain' }]);
const stored = { channel: 'C123', note: 'Hi {name}' };
renderInspector(stored, 'plain');

await screen.findByDisplayValue('note');
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain(specRefusal(stored, 'note'));
expect(screen.getAllByRole('button', { name: TOGGLE })).toHaveLength(2);
});

it('a token typed into a repeater value is refused in place', async () => {
mockRegistry([{ key: 'plain', label: 'Plain' }]);
renderInspector({ channel: 'C123' }, 'plain');

const cell = await screen.findByDisplayValue('C123');
expect(screen.queryByRole('alert')).toBeNull();
fireEvent.change(cell, { target: { value: '{record.channel}' } });
fireEvent.blur(cell);

await waitFor(() =>
expect(screen.getByRole('alert').textContent).toContain(specRefusal({ channel: '{record.channel}' }, 'channel')),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ASSIGNMENT_VALUE_ENVELOPE_REFUSAL,
CreateRecordConfigSchema,
FLOW_NODE_EXPRESSION_PATHS,
FlowValueSlotSchema,
ScreenConfigSchema,
ScriptConfigSchema,
SubflowConfigSchema,
Expand All @@ -38,7 +39,17 @@ describe('isValueEnvelopeSlot — read off the spec expression ledger (objectui#
expect(isValueEnvelopeSlot(undefined, ['config', 'assignments'])).toBe(false);
});

it('offers the envelope on exactly the six hand-table key/value maps the ledger names', () => {
it('reads the block an entry is rooted in: a connector_action node’s input map is `connectorConfig.input`', () => {
// The ledger roots `connector_action` `input.*` in the node's
// `connectorConfig` sibling block. The same key under `config` is not the
// map the executor reads (the flow parse refuses it as a loose key), so it
// is not offered there.
expect(isValueEnvelopeSlot('connector_action', ['connectorConfig', 'input'])).toBe(true);
expect(isValueEnvelopeSlot('connector_action', ['config', 'input'])).toBe(false);
expect(isValueEnvelopeSlot('subflow', ['connectorConfig', 'input'])).toBe(false);
});

it('offers the envelope on exactly the seven hand-table key/value maps the ledger names', () => {
// The key/value editor serves many maps (params, headers, filters, inputs)
// where an object naming a `dialect` is plain data. Every keyValue field in
// the designer table is enumerated here. If the ledger declares another
Expand All @@ -51,6 +62,11 @@ describe('isValueEnvelopeSlot — read off the spec expression ledger (objectui#
// again at 18.0.0-next.0 (ADR-0032 §3), which declares `screen`
// `defaults.*`, `script` `inputs.*` and `subflow` `input.*` as well; the
// next case measures the same confirmation for those three (objectui#12030).
// It grew to seven when `isValueEnvelopeSlot` started reading an entry's
// block, so the `connector_action` input map the ledger roots in
// `connectorConfig` is offered at the path the designer table writes; the
// next case measures its confirmation against the judge `registerFlow`
// applies there.
const keyValueFields = FLOW_NODE_CONFIG_TYPES.flatMap((type) =>
fieldsForNodeType(type)
.filter((f) => f.kind === 'keyValue')
Expand All @@ -62,18 +78,18 @@ describe('isValueEnvelopeSlot — read off the spec expression ledger (objectui#
.map(({ type, path }) => `${type}:${path.join('.')}`);
expect(offered.sort()).toEqual([
'assignment:config.assignments',
'connector_action:connectorConfig.input',
'create_record:config.fields',
'screen:config.defaults',
'script:config.inputs',
'subflow:config.input',
'update_record:config.fields',
]);
// The ledger's whole `value`-role list at 18.0.0-next.0. The six `KEY.*`
// maps above are the ones a key/value cell serves; the rest are single
// values (a filter, a collection, a recipient list, a record id) or a
// nested path, which this editor does not render per entry. The list grew
// at #27968 / #28150 with the one entry rooted in `connectorConfig`
// (`connector_action:input.*`), which `isValueEnvelopeSlot` does not offer.
// The ledger's whole `value`-role list. The seven `KEY.*` maps above are
// the ones a key/value cell serves; the rest are single values (a filter,
// a collection, a recipient list, a record id) or a nested path, which this
// editor does not render per entry. The list grew at #27968 / #28150 with
// the one entry rooted in `connectorConfig` (`connector_action:input.*`).
expect(
FLOW_NODE_EXPRESSION_PATHS.filter((e) => e.role === 'value').map((e) => `${e.nodeType}:${e.path}`).sort(),
).toEqual([
Expand Down Expand Up @@ -110,6 +126,10 @@ describe('isValueEnvelopeSlot — read off the spec expression ledger (objectui#
'screen:defaults': (v: unknown) => ScreenConfigSchema.safeParse({ defaults: { name: v } }),
'script:inputs': (v: unknown) => ScriptConfigSchema.safeParse({ function: 'my_fn', inputs: { name: v } }),
'subflow:input': (v: unknown) => SubflowConfigSchema.safeParse({ flowName: 'child_flow', input: { name: v } }),
// The input map's block schema does not judge its values: `registerFlow`
// judges each envelope the ledger resolves there with the value-slot
// schema, and the `{…}` token with `flowNodeValueTemplateRefusals`.
'connector_action:connectorConfig.input': (v: unknown) => FlowValueSlotSchema.safeParse(v),
} as const;
const MALFORMED = [{ dialect: 'cel' }, { dialect: 'js', source: 'a' }];
for (const [slot, parse] of Object.entries(slots)) {
Expand Down
Loading
Loading