feat: decode composite step inputs

This commit is contained in:
lda
2026-08-13 19:49:39 +07:00 Verified
parent 938864b4d2
commit 1bb684bfcd
22 changed files with 1339 additions and 119 deletions
@@ -1,11 +1,33 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { CapabilityNodeForm } from "./CapabilityNodeForm.js";
import { CapabilityNodeForm, type CapabilityNodeFormValue } from "./CapabilityNodeForm.js";
afterEach(() => cleanup());
describe("CapabilityNodeForm", () => {
it("accepts expression bindings in its callback value without authoring them", () => {
const value = {
stepId: "concat",
capabilityName: "wf.std.concat",
inputBindings: [
{
target: "request",
expression: {
kind: "array",
items: [
{ kind: "path", path: "state.foo" },
{ kind: "literal", value: "wowcool" },
],
},
},
],
} satisfies CapabilityNodeFormValue;
expect(value.inputBindings).toHaveLength(1);
expect(value.inputBindings[0]).toHaveProperty("expression.kind", "array");
});
it("submits explicit node metadata and serialized schema bindings", async () => {
const user = userEvent.setup();
const submissions: unknown[] = [];
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import type { InputBinding } from "../domain/draft-workspace-models.js";
import type { StepInputBinding } from "../domain/draft-workspace-models.js";
import { SchemaForm } from "../schema-form/SchemaForm.js";
import { normalizeSchema } from "../schema-form/schema-field.js";
import {
@@ -15,7 +15,7 @@ export type CapabilityNodeFormValue = {
readonly description?: string | null;
readonly retry?: number | null;
readonly timeoutSeconds?: number | null;
readonly inputBindings: ReadonlyArray<InputBinding> | null;
readonly inputBindings: ReadonlyArray<StepInputBinding> | null;
readonly inputMap?: Record<string, string> | null;
readonly routes?: Record<string, string> | null;
readonly bindOutputs?: Record<string, string>;
@@ -55,4 +55,25 @@ describe("canonical capability form projection", () => {
expect(projected?.initialInputValue).toEqual({ items: [] });
});
it("preserves a valid expression binding while projecting the legacy form", () => {
const expression = {
kind: "object",
fields: {
value: { kind: "path", path: "state.foo" },
label: { kind: "literal", value: "wowcool" },
},
} as const;
const projected = canonicalCapabilityFormData(
draft({
use: "wf.std.concat",
input: [{ target: "request", expression }],
}),
"render",
);
expect(projected?.initialValue.inputBindings).toEqual([
{ target: "request", expression },
]);
});
});
@@ -1,7 +1,12 @@
import type {
CapabilityNodeFormValue,
} from "./CapabilityNodeForm.js";
import type { InputBinding, InputPath, LocalInputPath } from "../domain/draft-workspace-models.js";
import type {
InputExpression,
InputPath,
LocalInputPath,
StepInputBinding,
} from "../domain/draft-workspace-models.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { FieldSources } from "../schema-form/schema-values.js";
import { formatTOMLPath, parseTOMLPath } from "../schema-form/schema-paths.js";
@@ -84,16 +89,48 @@ const inputPath = (value: unknown): value is InputPath =>
value.parts.every((part): part is string => typeof part === "string")
);
const inputBinding = (value: JsonRecord): InputBinding | null => {
const jsonValue = (value: unknown): boolean => {
if (
value === null ||
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value)) ||
typeof value === "string"
) {
return true;
}
if (Array.isArray(value)) return value.every(jsonValue);
return isRecord(value) && Object.values(value).every(jsonValue);
};
const inputExpression = (value: unknown): value is InputExpression => {
if (!isRecord(value) || typeof value.kind !== "string") return false;
switch (value.kind) {
case "literal":
return jsonValue(value.value);
case "path":
return inputPath(value.path);
case "array":
return Array.isArray(value.items) && value.items.every(inputExpression);
case "object":
return isRecord(value.fields) && Object.values(value.fields).every(inputExpression);
default:
return false;
}
};
const inputBinding = (value: JsonRecord): StepInputBinding | null => {
if (!localInputPath(value.target)) return null;
if (inputPath(value.path)) return { target: value.target, path: value.path };
if ("value" in value) return { target: value.target, value: value.value };
if (inputExpression(value.expression)) {
return { target: value.target, expression: value.expression };
}
return null;
};
const bindingFormData = (
capabilityName: string,
inputBindings: ReadonlyArray<InputBinding>,
inputBindings: ReadonlyArray<StepInputBinding>,
): Omit<CanonicalCapabilityFormData, "initialValue"> => {
const initialInputSources: Record<string, FieldSources[string]> = {};
let initialInputValue: unknown = undefined;
@@ -125,10 +162,14 @@ const formDataFromValue = (
const formDataFromBindings = (
capabilityName: string,
inputBindings: ReadonlyArray<InputBinding>,
inputBindings: ReadonlyArray<StepInputBinding>,
initialValue: Partial<CapabilityNodeFormValue>,
): CanonicalCapabilityFormData => {
return { ...bindingFormData(capabilityName, inputBindings), initialValue };
const preservesExpressions = inputBindings.some((binding) => "expression" in binding);
return {
...bindingFormData(capabilityName, inputBindings),
initialValue: preservesExpressions ? { ...initialValue, inputBindings } : initialValue,
};
};
export const capabilityFormDataFromValue = (
@@ -156,7 +197,7 @@ export const canonicalCapabilityFormData = (
? { timeoutSeconds }
: {}),
} satisfies Partial<CapabilityNodeFormValue>;
const inputBindings: InputBinding[] = [];
const inputBindings: StepInputBinding[] = [];
const input = step.input;
if (Array.isArray(input)) {
for (const rawBinding of input) {
@@ -5,6 +5,7 @@ import type {
DraftWorkspace,
InputBinding,
OutputBinding,
StepInputBinding,
} from "../domain/draft-workspace-models.js";
import type { DraftAuthoringClient } from "../domain/draft-authoring-client.js";
import type { DraftWorkspaceClient } from "../domain/draft-workspace-client.js";
@@ -18,9 +19,10 @@ import type { CapabilitySetupPatch } from "./selected-step-dataflow.js";
import { useDraftAuthoring } from "./useDraftAuthoring.js";
vi.mock("../context.js", () => ({ useConsoleWorkspace: vi.fn() }));
vi.mock("../domain/draft-authoring-client.js", () => ({
createDraftAuthoringClient: vi.fn(),
}));
vi.mock("../domain/draft-authoring-client.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../domain/draft-authoring-client.js")>();
return { ...actual, createDraftAuthoringClient: vi.fn() };
});
vi.mock("../domain/draft-workspace-client.js", () => ({
createDraftWorkspaceClient: vi.fn(),
}));
@@ -613,6 +615,37 @@ describe("useDraftAuthoring", () => {
expect(result.current.draft).toBe(canonical);
});
it("submits a recursive expression through the selected-step controller", async () => {
const initial = workspace({ revision: 7 });
const canonical = workspace({ revision: 8 });
const bindings = [
{
target: "request",
expression: {
kind: "object",
fields: {
value: { kind: "path", path: "state.foo" },
},
},
},
] satisfies ReadonlyArray<StepInputBinding>;
setStepInputBindings.mockResolvedValue(canonical);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
await act(async () => result.current.setStepInputs(bindings));
expect(setStepInputBindings).toHaveBeenCalledWith({
workspaceId: "draft-report",
revision: 7,
stepId: "render",
bindings,
});
expect(result.current.draft).toBe(canonical);
});
it("submits ordered output bindings and commits the returned draft", async () => {
const initial = workspace({ revision: 4 });
const canonical = workspace({ revision: 5 });
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useConsoleWorkspace } from "../context.js";
import {
createDraftAuthoringClient,
copyStepInputBinding,
type DraftAuthoringClient,
} from "../domain/draft-authoring-client.js";
import {
@@ -10,8 +11,8 @@ import {
} from "../domain/draft-workspace-client.js";
import type {
DraftWorkspace,
InputBinding,
OutputBinding,
StepInputBinding,
} from "../domain/draft-workspace-models.js";
import type { CapabilityNodeFormValue } from "./CapabilityNodeForm.js";
import type { RouteFormValue } from "./RouteForm.js";
@@ -35,7 +36,7 @@ export interface DraftAuthoringController {
readonly preservedCapabilityForm: PreservedCapabilityForm;
readonly addCapability: (input: CapabilityNodeFormValue) => Promise<void>;
readonly updateCapability: (input: CapabilityNodeFormValue) => Promise<void>;
readonly setStepInputs: (bindings: ReadonlyArray<InputBinding>) => Promise<void>;
readonly setStepInputs: (bindings: ReadonlyArray<StepInputBinding>) => Promise<void>;
readonly setStepOutputs: (bindings: ReadonlyArray<OutputBinding>) => Promise<void>;
readonly updateSetup: (patch: CapabilitySetupPatch) => Promise<void>;
readonly setRoute: (input: RouteFormValue) => Promise<void>;
@@ -100,7 +101,7 @@ type LastSubmission =
| {
readonly kind: "inputs";
readonly targetStepId: string;
readonly bindings: ReadonlyArray<InputBinding>;
readonly bindings: ReadonlyArray<StepInputBinding>;
}
| {
readonly kind: "outputs";
@@ -160,38 +161,6 @@ const mutationKey = (kind: string, input: unknown, revision: number): string =>
return `${kind}:${revision}:${encoded ?? "undefined"}`;
};
const copyJsonValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(copyJsonValue);
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, copyJsonValue(nestedValue)]),
);
}
return value;
};
const copyInputBinding = (binding: InputBinding): InputBinding => {
if ("path" in binding) {
return {
path:
typeof binding.path === "string"
? binding.path
: { root: binding.path.root, parts: [...binding.path.parts] },
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
};
}
return {
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
value: copyJsonValue(binding.value),
};
};
const copyOutputBinding = (binding: OutputBinding): OutputBinding => ({
source:
typeof binding.source === "string"
@@ -204,8 +173,8 @@ const copyOutputBinding = (binding: OutputBinding): OutputBinding => ({
});
const copyInputBindings = (
bindings: ReadonlyArray<InputBinding>,
): ReadonlyArray<InputBinding> => bindings.map(copyInputBinding);
bindings: ReadonlyArray<StepInputBinding>,
): ReadonlyArray<StepInputBinding> => bindings.map(copyStepInputBinding);
const copyOutputBindings = (
bindings: ReadonlyArray<OutputBinding>,
@@ -567,7 +536,7 @@ export const useDraftAuthoring = ({
const submitStepInputs = useCallback(
(
targetStepId: string,
bindings: ReadonlyArray<InputBinding>,
bindings: ReadonlyArray<StepInputBinding>,
allowTargetSelectionChange = false,
): Promise<void> => {
const submittedBindings = copyInputBindings(bindings);
@@ -629,7 +598,7 @@ export const useDraftAuthoring = ({
);
const setStepInputs = useCallback(
(bindings: ReadonlyArray<InputBinding>): Promise<void> => {
(bindings: ReadonlyArray<StepInputBinding>): Promise<void> => {
const targetStepId = selectedStepId();
return targetStepId === null
? missingSelectedStep()