fix: close selected-step dataflow review findings
This commit is contained in:
@@ -59,7 +59,7 @@ describe("CapabilitySetupForm", () => {
|
||||
expect(submissions).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects timeout zero and accepts a positive timeout", async () => {
|
||||
it("requires timeout to be a positive integer", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: unknown[] = [];
|
||||
render(<CapabilitySetupForm onSubmit={(value) => { submissions.push(value); }} />);
|
||||
@@ -72,8 +72,15 @@ describe("CapabilitySetupForm", () => {
|
||||
await user.clear(screen.getByRole("spinbutton", { name: "Timeout seconds" }));
|
||||
await user.type(screen.getByRole("spinbutton", { name: "Timeout seconds" }), "2.5");
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Timeout must be a whole number greater than 0.");
|
||||
expect(submissions).toEqual([]);
|
||||
|
||||
expect(submissions).toEqual([{ timeoutSeconds: 2.5 }]);
|
||||
await user.clear(screen.getByRole("spinbutton", { name: "Timeout seconds" }));
|
||||
await user.type(screen.getByRole("spinbutton", { name: "Timeout seconds" }), "2");
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
expect(submissions).toEqual([{ timeoutSeconds: 2 }]);
|
||||
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveAttribute("step", "1");
|
||||
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveAttribute("inputmode", "numeric");
|
||||
});
|
||||
|
||||
it("associates local retry errors with the retry control and avoids duplicate ids", async () => {
|
||||
|
||||
@@ -105,6 +105,8 @@ export const CapabilitySetupForm = ({
|
||||
const parsed = Number(timeoutSeconds);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
nextIssues.timeoutSeconds = "Timeout must be greater than 0.";
|
||||
} else if (!Number.isInteger(parsed)) {
|
||||
nextIssues.timeoutSeconds = "Timeout must be a whole number greater than 0.";
|
||||
} else {
|
||||
patch.timeoutSeconds = parsed;
|
||||
}
|
||||
@@ -160,10 +162,10 @@ export const CapabilitySetupForm = ({
|
||||
aria-label="Timeout seconds"
|
||||
aria-invalid={diagnosticFor("timeoutSeconds") !== null}
|
||||
id={controlId("timeoutSeconds")}
|
||||
inputMode="decimal"
|
||||
min="0.000001"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
onChange={(event) => { touch("timeoutSeconds"); setTimeoutSeconds(event.target.value); }}
|
||||
step="any"
|
||||
step={1}
|
||||
type="number"
|
||||
value={timeoutSeconds}
|
||||
/>
|
||||
|
||||
@@ -103,8 +103,8 @@ describe("ContextInspector", () => {
|
||||
expect(screen.getByRole("spinbutton", { name: "Retry" })).toHaveValue(2);
|
||||
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveValue(45);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
expect(screen.getByRole("textbox", { name: "Source path for input row 2" })).toHaveValue("input.count");
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
expect(screen.getByRole("combobox", { name: "Source path for input row 2" })).toHaveValue("input.count");
|
||||
});
|
||||
|
||||
it("keeps deferred actions focusable while keyboard activation does not dispatch", async () => {
|
||||
@@ -187,7 +187,7 @@ describe("ContextInspector", () => {
|
||||
expect(screen.getAllByText("Title is not accepted.")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("Retry must be non-negative.")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("Destination field is not declared.")).not.toHaveLength(0);
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveAttribute(
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveAttribute(
|
||||
"aria-invalid",
|
||||
"true",
|
||||
);
|
||||
|
||||
@@ -246,7 +246,7 @@ describe("DraftWorkbench", () => {
|
||||
within(inspector).getByRole("button", { name: "Add input row", hidden: true }),
|
||||
);
|
||||
await user.type(
|
||||
within(inspector).getByRole("textbox", { name: "Target for row 1", hidden: true }),
|
||||
within(inspector).getByRole("combobox", { name: "Target for row 1", hidden: true }),
|
||||
"title",
|
||||
);
|
||||
await user.click(
|
||||
@@ -263,7 +263,7 @@ describe("DraftWorkbench", () => {
|
||||
within(inspector).getByRole("tab", { name: "Outputs" }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(
|
||||
within(inspector).getByRole("textbox", { name: "Target for row 1", hidden: true }),
|
||||
within(inspector).getByRole("combobox", { name: "Target for row 1", hidden: true }),
|
||||
).toHaveValue("title");
|
||||
});
|
||||
|
||||
|
||||
@@ -135,6 +135,39 @@ describe("SelectedCapabilityInspector", () => {
|
||||
expect(controller.updateSetup).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("keeps explicit null and singleton binding containers visible as unsupported rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
const workspace = draft(
|
||||
"read",
|
||||
null,
|
||||
{ source: "text", target: "state.existing" },
|
||||
);
|
||||
const controller = controllerFor(workspace);
|
||||
|
||||
render(
|
||||
<SelectedCapabilityInspector
|
||||
capabilityDetail={detail}
|
||||
capabilityDetailMessage={null}
|
||||
capabilityDetailPhase="ready"
|
||||
controller={controller}
|
||||
draft={workspace}
|
||||
nodeKind="use"
|
||||
nodeRef="demo.read"
|
||||
stepId="read"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
expect(screen.getByRole("region", { name: "Raw unsupported input row 1" })).toHaveTextContent("null");
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
expect(controller.setStepInputs).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Outputs" }));
|
||||
expect(screen.getByRole("region", { name: "Raw unsupported output row 1" })).toHaveTextContent("text");
|
||||
await user.click(screen.getByRole("button", { name: "Save outputs" }));
|
||||
expect(controller.setStepOutputs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps diagnostic ids unique across failing setup and hidden binding forms", async () => {
|
||||
const user = userEvent.setup();
|
||||
const workspace = draft(
|
||||
@@ -162,7 +195,7 @@ describe("SelectedCapabilityInspector", () => {
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
await user.clear(screen.getByRole("textbox", { name: "Target for row 1" }));
|
||||
await user.clear(screen.getByRole("combobox", { name: "Target for row 1" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Outputs" }));
|
||||
await user.clear(screen.getByRole("combobox", { name: "Target for output row 1" }));
|
||||
@@ -192,7 +225,7 @@ describe("SelectedCapabilityInspector", () => {
|
||||
);
|
||||
|
||||
await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
rerender(
|
||||
<SelectedCapabilityInspector
|
||||
capabilityDetail={detail}
|
||||
@@ -207,7 +240,7 @@ describe("SelectedCapabilityInspector", () => {
|
||||
/>,
|
||||
);
|
||||
await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue("Second");
|
||||
});
|
||||
|
||||
|
||||
@@ -105,7 +105,9 @@ export const SelectedCapabilityInspector = ({
|
||||
? controller.preservedCapabilityForm.input
|
||||
: null;
|
||||
// Forms receive raw-row projections so malformed persisted entries stay in order.
|
||||
const inputRows = inputBindingRows(rawStep?.input ?? preservedForm?.inputBindings);
|
||||
const inputRows = inputBindingRows(
|
||||
rawStep?.input !== undefined ? rawStep.input : preservedForm?.inputBindings,
|
||||
);
|
||||
const outputRows = outputBindingRows(rawStep?.output);
|
||||
const inputDiagnostics = bindingDiagnosticsForStep(draft.diagnostics, stepId, "input", projected?.compiledNodeIndex ?? null);
|
||||
const outputDiagnostics = bindingDiagnosticsForStep(draft.diagnostics, stepId, "output", projected?.compiledNodeIndex ?? null);
|
||||
@@ -178,6 +180,8 @@ export const SelectedCapabilityInspector = ({
|
||||
key={`inputs:${stepId}:${controller.resetGeneration}`}
|
||||
initialRows={inputRows}
|
||||
inputSchema={capabilityDetail.inputSchema}
|
||||
workflowInputSchema={isRecord(draft.draft) ? draft.draft.input_schema : undefined}
|
||||
workflowStateSchema={isRecord(draft.draft) ? draft.draft.state_schema : undefined}
|
||||
onDirtyChange={controller.markDirty}
|
||||
onSubmit={controller.setStepInputs}
|
||||
rowDiagnostics={inputDiagnostics.rowIssues}
|
||||
|
||||
@@ -42,11 +42,11 @@ describe("StepInputBindingsForm", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole("group", { name: "Input row 1" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
expect(screen.getByRole("radio", { name: "Path for input row 1" })).toBeChecked();
|
||||
expect(screen.getByRole("textbox", { name: "Source path for input row 1" })).toHaveValue("input.title");
|
||||
expect(screen.getByRole("combobox", { name: "Source path for input row 1" })).toHaveValue("input.title");
|
||||
expect(screen.getByRole("combobox", { name: "Nullable" })).toHaveValue("0:null");
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 3" })).toHaveValue("nested.name");
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 3" })).toHaveValue("nested.name");
|
||||
expect(screen.getByText("Unsupported input binding.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Raw unsupported input row 4" })).toHaveTextContent('"target"');
|
||||
expect(screen.getByRole("button", { name: "Remove unsupported input row 4" })).toBeInTheDocument();
|
||||
@@ -158,10 +158,10 @@ describe("StepInputBindingsForm", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("payload.item");
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 2" })).toHaveValue(".");
|
||||
expect(screen.getByRole("textbox", { name: "Source path for input row 1" })).toHaveValue("input.source");
|
||||
expect(screen.getByRole("textbox", { name: "Source path for input row 2" })).toHaveValue("state.audit.latest");
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("payload.item");
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 2" })).toHaveValue(".");
|
||||
expect(screen.getByRole("combobox", { name: "Source path for input row 1" })).toHaveValue("input.source");
|
||||
expect(screen.getByRole("combobox", { name: "Source path for input row 2" })).toHaveValue("state.audit.latest");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
|
||||
@@ -198,8 +198,8 @@ describe("StepInputBindingsForm", () => {
|
||||
await user.clear(screen.getByRole("textbox", { name: "Name" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "after");
|
||||
await user.click(screen.getByRole("radio", { name: "Path for input row 1" }));
|
||||
await user.clear(screen.getByRole("textbox", { name: "Source path for input row 1" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Source path for input row 1" }), "input.nested");
|
||||
await user.clear(screen.getByRole("combobox", { name: "Source path for input row 1" }));
|
||||
await user.type(screen.getByRole("combobox", { name: "Source path for input row 1" }), "input.nested");
|
||||
await user.click(screen.getByRole("radio", { name: "Literal value for input row 1" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
|
||||
@@ -238,16 +238,85 @@ describe("StepInputBindingsForm", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.clear(screen.getByRole("textbox", { name: "Target for row 1" }));
|
||||
await user.clear(screen.getByRole("combobox", { name: "Target for row 1" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
|
||||
const target = screen.getByRole("textbox", { name: "Target for row 1" });
|
||||
const target = screen.getByRole("combobox", { name: "Target for row 1" });
|
||||
const describedBy = target.getAttribute("aria-describedby");
|
||||
expect(target).toHaveAttribute("aria-invalid", "true");
|
||||
expect(describedBy).toBeTruthy();
|
||||
expect(document.getElementById(describedBy ?? "")).toHaveTextContent("Target is required.");
|
||||
});
|
||||
|
||||
it("offers workflow source and nested capability target choices while retaining text entry", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: ReadonlyArray<InputBinding>[] = [];
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={{
|
||||
type: "object",
|
||||
properties: { profile: { type: "object", properties: { name: { type: "string" } } } },
|
||||
}}
|
||||
workflowInputSchema={{
|
||||
type: "object",
|
||||
properties: { request: { type: "object", properties: { id: { type: "string" } } } },
|
||||
}}
|
||||
workflowStateSchema={{
|
||||
type: "object",
|
||||
properties: { session: { type: "object", properties: { token: { type: "string" } } } },
|
||||
}}
|
||||
initialBindings={[{ path: "input.request.id", target: "profile.name" }]}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const target = screen.getByRole("combobox", { name: "Target for row 1" });
|
||||
const targetList = document.getElementById(target.getAttribute("list") ?? "");
|
||||
expect(target.getAttribute("list")).toBeTruthy();
|
||||
expect(targetList).not.toBeNull();
|
||||
expect(targetList?.querySelector('option[value="profile.name"]')).not.toBeNull();
|
||||
const source = screen.getByRole("combobox", { name: "Source path for input row 1" });
|
||||
const sourceList = document.getElementById(source.getAttribute("list") ?? "");
|
||||
expect(source.getAttribute("list")).toBeTruthy();
|
||||
expect(sourceList).not.toBeNull();
|
||||
expect(sourceList?.querySelector('option[value="input.request.id"]')).not.toBeNull();
|
||||
expect(sourceList?.querySelector('option[value="state.session.token"]')).not.toBeNull();
|
||||
|
||||
await user.clear(target);
|
||||
await user.type(target, "profile.custom");
|
||||
await user.clear(source);
|
||||
await user.type(source, "context.custom");
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
|
||||
expect(submissions).toEqual([[{ path: "context.custom", target: "profile.custom" }]]);
|
||||
});
|
||||
|
||||
it("rejects exact duplicate targets across path and literal rows with errors on both rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: ReadonlyArray<InputBinding>[] = [];
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={schema}
|
||||
initialRows={[
|
||||
{ kind: "canonical", index: 0, value: { path: "input.title", target: "title" } },
|
||||
{ kind: "canonical", index: 1, value: { target: "other", value: "literal" } },
|
||||
]}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.clear(screen.getByRole("combobox", { name: "Target for row 2" }));
|
||||
await user.type(screen.getByRole("combobox", { name: "Target for row 2" }), "title");
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
|
||||
expect(submissions).toEqual([]);
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveAttribute("aria-invalid", "true");
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 2" })).toHaveAttribute("aria-invalid", "true");
|
||||
expect(screen.getAllByRole("alert").filter((alert) =>
|
||||
alert.textContent?.includes("Target is duplicated") ?? false,
|
||||
)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows row diagnostics at the row that owns them", () => {
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
|
||||
@@ -14,9 +14,11 @@ import { serializeSchemaValues, type FieldSources } from "../schema-form/schema-
|
||||
import { formatBoundedJson } from "./format-bounded-json.js";
|
||||
import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js";
|
||||
import {
|
||||
capabilityLocalPathSuggestions,
|
||||
inputBindingRows,
|
||||
isJsonValue,
|
||||
serializeInputBindingRow,
|
||||
workflowSourceSuggestions,
|
||||
type InputBindingRow,
|
||||
} from "./selected-step-dataflow.js";
|
||||
|
||||
@@ -39,6 +41,8 @@ type FormRow = EditableRow | UnsupportedRow;
|
||||
|
||||
export type StepInputBindingsFormProps = {
|
||||
readonly inputSchema: unknown;
|
||||
readonly workflowInputSchema?: unknown;
|
||||
readonly workflowStateSchema?: unknown;
|
||||
readonly initialRows?: ReadonlyArray<InputBindingRow>;
|
||||
readonly initialBindings?: ReadonlyArray<InputBinding>;
|
||||
readonly rowDiagnostics?: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>;
|
||||
@@ -212,6 +216,8 @@ const bindingForRow = (
|
||||
|
||||
export const StepInputBindingsForm = ({
|
||||
inputSchema,
|
||||
workflowInputSchema,
|
||||
workflowStateSchema,
|
||||
initialRows,
|
||||
initialBindings,
|
||||
rowDiagnostics = EMPTY_DIAGNOSTICS,
|
||||
@@ -221,6 +227,10 @@ export const StepInputBindingsForm = ({
|
||||
}: StepInputBindingsFormProps) => {
|
||||
const formId = useId();
|
||||
const root = normalizeSchema(inputSchema);
|
||||
const sourceSuggestions = workflowSourceSuggestions(workflowInputSchema ?? null, workflowStateSchema ?? null);
|
||||
const targetSuggestions = capabilityLocalPathSuggestions(inputSchema);
|
||||
const sourceListId = `${formId}-workflow-sources`;
|
||||
const targetListId = `${formId}-capability-targets`;
|
||||
const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() =>
|
||||
rowsFrom(inputRows(initialRows, initialBindings), formId),
|
||||
);
|
||||
@@ -285,7 +295,7 @@ export const StepInputBindingsForm = ({
|
||||
const submit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
const nextIssues: Record<string, ReadonlyArray<string>> = {};
|
||||
const bindings: InputBinding[] = [];
|
||||
const completed: Array<{ readonly id: string; readonly binding: InputBinding }> = [];
|
||||
for (const row of rows) {
|
||||
if (row.kind === "unsupported") {
|
||||
nextIssues[row.id] = ["Remove or repair this unsupported input row before saving."];
|
||||
@@ -293,14 +303,28 @@ export const StepInputBindingsForm = ({
|
||||
}
|
||||
const result = bindingForRow(root, row);
|
||||
if (result.binding === null) nextIssues[row.id] = result.issues;
|
||||
else bindings.push(result.binding);
|
||||
else completed.push({ id: row.id, binding: result.binding });
|
||||
}
|
||||
const duplicateRows = new Map<string, string[]>();
|
||||
for (const item of completed) {
|
||||
const target = displayLocalInputPath(item.binding.target);
|
||||
duplicateRows.set(target, [...(duplicateRows.get(target) ?? []), item.id]);
|
||||
}
|
||||
for (const ids of duplicateRows.values()) {
|
||||
if (ids.length < 2) continue;
|
||||
for (const id of ids) {
|
||||
nextIssues[id] = [
|
||||
...(nextIssues[id] ?? []),
|
||||
"Target is duplicated in another input row.",
|
||||
];
|
||||
}
|
||||
}
|
||||
setLocalIssues(nextIssues);
|
||||
setFormIssue(unsupportedRows.length > 0
|
||||
? "Remove or repair every unsupported input row before saving."
|
||||
: null);
|
||||
if (Object.keys(nextIssues).length > 0) return;
|
||||
void Promise.resolve(onSubmit(bindings)).catch(() => undefined);
|
||||
void Promise.resolve(onSubmit(completed.map(({ binding }) => binding))).catch(() => undefined);
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
@@ -319,6 +343,12 @@ export const StepInputBindingsForm = ({
|
||||
return (
|
||||
<form className="schema-form authoring-form" noValidate onSubmit={submit}>
|
||||
{formIssue !== null && <p id={formErrorId} role="alert">{formIssue}</p>}
|
||||
<datalist id={sourceListId}>
|
||||
{sourceSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
|
||||
</datalist>
|
||||
<datalist id={targetListId}>
|
||||
{targetSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
|
||||
</datalist>
|
||||
<div className="schema-form__group">
|
||||
{rows.length === 0 && <p>No input bindings configured.</p>}
|
||||
{rows.map((row, index) => {
|
||||
@@ -377,6 +407,7 @@ export const StepInputBindingsForm = ({
|
||||
aria-invalid={hasIssues}
|
||||
aria-label={`Target for row ${rowNumber}`}
|
||||
id={targetId}
|
||||
list={targetListId}
|
||||
onChange={(event) => editRow(row.id, (current) => ({ ...current, target: event.target.value }))}
|
||||
type="text"
|
||||
value={row.target}
|
||||
@@ -416,6 +447,7 @@ export const StepInputBindingsForm = ({
|
||||
aria-invalid={hasIssues}
|
||||
aria-label={`Source path for input row ${rowNumber}`}
|
||||
id={pathId}
|
||||
list={sourceListId}
|
||||
onChange={(event) => editRow(row.id, (current) => ({ ...current, sourcePath: event.target.value }))}
|
||||
type="text"
|
||||
value={row.sourcePath}
|
||||
|
||||
@@ -295,6 +295,27 @@ describe("StepOutputBindingsForm", () => {
|
||||
expect(screen.queryByRole("button", { name: "Confirm clear outputs" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requires fresh confirmation when saving after removing the last stored row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: ReadonlyArray<OutputBinding>[] = [];
|
||||
render(
|
||||
<StepOutputBindingsForm
|
||||
outputSchema={outputSchema}
|
||||
stateSchema={stateSchema}
|
||||
initialBindings={[{ source: "text", target: "state.existing" }]}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove output row 1" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save outputs" }));
|
||||
|
||||
expect(submissions).toEqual([]);
|
||||
expect(screen.getByRole("button", { name: "Confirm clear outputs" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Confirm clear outputs" }));
|
||||
expect(submissions).toEqual([[]]);
|
||||
});
|
||||
|
||||
it.each(clearMutationCases)(
|
||||
"cancels pending clear confirmation after $name and preserves the mutated rows",
|
||||
async ({ mutate, expected }) => {
|
||||
|
||||
@@ -345,9 +345,11 @@ export const StepOutputBindingsForm = ({
|
||||
const targetSuggestions = stateTargetSuggestions(stateSchema);
|
||||
const targetListId = `${formId}-state-targets`;
|
||||
const formErrorId = `${formId}-form-error`;
|
||||
const initialRowValues = outputRows(initialRows, initialBindings);
|
||||
const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() =>
|
||||
rowsFrom(outputRows(initialRows, initialBindings), formId, sourceSuggestions),
|
||||
rowsFrom(initialRowValues, formId, sourceSuggestions),
|
||||
);
|
||||
const hadInitialRows = initialRowValues.length > 0;
|
||||
const [localIssues, setLocalIssues] = useState<Readonly<Record<string, ReadonlyArray<string>>>>({});
|
||||
const [formIssue, setFormIssue] = useState<string | null>(null);
|
||||
const [clearConfirmation, setClearConfirmation] = useState(false);
|
||||
@@ -432,6 +434,11 @@ export const StepOutputBindingsForm = ({
|
||||
? "Remove or repair every unsupported output row before saving."
|
||||
: null);
|
||||
if (Object.keys(nextIssues).length > 0) return;
|
||||
if (bindings.length === 0 && hadInitialRows) {
|
||||
setFormIssue(CLEAR_COPY);
|
||||
setClearConfirmation(true);
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(onSubmit(bindings)).catch(() => undefined);
|
||||
};
|
||||
|
||||
|
||||
@@ -161,6 +161,52 @@ describe("selected-step dataflow projection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves malformed binding containers and extra row keys for keyed and compiled steps", () => {
|
||||
const extraInput = { path: "input.items", target: "items", extra: true };
|
||||
const extraOutput = { source: "text", target: "state.report", extra: true };
|
||||
expect(inputBindingRows(undefined)).toEqual([]);
|
||||
expect(inputBindingRows(null)).toEqual([
|
||||
expect.objectContaining({ kind: "unsupported", index: 0, raw: null }),
|
||||
]);
|
||||
expect(inputBindingRows({ path: "input.items", target: "items" })).toEqual([
|
||||
expect.objectContaining({ kind: "unsupported", index: 0 }),
|
||||
]);
|
||||
expect(inputBindingRows([extraInput])).toEqual([
|
||||
expect.objectContaining({ kind: "unsupported", index: 0, raw: extraInput }),
|
||||
]);
|
||||
expect(outputBindingRows(null)).toEqual([
|
||||
expect.objectContaining({ kind: "unsupported", index: 0, raw: null }),
|
||||
]);
|
||||
expect(outputBindingRows({ source: "text", target: "state.report" })).toEqual([
|
||||
expect.objectContaining({ kind: "unsupported", index: 0 }),
|
||||
]);
|
||||
expect(outputBindingRows([extraOutput])).toEqual([
|
||||
expect.objectContaining({ kind: "unsupported", index: 0, raw: extraOutput }),
|
||||
]);
|
||||
|
||||
const keyed = projectSelectedStepDataflow({
|
||||
...keyedDraft,
|
||||
draft: { steps: { render: { use: "wf.std.concat", input: null, output: extraOutput } } },
|
||||
}, "render");
|
||||
const compiled = projectSelectedStepDataflow({
|
||||
...compiledDraft,
|
||||
draft: { nodes: [{ id: "render", node: "wf.std.concat", input: extraInput, output: null }] },
|
||||
}, "render");
|
||||
|
||||
expect(keyed?.inputs).toEqual([]);
|
||||
expect(keyed?.outputs).toEqual([]);
|
||||
expect(keyed?.unsupported.map(({ field, index, raw }) => ({ field, index, raw }))).toEqual([
|
||||
{ field: "input", index: 0, raw: null },
|
||||
{ field: "output", index: 0, raw: extraOutput },
|
||||
]);
|
||||
expect(compiled?.inputs).toEqual([]);
|
||||
expect(compiled?.outputs).toEqual([]);
|
||||
expect(compiled?.unsupported.map(({ field, index, raw }) => ({ field, index, raw }))).toEqual([
|
||||
{ field: "input", index: 0, raw: extraInput },
|
||||
{ field: "output", index: 0, raw: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null for a missing step", () => {
|
||||
expect(projectSelectedStepDataflow(keyedDraft, "missing")).toBeNull();
|
||||
});
|
||||
@@ -269,6 +315,17 @@ describe("selected-step schema helpers", () => {
|
||||
expect(stateTargetSuggestions(schema)).toEqual(["state.items", "state.items.0", "state.account", "state.account.name"]);
|
||||
});
|
||||
|
||||
it("combines workflow input and state source suggestions with nested local targets", () => {
|
||||
expect(workflowSourceSuggestions(
|
||||
{ type: "object", properties: { request: { type: "object", properties: { id: { type: "string" } } } } },
|
||||
{ type: "object", properties: { session: { type: "object", properties: { token: { type: "string" } } } } },
|
||||
)).toEqual(["input.request", "input.request.id", "state.session", "state.session.token"]);
|
||||
expect(capabilityLocalPathSuggestions({
|
||||
type: "object",
|
||||
properties: { profile: { type: "object", properties: { name: { type: "string" } } } },
|
||||
})).toEqual([".", "profile", "profile.name"]);
|
||||
});
|
||||
|
||||
it("previews only the selected output source schema", () => {
|
||||
const outputSchema = {
|
||||
type: "object",
|
||||
|
||||
@@ -26,6 +26,11 @@ const isRecord = (value: unknown): value is JsonRecord =>
|
||||
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));
|
||||
};
|
||||
|
||||
/** Guard the recursive JSON subset used by literal input bindings. */
|
||||
export const isJsonValue = (value: unknown): value is JsonValue => {
|
||||
if (value === null || typeof value === "boolean" || typeof value === "string") return true;
|
||||
@@ -65,6 +70,7 @@ const localPathParts = (value: unknown): string[] | null => {
|
||||
return parts === null ? null : [...parts];
|
||||
}
|
||||
if (!isRecord(value) || value.root !== "local") return null;
|
||||
if (!hasExactKeys(value, ["root", "parts"])) return null;
|
||||
const parts = stringParts(value.parts);
|
||||
return parts !== null && validPathParts(parts) ? parts : null;
|
||||
};
|
||||
@@ -80,6 +86,7 @@ const inputPathParts = (value: unknown): string[] | null => {
|
||||
}
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.root !== "input" && value.root !== "state" && value.root !== "context") return null;
|
||||
if (!hasExactKeys(value, ["root", "parts"])) return null;
|
||||
const parts = stringParts(value.parts);
|
||||
return parts !== null && validPathParts(parts) ? [value.root, ...parts] : null;
|
||||
};
|
||||
@@ -90,6 +97,7 @@ const statePathParts = (value: unknown): string[] | null => {
|
||||
return parts !== null && parts.length > 1 && parts[0] === "state" ? [...parts] : null;
|
||||
}
|
||||
if (!isRecord(value) || value.root !== "state") return null;
|
||||
if (!hasExactKeys(value, ["root", "parts"])) return null;
|
||||
const parts = stringParts(value.parts);
|
||||
return parts !== null && parts.length > 0 && validPathParts(parts) ? ["state", ...parts] : null;
|
||||
};
|
||||
@@ -141,14 +149,17 @@ const parsedInputBinding = (value: unknown): InputBinding | null => {
|
||||
const hasValue = hasOwn(value, "value");
|
||||
if (hasPath === hasValue) return null;
|
||||
if (hasPath) {
|
||||
if (!hasExactKeys(value, ["path", "target"])) return null;
|
||||
const path = inputPath(value.path);
|
||||
return path === null ? null : { path, target };
|
||||
}
|
||||
if (!hasExactKeys(value, ["target", "value"])) return null;
|
||||
return !isJsonValue(value.value) ? null : { target, value: value.value };
|
||||
};
|
||||
|
||||
const parsedOutputBinding = (value: unknown): OutputBinding | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
if (!hasExactKeys(value, ["source", "target"])) return null;
|
||||
const source = localPath(value.source);
|
||||
const target = statePath(value.target);
|
||||
return source === null || target === null ? null : { source, target };
|
||||
@@ -169,7 +180,13 @@ const parseRows = <T>(
|
||||
): ParsedRows<T> => {
|
||||
const values: T[] = [];
|
||||
const unsupported: UnsupportedBindingRow[] = [];
|
||||
const rows = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
||||
if (raw !== undefined && !Array.isArray(raw)) {
|
||||
return {
|
||||
values,
|
||||
unsupported: [{ field, index: 0, raw, reason: unsupportedReason(field, 0) }],
|
||||
};
|
||||
}
|
||||
const rows = raw === undefined ? [] : raw;
|
||||
rows.forEach((value, index) => {
|
||||
const parsed = parse(value);
|
||||
if (parsed === null) {
|
||||
@@ -268,7 +285,10 @@ const rowsFor = <T>(
|
||||
field: "input" | "output",
|
||||
): ReadonlyArray<BindingRow<T>> => {
|
||||
const rows: Array<BindingRow<T>> = [];
|
||||
const values = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
||||
if (raw !== undefined && !Array.isArray(raw)) {
|
||||
return [{ kind: "unsupported", field, index: 0, raw, reason: unsupportedReason(field, 0) }];
|
||||
}
|
||||
const values = raw === undefined ? [] : raw;
|
||||
values.forEach((value, index) => {
|
||||
const parsed = parse(value);
|
||||
if (parsed === null) {
|
||||
@@ -291,10 +311,12 @@ export const serializeInputBindingRow = (value: unknown): InputBinding | null =>
|
||||
const target = canonicalLocalPath(value.target);
|
||||
if (target === null) return null;
|
||||
if (hasOwn(value, "path") && !hasOwn(value, "value")) {
|
||||
if (!hasExactKeys(value, ["path", "target"])) return null;
|
||||
const path = canonicalInputPath(value.path);
|
||||
return path === null ? null : { path, target };
|
||||
}
|
||||
if (!hasOwn(value, "path") && hasOwn(value, "value") && isJsonValue(value.value)) {
|
||||
if (!hasExactKeys(value, ["target", "value"])) return null;
|
||||
return { target, value: value.value };
|
||||
}
|
||||
return null;
|
||||
@@ -315,6 +337,7 @@ export const serializeInputBindingRows = (
|
||||
|
||||
export const serializeOutputBindingRow = (value: unknown): OutputBinding | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
if (!hasExactKeys(value, ["source", "target"])) return null;
|
||||
const source = canonicalLocalPath(value.source);
|
||||
const target = canonicalStatePath(value.target);
|
||||
return source === null || target === null ? null : { source, target };
|
||||
|
||||
Reference in New Issue
Block a user