From 92a32df87a35a80fe895bb47e2515e1545ada149 Mon Sep 17 00:00:00 2001 From: lda Date: Fri, 14 Aug 2026 19:11:04 +0700 Subject: [PATCH] refactor: use canonical authoring choices --- .../authoring/AuthoringPathPicker.tsx | 15 +- .../authoring/ContextInspector.test.tsx | 6 +- .../authoring/DraftWorkbench.test.tsx | 4 +- .../authoring/InputExpressionControl.test.tsx | 78 ++++++++- .../authoring/InputExpressionControl.tsx | 38 ++--- .../SelectedCapabilityInspector.test.tsx | 117 ++++++++++++- .../authoring/SelectedCapabilityInspector.tsx | 18 +- .../authoring/StepInputBindingsForm.test.tsx | 156 ++++++++++++------ .../authoring/StepInputBindingsForm.tsx | 75 ++++----- .../authoring/StepOutputBindingsForm.test.tsx | 118 ++++++++----- .../authoring/StepOutputBindingsForm.tsx | 152 +++++------------ .../authoring/selected-step-dataflow.test.ts | 93 +++++++++-- .../authoring/selected-step-dataflow.ts | 44 ++++- 13 files changed, 605 insertions(+), 309 deletions(-) diff --git a/web/apps/console/src/workspace/authoring/AuthoringPathPicker.tsx b/web/apps/console/src/workspace/authoring/AuthoringPathPicker.tsx index fc548f2e..a7fcac65 100644 --- a/web/apps/console/src/workspace/authoring/AuthoringPathPicker.tsx +++ b/web/apps/console/src/workspace/authoring/AuthoringPathPicker.tsx @@ -12,6 +12,8 @@ export type AuthoringPathPickerProps = { readonly onChange: (value: string) => void; readonly label: string; readonly allowCustom?: boolean; + readonly describedBy?: string | undefined; + readonly invalid?: boolean; }; type OptionGroup = { @@ -44,12 +46,17 @@ export const AuthoringPathPicker = ({ onChange, label, allowCustom = false, + describedBy, + invalid = false, }: AuthoringPathPickerProps) => { const id = safeId(useId()); const searchId = `${id}-search`; const customId = `${id}-custom`; const [search, setSearch] = useState(""); const [customValue, setCustomValue] = useState(value); + const [advancedOpen, setAdvancedOpen] = useState( + () => allowCustom && value.trim() !== "" && !options.some((option) => option.path === value), + ); const requestedUses = normalizedUses(uses); const normalizedSearch = search.trim().toLocaleLowerCase(); @@ -118,10 +125,16 @@ export const AuthoringPathPicker = ({ {visibleOptions.length === 0 &&

No matching paths.

} {allowCustom && ( -
+
setAdvancedOpen(event.currentTarget.open)} + open={advancedOpen} + > Advanced { setCustomValue(event.target.value); diff --git a/web/apps/console/src/workspace/authoring/ContextInspector.test.tsx b/web/apps/console/src/workspace/authoring/ContextInspector.test.tsx index c2dc4b77..63ba41c3 100644 --- a/web/apps/console/src/workspace/authoring/ContextInspector.test.tsx +++ b/web/apps/console/src/workspace/authoring/ContextInspector.test.tsx @@ -103,8 +103,8 @@ describe("ContextInspector", () => { expect(screen.getByRole("spinbutton", { name: "Retry" })).toHaveValue(2); expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveValue(45); fireEvent.click(screen.getByRole("tab", { name: "Inputs" })); - expect(screen.getByRole("combobox", { name: "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 Target for row 1" })).toHaveValue("title"); + 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 () => { @@ -187,7 +187,7 @@ describe("ContextInspector", () => { expect(screen.getAllByText("Title is not accepted.")).not.toHaveLength(0); expect(screen.getAllByText("Retry must be non-negative.")).not.toHaveLength(0); expect(screen.getAllByText("Destination field is not declared.")).not.toHaveLength(0); - expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveAttribute( + expect(screen.getByRole("textbox", { name: "Custom Target for row 1" })).toHaveAttribute( "aria-invalid", "true", ); diff --git a/web/apps/console/src/workspace/authoring/DraftWorkbench.test.tsx b/web/apps/console/src/workspace/authoring/DraftWorkbench.test.tsx index 03c8d83a..e0d675a5 100644 --- a/web/apps/console/src/workspace/authoring/DraftWorkbench.test.tsx +++ b/web/apps/console/src/workspace/authoring/DraftWorkbench.test.tsx @@ -261,7 +261,7 @@ describe("DraftWorkbench", () => { within(inspector).getByRole("button", { name: "Add input row", hidden: true }), ); 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", ); await user.click( @@ -278,7 +278,7 @@ describe("DraftWorkbench", () => { within(inspector).getByRole("tab", { name: "Outputs" }), ).toHaveAttribute("aria-selected", "true"); 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"); }); diff --git a/web/apps/console/src/workspace/authoring/InputExpressionControl.test.tsx b/web/apps/console/src/workspace/authoring/InputExpressionControl.test.tsx index 91104709..006aed03 100644 --- a/web/apps/console/src/workspace/authoring/InputExpressionControl.test.tsx +++ b/web/apps/console/src/workspace/authoring/InputExpressionControl.test.tsx @@ -2,6 +2,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useState } from "react"; 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 type { ExpressionEditorState } from "./input-expression-editor.js"; import { InputExpressionControl } from "./InputExpressionControl.js"; @@ -11,7 +12,70 @@ afterEach(() => { vi.restoreAllMocks(); }); +const sourceOptions: ReadonlyArray = [ + { + 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", () => { + it("uses the inventory picker for every recursive path expression leaf", () => { + render( + , + ); + + 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 () => { const user = userEvent.setup(); const Harness = () => { @@ -439,7 +503,7 @@ describe("InputExpressionControl", () => { 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" }); 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); - expect(datalistIds).toHaveLength(2); - expect(new Set(datalistIds).size).toBe(datalistIds.length); + expect(searchIds).toHaveLength(2); + expect(new Set(searchIds).size).toBe(searchIds.length); + expect(document.querySelectorAll("datalist")).toHaveLength(0); expect(controlIds).toHaveLength(2); 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.queryByRole("option", { name: "Construct" })).toBeNull(); - await user.clear(screen.getByRole("combobox", { name: "Path for name" })); - await user.type(screen.getByRole("combobox", { name: "Path for name" }), "state.name"); + const path = screen.getByRole("textbox", { name: "Custom Path for name" }); + await user.clear(path); + await user.type(path, "state.name"); expect(state).toMatchObject({ kind: "path", path: "state.name" }); }); }); diff --git a/web/apps/console/src/workspace/authoring/InputExpressionControl.tsx b/web/apps/console/src/workspace/authoring/InputExpressionControl.tsx index 59933840..23847aed 100644 --- a/web/apps/console/src/workspace/authoring/InputExpressionControl.tsx +++ b/web/apps/console/src/workspace/authoring/InputExpressionControl.tsx @@ -1,5 +1,6 @@ import { useId, useState } from "react"; import { SchemaFieldControl } from "../schema-form/SchemaFieldControl.js"; +import type { AuthoringPathOption } from "../domain/authoring-contract-models.js"; import { rebaseSchemaField, UNCONSTRAINED_SCHEMA_REASON, @@ -10,12 +11,13 @@ import { defaultExpressionEditorState, type ExpressionEditorState, } from "./input-expression-editor.js"; +import { AuthoringPathPicker } from "./AuthoringPathPicker.js"; export type InputExpressionControlProps = { readonly field: SchemaField | null; readonly label: string; readonly onChange: (state: ExpressionEditorState) => void; - readonly sourceSuggestions?: ReadonlyArray; + readonly sourceOptions?: ReadonlyArray; readonly state: ExpressionEditorState; readonly showModeControl?: boolean; }; @@ -167,7 +169,7 @@ const InputExpressionLeaf = ({ field, label, onChange, - sourceSuggestions, + sourceOptions, state, idPrefix, }: { @@ -175,26 +177,20 @@ const InputExpressionLeaf = ({ readonly idPrefix: string; readonly label: string; readonly onChange: (state: ExpressionEditorState) => void; - readonly sourceSuggestions: ReadonlyArray; + readonly sourceOptions: ReadonlyArray; readonly state: ExpressionEditorState; }) => { if (state.kind === "path") { - const pathListId = `${idPrefix}-paths`; return (
- - - {sourceSuggestions.map((suggestion) => + onChange({ ...state, path, touched: true })} + options={sourceOptions} + uses="step_input" + value={state.path} + /> {pathNeedsDeferredValidation(field, state.path) && (

Validated when the workflow runs

)} @@ -248,7 +244,7 @@ export const InputExpressionControl = ({ field, label, onChange, - sourceSuggestions = [], + sourceOptions = [], state, showModeControl = true, }: InputExpressionControlProps) => { @@ -301,7 +297,7 @@ export const InputExpressionControl = ({ items: state.items.map((candidate, candidateIndex) => candidateIndex === index ? next : candidate), }); }} - sourceSuggestions={sourceSuggestions} + sourceOptions={sourceOptions} state={item} />
@@ -397,7 +393,7 @@ export const InputExpressionControl = ({ ? { ...candidate, value: next } : candidate), })} - sourceSuggestions={sourceSuggestions} + sourceOptions={sourceOptions} state={entry.value} /> {!requiredField && ( @@ -474,7 +470,7 @@ export const InputExpressionControl = ({ idPrefix={`${controlId}-leaf`} label={label} onChange={onChange} - sourceSuggestions={sourceSuggestions} + sourceOptions={sourceOptions} state={state} /> ); diff --git a/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.test.tsx b/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.test.tsx index 53bd39dd..b425398b 100644 --- a/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.test.tsx +++ b/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.test.tsx @@ -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 { 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 { AuthoringContractInventory } from "../domain/authoring-contract-models.js"; import type { DraftWorkspace } from "../domain/draft-workspace-models.js"; import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js"; +import { useAuthoringContract } from "./useAuthoringContract.js"; import type { DraftAuthoringController } from "./useDraftAuthoring.js"; +vi.mock("./useAuthoringContract.js", () => ({ useAuthoringContract: vi.fn() })); + +const mockedUseAuthoringContract = vi.mocked(useAuthoringContract); + 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 = { kind: "node_spec", name: "demo.read", @@ -82,6 +147,42 @@ const controllerFor = (workspace: DraftWorkspace): DraftAuthoringController => ( }); 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( + , + ); + + 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 () => { const user = userEvent.setup(); const workspace = draft( @@ -252,7 +353,7 @@ describe("SelectedCapabilityInspector", () => { await user.click(screen.getByRole("tab", { name: "Inputs" })); expect(screen.getByRole("group", { name: "items" })).toBeInTheDocument(); expect(screen.getByRole("combobox", { name: "Value source for items item 1" })).toHaveValue("path"); - expect(screen.getByRole("combobox", { name: "Path for items item 1" })).toHaveValue("state.foo"); + expect(customInput("Path for items item 1")).toHaveValue("state.foo"); expect(screen.getByRole("textbox", { name: "Items item" })).toHaveValue("wowcool"); await user.click(screen.getByRole("button", { name: "Save inputs" })); @@ -322,7 +423,7 @@ describe("SelectedCapabilityInspector", () => { 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"); }); @@ -353,10 +454,10 @@ describe("SelectedCapabilityInspector", () => { }); await user.click(screen.getByRole("button", { name: "Save setup" })); 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("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" })); const diagnosticIds = [...document.querySelectorAll('[id$="-error"], [id$="-errors"]')] @@ -383,7 +484,7 @@ describe("SelectedCapabilityInspector", () => { ); 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( { />, ); 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"); }); diff --git a/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.tsx b/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.tsx index 0b5ad336..4b8c7e92 100644 --- a/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.tsx +++ b/web/apps/console/src/workspace/authoring/SelectedCapabilityInspector.tsx @@ -5,12 +5,14 @@ import { CapabilitySetupForm } from "./CapabilitySetupForm.js"; import { StepInputBindingsForm } from "./StepInputBindingsForm.js"; import { StepOutputBindingsForm } from "./StepOutputBindingsForm.js"; import { + authoringOptionsForUse, bindingDiagnosticsForStep, outputBindingRows, projectSelectedStepDataflow, stepInputBindingRows, } from "./selected-step-dataflow.js"; import type { DraftAuthoringController } from "./useDraftAuthoring.js"; +import { useAuthoringContract } from "./useAuthoringContract.js"; type InspectorTab = "setup" | "inputs" | "outputs"; @@ -120,6 +122,16 @@ export const SelectedCapabilityInspector = ({ if (target !== undefined) activateTab(target); }; 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 preservedForm = controller.preservedCapabilityForm?.kind === "update" && controller.preservedCapabilityForm.input.stepId === stepId @@ -202,8 +214,8 @@ export const SelectedCapabilityInspector = ({ canonicalVersion={`${stepId}:${controller.resetGeneration}`} initialRows={inputRows} inputSchema={capabilityDetail.inputSchema} - workflowInputSchema={isRecord(draft.draft) ? draft.draft.input_schema : undefined} - workflowStateSchema={isRecord(draft.draft) ? draft.draft.state_schema : undefined} + sourceOptions={inputSourceOptions} + targetOptions={inputTargetOptions} onDirtyChange={controller.markDirty} onSubmit={controller.setStepInputs} rowDiagnostics={inputDiagnostics.rowIssues} @@ -216,6 +228,8 @@ export const SelectedCapabilityInspector = ({ onDirtyChange={controller.markDirty} onSubmit={controller.setStepOutputs} outputSchema={capabilityDetail.outputSchema} + sourceOptions={outputSourceOptions} + targetOptions={outputTargetOptions} rowDiagnostics={outputDiagnostics.rowIssues} stateSchema={(isRecord(draft.draft) ? draft.draft.state_schema : null) ?? emptyStateSchema} /> diff --git a/web/apps/console/src/workspace/authoring/StepInputBindingsForm.test.tsx b/web/apps/console/src/workspace/authoring/StepInputBindingsForm.test.tsx index 221231cf..3a1f005c 100644 --- a/web/apps/console/src/workspace/authoring/StepInputBindingsForm.test.tsx +++ b/web/apps/console/src/workspace/authoring/StepInputBindingsForm.test.tsx @@ -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 { 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 { StepInputBindingsForm } from "./StepInputBindingsForm.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, + 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", () => { + it("uses inventory pickers for sources and targets while preserving canonical bindings", async () => { + const user = userEvent.setup(); + const submissions: ReadonlyArray[] = []; + 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( + { 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", () => { expect(displayLocalInputPath({ root: "local", parts: [] })).toBe("."); 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("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("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: "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.getByRole("region", { name: "Raw unsupported input row 4" })).toHaveTextContent('"target"'); 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(screen.getByRole("combobox", { name: "Target for row 2" })).toHaveValue("."); - expect(screen.getByRole("combobox", { name: "Source path for input row 1" })).toHaveValue("input.source"); - expect(screen.getByRole("combobox", { name: "Source path for input row 2" })).toHaveValue("state.audit.latest"); + expect(customInput("Target for row 1")).toHaveValue("payload.item"); + expect(customInput("Target for row 2")).toHaveValue("."); + expect(customInput("Source path for input row 1")).toHaveValue("input.source"); + expect(customInput("Source path for input row 2")).toHaveValue("state.audit.latest"); 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.type(screen.getByRole("textbox", { name: "Name" }), "after"); 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.type(screen.getByRole("combobox", { name: "Source path for input row 1" }), "input.nested"); + await user.clear(await editableCustomInput(user, "Source path for input row 1")); + 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("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" })); - const target = screen.getByRole("combobox", { name: "Target for row 1" }); + const target = customInput("Target for row 1"); const describedBy = target.getAttribute("aria-describedby"); + expect(describedBy).not.toBeNull(); expect(target).toHaveAttribute("aria-invalid", "true"); - expect(describedBy).toBeTruthy(); expect(document.getElementById(describedBy ?? "")).toHaveTextContent("Target is required."); }); @@ -277,35 +342,24 @@ describe("StepInputBindingsForm", () => { type: "object", properties: { profile: { type: "object", properties: { name: { type: "string" } } } }, }} - workflowInputSchema={{ - type: "object", - properties: { request: { type: "object", properties: { id: { type: "string" } } } }, - }} - workflowStateSchema={{ - type: "object", - properties: { session: { type: "object", properties: { token: { type: "string" } } } }, - }} + sourceOptions={[ + authoringOption("input.request.id", "Request id", "workflow_input", ["step_input"]), + authoringOption("state.session.token", "Session token", "workflow_state", ["step_input"]), + ]} + targetOptions={[authoringOption("step_input.profile.name", "Profile name", "step_input", ["step_input"])]} initialBindings={[{ path: "input.request.id", target: "profile.name" }]} onSubmit={(value) => { submissions.push(value); }} />, ); - const target = screen.getByRole("combobox", { name: "Target for row 1" }); - const targetList = document.getElementById(target.getAttribute("list") ?? ""); - expect(target.getAttribute("list")).toBeTruthy(); - expect(targetList).not.toBeNull(); - expect(targetList?.querySelector('option[value="profile.name"]')).not.toBeNull(); - const source = screen.getByRole("combobox", { name: "Source path for input row 1" }); - const sourceList = document.getElementById(source.getAttribute("list") ?? ""); - expect(source.getAttribute("list")).toBeTruthy(); - expect(sourceList).not.toBeNull(); - expect(sourceList?.querySelector('option[value="input.request.id"]')).not.toBeNull(); - expect(sourceList?.querySelector('option[value="state.session.token"]')).not.toBeNull(); + expect(screen.getByRole("button", { name: /Profile name/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Request id/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Session token/ })).toBeInTheDocument(); - await user.clear(target); - await user.type(target, "profile.custom"); - await user.clear(source); - await user.type(source, "context.custom"); + await user.clear(await editableCustomInput(user, "Target for row 1")); + await user.type(await editableCustomInput(user, "Target for row 1"), "profile.custom"); + await user.clear(await editableCustomInput(user, "Source path for input row 1")); + await user.type(await editableCustomInput(user, "Source path for input row 1"), "context.custom"); await user.click(screen.getByRole("button", { name: "Save inputs" })); 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.type(screen.getByRole("combobox", { name: "Target for row 2" }), "title"); + await user.clear(await editableCustomInput(user, "Target for row 2")); + await user.type(await editableCustomInput(user, "Target for row 2"), "title"); await user.click(screen.getByRole("button", { name: "Save inputs" })); expect(submissions).toEqual([]); - expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveAttribute("aria-invalid", "true"); - expect(screen.getByRole("combobox", { name: "Target for row 2" })).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByRole("region", { name: "Target for row 1" })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Target for row 2" })).toBeInTheDocument(); expect(screen.getAllByRole("alert").filter((alert) => alert.textContent?.includes("Target is duplicated") ?? false, )).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.type(secondTarget, "title"); + await user.type(await editableCustomInput(user, "Target for row 2"), "title"); await user.click(screen.getByRole("button", { name: "Save inputs" })); expect(submissions).toEqual([]); - await user.clear(secondTarget); - await user.type(secondTarget, "nullable"); + await user.clear(await editableCustomInput(user, "Target for row 2")); + await user.type(await editableCustomInput(user, "Target for row 2"), "nullable"); expect(screen.getByRole("button", { name: "Save inputs" })).not.toBeDisabled(); await user.click(screen.getByRole("button", { name: "Save inputs" })); expect(submissions).toHaveLength(1); @@ -379,7 +433,7 @@ describe("StepInputBindingsForm", () => { ); 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.type(source, "context.profile"); @@ -399,7 +453,7 @@ describe("StepInputBindingsForm", () => { onSubmit={() => undefined} />, ); - const target = screen.getByRole("combobox", { name: "Target for row 1" }); + const target = customInput("Target for row 1"); rerender( { onSubmit={() => undefined} />, ); - expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("edited"); + expect(customInput("Target for row 1")).toHaveValue("edited"); rerender( { 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", () => { @@ -467,13 +521,13 @@ describe("StepInputBindingsForm", () => { ); 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("button", { name: "Add item to items" })); await user.click(screen.getByRole("button", { name: "Add item to items" })); await user.selectOptions(screen.getByRole("combobox", { name: "Value source for items item 1" }), "path"); - const itemPath = screen.getByRole("combobox", { name: "Path for items item 1" }); + const itemPath = customInput("Path for items item 1"); await user.clear(itemPath); await user.type(itemPath, "state.foo"); await user.selectOptions(screen.getByRole("combobox", { name: "Value source for items item 2" }), "literal"); @@ -482,7 +536,7 @@ describe("StepInputBindingsForm", () => { await user.type(itemValue, "wowcool"); await user.click(screen.getByRole("button", { name: "Add input row" })); - await user.type(screen.getByRole("combobox", { name: "Target for row 2" }), "separator"); + await user.type(await editableCustomInput(user, "Target for row 2"), "separator"); await user.click(screen.getByRole("radio", { name: "Literal value for input row 2" })); await user.clear(screen.getByRole("textbox", { name: "Separator" })); await user.type(screen.getByRole("textbox", { name: "Separator" }), " "); diff --git a/web/apps/console/src/workspace/authoring/StepInputBindingsForm.tsx b/web/apps/console/src/workspace/authoring/StepInputBindingsForm.tsx index 84c2f73d..30f70edc 100644 --- a/web/apps/console/src/workspace/authoring/StepInputBindingsForm.tsx +++ b/web/apps/console/src/workspace/authoring/StepInputBindingsForm.tsx @@ -1,4 +1,7 @@ import { useId, useRef, useState, type FormEvent } from "react"; +import type { + AuthoringPathOption, +} from "../domain/authoring-contract-models.js"; import type { DraftDiagnostic, StepInputBinding, @@ -21,13 +24,14 @@ import { import { serializeSchemaValues, type FieldSources } from "../schema-form/schema-values.js"; import { formatBoundedJson } from "../domain/format-bounded-json.js"; import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js"; +import { AuthoringPathPicker } from "./AuthoringPathPicker.js"; import { - capabilityLocalPathSuggestions, + localPathFromPickerValue, + pickerValueForLocalPath, isJsonValue, serializeInputBindingRow, serializeStepInputBindingRow, stepInputBindingRows, - workflowSourceSuggestions, type StepInputBindingRow, } from "./selected-step-dataflow.js"; @@ -51,8 +55,8 @@ type FormRow = EditableRow | UnsupportedRow; export type StepInputBindingsFormProps = { readonly inputSchema: unknown; - readonly workflowInputSchema?: unknown; - readonly workflowStateSchema?: unknown; + readonly sourceOptions?: ReadonlyArray; + readonly targetOptions?: ReadonlyArray; /** Changes only when the parent has accepted a new canonical draft. */ readonly canonicalVersion?: string | number | null; readonly initialRows?: ReadonlyArray; @@ -294,8 +298,8 @@ const duplicateIssuesForRows = ( const StepInputBindingsFormContent = ({ canonicalVersion = null, inputSchema, - workflowInputSchema, - workflowStateSchema, + sourceOptions = [], + targetOptions = [], initialRows, initialBindings, rowDiagnostics = EMPTY_DIAGNOSTICS, @@ -305,10 +309,6 @@ const StepInputBindingsFormContent = ({ }: StepInputBindingsFormProps) => { const formId = useId(); const root = normalizeSchema(inputSchema); - const sourceSuggestions = workflowSourceSuggestions(workflowInputSchema ?? null, workflowStateSchema ?? null); - const targetSuggestions = capabilityLocalPathSuggestions(inputSchema); - const sourceListId = `${formId}-workflow-sources`; - const targetListId = `${formId}-capability-targets`; const [rows, setRows] = useState>(() => rowsFrom(inputRows(initialRows, initialBindings), formId, root), ); @@ -420,12 +420,6 @@ const StepInputBindingsFormContent = ({ return (
{formIssue !== null && } - - {sourceSuggestions.map((suggestion) => - - {targetSuggestions.map((suggestion) =>
{rows.length === 0 &&

No input bindings configured.

} {rows.map((row, index) => { @@ -469,9 +463,7 @@ const StepInputBindingsFormContent = ({ ...(duplicateIssues.get(row.id) ?? []), ...bindingForRow(root, row).issues, ])]; - const targetId = `${row.id}-target`; const errorId = `${row.id}-errors`; - const pathId = `${row.id}-source-path`; const literalId = `${row.id}-literal`; const pathModeId = `${row.id}-path-mode`; const literalModeId = `${row.id}-literal-mode`; @@ -484,16 +476,18 @@ const StepInputBindingsFormContent = ({ return (
Input row {rowNumber} - - editRow(row.id, (current) => ({ ...current, target: event.target.value }))} - type="text" - value={row.target} + editRow(row.id, (current) => ({ + ...current, + target: localPathFromPickerValue(target, targetOptions, "step_input"), + }))} + options={targetOptions} + uses="step_input" + value={pickerValueForLocalPath(row.target, targetOptions, "step_input")} />
Value source @@ -540,19 +534,16 @@ const StepInputBindingsFormContent = ({
{row.mode === "path" ? ( - + editRow(row.id, (current) => ({ ...current, sourcePath: path }))} + options={sourceOptions} + uses="step_input" + value={row.sourcePath} + /> ) : row.mode === "expression" ? ( diff --git a/web/apps/console/src/workspace/authoring/StepOutputBindingsForm.test.tsx b/web/apps/console/src/workspace/authoring/StepOutputBindingsForm.test.tsx index 7c14211f..167a220b 100644 --- a/web/apps/console/src/workspace/authoring/StepOutputBindingsForm.test.tsx +++ b/web/apps/console/src/workspace/authoring/StepOutputBindingsForm.test.tsx @@ -1,6 +1,7 @@ import { cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; 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 { 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; type ClearMutationCase = { readonly name: string; @@ -31,13 +47,26 @@ type ClearMutationCase = { readonly expected: ReadonlyArray; }; +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 = [ { name: "add", mutate: async (user) => { await user.click(screen.getByRole("button", { name: "Add output row" })); await user.type( - screen.getByRole("combobox", { name: "Target for output row 3" }), + await editableCustomInput(user, "Target for output row 3"), "state.third", ); }, @@ -50,7 +79,7 @@ const clearMutationCases: ReadonlyArray = [ { name: "edit", 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.type(target, "state.edited"); }, @@ -69,30 +98,49 @@ const clearMutationCases: ReadonlyArray = [ ]; describe("StepOutputBindingsForm", () => { + it("uses inventory step-output and state-target pickers while preserving local output bindings", async () => { + const user = userEvent.setup(); + const submissions: ReadonlyArray[] = []; + render( + { 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", () => { render( undefined} />, ); - const source = screen.getByRole("combobox", { name: "Source choice for output row 1" }); - expect(within(source).getByRole("option", { name: "Whole output (.)" })).toBeInTheDocument(); - expect(within(source).getByRole("option", { name: "text" })).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(); - } + expect(screen.getByRole("button", { name: /Text/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Latest audit/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Existing state/ })).toBeInTheDocument(); }); 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( - "state.report.markdown", - ); + expect(customInput("Target for output row 1")).toHaveValue("state.report.markdown"); expect(screen.getByRole("region", { name: "Inferred schema for output row 1" })) .toHaveTextContent('"type": "integer"'); @@ -152,10 +198,7 @@ describe("StepOutputBindingsForm", () => { />, ); - expect(screen.getByRole("combobox", { name: "Source choice for output row 1" })) - .toHaveValue("custom-source"); - expect(screen.getByRole("textbox", { name: "Local source path for output row 1" })) - .toHaveValue("nested.whole"); + expect(customInput("Source path for output row 1")).toHaveValue("nested.whole"); 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" })) - .toHaveValue("payload.item"); - expect(screen.getByRole("textbox", { name: "Local source path for output row 2" })) - .toHaveValue("."); + expect(customInput("Source path for output row 1")).toHaveValue("payload.item"); + expect(customInput("Source path for output row 2")).toHaveValue("."); 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" })); - expect(screen.getByRole("textbox", { name: "Local source path for output row 1" })) - .toHaveValue("."); - await user.type( - screen.getByRole("combobox", { name: "Target for output row 1" }), - "state.new", - ); + expect(customInput("Source path for output row 1")).toHaveValue("."); + await user.type(await editableCustomInput(user, "Target for output row 1"), "state.new"); await user.click(screen.getByRole("button", { name: "Save outputs" })); expect(submissions).toEqual([[{ source: ".", target: "state.new" }]]); @@ -229,15 +266,17 @@ describe("StepOutputBindingsForm", () => { properties: { __custom__: { type: "string" }, text: { type: "string" } }, }} 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" }]} onSubmit={(value) => { submissions.push(value); }} />, ); - const source = screen.getByRole("combobox", { name: "Source choice for output row 1" }); - const customSchemaOption = within(source).getAllByRole("option", { name: "__custom__" })[0]; - expect(customSchemaOption).toBeDefined(); - await user.selectOptions(source, customSchemaOption ?? ""); + await user.click(screen.getByRole("button", { name: /__custom__/ })); await user.click(screen.getByRole("button", { name: "Save outputs" })); 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"); - expect(describedBy).toBeTruthy(); + expect(describedBy).not.toBeNull(); + expect(target).toHaveAttribute("aria-invalid", "true"); expect(document.getElementById(describedBy ?? "")).toHaveTextContent("Target field is not declared."); }); }); diff --git a/web/apps/console/src/workspace/authoring/StepOutputBindingsForm.tsx b/web/apps/console/src/workspace/authoring/StepOutputBindingsForm.tsx index 66c1875d..f146b773 100644 --- a/web/apps/console/src/workspace/authoring/StepOutputBindingsForm.tsx +++ b/web/apps/console/src/workspace/authoring/StepOutputBindingsForm.tsx @@ -1,4 +1,7 @@ import { useId, useRef, useState, type FormEvent } from "react"; +import type { + AuthoringPathOption, +} from "../domain/authoring-contract-models.js"; import type { DraftDiagnostic, LocalInputPath, @@ -6,12 +9,13 @@ import type { StatePath, } from "../domain/draft-workspace-models.js"; import { formatBoundedJson } from "../domain/format-bounded-json.js"; +import { AuthoringPathPicker } from "./AuthoringPathPicker.js"; import { - capabilityLocalPathSuggestions, inferredStateSchemaPreview, + localPathFromPickerValue, outputBindingRows, + pickerValueForLocalPath, serializeOutputBindingRow, - stateTargetSuggestions, type OutputBindingRow, } from "./selected-step-dataflow.js"; import { formatTOMLPath } from "../schema-form/schema-paths.js"; @@ -20,15 +24,10 @@ type EditableRow = { readonly kind: "canonical"; readonly id: string; readonly rawIndex: number; - readonly sourceSelection: SourceSelection; readonly sourcePath: string; readonly target: string; }; -type SourceSelection = - | { readonly kind: "schema"; readonly index: number } - | { readonly kind: "custom" }; - type UnsupportedRow = Extract & { readonly id: string; }; @@ -38,6 +37,8 @@ type FormRow = EditableRow | UnsupportedRow; export type StepOutputBindingsFormProps = { readonly outputSchema: unknown; readonly stateSchema: unknown; + readonly sourceOptions?: ReadonlyArray; + readonly targetOptions?: ReadonlyArray; readonly initialRows?: ReadonlyArray; readonly initialBindings?: ReadonlyArray; readonly rowDiagnostics?: Readonly>>; @@ -48,8 +49,6 @@ export type StepOutputBindingsFormProps = { const EMPTY_ROWS: ReadonlyArray = []; const EMPTY_DIAGNOSTICS: Readonly>> = {}; -const CUSTOM_SOURCE_OPTION = "custom-source"; -const SCHEMA_SOURCE_OPTION_PREFIX = "schema-source-"; 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."; @@ -59,43 +58,15 @@ const displayLocalPath = (value: LocalInputPath): string => const displayStatePath = (value: StatePath): string => typeof value === "string" ? value : formatTOMLPath(["state", ...value.parts]); -const sourceSelectionFor = ( - sourcePath: string, - sourceSuggestions: ReadonlyArray, -): 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, -): { 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 = ( rows: ReadonlyArray, formId: string, - sourceSuggestions: ReadonlyArray, ): ReadonlyArray => rows.map((row, index) => { if (row.kind === "unsupported") return { ...row, id: `${formId}-output-row-${index}` }; return { kind: "canonical", id: `${formId}-output-row-${index}`, rawIndex: row.index, - sourceSelection: sourceSelectionFor(displayLocalPath(row.value.source), sourceSuggestions), sourcePath: displayLocalPath(row.value.source), target: displayStatePath(row.value.target), }; @@ -194,8 +165,8 @@ type OutputRowEditorProps = { readonly index: number; readonly rowCount: number; readonly outputSchema: unknown; - readonly sourceSuggestions: ReadonlyArray; - readonly targetListId: string; + readonly sourceOptions: ReadonlyArray; + readonly targetOptions: ReadonlyArray; readonly rowDiagnostics: Readonly>>; readonly localIssues: Readonly>>; readonly onEdit: EditRow; @@ -209,8 +180,8 @@ const OutputRowEditor = ({ index, rowCount, outputSchema, - sourceSuggestions, - targetListId, + sourceOptions, + targetOptions, rowDiagnostics, localIssues, onEdit, @@ -220,70 +191,34 @@ const OutputRowEditor = ({ const issues = rowIssueMessages(row, rowDiagnostics, localIssues); const errorId = `${row.id}-errors`; 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; return (
Output row {rowNumber}
-
- - -
- - + onEdit(row.id, (current) => ({ + ...current, + sourcePath: localPathFromPickerValue(source, sourceOptions, "step_output"), + }))} + options={sourceOptions} + uses="step_output_source" + value={pickerValueForLocalPath(row.sourcePath, sourceOptions, "step_output")} + /> + onEdit(row.id, (current) => ({ ...current, target }))} + options={targetOptions} + uses="state_target" + value={row.target} + />
{preview !== null ? (
@@ -332,7 +267,9 @@ const OutputRowEditor = ({ export const StepOutputBindingsForm = ({ outputSchema, - stateSchema, + stateSchema: _stateSchema, + sourceOptions = [], + targetOptions = [], initialRows, initialBindings, rowDiagnostics = EMPTY_DIAGNOSTICS, @@ -341,13 +278,10 @@ export const StepOutputBindingsForm = ({ submitLabel = "Save outputs", }: StepOutputBindingsFormProps) => { const formId = useId(); - const sourceSuggestions = capabilityLocalPathSuggestions(outputSchema); - const targetSuggestions = stateTargetSuggestions(stateSchema); - const targetListId = `${formId}-state-targets`; const formErrorId = `${formId}-form-error`; const initialRowValues = outputRows(initialRows, initialBindings); const [rows, setRows] = useState>(() => - rowsFrom(initialRowValues, formId, sourceSuggestions), + rowsFrom(initialRowValues, formId), ); const hadInitialRows = initialRowValues.length > 0; const [localIssues, setLocalIssues] = useState>>>({}); @@ -404,7 +338,6 @@ export const StepOutputBindingsForm = ({ kind: "canonical", id, rawIndex: -1, - sourceSelection: sourceSelectionFor(sourcePath, sourceSuggestions), sourcePath, target: "", }, @@ -466,9 +399,6 @@ export const StepOutputBindingsForm = ({ return ( {formIssue !== null && } - - {targetSuggestions.map((suggestion) =>

{CLEAR_COPY}

{rows.length === 0 &&

No output bindings configured.

} @@ -494,8 +424,8 @@ export const StepOutputBindingsForm = ({ rowCount={rows.length} rowDiagnostics={rowDiagnostics} rowNumber={index + 1} - sourceSuggestions={sourceSuggestions} - targetListId={targetListId} + sourceOptions={sourceOptions} + targetOptions={targetOptions} /> ))}