feat: author composite step inputs

This commit is contained in:
lda
2026-08-14 01:46:13 +07:00 Verified
parent 18c9ef12ee
commit c062680c6c
9 changed files with 860 additions and 41 deletions
+93
View File
@@ -990,6 +990,86 @@ tbody tr:hover {
font-size: 0.82rem;
}
/* Composite expressions stay visually lighter than the row that owns them;
the left rule makes nesting legible without stacking full cards. */
.input-expression-control {
display: grid;
gap: 0.55rem;
min-width: 0;
}
.input-expression-control__mode {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.7fr);
align-items: center;
gap: 0.55rem;
margin: 0;
color: var(--color-slate);
font-size: 0.78rem;
}
.input-expression-control__construct,
.input-expression-control__item,
.input-expression-control__field {
min-width: 0;
margin: 0;
border: 0;
}
.input-expression-control__construct {
display: grid;
gap: 0.55rem;
padding: 0.6rem 0.7rem;
border-left: 2px solid var(--color-signal-green);
background: rgba(255, 255, 255, 0.48);
}
.input-expression-control__construct > legend,
.input-expression-control__field > legend,
.input-expression-control__item > legend {
padding: 0 0.3rem 0 0;
color: var(--color-ink);
font-family: var(--font-mono);
font-size: 0.76rem;
font-weight: 700;
}
.input-expression-control__item,
.input-expression-control__field {
display: grid;
gap: 0.45rem;
padding: 0.45rem 0 0.45rem 0.65rem;
border-left: 1px solid var(--color-border);
}
.input-expression-control__item-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.input-expression-control__additional {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.55rem;
padding-top: 0.35rem;
}
.input-expression-control__leaf {
min-width: 0;
}
.input-expression-control__leaf .schema-form__field {
margin: 0;
}
.input-expression-control input[type="text"],
.input-expression-control textarea,
.input-expression-control select {
min-width: 0;
}
/* Capability discovery keeps the result list compact while reserving a readable
contract pane for the selected capability. */
.capability-discovery {
@@ -2052,6 +2132,19 @@ tbody tr:nth-child(10) { animation-delay: 270ms; }
width: 100%;
}
.input-expression-control__mode {
grid-template-columns: 1fr;
align-items: stretch;
}
.input-expression-control__additional {
grid-template-columns: 1fr;
}
.input-expression-control__item-actions button {
min-height: 2.5rem;
}
.draft-workbench {
grid-template-columns: 1fr;
grid-template-areas:
@@ -0,0 +1,102 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { normalizeSchema } from "../schema-form/schema-field.js";
import type { ExpressionEditorState } from "./input-expression-editor.js";
import { InputExpressionControl } from "./InputExpressionControl.js";
afterEach(() => cleanup());
describe("InputExpressionControl", () => {
it("adds, removes, and reorders array expressions with accessible controls", async () => {
const user = userEvent.setup();
let state: ExpressionEditorState = { kind: "array", items: [] };
const renderControl = (): void => {
render(
<InputExpressionControl
field={normalizeSchema({ type: "array", items: { type: "string" } })}
label="items"
onChange={(next) => {
state = next;
cleanup();
renderControl();
}}
state={state}
/>,
);
};
renderControl();
await user.click(screen.getByRole("button", { name: "Add item to items" }));
await user.click(screen.getByRole("button", { name: "Add item to items" }));
expect(screen.getByRole("group", { name: "items item 1" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "items item 2" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Move items item 2 up" }));
expect(state).toMatchObject({ kind: "array", items: [{ kind: "literal" }, { kind: "literal" }] });
await user.click(screen.getByRole("button", { name: "Remove items item 1" }));
expect(screen.getByRole("group", { name: "items item 1" })).toBeInTheDocument();
expect(screen.queryByRole("group", { name: "items item 2" })).toBeNull();
});
it("supports nested object fields and named additional properties", async () => {
const user = userEvent.setup();
let state: ExpressionEditorState = {
kind: "object",
fields: [{ name: "known", value: { kind: "literal", value: "before", touched: false } }],
};
const renderControl = (): void => {
render(
<InputExpressionControl
field={normalizeSchema({
type: "object",
properties: { known: { type: "string" } },
additionalProperties: { type: "number" },
})}
label="payload"
onChange={(next) => {
state = next;
cleanup();
renderControl();
}}
state={state}
/>,
);
};
renderControl();
const name = screen.getByRole("textbox", { name: "Additional property name for payload" });
await user.type(name, "count");
await user.click(screen.getByRole("button", { name: "Add property to payload" }));
expect(screen.getByRole("group", { name: "payload.count" })).toBeInTheDocument();
expect(state).toMatchObject({ kind: "object", fields: [{ name: "known" }, { name: "count" }] });
await user.type(screen.getByRole("textbox", { name: "Additional property name for payload" }), "count");
expect(screen.getByRole("button", { name: "Add property to payload" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Remove payload.count" }));
expect(screen.queryByRole("group", { name: "payload.count" })).toBeNull();
});
it("exposes deferred path guidance and keeps construct unavailable for scalar schemas", async () => {
const user = userEvent.setup();
let state: ExpressionEditorState = { kind: "path", path: "context.profile", touched: false };
const control = () => (
<InputExpressionControl
field={normalizeSchema({ type: "string" })}
label="name"
onChange={(next) => {
state = next;
view.rerender(control());
}}
state={state}
/>
);
const view = render(control());
expect(screen.getByText("Validated when the workflow runs")).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Construct" })).toBeNull();
await user.clear(screen.getByRole("combobox", { name: "Path for name" }));
await user.type(screen.getByRole("combobox", { name: "Path for name" }), "state.name");
expect(state).toMatchObject({ kind: "path", path: "state.name" });
});
});
@@ -0,0 +1,359 @@
import { useState } from "react";
import { SchemaFieldControl } from "../schema-form/SchemaFieldControl.js";
import { rebaseSchemaField, type SchemaField } from "../schema-form/schema-field.js";
import type { FieldSources } from "../schema-form/schema-values.js";
import type { ExpressionEditorState } from "./input-expression-editor.js";
export type InputExpressionControlProps = {
readonly field: SchemaField | null;
readonly label: string;
readonly onChange: (state: ExpressionEditorState) => void;
readonly sourceSuggestions?: ReadonlyArray<string>;
readonly state: ExpressionEditorState;
readonly showModeControl?: boolean;
};
const isConstructField = (field: SchemaField | null): boolean =>
field?.kind === "array" || field?.kind === "object";
const defaultLiteralValue = (field: SchemaField | null): unknown => {
if (field?.hasDefault) return field.defaultValue;
if (field?.kind === "boolean") return false;
if (field?.kind === "number" || field?.kind === "integer") return 0;
if (field?.kind === "enum") return field.enumValues[0] ?? null;
return "";
};
export const defaultExpressionEditorState = (
field: SchemaField | null,
): ExpressionEditorState => {
if (field?.kind === "array") return { kind: "array", items: [] };
if (field?.kind === "object") {
return {
kind: "object",
fields: field.children.map((child) => ({
name: child.key,
value: defaultExpressionEditorState(child),
})),
};
}
return { kind: "literal", value: defaultLiteralValue(field), touched: false };
};
const valueSourceFor = (state: ExpressionEditorState): "path" | "literal" | "construct" =>
state.kind === "array" || state.kind === "object" ? "construct" : state.kind;
const stateForSource = (
source: "path" | "literal" | "construct",
field: SchemaField | null,
current: ExpressionEditorState,
): ExpressionEditorState => {
if (source === "path") {
return current.kind === "path"
? current
: { kind: "path", path: "input.", touched: true };
}
if (source === "literal") {
return current.kind === "literal"
? current
: { kind: "literal", value: defaultLiteralValue(field), touched: true };
}
return isConstructField(field) ? defaultExpressionEditorState(field) : current;
};
const fieldForName = (field: SchemaField | null, name: string): SchemaField | null => {
if (field?.kind !== "object") return null;
return field.children.find((child) => child.key === name) ??
(field.additionalPropertiesKind === "schema" ? field.additionalProperty : null);
};
const labelForName = (label: string, name: string): string =>
`${label} field ${name}`;
const pathNeedsDeferredValidation = (field: SchemaField | null, path: string): boolean =>
path.startsWith("context.") ||
field === null ||
field.fallbackReason === "The schema is unconstrained; edit JSON directly.";
const InputExpressionLeaf = ({
field,
label,
onChange,
sourceSuggestions,
state,
}: {
readonly field: SchemaField | null;
readonly label: string;
readonly onChange: (state: ExpressionEditorState) => void;
readonly sourceSuggestions: ReadonlyArray<string>;
readonly state: ExpressionEditorState;
}) => {
if (state.kind === "path") {
return (
<div className="input-expression-control__leaf">
<label>
Path for {label}
<input
aria-label={`Path for ${label}`}
list={`${label.replaceAll(/[^a-zA-Z0-9]+/g, "-")}-paths`}
onChange={(event) => onChange({ ...state, path: event.target.value, touched: true })}
type="text"
value={state.path}
/>
</label>
<datalist id={`${label.replaceAll(/[^a-zA-Z0-9]+/g, "-")}-paths`}>
{sourceSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
</datalist>
{pathNeedsDeferredValidation(field, state.path) && (
<p className="schema-form__fallback-reason">Validated when the workflow runs</p>
)}
</div>
);
}
const literalState = state.kind === "literal"
? state
: { kind: "literal" as const, value: null, touched: true };
if (field === null) {
const text = typeof literalState.value === "string"
? literalState.value
: JSON.stringify(literalState.value, null, 2) ?? "";
return (
<label>
Literal value for {label}
<textarea
aria-label={`Literal value for ${label}`}
onChange={(event) => {
try {
onChange({ ...literalState, value: JSON.parse(event.target.value), touched: true });
} catch {
onChange({ ...literalState, value: event.target.value, touched: true });
}
}}
value={text}
/>
</label>
);
}
const sources: FieldSources = {};
return (
<div className="input-expression-control__leaf">
<SchemaFieldControl
diagnostics={[]}
field={field}
onArrayItemRemove={() => undefined}
onSourceChange={() => undefined}
onValueChange={(_, value) => onChange({ ...literalState, value, touched: true })}
showSourceControl={false}
sources={sources}
value={literalState.value}
/>
</div>
);
};
export const InputExpressionControl = ({
field,
label,
onChange,
sourceSuggestions = [],
state,
showModeControl = true,
}: InputExpressionControlProps) => {
const [additionalName, setAdditionalName] = useState("");
const source = valueSourceFor(state);
const selectSource = (next: "path" | "literal" | "construct"): void =>
onChange(stateForSource(next, field, state));
const content = state.kind === "array" ? (
<fieldset className="input-expression-control__construct" aria-label={label}>
<legend>{label}</legend>
{field?.description && <p>{field.description}</p>}
{state.items.map((item, index) => {
const itemField = field?.kind === "array" && field.item !== null
? rebaseSchemaField(field.item, [...field.path, index])
: null;
const itemLabel = `${label} item ${index + 1}`;
return (
<fieldset
aria-label={itemLabel}
className="input-expression-control__item"
key={`${itemLabel}-${index}`}
>
<InputExpressionControl
field={itemField}
label={itemLabel}
onChange={(next) => onChange({
kind: "array",
items: state.items.map((candidate, candidateIndex) => candidateIndex === index ? next : candidate),
})}
sourceSuggestions={sourceSuggestions}
state={item}
/>
<div className="input-expression-control__item-actions">
<button
aria-label={`Move ${itemLabel} up`}
className="schema-form__secondary-action"
disabled={index === 0}
onClick={() => onChange({
kind: "array",
items: state.items.map((candidate, candidateIndex) =>
candidateIndex === index - 1
? state.items[index]!
: candidateIndex === index
? state.items[index - 1]!
: candidate,
),
})}
type="button"
>
Move up
</button>
<button
aria-label={`Move ${itemLabel} down`}
className="schema-form__secondary-action"
disabled={index === state.items.length - 1}
onClick={() => onChange({
kind: "array",
items: state.items.map((candidate, candidateIndex) =>
candidateIndex === index
? state.items[index + 1]!
: candidateIndex === index + 1
? state.items[index]!
: candidate,
),
})}
type="button"
>
Move down
</button>
<button
aria-label={`Remove ${itemLabel}`}
className="schema-form__secondary-action"
onClick={() => onChange({ kind: "array", items: state.items.filter((_, itemIndex) => itemIndex !== index) })}
type="button"
>
Remove
</button>
</div>
</fieldset>
);
})}
<button
className="schema-form__secondary-action"
onClick={() => onChange({ kind: "array", items: [...state.items, defaultExpressionEditorState(field?.item ?? null)] })}
type="button"
>
Add item to {label}
</button>
</fieldset>
) : state.kind === "object" ? (
<fieldset className="input-expression-control__construct" aria-label={label}>
<legend>{label}</legend>
{field?.description && <p>{field.description}</p>}
{state.fields.map((entry) => {
const childField = fieldForName(field, entry.name);
const declaredField = field?.kind === "object" && field.children.some((child) => child.key === entry.name);
const childLabel = childField === null && field?.additionalPropertiesKind !== "schema"
? labelForName(label, entry.name)
: `${label}.${entry.name}`;
return (
<fieldset
aria-label={childLabel}
className="input-expression-control__field"
key={entry.name}
>
<InputExpressionControl
field={childField}
label={childLabel}
onChange={(next) => onChange({
kind: "object",
fields: state.fields.map((candidate) => candidate.name === entry.name
? { ...candidate, value: next }
: candidate),
})}
sourceSuggestions={sourceSuggestions}
state={entry.value}
/>
{!declaredField && (
<div className="input-expression-control__item-actions">
<button
aria-label={`Remove ${childLabel}`}
className="schema-form__secondary-action"
onClick={() => onChange({
kind: "object",
fields: state.fields.filter((candidate) => candidate.name !== entry.name),
})}
type="button"
>
Remove {entry.name}
</button>
</div>
)}
</fieldset>
);
})}
{(field?.additionalPropertiesKind === "allowed" || field?.additionalPropertiesKind === "schema") && (
<div className="input-expression-control__additional">
<label>
Additional property name for {label}
<input
aria-label={`Additional property name for ${label}`}
onChange={(event) => setAdditionalName(event.target.value)}
type="text"
value={additionalName}
/>
</label>
<button
className="schema-form__secondary-action"
disabled={additionalName.trim() === "" || state.fields.some((entry) => entry.name === additionalName.trim())}
onClick={() => {
const name = additionalName.trim();
if (name === "" || state.fields.some((entry) => entry.name === name)) return;
onChange({
kind: "object",
fields: [...state.fields, {
name,
value: defaultExpressionEditorState(field.additionalProperty),
}],
});
setAdditionalName("");
}}
type="button"
>
Add property to {label}
</button>
</div>
)}
</fieldset>
) : (
<InputExpressionLeaf
field={field}
label={label}
onChange={onChange}
sourceSuggestions={sourceSuggestions}
state={state}
/>
);
return (
<div className="input-expression-control">
{showModeControl && (
<label className="input-expression-control__mode">
Value source for {label}
<select
aria-label={`Value source for ${label}`}
onChange={(event) => selectSource(event.target.value as "path" | "literal" | "construct")}
value={source}
>
<option value="path">Path</option>
<option value="literal">Literal</option>
{isConstructField(field) && <option value="construct">Construct</option>}
</select>
</label>
)}
{content}
</div>
);
};
@@ -21,6 +21,19 @@ const detail: CapabilityDetail = {
acceptsContext: false,
};
const compositeDetail: CapabilityDetail = {
...detail,
name: "demo.concat",
description: "Concatenate report values",
inputSchema: {
type: "object",
properties: {
items: { type: "array", minItems: 2, items: { type: "string" } },
},
required: ["items"],
},
};
const draft = (stepId: string, input: unknown, output: unknown): DraftWorkspace => ({
workspaceId: "draft-report",
revision: 3,
@@ -112,11 +125,8 @@ describe("SelectedCapabilityInspector", () => {
await user.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("region", { name: "Raw unsupported input row 2" })).toHaveTextContent("broken");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(screen.getByRole("button", { name: "Save inputs" })).toBeDisabled();
expect(controller.setStepInputs).not.toHaveBeenCalled();
expect(screen.getAllByRole("alert").some((alert) =>
alert.textContent?.includes("Remove or repair every unsupported input row before saving.") ?? false,
)).toBe(true);
await user.click(screen.getByRole("button", { name: "Remove unsupported input row 2" }));
await user.click(screen.getByRole("button", { name: "Save inputs" }));
@@ -209,6 +219,58 @@ describe("SelectedCapabilityInspector", () => {
)).toBe(true);
});
it("rehydrates and saves a valid composite input through the mounted inspector", async () => {
const user = userEvent.setup();
const originalWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
const workspace = draft("read", [{
target: "items",
expression: {
kind: "array",
items: [
{ kind: "path", path: "state.foo" },
{ kind: "literal", value: "wowcool" },
],
},
}], []);
const controller = controllerFor(workspace);
try {
render(
<SelectedCapabilityInspector
capabilityDetail={compositeDetail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={controller}
draft={workspace}
nodeKind="use"
nodeRef="demo.concat"
stepId="read"
/>,
);
await user.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("group", { name: "items" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Value source for items item 1" })).toHaveValue("path");
expect(screen.getByRole("combobox", { name: "Path for items item 1" })).toHaveValue("state.foo");
expect(screen.getByRole("textbox", { name: "Items item" })).toHaveValue("wowcool");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(controller.setStepInputs).toHaveBeenCalledWith([{
target: "items",
expression: {
kind: "array",
items: [
{ kind: "path", path: "state.foo" },
{ kind: "literal", value: "wowcool" },
],
},
}]);
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
}
});
it("keeps diagnostic ids unique across failing setup and hidden binding forms", async () => {
const user = userEvent.setup();
const workspace = draft(
@@ -6,9 +6,9 @@ import { StepInputBindingsForm } from "./StepInputBindingsForm.js";
import { StepOutputBindingsForm } from "./StepOutputBindingsForm.js";
import {
bindingDiagnosticsForStep,
inputBindingRows,
outputBindingRows,
projectSelectedStepDataflow,
stepInputBindingRows,
} from "./selected-step-dataflow.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
@@ -126,7 +126,7 @@ export const SelectedCapabilityInspector = ({
? controller.preservedCapabilityForm.input
: null;
// Forms receive raw-row projections so malformed persisted entries stay in order.
const inputRows = inputBindingRows(
const inputRows = stepInputBindingRows(
rawStep?.input !== undefined ? rawStep.input : preservedForm?.inputBindings,
);
const outputRows = outputBindingRows(rawStep?.output);
@@ -1,7 +1,7 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { InputBinding } from "../domain/draft-workspace-models.js";
import type { StepInputBinding } from "../domain/draft-workspace-models.js";
import { StepInputBindingsForm } from "./StepInputBindingsForm.js";
import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js";
@@ -58,7 +58,7 @@ describe("StepInputBindingsForm", () => {
it("submits canonical rows in exact reordered order and preserves fan-out", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<InputBinding>[] = [];
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={schema}
@@ -81,7 +81,7 @@ describe("StepInputBindingsForm", () => {
it("supports explicit clear and removes unsupported rows before saving", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<InputBinding>[] = [];
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={schema}
@@ -121,7 +121,7 @@ describe("StepInputBindingsForm", () => {
it("blocks clear until every unsupported row is explicitly removed", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<InputBinding>[] = [];
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={schema}
@@ -152,7 +152,7 @@ describe("StepInputBindingsForm", () => {
it("round-trips whole and nested local paths without adding the local root", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<InputBinding>[] = [];
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={schema}
@@ -193,7 +193,7 @@ describe("StepInputBindingsForm", () => {
it("uses explicit row modes and immutably edits a whole object literal", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<InputBinding>[] = [];
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={{
@@ -228,7 +228,7 @@ describe("StepInputBindingsForm", () => {
it("preserves array shape while editing a whole array literal", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<InputBinding>[] = [];
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={{ type: "object", properties: { items: { type: "array", items: { type: "string" } } } }}
@@ -270,7 +270,7 @@ describe("StepInputBindingsForm", () => {
it("offers workflow source and nested capability target choices while retaining text entry", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<InputBinding>[] = [];
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={{
@@ -313,7 +313,7 @@ describe("StepInputBindingsForm", () => {
it("rejects exact duplicate targets across path and literal rows with errors on both rows", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<InputBinding>[] = [];
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={schema}
@@ -351,4 +351,103 @@ describe("StepInputBindingsForm", () => {
expect(screen.getByRole("alert")).toHaveTextContent("Target does not exist.");
});
it("constructs an ordered concat expression from path and literal items", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={{
type: "object",
properties: {
items: { type: "array", minItems: 2, items: { type: "string" } },
separator: { type: "string" },
},
required: ["items", "separator"],
}}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
await user.click(screen.getByRole("button", { name: "Add input row" }));
await user.type(screen.getByRole("combobox", { name: "Target for row 1" }), "items");
await user.click(screen.getByRole("radio", { name: "Construct value for input row 1" }));
await user.click(screen.getByRole("button", { name: "Add item to items" }));
await user.click(screen.getByRole("button", { name: "Add item to items" }));
await user.selectOptions(screen.getByRole("combobox", { name: "Value source for items item 1" }), "path");
const itemPath = screen.getByRole("combobox", { name: "Path for items item 1" });
await user.clear(itemPath);
await user.type(itemPath, "state.foo");
await user.selectOptions(screen.getByRole("combobox", { name: "Value source for items item 2" }), "literal");
const itemValue = screen.getByRole("textbox", { name: "Items item" });
await user.clear(itemValue);
await user.type(itemValue, "wowcool");
await user.click(screen.getByRole("button", { name: "Add input row" }));
await user.type(screen.getByRole("combobox", { name: "Target for row 2" }), "separator");
await user.click(screen.getByRole("radio", { name: "Literal value for input row 2" }));
await user.clear(screen.getByRole("textbox", { name: "Separator" }));
await user.type(screen.getByRole("textbox", { name: "Separator" }), " ");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toEqual([[
{
target: "items",
expression: {
kind: "array",
items: [
{ kind: "path", path: "state.foo" },
{ kind: "literal", value: "wowcool" },
],
},
},
{ target: "separator", value: " " },
]]);
});
it("blocks saving a constructed array below its minimum cardinality", () => {
render(
<StepInputBindingsForm
inputSchema={{
type: "object",
properties: { items: { type: "array", minItems: 2, items: { type: "string" } } },
}}
initialRows={[{
kind: "canonical",
index: 0,
value: {
target: "items",
expression: { kind: "array", items: [{ kind: "literal", value: "only" }] },
},
}]}
onSubmit={() => undefined}
/>,
);
expect(screen.getByRole("button", { name: "Save inputs" })).toBeDisabled();
});
it.each([
{
name: "above its maximum cardinality",
field: { type: "array", maxItems: 1, items: { type: "string" } },
expression: { kind: "array", items: [{ kind: "literal", value: "one" }, { kind: "literal", value: "two" }] },
},
{
name: "when a required object field is missing",
field: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
expression: { kind: "object", fields: {} },
},
] as const)("blocks saving a constructed expression $name", ({ field, expression }) => {
render(
<StepInputBindingsForm
inputSchema={{ type: "object", properties: { value: field } }}
initialRows={[{ kind: "canonical", index: 0, value: { target: "value", expression } }]}
onSubmit={() => undefined}
/>,
);
expect(screen.getByRole("button", { name: "Save inputs" })).toBeDisabled();
});
});
@@ -1,8 +1,15 @@
import { useId, useRef, useState, type FormEvent } from "react";
import type {
DraftDiagnostic,
InputBinding,
StepInputBinding,
} from "../domain/draft-workspace-models.js";
import { InputExpressionControl, defaultExpressionEditorState } from "./InputExpressionControl.js";
import {
projectExpressionEditorState,
serializeExpressionEditorState,
validateExpressionEditorState,
type ExpressionEditorState,
} from "./input-expression-editor.js";
import { SchemaFieldControl } from "../schema-form/SchemaFieldControl.js";
import { formatTOMLPath, parseGraphSourcePath, parseTOMLPath } from "../schema-form/schema-paths.js";
import {
@@ -15,11 +22,12 @@ import { formatBoundedJson } from "../domain/format-bounded-json.js";
import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js";
import {
capabilityLocalPathSuggestions,
inputBindingRows,
isJsonValue,
serializeInputBindingRow,
serializeStepInputBindingRow,
stepInputBindingRows,
workflowSourceSuggestions,
type InputBindingRow,
type StepInputBindingRow,
} from "./selected-step-dataflow.js";
type EditableRow = {
@@ -27,13 +35,14 @@ type EditableRow = {
readonly id: string;
readonly rawIndex: number;
readonly target: string;
readonly mode: "path" | "literal";
readonly mode: "path" | "literal" | "expression";
readonly sourcePath: string;
readonly value: unknown;
readonly jsonText: string | null;
readonly expression: ExpressionEditorState | null;
};
type UnsupportedRow = Extract<InputBindingRow, { readonly kind: "unsupported" }> & {
type UnsupportedRow = Extract<StepInputBindingRow, { readonly kind: "unsupported" }> & {
readonly id: string;
};
@@ -43,15 +52,15 @@ export type StepInputBindingsFormProps = {
readonly inputSchema: unknown;
readonly workflowInputSchema?: unknown;
readonly workflowStateSchema?: unknown;
readonly initialRows?: ReadonlyArray<InputBindingRow>;
readonly initialBindings?: ReadonlyArray<InputBinding>;
readonly initialRows?: ReadonlyArray<StepInputBindingRow>;
readonly initialBindings?: ReadonlyArray<StepInputBinding>;
readonly rowDiagnostics?: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>;
readonly onSubmit: (bindings: ReadonlyArray<InputBinding>) => void | Promise<void>;
readonly onSubmit: (bindings: ReadonlyArray<StepInputBinding>) => void | Promise<void>;
readonly onDirtyChange?: (dirty: boolean) => void;
readonly submitLabel?: string;
};
const EMPTY_ROWS: ReadonlyArray<InputBindingRow> = [];
const EMPTY_ROWS: ReadonlyArray<StepInputBindingRow> = [];
const EMPTY_DIAGNOSTICS: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>> = {};
const jsonText = (value: unknown): string => {
@@ -60,10 +69,39 @@ const jsonText = (value: unknown): string => {
};
const rowsFrom = (
rows: ReadonlyArray<InputBindingRow>,
rows: ReadonlyArray<StepInputBindingRow>,
formId: string,
root: SchemaField,
): ReadonlyArray<FormRow> => rows.map((row, index) => {
if (row.kind === "unsupported") return { ...row, id: `${formId}-input-row-${index}` };
if ("expression" in row.value) {
const target = displayLocalInputPath(row.value.target);
const projection = projectExpressionEditorState(
row.value.expression,
schemaFieldForTarget(root, target) ?? {},
);
if (projection.kind === "unsupported") {
return {
kind: "unsupported",
field: "input",
index: row.index,
raw: row.value,
reason: projection.reason,
id: `${formId}-input-row-${index}`,
};
}
return {
kind: "canonical",
id: `${formId}-input-row-${index}`,
rawIndex: row.index,
target,
mode: "expression",
sourcePath: "input.",
value: null,
jsonText: null,
expression: projection.state,
};
}
if ("path" in row.value) {
return {
kind: "canonical",
@@ -74,6 +112,7 @@ const rowsFrom = (
sourcePath: displayGraphInputPath(row.value.path),
value: null,
jsonText: null,
expression: null,
};
}
return {
@@ -85,15 +124,16 @@ const rowsFrom = (
sourcePath: "input.",
value: row.value.value,
jsonText: null,
expression: null,
};
});
const inputRows = (
initialRows: ReadonlyArray<InputBindingRow> | undefined,
initialBindings: ReadonlyArray<InputBinding> | undefined,
): ReadonlyArray<InputBindingRow> => {
initialRows: ReadonlyArray<StepInputBindingRow> | undefined,
initialBindings: ReadonlyArray<StepInputBinding> | undefined,
): ReadonlyArray<StepInputBindingRow> => {
if (initialRows !== undefined) return initialRows;
return initialBindings === undefined ? EMPTY_ROWS : inputBindingRows(initialBindings);
return initialBindings === undefined ? EMPTY_ROWS : stepInputBindingRows(initialBindings);
};
const updateRow = (
@@ -193,7 +233,7 @@ const literalValueFor = (
const bindingForRow = (
root: SchemaField,
row: EditableRow,
): { readonly binding: InputBinding | null; readonly issues: ReadonlyArray<string> } => {
): { readonly binding: StepInputBinding | null; readonly issues: ReadonlyArray<string> } => {
const target = row.target.trim();
if (target === "") return { binding: null, issues: ["Target is required."] };
if (row.mode === "path") {
@@ -205,6 +245,19 @@ const bindingForRow = (
? { binding: null, issues: ["Enter a valid target and source path."] }
: { binding, issues: [] };
}
if (row.mode === "expression") {
if (row.expression === null) return { binding: null, issues: ["Construct an expression before saving."] };
const field = schemaFieldForTarget(root, target);
const validation = validateExpressionEditorState(row.expression, field ?? {});
const expression = serializeExpressionEditorState(row.expression);
const issues = validation.issues.map((item) => item.message);
if (expression === null) issues.push("Expression must be valid finite JSON.");
if (issues.length > 0 || expression === null) return { binding: null, issues };
const binding = serializeStepInputBindingRow({ target, expression });
return binding === null
? { binding: null, issues: ["Enter a valid expression target."] }
: { binding, issues: [] };
}
const field = schemaFieldForTarget(root, target);
const literal = literalValueFor(field, row);
if (literal.issues.length > 0) return { binding: null, issues: literal.issues };
@@ -232,7 +285,7 @@ export const StepInputBindingsForm = ({
const sourceListId = `${formId}-workflow-sources`;
const targetListId = `${formId}-capability-targets`;
const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() =>
rowsFrom(inputRows(initialRows, initialBindings), formId),
rowsFrom(inputRows(initialRows, initialBindings), formId, root),
);
const [localIssues, setLocalIssues] = useState<Readonly<Record<string, ReadonlyArray<string>>>>({});
const [formIssue, setFormIssue] = useState<string | null>(null);
@@ -285,17 +338,23 @@ export const StepInputBindingsForm = ({
sourcePath: "input.",
value: null,
jsonText: null,
expression: null,
},
]);
markDirty();
};
const unsupportedRows = rows.filter((row): row is UnsupportedRow => row.kind === "unsupported");
const hasBlockingIssues = rows.some((row) => {
if (row.kind === "unsupported") return true;
return rowIssueMessages(row, rowDiagnostics, localIssues).length > 0 ||
bindingForRow(root, row).issues.length > 0;
});
const submit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
const nextIssues: Record<string, ReadonlyArray<string>> = {};
const completed: Array<{ readonly id: string; readonly binding: InputBinding }> = [];
const completed: Array<{ readonly id: string; readonly binding: StepInputBinding }> = [];
for (const row of rows) {
if (row.kind === "unsupported") {
nextIssues[row.id] = ["Remove or repair this unsupported input row before saving."];
@@ -389,13 +448,18 @@ export const StepInputBindingsForm = ({
);
}
const field = schemaFieldForTarget(root, row.target.trim());
const issues = rowIssueMessages(row, rowDiagnostics, localIssues);
const issues = [...new Set([
...rowIssueMessages(row, rowDiagnostics, localIssues),
...bindingForRow(root, row).issues,
])];
const targetId = `${row.id}-target`;
const errorId = `${row.id}-errors`;
const pathId = `${row.id}-source-path`;
const literalId = `${row.id}-literal`;
const pathModeId = `${row.id}-path-mode`;
const literalModeId = `${row.id}-literal-mode`;
const expressionModeId = `${row.id}-expression-mode`;
const canConstruct = field?.kind === "array" || field?.kind === "object";
const hasIssues = issues.length > 0;
const literalSources: FieldSources = field === null
? {}
@@ -439,6 +503,23 @@ export const StepInputBindingsForm = ({
/>
Literal value
</label>
{canConstruct && (
<label htmlFor={expressionModeId}>
<input
aria-label={`Construct value for input row ${rowNumber}`}
checked={row.mode === "expression"}
id={expressionModeId}
name={`${row.id}-mode`}
onChange={() => editRow(row.id, (current) => ({
...current,
mode: "expression",
expression: current.expression ?? defaultExpressionEditorState(field),
}))}
type="radio"
/>
Construct
</label>
)}
</div>
</fieldset>
{row.mode === "path" ? (
@@ -455,6 +536,18 @@ export const StepInputBindingsForm = ({
value={row.sourcePath}
/>
</label>
) : row.mode === "expression" ? (
<InputExpressionControl
field={field}
label={row.target.trim() || `input row ${rowNumber}`}
onChange={(next) => editRow(row.id, (current) => ({
...current,
expression: next,
}))}
sourceSuggestions={sourceSuggestions}
state={row.expression ?? defaultExpressionEditorState(field)}
showModeControl={false}
/>
) : field !== null ? (
<SchemaFieldControl
diagnostics={[]}
@@ -531,7 +624,7 @@ export const StepInputBindingsForm = ({
</button>
</div>
<div className="schema-form__source-options">
<button type="submit">{submitLabel}</button>
<button disabled={hasBlockingIssues} type="submit">{submitLabel}</button>
<button
aria-describedby={formIssue !== null ? formErrorId : undefined}
onClick={clear}
@@ -39,12 +39,20 @@ type JsonRecord = Record<string, unknown>;
const isRecord = (value: unknown): value is JsonRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isNormalizedSchemaField = (value: unknown): value is SchemaField =>
isRecord(value) &&
Array.isArray(value.path) &&
typeof value.kind === "string" &&
Array.isArray(value.children) &&
Object.prototype.hasOwnProperty.call(value, "fallbackReason");
const hasOwn = (value: JsonRecord, key: string): boolean =>
Object.prototype.hasOwnProperty.call(value, key);
const hasExactKeys = (value: JsonRecord, keys: ReadonlyArray<string>): boolean => {
const actual = Reflect.ownKeys(value);
return actual.length === keys.length && keys.every((key) => actual.includes(key));
const expected = new Set(keys);
return actual.length === expected.size && actual.every((key) => typeof key === "string" && expected.has(key));
};
/** Keep editor literals within the same finite JSON subset as canonical bindings. */
@@ -160,7 +168,7 @@ export const projectExpressionEditorState = (
): ExpressionProjection => {
const parsed = parseInputExpression(expression);
if (parsed === null) return unsupported(expression, "The stored expression is malformed or exceeds editor limits.");
return project(parsed, normalizeSchema(schema));
return project(parsed, isNormalizedSchemaField(schema) ? schema : normalizeSchema(schema));
};
const copyStateToExpression = (state: ExpressionEditorState): InputExpression | null => {
@@ -230,7 +238,8 @@ const literalIssues = (
if (field.kind === "object") {
if (!isRecord(value)) return [issue(path, "Expected an object literal.")];
const issues: ExpressionValidationIssue[] = [];
const required = new Set(field.children.filter((child) => child.required).map((child) => child.key));
const required = new Set<string>();
for (const child of field.children) if (child.required) required.add(child.key);
for (const name of required) if (!hasOwn(value, name)) issues.push(issue([...path, name], "Required property is missing."));
for (const [name, item] of Object.entries(value)) {
const child = fieldForObjectName(field, name);
@@ -274,7 +283,8 @@ const validateState = (
if (entry.name.length === 0) issues.push(issue(path, "Object field name is required."));
}
if (field?.kind === "object") {
const required = field.children.filter((child) => child.required).map((child) => child.key);
const required = new Set<string>();
for (const child of field.children) if (child.required) required.add(child.key);
for (const name of required) if (!seen.has(name)) issues.push(issue([...path, name], "Required property is missing."));
}
for (const entry of state.fields) {
@@ -294,7 +304,8 @@ export const validateExpressionEditorState = (
state: ExpressionEditorState,
schema: unknown,
): ExpressionValidation => {
const issues = [...validateState(state, normalizeSchema(schema), [])];
const field = isNormalizedSchemaField(schema) ? schema : normalizeSchema(schema);
const issues = [...validateState(state, field, [])];
if (issues.length === 0) {
const expression = copyStateToExpression(state);
if (expression === null || !hasBoundedInputExpressionNodeBudget(expression)) {