refactor: use canonical authoring choices

This commit is contained in:
lda
2026-08-14 19:11:04 +07:00 Verified
parent eae1a62fb2
commit 92a32df87a
13 changed files with 605 additions and 309 deletions
@@ -12,6 +12,8 @@ export type AuthoringPathPickerProps = {
readonly onChange: (value: string) => void; readonly onChange: (value: string) => void;
readonly label: string; readonly label: string;
readonly allowCustom?: boolean; readonly allowCustom?: boolean;
readonly describedBy?: string | undefined;
readonly invalid?: boolean;
}; };
type OptionGroup = { type OptionGroup = {
@@ -44,12 +46,17 @@ export const AuthoringPathPicker = ({
onChange, onChange,
label, label,
allowCustom = false, allowCustom = false,
describedBy,
invalid = false,
}: AuthoringPathPickerProps) => { }: AuthoringPathPickerProps) => {
const id = safeId(useId()); const id = safeId(useId());
const searchId = `${id}-search`; const searchId = `${id}-search`;
const customId = `${id}-custom`; const customId = `${id}-custom`;
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [customValue, setCustomValue] = useState(value); const [customValue, setCustomValue] = useState(value);
const [advancedOpen, setAdvancedOpen] = useState(
() => allowCustom && value.trim() !== "" && !options.some((option) => option.path === value),
);
const requestedUses = normalizedUses(uses); const requestedUses = normalizedUses(uses);
const normalizedSearch = search.trim().toLocaleLowerCase(); const normalizedSearch = search.trim().toLocaleLowerCase();
@@ -118,10 +125,16 @@ export const AuthoringPathPicker = ({
{visibleOptions.length === 0 && <p>No matching paths.</p>} {visibleOptions.length === 0 && <p>No matching paths.</p>}
</div> </div>
{allowCustom && ( {allowCustom && (
<details className="authoring-path-picker__advanced"> <details
className="authoring-path-picker__advanced"
onToggle={(event) => setAdvancedOpen(event.currentTarget.open)}
open={advancedOpen}
>
<summary>Advanced</summary> <summary>Advanced</summary>
<label htmlFor={customId}>Custom {label}</label> <label htmlFor={customId}>Custom {label}</label>
<input <input
aria-describedby={describedBy}
aria-invalid={invalid}
id={customId} id={customId}
onChange={(event) => { onChange={(event) => {
setCustomValue(event.target.value); setCustomValue(event.target.value);
@@ -103,8 +103,8 @@ describe("ContextInspector", () => {
expect(screen.getByRole("spinbutton", { name: "Retry" })).toHaveValue(2); expect(screen.getByRole("spinbutton", { name: "Retry" })).toHaveValue(2);
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveValue(45); expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveValue(45);
fireEvent.click(screen.getByRole("tab", { name: "Inputs" })); fireEvent.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title"); expect(screen.getByRole("textbox", { name: "Custom Target for row 1" })).toHaveValue("title");
expect(screen.getByRole("combobox", { name: "Source path for input row 2" })).toHaveValue("input.count"); expect(screen.getByRole("textbox", { name: "Custom Source path for input row 2" })).toHaveValue("input.count");
}); });
it("keeps deferred actions focusable while keyboard activation does not dispatch", async () => { 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("Title is not accepted.")).not.toHaveLength(0);
expect(screen.getAllByText("Retry must be non-negative.")).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.getAllByText("Destination field is not declared.")).not.toHaveLength(0);
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveAttribute( expect(screen.getByRole("textbox", { name: "Custom Target for row 1" })).toHaveAttribute(
"aria-invalid", "aria-invalid",
"true", "true",
); );
@@ -261,7 +261,7 @@ describe("DraftWorkbench", () => {
within(inspector).getByRole("button", { name: "Add input row", hidden: true }), within(inspector).getByRole("button", { name: "Add input row", hidden: true }),
); );
await user.type( await user.type(
within(inspector).getByRole("combobox", { name: "Target for row 1", hidden: true }), within(inspector).getByRole("textbox", { name: "Custom Target for row 1", hidden: true }),
"title", "title",
); );
await user.click( await user.click(
@@ -278,7 +278,7 @@ describe("DraftWorkbench", () => {
within(inspector).getByRole("tab", { name: "Outputs" }), within(inspector).getByRole("tab", { name: "Outputs" }),
).toHaveAttribute("aria-selected", "true"); ).toHaveAttribute("aria-selected", "true");
expect( expect(
within(inspector).getByRole("combobox", { name: "Target for row 1", hidden: true }), within(inspector).getByRole("textbox", { name: "Custom Target for row 1", hidden: true }),
).toHaveValue("title"); ).toHaveValue("title");
}); });
@@ -2,6 +2,7 @@ import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { useState } from "react"; import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { AuthoringPathOption } from "../domain/authoring-contract-models.js";
import { normalizeSchema } from "../schema-form/schema-field.js"; import { normalizeSchema } from "../schema-form/schema-field.js";
import type { ExpressionEditorState } from "./input-expression-editor.js"; import type { ExpressionEditorState } from "./input-expression-editor.js";
import { InputExpressionControl } from "./InputExpressionControl.js"; import { InputExpressionControl } from "./InputExpressionControl.js";
@@ -11,7 +12,70 @@ afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
const sourceOptions: ReadonlyArray<AuthoringPathOption> = [
{
path: "input.title",
label: "Title",
origin: "workflow_input",
schema: { type: "string" },
required: true,
availability: "available",
uses: ["step_input"],
},
{
path: "context.loop_item",
label: "Loop item",
origin: "runtime_context",
schema: { type: "string" },
required: false,
availability: "conditional",
uses: ["step_input"],
reason: "Only available inside the foreach body.",
},
];
describe("InputExpressionControl", () => { describe("InputExpressionControl", () => {
it("uses the inventory picker for every recursive path expression leaf", () => {
render(
<InputExpressionControl
field={normalizeSchema({
type: "array",
items: {
type: "object",
properties: {
first: { type: "string" },
nested: { type: "array", items: { type: "string" } },
},
},
})}
label="items"
onChange={vi.fn()}
sourceOptions={sourceOptions}
state={{
kind: "array",
items: [{
kind: "object",
fields: [
{ name: "first", value: { kind: "path", path: "input.title", touched: false } },
{
name: "nested",
value: {
kind: "array",
items: [{ kind: "path", path: "context.loop_item", touched: false }],
},
},
],
}],
}}
showModeControl={false}
/>,
);
expect(screen.getAllByRole("group", { name: "Workflow input" })).toHaveLength(2);
expect(screen.getAllByRole("group", { name: "Runtime context" })).toHaveLength(2);
expect(document.querySelectorAll("datalist")).toHaveLength(0);
});
it("offers construct mode for an unconstrained target", async () => { it("offers construct mode for an unconstrained target", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const Harness = () => { const Harness = () => {
@@ -439,7 +503,7 @@ describe("InputExpressionControl", () => {
expect(screen.getByRole("textbox", { name: "Additional property name for items item 1" })).toHaveValue("second-local"); expect(screen.getByRole("textbox", { name: "Additional property name for items item 1" })).toHaveValue("second-local");
}); });
it("gives repeated labels unique datalist and typed-leaf control ids", () => { it("gives repeated labels unique picker and typed-leaf control ids", () => {
const field = normalizeSchema({ type: "string" }); const field = normalizeSchema({ type: "string" });
render( render(
<> <>
@@ -470,10 +534,11 @@ describe("InputExpressionControl", () => {
</>, </>,
); );
const datalistIds = [...document.querySelectorAll("datalist")].map((element) => element.id); const searchIds = [...document.querySelectorAll('input[type="search"]')].map((element) => element.id);
const controlIds = [...document.querySelectorAll("textarea")].map((element) => element.id); const controlIds = [...document.querySelectorAll("textarea")].map((element) => element.id);
expect(datalistIds).toHaveLength(2); expect(searchIds).toHaveLength(2);
expect(new Set(datalistIds).size).toBe(datalistIds.length); expect(new Set(searchIds).size).toBe(searchIds.length);
expect(document.querySelectorAll("datalist")).toHaveLength(0);
expect(controlIds).toHaveLength(2); expect(controlIds).toHaveLength(2);
expect(new Set(controlIds).size).toBe(controlIds.length); expect(new Set(controlIds).size).toBe(controlIds.length);
}); });
@@ -496,8 +561,9 @@ describe("InputExpressionControl", () => {
expect(screen.getByText("Validated when the workflow runs")).toBeInTheDocument(); expect(screen.getByText("Validated when the workflow runs")).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Construct" })).toBeNull(); expect(screen.queryByRole("option", { name: "Construct" })).toBeNull();
await user.clear(screen.getByRole("combobox", { name: "Path for name" })); const path = screen.getByRole("textbox", { name: "Custom Path for name" });
await user.type(screen.getByRole("combobox", { name: "Path for name" }), "state.name"); await user.clear(path);
await user.type(path, "state.name");
expect(state).toMatchObject({ kind: "path", path: "state.name" }); expect(state).toMatchObject({ kind: "path", path: "state.name" });
}); });
}); });
@@ -1,5 +1,6 @@
import { useId, useState } from "react"; import { useId, useState } from "react";
import { SchemaFieldControl } from "../schema-form/SchemaFieldControl.js"; import { SchemaFieldControl } from "../schema-form/SchemaFieldControl.js";
import type { AuthoringPathOption } from "../domain/authoring-contract-models.js";
import { import {
rebaseSchemaField, rebaseSchemaField,
UNCONSTRAINED_SCHEMA_REASON, UNCONSTRAINED_SCHEMA_REASON,
@@ -10,12 +11,13 @@ import {
defaultExpressionEditorState, defaultExpressionEditorState,
type ExpressionEditorState, type ExpressionEditorState,
} from "./input-expression-editor.js"; } from "./input-expression-editor.js";
import { AuthoringPathPicker } from "./AuthoringPathPicker.js";
export type InputExpressionControlProps = { export type InputExpressionControlProps = {
readonly field: SchemaField | null; readonly field: SchemaField | null;
readonly label: string; readonly label: string;
readonly onChange: (state: ExpressionEditorState) => void; readonly onChange: (state: ExpressionEditorState) => void;
readonly sourceSuggestions?: ReadonlyArray<string>; readonly sourceOptions?: ReadonlyArray<AuthoringPathOption>;
readonly state: ExpressionEditorState; readonly state: ExpressionEditorState;
readonly showModeControl?: boolean; readonly showModeControl?: boolean;
}; };
@@ -167,7 +169,7 @@ const InputExpressionLeaf = ({
field, field,
label, label,
onChange, onChange,
sourceSuggestions, sourceOptions,
state, state,
idPrefix, idPrefix,
}: { }: {
@@ -175,26 +177,20 @@ const InputExpressionLeaf = ({
readonly idPrefix: string; readonly idPrefix: string;
readonly label: string; readonly label: string;
readonly onChange: (state: ExpressionEditorState) => void; readonly onChange: (state: ExpressionEditorState) => void;
readonly sourceSuggestions: ReadonlyArray<string>; readonly sourceOptions: ReadonlyArray<AuthoringPathOption>;
readonly state: ExpressionEditorState; readonly state: ExpressionEditorState;
}) => { }) => {
if (state.kind === "path") { if (state.kind === "path") {
const pathListId = `${idPrefix}-paths`;
return ( return (
<div className="input-expression-control__leaf"> <div className="input-expression-control__leaf">
<label> <AuthoringPathPicker
Path for {label} allowCustom
<input label={`Path for ${label}`}
aria-label={`Path for ${label}`} onChange={(path) => onChange({ ...state, path, touched: true })}
list={pathListId} options={sourceOptions}
onChange={(event) => onChange({ ...state, path: event.target.value, touched: true })} uses="step_input"
type="text"
value={state.path} value={state.path}
/> />
</label>
<datalist id={pathListId}>
{sourceSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
</datalist>
{pathNeedsDeferredValidation(field, state.path) && ( {pathNeedsDeferredValidation(field, state.path) && (
<p className="schema-form__fallback-reason">Validated when the workflow runs</p> <p className="schema-form__fallback-reason">Validated when the workflow runs</p>
)} )}
@@ -248,7 +244,7 @@ export const InputExpressionControl = ({
field, field,
label, label,
onChange, onChange,
sourceSuggestions = [], sourceOptions = [],
state, state,
showModeControl = true, showModeControl = true,
}: InputExpressionControlProps) => { }: InputExpressionControlProps) => {
@@ -301,7 +297,7 @@ export const InputExpressionControl = ({
items: state.items.map((candidate, candidateIndex) => candidateIndex === index ? next : candidate), items: state.items.map((candidate, candidateIndex) => candidateIndex === index ? next : candidate),
}); });
}} }}
sourceSuggestions={sourceSuggestions} sourceOptions={sourceOptions}
state={item} state={item}
/> />
<div className="input-expression-control__item-actions"> <div className="input-expression-control__item-actions">
@@ -397,7 +393,7 @@ export const InputExpressionControl = ({
? { ...candidate, value: next } ? { ...candidate, value: next }
: candidate), : candidate),
})} })}
sourceSuggestions={sourceSuggestions} sourceOptions={sourceOptions}
state={entry.value} state={entry.value}
/> />
{!requiredField && ( {!requiredField && (
@@ -474,7 +470,7 @@ export const InputExpressionControl = ({
idPrefix={`${controlId}-leaf`} idPrefix={`${controlId}-leaf`}
label={label} label={label}
onChange={onChange} onChange={onChange}
sourceSuggestions={sourceSuggestions} sourceOptions={sourceOptions}
state={state} state={state}
/> />
); );
@@ -1,13 +1,78 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CapabilityDetail } from "../domain/capability-models.js"; import type { CapabilityDetail } from "../domain/capability-models.js";
import type { AuthoringContractInventory } from "../domain/authoring-contract-models.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js"; import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js"; import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js";
import { useAuthoringContract } from "./useAuthoringContract.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js"; import type { DraftAuthoringController } from "./useDraftAuthoring.js";
vi.mock("./useAuthoringContract.js", () => ({ useAuthoringContract: vi.fn() }));
const mockedUseAuthoringContract = vi.mocked(useAuthoringContract);
afterEach(() => cleanup()); afterEach(() => cleanup());
const inventory: AuthoringContractInventory = {
workspaceId: "draft-report",
revision: 3,
selectedStepId: "read",
readableSources: [{
path: "input.title",
label: "Inventory title",
origin: "workflow_input",
schema: { type: "string" },
required: true,
availability: "available",
uses: ["step_input"],
}],
stepInputTargets: [{
path: "step_input.title",
label: "Inventory title target",
origin: "step_input",
schema: { type: "string" },
required: true,
availability: "available",
uses: ["step_input"],
}],
stepOutputSources: [{
path: "step_output.text",
label: "Inventory text",
origin: "step_output",
schema: { type: "string" },
required: false,
availability: "available",
uses: ["step_output_source"],
}],
stateTargets: [{
path: "state.existing",
label: "Inventory state",
origin: "workflow_state",
schema: { type: "string" },
required: false,
availability: "available",
uses: ["state_target"],
}],
workflowOutputTargets: [],
entrySteps: [],
workflowOutcomes: [],
warnings: [],
};
beforeEach(() => {
mockedUseAuthoringContract.mockReturnValue({
phase: "disconnected",
inventory: null,
message: null,
refresh: vi.fn(),
});
});
const customInput = (label: string) =>
within(screen.getByRole("region", { name: label }))
.getByRole("textbox", { name: `Custom ${label}` });
const detail: CapabilityDetail = { const detail: CapabilityDetail = {
kind: "node_spec", kind: "node_spec",
name: "demo.read", name: "demo.read",
@@ -82,6 +147,42 @@ const controllerFor = (workspace: DraftWorkspace): DraftAuthoringController => (
}); });
describe("SelectedCapabilityInspector", () => { describe("SelectedCapabilityInspector", () => {
it("threads inventory-backed pickers into the selected step editors", async () => {
const user = userEvent.setup();
mockedUseAuthoringContract.mockReturnValue({
phase: "ready",
inventory,
message: null,
refresh: vi.fn(),
});
const workspace = draft(
"read",
[{ target: "title", path: "input.title" }],
[{ source: "text", target: "state.existing" }],
);
render(
<SelectedCapabilityInspector
capabilityDetail={detail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={controllerFor(workspace)}
draft={workspace}
nodeKind="use"
nodeRef="demo.read"
stepId="read"
/>,
);
await user.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("button", { name: /Inventory title target/ })).toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: "Source path for input row 1" })).toBeNull();
await user.click(screen.getByRole("tab", { name: "Outputs" }));
expect(screen.getByRole("button", { name: /Inventory text/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Inventory state/ })).toBeInTheDocument();
});
it("composes setup, inputs, and outputs while preserving malformed rows and dispatching focused saves", async () => { it("composes setup, inputs, and outputs while preserving malformed rows and dispatching focused saves", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const workspace = draft( const workspace = draft(
@@ -252,7 +353,7 @@ describe("SelectedCapabilityInspector", () => {
await user.click(screen.getByRole("tab", { name: "Inputs" })); await user.click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("group", { name: "items" })).toBeInTheDocument(); 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: "Value source for items item 1" })).toHaveValue("path");
expect(screen.getByRole("combobox", { name: "Path for items item 1" })).toHaveValue("state.foo"); expect(customInput("Path for items item 1")).toHaveValue("state.foo");
expect(screen.getByRole("textbox", { name: "Items item" })).toHaveValue("wowcool"); expect(screen.getByRole("textbox", { name: "Items item" })).toHaveValue("wowcool");
await user.click(screen.getByRole("button", { name: "Save inputs" })); await user.click(screen.getByRole("button", { name: "Save inputs" }));
@@ -322,7 +423,7 @@ describe("SelectedCapabilityInspector", () => {
stepId="read" stepId="read"
/>, />,
); );
expect(screen.getByRole("combobox", { name: "Path for items item 1" })).toHaveValue("state.bar"); expect(customInput("Path for items item 1")).toHaveValue("state.bar");
expect(screen.getByRole("textbox", { name: "Items item" })).toHaveValue("after"); expect(screen.getByRole("textbox", { name: "Items item" })).toHaveValue("after");
}); });
@@ -353,10 +454,10 @@ describe("SelectedCapabilityInspector", () => {
}); });
await user.click(screen.getByRole("button", { name: "Save setup" })); await user.click(screen.getByRole("button", { name: "Save setup" }));
await user.click(screen.getByRole("tab", { name: "Inputs" })); await user.click(screen.getByRole("tab", { name: "Inputs" }));
await user.clear(screen.getByRole("combobox", { name: "Target for row 1" })); await user.clear(customInput("Target for row 1"));
await user.click(screen.getByRole("button", { name: "Save inputs" })); await user.click(screen.getByRole("button", { name: "Save inputs" }));
await user.click(screen.getByRole("tab", { name: "Outputs" })); await user.click(screen.getByRole("tab", { name: "Outputs" }));
await user.clear(screen.getByRole("combobox", { name: "Target for output row 1" })); await user.clear(customInput("Target for output row 1"));
await user.click(screen.getByRole("button", { name: "Save outputs" })); await user.click(screen.getByRole("button", { name: "Save outputs" }));
const diagnosticIds = [...document.querySelectorAll('[id$="-error"], [id$="-errors"]')] const diagnosticIds = [...document.querySelectorAll('[id$="-error"], [id$="-errors"]')]
@@ -383,7 +484,7 @@ describe("SelectedCapabilityInspector", () => {
); );
await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" })); await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title"); expect(customInput("Target for row 1")).toHaveValue("title");
rerender( rerender(
<SelectedCapabilityInspector <SelectedCapabilityInspector
capabilityDetail={detail} capabilityDetail={detail}
@@ -398,7 +499,7 @@ describe("SelectedCapabilityInspector", () => {
/>, />,
); );
await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" })); await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" }));
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title"); expect(customInput("Target for row 1")).toHaveValue("title");
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue("Second"); expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue("Second");
}); });
@@ -5,12 +5,14 @@ import { CapabilitySetupForm } from "./CapabilitySetupForm.js";
import { StepInputBindingsForm } from "./StepInputBindingsForm.js"; import { StepInputBindingsForm } from "./StepInputBindingsForm.js";
import { StepOutputBindingsForm } from "./StepOutputBindingsForm.js"; import { StepOutputBindingsForm } from "./StepOutputBindingsForm.js";
import { import {
authoringOptionsForUse,
bindingDiagnosticsForStep, bindingDiagnosticsForStep,
outputBindingRows, outputBindingRows,
projectSelectedStepDataflow, projectSelectedStepDataflow,
stepInputBindingRows, stepInputBindingRows,
} from "./selected-step-dataflow.js"; } from "./selected-step-dataflow.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js"; import type { DraftAuthoringController } from "./useDraftAuthoring.js";
import { useAuthoringContract } from "./useAuthoringContract.js";
type InspectorTab = "setup" | "inputs" | "outputs"; type InspectorTab = "setup" | "inputs" | "outputs";
@@ -120,6 +122,16 @@ export const SelectedCapabilityInspector = ({
if (target !== undefined) activateTab(target); if (target !== undefined) activateTab(target);
}; };
const rawStep = selectedStep(draft, stepId); const rawStep = selectedStep(draft, stepId);
const authoringContract = useAuthoringContract({
workspaceId: draft.workspaceId,
revision: draft.revision,
selectedStepId: stepId,
});
const inventory = authoringContract.inventory;
const inputSourceOptions = authoringOptionsForUse(inventory?.readableSources ?? [], "step_input");
const inputTargetOptions = authoringOptionsForUse(inventory?.stepInputTargets ?? [], "step_input");
const outputSourceOptions = authoringOptionsForUse(inventory?.stepOutputSources ?? [], "step_output_source");
const outputTargetOptions = authoringOptionsForUse(inventory?.stateTargets ?? [], "state_target");
const projected = projectSelectedStepDataflow(draft, stepId); const projected = projectSelectedStepDataflow(draft, stepId);
const preservedForm = controller.preservedCapabilityForm?.kind === "update" && const preservedForm = controller.preservedCapabilityForm?.kind === "update" &&
controller.preservedCapabilityForm.input.stepId === stepId controller.preservedCapabilityForm.input.stepId === stepId
@@ -202,8 +214,8 @@ export const SelectedCapabilityInspector = ({
canonicalVersion={`${stepId}:${controller.resetGeneration}`} canonicalVersion={`${stepId}:${controller.resetGeneration}`}
initialRows={inputRows} initialRows={inputRows}
inputSchema={capabilityDetail.inputSchema} inputSchema={capabilityDetail.inputSchema}
workflowInputSchema={isRecord(draft.draft) ? draft.draft.input_schema : undefined} sourceOptions={inputSourceOptions}
workflowStateSchema={isRecord(draft.draft) ? draft.draft.state_schema : undefined} targetOptions={inputTargetOptions}
onDirtyChange={controller.markDirty} onDirtyChange={controller.markDirty}
onSubmit={controller.setStepInputs} onSubmit={controller.setStepInputs}
rowDiagnostics={inputDiagnostics.rowIssues} rowDiagnostics={inputDiagnostics.rowIssues}
@@ -216,6 +228,8 @@ export const SelectedCapabilityInspector = ({
onDirtyChange={controller.markDirty} onDirtyChange={controller.markDirty}
onSubmit={controller.setStepOutputs} onSubmit={controller.setStepOutputs}
outputSchema={capabilityDetail.outputSchema} outputSchema={capabilityDetail.outputSchema}
sourceOptions={outputSourceOptions}
targetOptions={outputTargetOptions}
rowDiagnostics={outputDiagnostics.rowIssues} rowDiagnostics={outputDiagnostics.rowIssues}
stateSchema={(isRecord(draft.draft) ? draft.draft.state_schema : null) ?? emptyStateSchema} stateSchema={(isRecord(draft.draft) ? draft.draft.state_schema : null) ?? emptyStateSchema}
/> />
@@ -1,6 +1,7 @@
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { AuthoringPathOption } from "../domain/authoring-contract-models.js";
import type { StepInputBinding } from "../domain/draft-workspace-models.js"; import type { StepInputBinding } from "../domain/draft-workspace-models.js";
import { StepInputBindingsForm } from "./StepInputBindingsForm.js"; import { StepInputBindingsForm } from "./StepInputBindingsForm.js";
import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js"; import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js";
@@ -19,7 +20,71 @@ const schema = {
}, },
}; };
const authoringOption = (
path: string,
label: string,
origin: AuthoringPathOption["origin"],
uses: AuthoringPathOption["uses"],
): AuthoringPathOption => ({
path,
label,
origin,
schema: { type: "string" },
required: false,
availability: "available",
uses,
});
const customInput = (label: string) =>
within(screen.getByRole("region", { name: label }))
.getByRole("textbox", { name: `Custom ${label}` });
const editableCustomInput = async (
user: ReturnType<typeof userEvent.setup>,
label: string,
) => {
const picker = screen.getByRole("region", { name: label });
const details = picker.querySelector("details");
if (!(details instanceof HTMLDetailsElement) || !details.open) {
await user.click(within(picker).getByText("Advanced"));
}
return within(picker).getByRole("textbox", { name: `Custom ${label}` });
};
describe("StepInputBindingsForm", () => { describe("StepInputBindingsForm", () => {
it("uses inventory pickers for sources and targets while preserving canonical bindings", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<StepInputBinding>[] = [];
const sourceOptions = [
authoringOption("input.request.id", "Request id", "workflow_input", ["step_input"]),
authoringOption("context.loop_item", "Loop item", "runtime_context", ["step_input"]),
];
const targetOptions = [
authoringOption("step_input.profile.name", "Profile name", "step_input", ["step_input"]),
];
render(
<StepInputBindingsForm
inputSchema={schema}
sourceOptions={sourceOptions}
targetOptions={targetOptions}
initialBindings={[{ path: "input.request.id", target: "profile.name" }]}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
expect(screen.getByRole("group", { name: "Workflow input" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Runtime context" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Step input" })).toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: "Source path for input row 1" })).toBeNull();
await user.click(screen.getByRole("button", { name: /Request id/ }));
await user.click(screen.getByRole("button", { name: /Profile name/ }));
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toEqual([[{ path: "input.request.id", target: "profile.name" }]]);
});
it("formats local and graph path objects at whole and nested paths", () => { it("formats local and graph path objects at whole and nested paths", () => {
expect(displayLocalInputPath({ root: "local", parts: [] })).toBe("."); expect(displayLocalInputPath({ root: "local", parts: [] })).toBe(".");
expect(displayLocalInputPath({ root: "local", parts: ["payload", "item"] })).toBe("payload.item"); expect(displayLocalInputPath({ root: "local", parts: ["payload", "item"] })).toBe("payload.item");
@@ -42,11 +107,11 @@ describe("StepInputBindingsForm", () => {
); );
expect(screen.getByRole("group", { name: "Input row 1" })).toBeInTheDocument(); expect(screen.getByRole("group", { name: "Input row 1" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("title"); expect(customInput("Target for row 1")).toHaveValue("title");
expect(screen.getByRole("radio", { name: "Path for input row 1" })).toBeChecked(); expect(screen.getByRole("radio", { name: "Path for input row 1" })).toBeChecked();
expect(screen.getByRole("combobox", { name: "Source path for input row 1" })).toHaveValue("input.title"); expect(customInput("Source path for input row 1")).toHaveValue("input.title");
expect(screen.getByRole("combobox", { name: "Nullable" })).toHaveValue("0:null"); expect(screen.getByRole("combobox", { name: "Nullable" })).toHaveValue("0:null");
expect(screen.getByRole("combobox", { name: "Target for row 3" })).toHaveValue("nested.name"); expect(customInput("Target for row 3")).toHaveValue("nested.name");
expect(screen.getByText("Unsupported input binding.")).toBeInTheDocument(); expect(screen.getByText("Unsupported input binding.")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Raw unsupported input row 4" })).toHaveTextContent('"target"'); expect(screen.getByRole("region", { name: "Raw unsupported input row 4" })).toHaveTextContent('"target"');
expect(screen.getByRole("button", { name: "Remove unsupported input row 4" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Remove unsupported input row 4" })).toBeInTheDocument();
@@ -178,10 +243,10 @@ describe("StepInputBindingsForm", () => {
/>, />,
); );
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("payload.item"); expect(customInput("Target for row 1")).toHaveValue("payload.item");
expect(screen.getByRole("combobox", { name: "Target for row 2" })).toHaveValue("."); expect(customInput("Target for row 2")).toHaveValue(".");
expect(screen.getByRole("combobox", { name: "Source path for input row 1" })).toHaveValue("input.source"); expect(customInput("Source path for input row 1")).toHaveValue("input.source");
expect(screen.getByRole("combobox", { name: "Source path for input row 2" })).toHaveValue("state.audit.latest"); expect(customInput("Source path for input row 2")).toHaveValue("state.audit.latest");
await user.click(screen.getByRole("button", { name: "Save inputs" })); await user.click(screen.getByRole("button", { name: "Save inputs" }));
@@ -218,8 +283,8 @@ describe("StepInputBindingsForm", () => {
await user.clear(screen.getByRole("textbox", { name: "Name" })); await user.clear(screen.getByRole("textbox", { name: "Name" }));
await user.type(screen.getByRole("textbox", { name: "Name" }), "after"); await user.type(screen.getByRole("textbox", { name: "Name" }), "after");
await user.click(screen.getByRole("radio", { name: "Path for input row 1" })); await user.click(screen.getByRole("radio", { name: "Path for input row 1" }));
await user.clear(screen.getByRole("combobox", { name: "Source path for input row 1" })); await user.clear(await editableCustomInput(user, "Source path for input row 1"));
await user.type(screen.getByRole("combobox", { name: "Source path for input row 1" }), "input.nested"); await user.type(await editableCustomInput(user, "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("radio", { name: "Literal value for input row 1" }));
await user.click(screen.getByRole("button", { name: "Save inputs" })); await user.click(screen.getByRole("button", { name: "Save inputs" }));
@@ -258,13 +323,13 @@ describe("StepInputBindingsForm", () => {
/>, />,
); );
await user.clear(screen.getByRole("combobox", { name: "Target for row 1" })); await user.clear(await editableCustomInput(user, "Target for row 1"));
await user.click(screen.getByRole("button", { name: "Save inputs" })); await user.click(screen.getByRole("button", { name: "Save inputs" }));
const target = screen.getByRole("combobox", { name: "Target for row 1" }); const target = customInput("Target for row 1");
const describedBy = target.getAttribute("aria-describedby"); const describedBy = target.getAttribute("aria-describedby");
expect(describedBy).not.toBeNull();
expect(target).toHaveAttribute("aria-invalid", "true"); expect(target).toHaveAttribute("aria-invalid", "true");
expect(describedBy).toBeTruthy();
expect(document.getElementById(describedBy ?? "")).toHaveTextContent("Target is required."); expect(document.getElementById(describedBy ?? "")).toHaveTextContent("Target is required.");
}); });
@@ -277,35 +342,24 @@ describe("StepInputBindingsForm", () => {
type: "object", type: "object",
properties: { profile: { type: "object", properties: { name: { type: "string" } } } }, properties: { profile: { type: "object", properties: { name: { type: "string" } } } },
}} }}
workflowInputSchema={{ sourceOptions={[
type: "object", authoringOption("input.request.id", "Request id", "workflow_input", ["step_input"]),
properties: { request: { type: "object", properties: { id: { type: "string" } } } }, authoringOption("state.session.token", "Session token", "workflow_state", ["step_input"]),
}} ]}
workflowStateSchema={{ targetOptions={[authoringOption("step_input.profile.name", "Profile name", "step_input", ["step_input"])]}
type: "object",
properties: { session: { type: "object", properties: { token: { type: "string" } } } },
}}
initialBindings={[{ path: "input.request.id", target: "profile.name" }]} initialBindings={[{ path: "input.request.id", target: "profile.name" }]}
onSubmit={(value) => { submissions.push(value); }} onSubmit={(value) => { submissions.push(value); }}
/>, />,
); );
const target = screen.getByRole("combobox", { name: "Target for row 1" }); expect(screen.getByRole("button", { name: /Profile name/ })).toBeInTheDocument();
const targetList = document.getElementById(target.getAttribute("list") ?? ""); expect(screen.getByRole("button", { name: /Request id/ })).toBeInTheDocument();
expect(target.getAttribute("list")).toBeTruthy(); expect(screen.getByRole("button", { name: /Session token/ })).toBeInTheDocument();
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.clear(await editableCustomInput(user, "Target for row 1"));
await user.type(target, "profile.custom"); await user.type(await editableCustomInput(user, "Target for row 1"), "profile.custom");
await user.clear(source); await user.clear(await editableCustomInput(user, "Source path for input row 1"));
await user.type(source, "context.custom"); await user.type(await editableCustomInput(user, "Source path for input row 1"), "context.custom");
await user.click(screen.getByRole("button", { name: "Save inputs" })); await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toEqual([[{ path: "context.custom", target: "profile.custom" }]]); expect(submissions).toEqual([[{ path: "context.custom", target: "profile.custom" }]]);
@@ -325,13 +379,13 @@ describe("StepInputBindingsForm", () => {
/>, />,
); );
await user.clear(screen.getByRole("combobox", { name: "Target for row 2" })); await user.clear(await editableCustomInput(user, "Target for row 2"));
await user.type(screen.getByRole("combobox", { name: "Target for row 2" }), "title"); await user.type(await editableCustomInput(user, "Target for row 2"), "title");
await user.click(screen.getByRole("button", { name: "Save inputs" })); await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toEqual([]); expect(submissions).toEqual([]);
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveAttribute("aria-invalid", "true"); expect(screen.getByRole("region", { name: "Target for row 1" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Target for row 2" })).toHaveAttribute("aria-invalid", "true"); expect(screen.getByRole("region", { name: "Target for row 2" })).toBeInTheDocument();
expect(screen.getAllByRole("alert").filter((alert) => expect(screen.getAllByRole("alert").filter((alert) =>
alert.textContent?.includes("Target is duplicated") ?? false, alert.textContent?.includes("Target is duplicated") ?? false,
)).toHaveLength(2); )).toHaveLength(2);
@@ -351,14 +405,14 @@ describe("StepInputBindingsForm", () => {
/>, />,
); );
const secondTarget = screen.getByRole("combobox", { name: "Target for row 2" }); const secondTarget = await editableCustomInput(user, "Target for row 2");
await user.clear(secondTarget); await user.clear(secondTarget);
await user.type(secondTarget, "title"); await user.type(await editableCustomInput(user, "Target for row 2"), "title");
await user.click(screen.getByRole("button", { name: "Save inputs" })); await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toEqual([]); expect(submissions).toEqual([]);
await user.clear(secondTarget); await user.clear(await editableCustomInput(user, "Target for row 2"));
await user.type(secondTarget, "nullable"); await user.type(await editableCustomInput(user, "Target for row 2"), "nullable");
expect(screen.getByRole("button", { name: "Save inputs" })).not.toBeDisabled(); expect(screen.getByRole("button", { name: "Save inputs" })).not.toBeDisabled();
await user.click(screen.getByRole("button", { name: "Save inputs" })); await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toHaveLength(1); expect(submissions).toHaveLength(1);
@@ -379,7 +433,7 @@ describe("StepInputBindingsForm", () => {
); );
expect(screen.getByRole("button", { name: "Save inputs" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Save inputs" })).toBeDisabled();
const source = screen.getByRole("combobox", { name: "Source path for input row 1" }); const source = await editableCustomInput(user, "Source path for input row 1");
await user.clear(source); await user.clear(source);
await user.type(source, "context.profile"); await user.type(source, "context.profile");
@@ -399,7 +453,7 @@ describe("StepInputBindingsForm", () => {
onSubmit={() => undefined} onSubmit={() => undefined}
/>, />,
); );
const target = screen.getByRole("combobox", { name: "Target for row 1" }); const target = customInput("Target for row 1");
rerender( rerender(
<StepInputBindingsForm <StepInputBindingsForm
@@ -421,7 +475,7 @@ describe("StepInputBindingsForm", () => {
onSubmit={() => undefined} onSubmit={() => undefined}
/>, />,
); );
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("edited"); expect(customInput("Target for row 1")).toHaveValue("edited");
rerender( rerender(
<StepInputBindingsForm <StepInputBindingsForm
@@ -431,7 +485,7 @@ describe("StepInputBindingsForm", () => {
onSubmit={() => undefined} onSubmit={() => undefined}
/>, />,
); );
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("nullable"); expect(customInput("Target for row 1")).toHaveValue("nullable");
}); });
it("shows row diagnostics at the row that owns them", () => { it("shows row diagnostics at the row that owns them", () => {
@@ -467,13 +521,13 @@ describe("StepInputBindingsForm", () => {
); );
await user.click(screen.getByRole("button", { name: "Add input row" })); await user.click(screen.getByRole("button", { name: "Add input row" }));
await user.type(screen.getByRole("combobox", { name: "Target for row 1" }), "items"); await user.type(await editableCustomInput(user, "Target for row 1"), "items");
await user.click(screen.getByRole("radio", { name: "Construct value for input row 1" })); 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.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"); 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" }); const itemPath = customInput("Path for items item 1");
await user.clear(itemPath); await user.clear(itemPath);
await user.type(itemPath, "state.foo"); await user.type(itemPath, "state.foo");
await user.selectOptions(screen.getByRole("combobox", { name: "Value source for items item 2" }), "literal"); await user.selectOptions(screen.getByRole("combobox", { name: "Value source for items item 2" }), "literal");
@@ -482,7 +536,7 @@ describe("StepInputBindingsForm", () => {
await user.type(itemValue, "wowcool"); await user.type(itemValue, "wowcool");
await user.click(screen.getByRole("button", { name: "Add input row" })); await user.click(screen.getByRole("button", { name: "Add input row" }));
await user.type(screen.getByRole("combobox", { name: "Target for row 2" }), "separator"); await user.type(await editableCustomInput(user, "Target for row 2"), "separator");
await user.click(screen.getByRole("radio", { name: "Literal value for input row 2" })); await user.click(screen.getByRole("radio", { name: "Literal value for input row 2" }));
await user.clear(screen.getByRole("textbox", { name: "Separator" })); await user.clear(screen.getByRole("textbox", { name: "Separator" }));
await user.type(screen.getByRole("textbox", { name: "Separator" }), " "); await user.type(screen.getByRole("textbox", { name: "Separator" }), " ");
@@ -1,4 +1,7 @@
import { useId, useRef, useState, type FormEvent } from "react"; import { useId, useRef, useState, type FormEvent } from "react";
import type {
AuthoringPathOption,
} from "../domain/authoring-contract-models.js";
import type { import type {
DraftDiagnostic, DraftDiagnostic,
StepInputBinding, StepInputBinding,
@@ -21,13 +24,14 @@ import {
import { serializeSchemaValues, type FieldSources } from "../schema-form/schema-values.js"; import { serializeSchemaValues, type FieldSources } from "../schema-form/schema-values.js";
import { formatBoundedJson } from "../domain/format-bounded-json.js"; import { formatBoundedJson } from "../domain/format-bounded-json.js";
import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js"; import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js";
import { AuthoringPathPicker } from "./AuthoringPathPicker.js";
import { import {
capabilityLocalPathSuggestions, localPathFromPickerValue,
pickerValueForLocalPath,
isJsonValue, isJsonValue,
serializeInputBindingRow, serializeInputBindingRow,
serializeStepInputBindingRow, serializeStepInputBindingRow,
stepInputBindingRows, stepInputBindingRows,
workflowSourceSuggestions,
type StepInputBindingRow, type StepInputBindingRow,
} from "./selected-step-dataflow.js"; } from "./selected-step-dataflow.js";
@@ -51,8 +55,8 @@ type FormRow = EditableRow | UnsupportedRow;
export type StepInputBindingsFormProps = { export type StepInputBindingsFormProps = {
readonly inputSchema: unknown; readonly inputSchema: unknown;
readonly workflowInputSchema?: unknown; readonly sourceOptions?: ReadonlyArray<AuthoringPathOption>;
readonly workflowStateSchema?: unknown; readonly targetOptions?: ReadonlyArray<AuthoringPathOption>;
/** Changes only when the parent has accepted a new canonical draft. */ /** Changes only when the parent has accepted a new canonical draft. */
readonly canonicalVersion?: string | number | null; readonly canonicalVersion?: string | number | null;
readonly initialRows?: ReadonlyArray<StepInputBindingRow>; readonly initialRows?: ReadonlyArray<StepInputBindingRow>;
@@ -294,8 +298,8 @@ const duplicateIssuesForRows = (
const StepInputBindingsFormContent = ({ const StepInputBindingsFormContent = ({
canonicalVersion = null, canonicalVersion = null,
inputSchema, inputSchema,
workflowInputSchema, sourceOptions = [],
workflowStateSchema, targetOptions = [],
initialRows, initialRows,
initialBindings, initialBindings,
rowDiagnostics = EMPTY_DIAGNOSTICS, rowDiagnostics = EMPTY_DIAGNOSTICS,
@@ -305,10 +309,6 @@ const StepInputBindingsFormContent = ({
}: StepInputBindingsFormProps) => { }: StepInputBindingsFormProps) => {
const formId = useId(); const formId = useId();
const root = normalizeSchema(inputSchema); 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>>(() => const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() =>
rowsFrom(inputRows(initialRows, initialBindings), formId, root), rowsFrom(inputRows(initialRows, initialBindings), formId, root),
); );
@@ -420,12 +420,6 @@ const StepInputBindingsFormContent = ({
return ( return (
<form className="schema-form authoring-form" noValidate onSubmit={submit}> <form className="schema-form authoring-form" noValidate onSubmit={submit}>
{formIssue !== null && <p id={formErrorId} role="alert">{formIssue}</p>} {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"> <div className="schema-form__group">
{rows.length === 0 && <p>No input bindings configured.</p>} {rows.length === 0 && <p>No input bindings configured.</p>}
{rows.map((row, index) => { {rows.map((row, index) => {
@@ -469,9 +463,7 @@ const StepInputBindingsFormContent = ({
...(duplicateIssues.get(row.id) ?? []), ...(duplicateIssues.get(row.id) ?? []),
...bindingForRow(root, row).issues, ...bindingForRow(root, row).issues,
])]; ])];
const targetId = `${row.id}-target`;
const errorId = `${row.id}-errors`; const errorId = `${row.id}-errors`;
const pathId = `${row.id}-source-path`;
const literalId = `${row.id}-literal`; const literalId = `${row.id}-literal`;
const pathModeId = `${row.id}-path-mode`; const pathModeId = `${row.id}-path-mode`;
const literalModeId = `${row.id}-literal-mode`; const literalModeId = `${row.id}-literal-mode`;
@@ -484,16 +476,18 @@ const StepInputBindingsFormContent = ({
return ( return (
<fieldset aria-label={`Input row ${rowNumber}`} className="schema-form__group" key={row.id}> <fieldset aria-label={`Input row ${rowNumber}`} className="schema-form__group" key={row.id}>
<legend>Input row {rowNumber}</legend> <legend>Input row {rowNumber}</legend>
<label htmlFor={targetId}>Target</label> <AuthoringPathPicker
<input allowCustom
aria-describedby={hasIssues ? errorId : undefined} describedBy={hasIssues ? errorId : undefined}
aria-invalid={hasIssues} invalid={hasIssues}
aria-label={`Target for row ${rowNumber}`} label={`Target for row ${rowNumber}`}
id={targetId} onChange={(target) => editRow(row.id, (current) => ({
list={targetListId} ...current,
onChange={(event) => editRow(row.id, (current) => ({ ...current, target: event.target.value }))} target: localPathFromPickerValue(target, targetOptions, "step_input"),
type="text" }))}
value={row.target} options={targetOptions}
uses="step_input"
value={pickerValueForLocalPath(row.target, targetOptions, "step_input")}
/> />
<fieldset aria-label={`Source mode for input row ${rowNumber}`} className="schema-form__source"> <fieldset aria-label={`Source mode for input row ${rowNumber}`} className="schema-form__source">
<legend>Value source</legend> <legend>Value source</legend>
@@ -540,19 +534,16 @@ const StepInputBindingsFormContent = ({
</div> </div>
</fieldset> </fieldset>
{row.mode === "path" ? ( {row.mode === "path" ? (
<label htmlFor={pathId}> <AuthoringPathPicker
Source path for input row {rowNumber} allowCustom
<input describedBy={hasIssues ? errorId : undefined}
aria-describedby={hasIssues ? errorId : undefined} invalid={hasIssues}
aria-invalid={hasIssues} label={`Source path for input row ${rowNumber}`}
aria-label={`Source path for input row ${rowNumber}`} onChange={(path) => editRow(row.id, (current) => ({ ...current, sourcePath: path }))}
id={pathId} options={sourceOptions}
list={sourceListId} uses="step_input"
onChange={(event) => editRow(row.id, (current) => ({ ...current, sourcePath: event.target.value }))}
type="text"
value={row.sourcePath} value={row.sourcePath}
/> />
</label>
) : row.mode === "expression" ? ( ) : row.mode === "expression" ? (
<InputExpressionControl <InputExpressionControl
field={field} field={field}
@@ -562,7 +553,7 @@ const StepInputBindingsFormContent = ({
...current, ...current,
expression: next, expression: next,
}))} }))}
sourceSuggestions={sourceSuggestions} sourceOptions={sourceOptions}
state={row.expression ?? defaultExpressionEditorState(field)} state={row.expression ?? defaultExpressionEditorState(field)}
showModeControl={false} showModeControl={false}
/> />
@@ -1,6 +1,7 @@
import { cleanup, render, screen, within } from "@testing-library/react"; import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import type { AuthoringPathOption } from "../domain/authoring-contract-models.js";
import type { DraftDiagnostic, OutputBinding } from "../domain/draft-workspace-models.js"; import type { DraftDiagnostic, OutputBinding } from "../domain/draft-workspace-models.js";
import { StepOutputBindingsForm } from "./StepOutputBindingsForm.js"; import { StepOutputBindingsForm } from "./StepOutputBindingsForm.js";
@@ -24,6 +25,21 @@ const stateSchema = {
}, },
}; };
const authoringOption = (
path: string,
label: string,
origin: AuthoringPathOption["origin"],
uses: AuthoringPathOption["uses"],
): AuthoringPathOption => ({
path,
label,
origin,
schema: { type: "string" },
required: false,
availability: "available",
uses,
});
type FormUser = ReturnType<typeof userEvent.setup>; type FormUser = ReturnType<typeof userEvent.setup>;
type ClearMutationCase = { type ClearMutationCase = {
readonly name: string; readonly name: string;
@@ -31,13 +47,26 @@ type ClearMutationCase = {
readonly expected: ReadonlyArray<OutputBinding>; readonly expected: ReadonlyArray<OutputBinding>;
}; };
const customInput = (label: string) =>
within(screen.getByRole("region", { name: label }))
.getByRole("textbox", { name: `Custom ${label}` });
const editableCustomInput = async (user: FormUser, label: string) => {
const picker = screen.getByRole("region", { name: label });
const details = picker.querySelector("details");
if (!(details instanceof HTMLDetailsElement) || !details.open) {
await user.click(within(picker).getByText("Advanced"));
}
return within(picker).getByRole("textbox", { name: `Custom ${label}` });
};
const clearMutationCases: ReadonlyArray<ClearMutationCase> = [ const clearMutationCases: ReadonlyArray<ClearMutationCase> = [
{ {
name: "add", name: "add",
mutate: async (user) => { mutate: async (user) => {
await user.click(screen.getByRole("button", { name: "Add output row" })); await user.click(screen.getByRole("button", { name: "Add output row" }));
await user.type( await user.type(
screen.getByRole("combobox", { name: "Target for output row 3" }), await editableCustomInput(user, "Target for output row 3"),
"state.third", "state.third",
); );
}, },
@@ -50,7 +79,7 @@ const clearMutationCases: ReadonlyArray<ClearMutationCase> = [
{ {
name: "edit", name: "edit",
mutate: async (user) => { mutate: async (user) => {
const target = screen.getByRole("combobox", { name: "Target for output row 1" }); const target = await editableCustomInput(user, "Target for output row 1");
await user.clear(target); await user.clear(target);
await user.type(target, "state.edited"); await user.type(target, "state.edited");
}, },
@@ -69,30 +98,49 @@ const clearMutationCases: ReadonlyArray<ClearMutationCase> = [
]; ];
describe("StepOutputBindingsForm", () => { describe("StepOutputBindingsForm", () => {
it("uses inventory step-output and state-target pickers while preserving local output bindings", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<OutputBinding>[] = [];
render(
<StepOutputBindingsForm
outputSchema={{ type: "object", properties: { text: { type: "string" } } }}
stateSchema={stateSchema}
sourceOptions={[authoringOption("step_output.text", "Text output", "step_output", ["step_output_source"])]}
targetOptions={[authoringOption("state.existing", "Existing state", "workflow_state", ["state_target"])]}
initialBindings={[{ source: "text", target: "state.existing" }]}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
expect(screen.getByRole("group", { name: "Step output" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "State" })).toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: "Source choice for output row 1" })).toBeNull();
await user.click(screen.getByRole("button", { name: /Text output/ }));
await user.click(screen.getByRole("button", { name: /Existing state/ }));
await user.click(screen.getByRole("button", { name: "Save outputs" }));
expect(submissions).toEqual([[{ source: "text", target: "state.existing" }]]);
});
it("offers capability output sources and existing state targets", () => { it("offers capability output sources and existing state targets", () => {
render( render(
<StepOutputBindingsForm <StepOutputBindingsForm
outputSchema={outputSchema} outputSchema={outputSchema}
stateSchema={stateSchema} stateSchema={stateSchema}
sourceOptions={[
authoringOption("step_output.text", "Text", "step_output", ["step_output_source"]),
authoringOption("step_output.audit.latest", "Latest audit", "step_output", ["step_output_source"]),
]}
targetOptions={[authoringOption("state.existing", "Existing state", "workflow_state", ["state_target"])]}
initialBindings={[{ source: "text", target: "state.existing" }]} initialBindings={[{ source: "text", target: "state.existing" }]}
onSubmit={() => undefined} onSubmit={() => undefined}
/>, />,
); );
const source = screen.getByRole("combobox", { name: "Source choice for output row 1" }); expect(screen.getByRole("button", { name: /Text/ })).toBeInTheDocument();
expect(within(source).getByRole("option", { name: "Whole output (.)" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Latest audit/ })).toBeInTheDocument();
expect(within(source).getByRole("option", { name: "text" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Existing state/ })).toBeInTheDocument();
expect(within(source).getByRole("option", { name: "audit.latest" })).toBeInTheDocument();
const target = screen.getByRole("combobox", { name: "Target for output row 1" });
expect(target).toHaveValue("state.existing");
const targetList = target.getAttribute("list");
expect(targetList).toBeTruthy();
const targetListElement = document.getElementById(targetList ?? "");
expect(targetListElement).not.toBeNull();
if (targetListElement !== null) {
expect(targetListElement.querySelector('option[value="state.existing"]')).not.toBeNull();
}
}); });
it("submits a nested state target and shows the selected source schema preview", async () => { it("submits a nested state target and shows the selected source schema preview", async () => {
@@ -107,9 +155,7 @@ describe("StepOutputBindingsForm", () => {
/>, />,
); );
expect(screen.getByRole("combobox", { name: "Target for output row 1" })).toHaveValue( expect(customInput("Target for output row 1")).toHaveValue("state.report.markdown");
"state.report.markdown",
);
expect(screen.getByRole("region", { name: "Inferred schema for output row 1" })) expect(screen.getByRole("region", { name: "Inferred schema for output row 1" }))
.toHaveTextContent('"type": "integer"'); .toHaveTextContent('"type": "integer"');
@@ -152,10 +198,7 @@ describe("StepOutputBindingsForm", () => {
/>, />,
); );
expect(screen.getByRole("combobox", { name: "Source choice for output row 1" })) expect(customInput("Source path for output row 1")).toHaveValue("nested.whole");
.toHaveValue("custom-source");
expect(screen.getByRole("textbox", { name: "Local source path for output row 1" }))
.toHaveValue("nested.whole");
await user.click(screen.getByRole("button", { name: "Save outputs" })); await user.click(screen.getByRole("button", { name: "Save outputs" }));
@@ -183,10 +226,8 @@ describe("StepOutputBindingsForm", () => {
/>, />,
); );
expect(screen.getByRole("textbox", { name: "Local source path for output row 1" })) expect(customInput("Source path for output row 1")).toHaveValue("payload.item");
.toHaveValue("payload.item"); expect(customInput("Source path for output row 2")).toHaveValue(".");
expect(screen.getByRole("textbox", { name: "Local source path for output row 2" }))
.toHaveValue(".");
await user.click(screen.getByRole("button", { name: "Save outputs" })); await user.click(screen.getByRole("button", { name: "Save outputs" }));
@@ -208,12 +249,8 @@ describe("StepOutputBindingsForm", () => {
); );
await user.click(screen.getByRole("button", { name: "Add output row" })); await user.click(screen.getByRole("button", { name: "Add output row" }));
expect(screen.getByRole("textbox", { name: "Local source path for output row 1" })) expect(customInput("Source path for output row 1")).toHaveValue(".");
.toHaveValue("."); await user.type(await editableCustomInput(user, "Target for output row 1"), "state.new");
await user.type(
screen.getByRole("combobox", { name: "Target for output row 1" }),
"state.new",
);
await user.click(screen.getByRole("button", { name: "Save outputs" })); await user.click(screen.getByRole("button", { name: "Save outputs" }));
expect(submissions).toEqual([[{ source: ".", target: "state.new" }]]); expect(submissions).toEqual([[{ source: ".", target: "state.new" }]]);
@@ -229,15 +266,17 @@ describe("StepOutputBindingsForm", () => {
properties: { __custom__: { type: "string" }, text: { type: "string" } }, properties: { __custom__: { type: "string" }, text: { type: "string" } },
}} }}
stateSchema={stateSchema} stateSchema={stateSchema}
sourceOptions={[
authoringOption("step_output.__custom__", "__custom__", "step_output", ["step_output_source"]),
authoringOption("step_output.text", "Text", "step_output", ["step_output_source"]),
]}
targetOptions={[authoringOption("state.existing", "Existing state", "workflow_state", ["state_target"])]}
initialBindings={[{ source: "text", target: "state.existing" }]} initialBindings={[{ source: "text", target: "state.existing" }]}
onSubmit={(value) => { submissions.push(value); }} onSubmit={(value) => { submissions.push(value); }}
/>, />,
); );
const source = screen.getByRole("combobox", { name: "Source choice for output row 1" }); await user.click(screen.getByRole("button", { name: /__custom__/ }));
const customSchemaOption = within(source).getAllByRole("option", { name: "__custom__" })[0];
expect(customSchemaOption).toBeDefined();
await user.selectOptions(source, customSchemaOption ?? "");
await user.click(screen.getByRole("button", { name: "Save outputs" })); await user.click(screen.getByRole("button", { name: "Save outputs" }));
expect(submissions).toEqual([[{ source: "__custom__", target: "state.existing" }]]); expect(submissions).toEqual([[{ source: "__custom__", target: "state.existing" }]]);
@@ -426,9 +465,10 @@ describe("StepOutputBindingsForm", () => {
/>, />,
); );
const target = screen.getByRole("combobox", { name: "Target for output row 1" }); const target = customInput("Target for output row 1");
const describedBy = target.getAttribute("aria-describedby"); const describedBy = target.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy(); expect(describedBy).not.toBeNull();
expect(target).toHaveAttribute("aria-invalid", "true");
expect(document.getElementById(describedBy ?? "")).toHaveTextContent("Target field is not declared."); expect(document.getElementById(describedBy ?? "")).toHaveTextContent("Target field is not declared.");
}); });
}); });
@@ -1,4 +1,7 @@
import { useId, useRef, useState, type FormEvent } from "react"; import { useId, useRef, useState, type FormEvent } from "react";
import type {
AuthoringPathOption,
} from "../domain/authoring-contract-models.js";
import type { import type {
DraftDiagnostic, DraftDiagnostic,
LocalInputPath, LocalInputPath,
@@ -6,12 +9,13 @@ import type {
StatePath, StatePath,
} from "../domain/draft-workspace-models.js"; } from "../domain/draft-workspace-models.js";
import { formatBoundedJson } from "../domain/format-bounded-json.js"; import { formatBoundedJson } from "../domain/format-bounded-json.js";
import { AuthoringPathPicker } from "./AuthoringPathPicker.js";
import { import {
capabilityLocalPathSuggestions,
inferredStateSchemaPreview, inferredStateSchemaPreview,
localPathFromPickerValue,
outputBindingRows, outputBindingRows,
pickerValueForLocalPath,
serializeOutputBindingRow, serializeOutputBindingRow,
stateTargetSuggestions,
type OutputBindingRow, type OutputBindingRow,
} from "./selected-step-dataflow.js"; } from "./selected-step-dataflow.js";
import { formatTOMLPath } from "../schema-form/schema-paths.js"; import { formatTOMLPath } from "../schema-form/schema-paths.js";
@@ -20,15 +24,10 @@ type EditableRow = {
readonly kind: "canonical"; readonly kind: "canonical";
readonly id: string; readonly id: string;
readonly rawIndex: number; readonly rawIndex: number;
readonly sourceSelection: SourceSelection;
readonly sourcePath: string; readonly sourcePath: string;
readonly target: string; readonly target: string;
}; };
type SourceSelection =
| { readonly kind: "schema"; readonly index: number }
| { readonly kind: "custom" };
type UnsupportedRow = Extract<OutputBindingRow, { readonly kind: "unsupported" }> & { type UnsupportedRow = Extract<OutputBindingRow, { readonly kind: "unsupported" }> & {
readonly id: string; readonly id: string;
}; };
@@ -38,6 +37,8 @@ type FormRow = EditableRow | UnsupportedRow;
export type StepOutputBindingsFormProps = { export type StepOutputBindingsFormProps = {
readonly outputSchema: unknown; readonly outputSchema: unknown;
readonly stateSchema: unknown; readonly stateSchema: unknown;
readonly sourceOptions?: ReadonlyArray<AuthoringPathOption>;
readonly targetOptions?: ReadonlyArray<AuthoringPathOption>;
readonly initialRows?: ReadonlyArray<OutputBindingRow>; readonly initialRows?: ReadonlyArray<OutputBindingRow>;
readonly initialBindings?: ReadonlyArray<OutputBinding>; readonly initialBindings?: ReadonlyArray<OutputBinding>;
readonly rowDiagnostics?: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>; readonly rowDiagnostics?: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>;
@@ -48,8 +49,6 @@ export type StepOutputBindingsFormProps = {
const EMPTY_ROWS: ReadonlyArray<OutputBindingRow> = []; const EMPTY_ROWS: ReadonlyArray<OutputBindingRow> = [];
const EMPTY_DIAGNOSTICS: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>> = {}; const EMPTY_DIAGNOSTICS: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>> = {};
const CUSTOM_SOURCE_OPTION = "custom-source";
const SCHEMA_SOURCE_OPTION_PREFIX = "schema-source-";
const CLEAR_COPY = const CLEAR_COPY =
"Saving a new target asks the workflow API to project this output schema into state. Clearing bindings does not delete existing state fields."; "Saving a new target asks the workflow API to project this output schema into state. Clearing bindings does not delete existing state fields.";
@@ -59,43 +58,15 @@ const displayLocalPath = (value: LocalInputPath): string =>
const displayStatePath = (value: StatePath): string => const displayStatePath = (value: StatePath): string =>
typeof value === "string" ? value : formatTOMLPath(["state", ...value.parts]); typeof value === "string" ? value : formatTOMLPath(["state", ...value.parts]);
const sourceSelectionFor = (
sourcePath: string,
sourceSuggestions: ReadonlyArray<string>,
): SourceSelection => {
const index = sourceSuggestions.indexOf(sourcePath);
return index < 0 ? { kind: "custom" } : { kind: "schema", index };
};
const sourceOptionValue = (selection: SourceSelection): string =>
selection.kind === "custom"
? CUSTOM_SOURCE_OPTION
: `${SCHEMA_SOURCE_OPTION_PREFIX}${selection.index}`;
const sourceSelectionFromOption = (
value: string,
sourceSuggestions: ReadonlyArray<string>,
): { readonly selection: SourceSelection; readonly sourcePath: string } | null => {
if (value === CUSTOM_SOURCE_OPTION) return { selection: { kind: "custom" }, sourcePath: "" };
if (!value.startsWith(SCHEMA_SOURCE_OPTION_PREFIX)) return null;
const index = Number(value.slice(SCHEMA_SOURCE_OPTION_PREFIX.length));
const sourcePath = sourceSuggestions[index];
return sourcePath === undefined
? null
: { selection: { kind: "schema", index }, sourcePath };
};
const rowsFrom = ( const rowsFrom = (
rows: ReadonlyArray<OutputBindingRow>, rows: ReadonlyArray<OutputBindingRow>,
formId: string, formId: string,
sourceSuggestions: ReadonlyArray<string>,
): ReadonlyArray<FormRow> => rows.map((row, index) => { ): ReadonlyArray<FormRow> => rows.map((row, index) => {
if (row.kind === "unsupported") return { ...row, id: `${formId}-output-row-${index}` }; if (row.kind === "unsupported") return { ...row, id: `${formId}-output-row-${index}` };
return { return {
kind: "canonical", kind: "canonical",
id: `${formId}-output-row-${index}`, id: `${formId}-output-row-${index}`,
rawIndex: row.index, rawIndex: row.index,
sourceSelection: sourceSelectionFor(displayLocalPath(row.value.source), sourceSuggestions),
sourcePath: displayLocalPath(row.value.source), sourcePath: displayLocalPath(row.value.source),
target: displayStatePath(row.value.target), target: displayStatePath(row.value.target),
}; };
@@ -194,8 +165,8 @@ type OutputRowEditorProps = {
readonly index: number; readonly index: number;
readonly rowCount: number; readonly rowCount: number;
readonly outputSchema: unknown; readonly outputSchema: unknown;
readonly sourceSuggestions: ReadonlyArray<string>; readonly sourceOptions: ReadonlyArray<AuthoringPathOption>;
readonly targetListId: string; readonly targetOptions: ReadonlyArray<AuthoringPathOption>;
readonly rowDiagnostics: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>; readonly rowDiagnostics: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>;
readonly localIssues: Readonly<Record<string, ReadonlyArray<string>>>; readonly localIssues: Readonly<Record<string, ReadonlyArray<string>>>;
readonly onEdit: EditRow; readonly onEdit: EditRow;
@@ -209,8 +180,8 @@ const OutputRowEditor = ({
index, index,
rowCount, rowCount,
outputSchema, outputSchema,
sourceSuggestions, sourceOptions,
targetListId, targetOptions,
rowDiagnostics, rowDiagnostics,
localIssues, localIssues,
onEdit, onEdit,
@@ -220,70 +191,34 @@ const OutputRowEditor = ({
const issues = rowIssueMessages(row, rowDiagnostics, localIssues); const issues = rowIssueMessages(row, rowDiagnostics, localIssues);
const errorId = `${row.id}-errors`; const errorId = `${row.id}-errors`;
const preview = inferredStateSchemaPreview(outputSchema, row.sourcePath, row.target); const preview = inferredStateSchemaPreview(outputSchema, row.sourcePath, row.target);
const targetId = `${row.id}-target`;
const sourceChoiceId = `${row.id}-source-choice`;
const sourcePathId = `${row.id}-source-path`;
const hasIssues = issues.length > 0; const hasIssues = issues.length > 0;
return ( return (
<fieldset aria-label={`Output row ${rowNumber}`} className="schema-form__group"> <fieldset aria-label={`Output row ${rowNumber}`} className="schema-form__group">
<legend>Output row {rowNumber}</legend> <legend>Output row {rowNumber}</legend>
<div className="schema-form__output-row-fields"> <div className="schema-form__output-row-fields">
<div className="schema-form__field"> <AuthoringPathPicker
<label htmlFor={sourceChoiceId}>Source choice</label> allowCustom
<select describedBy={hasIssues ? errorId : undefined}
aria-label={`Source choice for output row ${rowNumber}`} invalid={hasIssues}
id={sourceChoiceId} label={`Source path for output row ${rowNumber}`}
onChange={(event) => { onChange={(source) => onEdit(row.id, (current) => ({
const next = sourceSelectionFromOption(event.target.value, sourceSuggestions);
if (next === null) return;
onEdit(row.id, (current) => ({
...current, ...current,
sourceSelection: next.selection, sourcePath: localPathFromPickerValue(source, sourceOptions, "step_output"),
sourcePath: next.sourcePath === "" ? current.sourcePath : next.sourcePath,
}));
}}
value={sourceOptionValue(row.sourceSelection)}
>
{sourceSuggestions.map((suggestion, index) => (
<option key={suggestion} value={`${SCHEMA_SOURCE_OPTION_PREFIX}${index}`}>
{suggestion === "." ? "Whole output (.)" : suggestion}
</option>
))}
<option value={CUSTOM_SOURCE_OPTION}>Custom local path</option>
</select>
</div>
<label htmlFor={sourcePathId}>
Local source path
<input
aria-describedby={hasIssues ? errorId : undefined}
aria-invalid={hasIssues}
aria-label={`Local source path for output row ${rowNumber}`}
id={sourcePathId}
onChange={(event) => onEdit(row.id, (current) => ({
...current,
sourceSelection: sourceSelectionFor(event.target.value, sourceSuggestions),
sourcePath: event.target.value,
}))} }))}
type="text" options={sourceOptions}
value={row.sourcePath} uses="step_output_source"
value={pickerValueForLocalPath(row.sourcePath, sourceOptions, "step_output")}
/> />
</label> <AuthoringPathPicker
<label htmlFor={targetId}> allowCustom
Target describedBy={hasIssues ? errorId : undefined}
<input invalid={hasIssues}
aria-describedby={hasIssues ? errorId : undefined} label={`Target for output row ${rowNumber}`}
aria-invalid={hasIssues} onChange={(target) => onEdit(row.id, (current) => ({ ...current, target }))}
aria-label={`Target for output row ${rowNumber}`} options={targetOptions}
id={targetId} uses="state_target"
list={targetListId}
onChange={(event) => onEdit(row.id, (current) => ({
...current,
target: event.target.value,
}))}
type="text"
value={row.target} value={row.target}
/> />
</label>
</div> </div>
{preview !== null ? ( {preview !== null ? (
<details className="schema-form__preview"> <details className="schema-form__preview">
@@ -332,7 +267,9 @@ const OutputRowEditor = ({
export const StepOutputBindingsForm = ({ export const StepOutputBindingsForm = ({
outputSchema, outputSchema,
stateSchema, stateSchema: _stateSchema,
sourceOptions = [],
targetOptions = [],
initialRows, initialRows,
initialBindings, initialBindings,
rowDiagnostics = EMPTY_DIAGNOSTICS, rowDiagnostics = EMPTY_DIAGNOSTICS,
@@ -341,13 +278,10 @@ export const StepOutputBindingsForm = ({
submitLabel = "Save outputs", submitLabel = "Save outputs",
}: StepOutputBindingsFormProps) => { }: StepOutputBindingsFormProps) => {
const formId = useId(); const formId = useId();
const sourceSuggestions = capabilityLocalPathSuggestions(outputSchema);
const targetSuggestions = stateTargetSuggestions(stateSchema);
const targetListId = `${formId}-state-targets`;
const formErrorId = `${formId}-form-error`; const formErrorId = `${formId}-form-error`;
const initialRowValues = outputRows(initialRows, initialBindings); const initialRowValues = outputRows(initialRows, initialBindings);
const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() => const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() =>
rowsFrom(initialRowValues, formId, sourceSuggestions), rowsFrom(initialRowValues, formId),
); );
const hadInitialRows = initialRowValues.length > 0; const hadInitialRows = initialRowValues.length > 0;
const [localIssues, setLocalIssues] = useState<Readonly<Record<string, ReadonlyArray<string>>>>({}); const [localIssues, setLocalIssues] = useState<Readonly<Record<string, ReadonlyArray<string>>>>({});
@@ -404,7 +338,6 @@ export const StepOutputBindingsForm = ({
kind: "canonical", kind: "canonical",
id, id,
rawIndex: -1, rawIndex: -1,
sourceSelection: sourceSelectionFor(sourcePath, sourceSuggestions),
sourcePath, sourcePath,
target: "", target: "",
}, },
@@ -466,9 +399,6 @@ export const StepOutputBindingsForm = ({
return ( return (
<form className="schema-form authoring-form output-bindings-form" noValidate onSubmit={submit}> <form className="schema-form authoring-form output-bindings-form" noValidate onSubmit={submit}>
{formIssue !== null && <p id={formErrorId} role="alert">{formIssue}</p>} {formIssue !== null && <p id={formErrorId} role="alert">{formIssue}</p>}
<datalist id={targetListId}>
{targetSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
</datalist>
<p className="schema-form__note">{CLEAR_COPY}</p> <p className="schema-form__note">{CLEAR_COPY}</p>
<div className="schema-form__group"> <div className="schema-form__group">
{rows.length === 0 && <p>No output bindings configured.</p>} {rows.length === 0 && <p>No output bindings configured.</p>}
@@ -494,8 +424,8 @@ export const StepOutputBindingsForm = ({
rowCount={rows.length} rowCount={rows.length}
rowDiagnostics={rowDiagnostics} rowDiagnostics={rowDiagnostics}
rowNumber={index + 1} rowNumber={index + 1}
sourceSuggestions={sourceSuggestions} sourceOptions={sourceOptions}
targetListId={targetListId} targetOptions={targetOptions}
/> />
))} ))}
<button className="schema-form__secondary-action" onClick={addRow} type="button"> <button className="schema-form__secondary-action" onClick={addRow} type="button">
@@ -1,11 +1,18 @@
import { readdirSync, readFileSync } from "node:fs";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AuthoringPathOption } from "../domain/authoring-contract-models.js";
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js"; import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
import { import {
authoringOptionsForUse,
bindingDiagnosticsForStep, bindingDiagnosticsForStep,
capabilityLocalPathSuggestions, capabilityLocalPathSuggestions,
inferredStateSchemaPreview, inferredStateSchemaPreview,
isJsonValue, isJsonValue,
inputBindingRows, inputBindingRows,
localPathFromPickerValue,
pickerValueForLocalPath,
outputBindingRows, outputBindingRows,
projectSelectedStepDataflow, projectSelectedStepDataflow,
serializeInputBindingRow, serializeInputBindingRow,
@@ -13,7 +20,6 @@ import {
serializeOutputBindingRow, serializeOutputBindingRow,
serializeOutputBindingRows, serializeOutputBindingRows,
stateTargetSuggestions, stateTargetSuggestions,
workflowSourceSuggestions,
} from "./selected-step-dataflow.js"; } from "./selected-step-dataflow.js";
const summary = { const summary = {
@@ -323,17 +329,61 @@ describe("selected-step dataflow projection", () => {
}); });
describe("selected-step schema helpers", () => { describe("selected-step schema helpers", () => {
it("returns canonical schema path suggestions", () => { const inventoryOptions: ReadonlyArray<AuthoringPathOption> = [
expect(workflowSourceSuggestions(schema, { {
type: "object", availability: "available",
properties: { fallback: { type: "string" } }, label: "Input title",
})).toEqual([ origin: "workflow_input",
"input.items", path: "input.title",
"input.items.0", required: false,
"input.account", schema: { type: "string" },
"input.account.name", uses: ["step_input"],
"state.fallback", },
{
availability: "available",
label: "Step output text",
origin: "step_output",
path: "step_output.text",
required: false,
schema: { type: "string" },
uses: ["step_output_source"],
},
{
availability: "available",
label: "State report",
origin: "workflow_state",
path: "state.report",
required: false,
schema: { type: "string" },
uses: ["state_target"],
},
];
it("filters inventory by picker use and preserves local binding shapes", () => {
expect(authoringOptionsForUse(inventoryOptions, "step_input")).toEqual([
inventoryOptions[0],
]); ]);
expect(authoringOptionsForUse(inventoryOptions, "step_output_source")).toEqual([
inventoryOptions[1],
]);
expect(pickerValueForLocalPath("text", inventoryOptions, "step_output")).toBe("step_output.text");
expect(localPathFromPickerValue("step_output.text", inventoryOptions, "step_output")).toBe("text");
expect(localPathFromPickerValue("custom.value", inventoryOptions, "step_output")).toBe("custom.value");
const wholeOutput: AuthoringPathOption = {
availability: "available",
label: "Whole output",
origin: "step_output",
path: "step_output",
required: false,
schema: { type: "object" },
uses: ["step_output_source"],
};
expect(pickerValueForLocalPath(".", [wholeOutput], "step_output")).toBe("step_output");
expect(localPathFromPickerValue("step_output", [wholeOutput], "step_output")).toBe(".");
});
it("returns canonical schema path suggestions", () => {
expect(capabilityLocalPathSuggestions(schema)).toEqual([ expect(capabilityLocalPathSuggestions(schema)).toEqual([
".", ".",
"items", "items",
@@ -344,15 +394,26 @@ describe("selected-step schema helpers", () => {
expect(stateTargetSuggestions(schema)).toEqual(["state.items", "state.items.0", "state.account", "state.account.name"]); 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", () => { it("keeps nested local capability targets and state targets schema-backed", () => {
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({ expect(capabilityLocalPathSuggestions({
type: "object", type: "object",
properties: { profile: { type: "object", properties: { name: { type: "string" } } } }, properties: { profile: { type: "object", properties: { name: { type: "string" } } } },
})).toEqual([".", "profile", "profile.name"]); })).toEqual([".", "profile", "profile.name"]);
expect(stateTargetSuggestions({
type: "object",
properties: { session: { type: "object", properties: { token: { type: "string" } } } },
})).toEqual(["state.session", "state.session.token"]);
});
it("does not hardcode runtime context field names in production authoring files", () => {
const directory = dirname(fileURLToPath(import.meta.url));
const productionFiles = readdirSync(directory)
.filter((file) => /\.tsx?$/.test(file) && !file.includes(".test."));
const source = productionFiles
.map((file) => readFileSync(`${directory}/${file}`, "utf8"))
.join("\n");
expect(source).not.toMatch(/context\.(?:loop_item|item|index|key|value)\b/);
}); });
it("previews only the selected output source schema", () => { it("previews only the selected output source schema", () => {
@@ -1,3 +1,7 @@
import type {
AuthoringPathOption,
AuthoringPathUse,
} from "../domain/authoring-contract-models.js";
import type { import type {
DraftDiagnostic, DraftDiagnostic,
DraftWorkspace, DraftWorkspace,
@@ -402,13 +406,39 @@ const prefixedSchemaPaths = (prefix: string, schema: unknown): ReadonlyArray<str
return parts === null ? [] : [formatTOMLPath([prefix, ...parts])]; return parts === null ? [] : [formatTOMLPath([prefix, ...parts])];
}); });
export const workflowSourceSuggestions = ( export const authoringOptionsForUse = (
inputSchema: unknown, options: ReadonlyArray<AuthoringPathOption>,
stateSchema: unknown, use: AuthoringPathUse,
): ReadonlyArray<string> => [ ): ReadonlyArray<AuthoringPathOption> => options.filter((option) => option.uses.includes(use));
...prefixedSchemaPaths("input", inputSchema),
...prefixedSchemaPaths("state", stateSchema), /**
]; * Keeps picker values canonical while binding editors continue submitting
* local paths for step inputs and step outputs.
*/
export const pickerValueForLocalPath = (
localPath: string,
options: ReadonlyArray<AuthoringPathOption>,
root: "step_input" | "step_output",
): string => {
const canonicalPath = localPath === "." ? root : `${root}.${localPath}`;
return options.find((option) => option.path === localPath)?.path ??
options.find((option) => option.path === canonicalPath)?.path ??
localPath;
};
export const localPathFromPickerValue = (
pickerValue: string,
options: ReadonlyArray<AuthoringPathOption>,
root: "step_input" | "step_output",
): string => {
const option = options.find((candidate) => candidate.path === pickerValue);
if (option === undefined) return pickerValue;
return pickerValue === root
? "."
: pickerValue.startsWith(`${root}.`)
? pickerValue.slice(`${root}.`.length)
: pickerValue;
};
export const capabilityLocalPathSuggestions = (capabilitySchema: unknown): ReadonlyArray<string> => [ export const capabilityLocalPathSuggestions = (capabilitySchema: unknown): ReadonlyArray<string> => [
".", ".",