feat: author composite step inputs
This commit is contained in:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user