refactor: use canonical authoring choices
This commit is contained in:
@@ -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 && <p>No matching paths.</p>}
|
||||
</div>
|
||||
{allowCustom && (
|
||||
<details className="authoring-path-picker__advanced">
|
||||
<details
|
||||
className="authoring-path-picker__advanced"
|
||||
onToggle={(event) => setAdvancedOpen(event.currentTarget.open)}
|
||||
open={advancedOpen}
|
||||
>
|
||||
<summary>Advanced</summary>
|
||||
<label htmlFor={customId}>Custom {label}</label>
|
||||
<input
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
id={customId}
|
||||
onChange={(event) => {
|
||||
setCustomValue(event.target.value);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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<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", () => {
|
||||
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 () => {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>;
|
||||
readonly sourceOptions?: ReadonlyArray<AuthoringPathOption>;
|
||||
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<string>;
|
||||
readonly sourceOptions: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly state: ExpressionEditorState;
|
||||
}) => {
|
||||
if (state.kind === "path") {
|
||||
const pathListId = `${idPrefix}-paths`;
|
||||
return (
|
||||
<div className="input-expression-control__leaf">
|
||||
<label>
|
||||
Path for {label}
|
||||
<input
|
||||
aria-label={`Path for ${label}`}
|
||||
list={pathListId}
|
||||
onChange={(event) => onChange({ ...state, path: event.target.value, touched: true })}
|
||||
type="text"
|
||||
value={state.path}
|
||||
/>
|
||||
</label>
|
||||
<datalist id={pathListId}>
|
||||
{sourceSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
|
||||
</datalist>
|
||||
<AuthoringPathPicker
|
||||
allowCustom
|
||||
label={`Path for ${label}`}
|
||||
onChange={(path) => onChange({ ...state, path, touched: true })}
|
||||
options={sourceOptions}
|
||||
uses="step_input"
|
||||
value={state.path}
|
||||
/>
|
||||
{pathNeedsDeferredValidation(field, state.path) && (
|
||||
<p className="schema-form__fallback-reason">Validated when the workflow runs</p>
|
||||
)}
|
||||
@@ -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}
|
||||
/>
|
||||
<div className="input-expression-control__item-actions">
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
<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 () => {
|
||||
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(
|
||||
<SelectedCapabilityInspector
|
||||
capabilityDetail={detail}
|
||||
@@ -398,7 +499,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");
|
||||
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue("Second");
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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<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", () => {
|
||||
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", () => {
|
||||
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(
|
||||
<StepInputBindingsForm
|
||||
@@ -421,7 +475,7 @@ describe("StepInputBindingsForm", () => {
|
||||
onSubmit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("edited");
|
||||
expect(customInput("Target for row 1")).toHaveValue("edited");
|
||||
|
||||
rerender(
|
||||
<StepInputBindingsForm
|
||||
@@ -431,7 +485,7 @@ describe("StepInputBindingsForm", () => {
|
||||
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" }), " ");
|
||||
|
||||
@@ -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<AuthoringPathOption>;
|
||||
readonly targetOptions?: ReadonlyArray<AuthoringPathOption>;
|
||||
/** Changes only when the parent has accepted a new canonical draft. */
|
||||
readonly canonicalVersion?: string | number | null;
|
||||
readonly initialRows?: ReadonlyArray<StepInputBindingRow>;
|
||||
@@ -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<ReadonlyArray<FormRow>>(() =>
|
||||
rowsFrom(inputRows(initialRows, initialBindings), formId, root),
|
||||
);
|
||||
@@ -420,12 +420,6 @@ const StepInputBindingsFormContent = ({
|
||||
return (
|
||||
<form className="schema-form authoring-form" noValidate onSubmit={submit}>
|
||||
{formIssue !== null && <p id={formErrorId} role="alert">{formIssue}</p>}
|
||||
<datalist id={sourceListId}>
|
||||
{sourceSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
|
||||
</datalist>
|
||||
<datalist id={targetListId}>
|
||||
{targetSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
|
||||
</datalist>
|
||||
<div className="schema-form__group">
|
||||
{rows.length === 0 && <p>No input bindings configured.</p>}
|
||||
{rows.map((row, index) => {
|
||||
@@ -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 (
|
||||
<fieldset aria-label={`Input row ${rowNumber}`} className="schema-form__group" key={row.id}>
|
||||
<legend>Input row {rowNumber}</legend>
|
||||
<label htmlFor={targetId}>Target</label>
|
||||
<input
|
||||
aria-describedby={hasIssues ? errorId : undefined}
|
||||
aria-invalid={hasIssues}
|
||||
aria-label={`Target for row ${rowNumber}`}
|
||||
id={targetId}
|
||||
list={targetListId}
|
||||
onChange={(event) => editRow(row.id, (current) => ({ ...current, target: event.target.value }))}
|
||||
type="text"
|
||||
value={row.target}
|
||||
<AuthoringPathPicker
|
||||
allowCustom
|
||||
describedBy={hasIssues ? errorId : undefined}
|
||||
invalid={hasIssues}
|
||||
label={`Target for row ${rowNumber}`}
|
||||
onChange={(target) => editRow(row.id, (current) => ({
|
||||
...current,
|
||||
target: localPathFromPickerValue(target, targetOptions, "step_input"),
|
||||
}))}
|
||||
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">
|
||||
<legend>Value source</legend>
|
||||
@@ -540,19 +534,16 @@ const StepInputBindingsFormContent = ({
|
||||
</div>
|
||||
</fieldset>
|
||||
{row.mode === "path" ? (
|
||||
<label htmlFor={pathId}>
|
||||
Source path for input row {rowNumber}
|
||||
<input
|
||||
aria-describedby={hasIssues ? errorId : undefined}
|
||||
aria-invalid={hasIssues}
|
||||
aria-label={`Source path for input row ${rowNumber}`}
|
||||
id={pathId}
|
||||
list={sourceListId}
|
||||
onChange={(event) => editRow(row.id, (current) => ({ ...current, sourcePath: event.target.value }))}
|
||||
type="text"
|
||||
value={row.sourcePath}
|
||||
/>
|
||||
</label>
|
||||
<AuthoringPathPicker
|
||||
allowCustom
|
||||
describedBy={hasIssues ? errorId : undefined}
|
||||
invalid={hasIssues}
|
||||
label={`Source path for input row ${rowNumber}`}
|
||||
onChange={(path) => editRow(row.id, (current) => ({ ...current, sourcePath: path }))}
|
||||
options={sourceOptions}
|
||||
uses="step_input"
|
||||
value={row.sourcePath}
|
||||
/>
|
||||
) : row.mode === "expression" ? (
|
||||
<InputExpressionControl
|
||||
field={field}
|
||||
@@ -562,7 +553,7 @@ const StepInputBindingsFormContent = ({
|
||||
...current,
|
||||
expression: next,
|
||||
}))}
|
||||
sourceSuggestions={sourceSuggestions}
|
||||
sourceOptions={sourceOptions}
|
||||
state={row.expression ?? defaultExpressionEditorState(field)}
|
||||
showModeControl={false}
|
||||
/>
|
||||
|
||||
@@ -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<typeof userEvent.setup>;
|
||||
type ClearMutationCase = {
|
||||
readonly name: string;
|
||||
@@ -31,13 +47,26 @@ type ClearMutationCase = {
|
||||
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> = [
|
||||
{
|
||||
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<ClearMutationCase> = [
|
||||
{
|
||||
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<ClearMutationCase> = [
|
||||
];
|
||||
|
||||
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", () => {
|
||||
render(
|
||||
<StepOutputBindingsForm
|
||||
outputSchema={outputSchema}
|
||||
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" }]}
|
||||
onSubmit={() => 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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<OutputBindingRow, { readonly kind: "unsupported" }> & {
|
||||
readonly id: string;
|
||||
};
|
||||
@@ -38,6 +37,8 @@ type FormRow = EditableRow | UnsupportedRow;
|
||||
export type StepOutputBindingsFormProps = {
|
||||
readonly outputSchema: unknown;
|
||||
readonly stateSchema: unknown;
|
||||
readonly sourceOptions?: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly targetOptions?: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly initialRows?: ReadonlyArray<OutputBindingRow>;
|
||||
readonly initialBindings?: ReadonlyArray<OutputBinding>;
|
||||
readonly rowDiagnostics?: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>;
|
||||
@@ -48,8 +49,6 @@ export type StepOutputBindingsFormProps = {
|
||||
|
||||
const EMPTY_ROWS: ReadonlyArray<OutputBindingRow> = [];
|
||||
const EMPTY_DIAGNOSTICS: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>> = {};
|
||||
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<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 = (
|
||||
rows: ReadonlyArray<OutputBindingRow>,
|
||||
formId: string,
|
||||
sourceSuggestions: ReadonlyArray<string>,
|
||||
): ReadonlyArray<FormRow> => 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<string>;
|
||||
readonly targetListId: string;
|
||||
readonly sourceOptions: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly targetOptions: ReadonlyArray<AuthoringPathOption>;
|
||||
readonly rowDiagnostics: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>;
|
||||
readonly localIssues: Readonly<Record<string, ReadonlyArray<string>>>;
|
||||
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 (
|
||||
<fieldset aria-label={`Output row ${rowNumber}`} className="schema-form__group">
|
||||
<legend>Output row {rowNumber}</legend>
|
||||
<div className="schema-form__output-row-fields">
|
||||
<div className="schema-form__field">
|
||||
<label htmlFor={sourceChoiceId}>Source choice</label>
|
||||
<select
|
||||
aria-label={`Source choice for output row ${rowNumber}`}
|
||||
id={sourceChoiceId}
|
||||
onChange={(event) => {
|
||||
const next = sourceSelectionFromOption(event.target.value, sourceSuggestions);
|
||||
if (next === null) return;
|
||||
onEdit(row.id, (current) => ({
|
||||
...current,
|
||||
sourceSelection: next.selection,
|
||||
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"
|
||||
value={row.sourcePath}
|
||||
/>
|
||||
</label>
|
||||
<label htmlFor={targetId}>
|
||||
Target
|
||||
<input
|
||||
aria-describedby={hasIssues ? errorId : undefined}
|
||||
aria-invalid={hasIssues}
|
||||
aria-label={`Target for output row ${rowNumber}`}
|
||||
id={targetId}
|
||||
list={targetListId}
|
||||
onChange={(event) => onEdit(row.id, (current) => ({
|
||||
...current,
|
||||
target: event.target.value,
|
||||
}))}
|
||||
type="text"
|
||||
value={row.target}
|
||||
/>
|
||||
</label>
|
||||
<AuthoringPathPicker
|
||||
allowCustom
|
||||
describedBy={hasIssues ? errorId : undefined}
|
||||
invalid={hasIssues}
|
||||
label={`Source path for output row ${rowNumber}`}
|
||||
onChange={(source) => onEdit(row.id, (current) => ({
|
||||
...current,
|
||||
sourcePath: localPathFromPickerValue(source, sourceOptions, "step_output"),
|
||||
}))}
|
||||
options={sourceOptions}
|
||||
uses="step_output_source"
|
||||
value={pickerValueForLocalPath(row.sourcePath, sourceOptions, "step_output")}
|
||||
/>
|
||||
<AuthoringPathPicker
|
||||
allowCustom
|
||||
describedBy={hasIssues ? errorId : undefined}
|
||||
invalid={hasIssues}
|
||||
label={`Target for output row ${rowNumber}`}
|
||||
onChange={(target) => onEdit(row.id, (current) => ({ ...current, target }))}
|
||||
options={targetOptions}
|
||||
uses="state_target"
|
||||
value={row.target}
|
||||
/>
|
||||
</div>
|
||||
{preview !== null ? (
|
||||
<details className="schema-form__preview">
|
||||
@@ -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<ReadonlyArray<FormRow>>(() =>
|
||||
rowsFrom(initialRowValues, formId, sourceSuggestions),
|
||||
rowsFrom(initialRowValues, formId),
|
||||
);
|
||||
const hadInitialRows = initialRowValues.length > 0;
|
||||
const [localIssues, setLocalIssues] = useState<Readonly<Record<string, ReadonlyArray<string>>>>({});
|
||||
@@ -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 (
|
||||
<form className="schema-form authoring-form output-bindings-form" noValidate onSubmit={submit}>
|
||||
{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>
|
||||
<div className="schema-form__group">
|
||||
{rows.length === 0 && <p>No output bindings configured.</p>}
|
||||
@@ -494,8 +424,8 @@ export const StepOutputBindingsForm = ({
|
||||
rowCount={rows.length}
|
||||
rowDiagnostics={rowDiagnostics}
|
||||
rowNumber={index + 1}
|
||||
sourceSuggestions={sourceSuggestions}
|
||||
targetListId={targetListId}
|
||||
sourceOptions={sourceOptions}
|
||||
targetOptions={targetOptions}
|
||||
/>
|
||||
))}
|
||||
<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 type { AuthoringPathOption } from "../domain/authoring-contract-models.js";
|
||||
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import {
|
||||
authoringOptionsForUse,
|
||||
bindingDiagnosticsForStep,
|
||||
capabilityLocalPathSuggestions,
|
||||
inferredStateSchemaPreview,
|
||||
isJsonValue,
|
||||
inputBindingRows,
|
||||
localPathFromPickerValue,
|
||||
pickerValueForLocalPath,
|
||||
outputBindingRows,
|
||||
projectSelectedStepDataflow,
|
||||
serializeInputBindingRow,
|
||||
@@ -13,7 +20,6 @@ import {
|
||||
serializeOutputBindingRow,
|
||||
serializeOutputBindingRows,
|
||||
stateTargetSuggestions,
|
||||
workflowSourceSuggestions,
|
||||
} from "./selected-step-dataflow.js";
|
||||
|
||||
const summary = {
|
||||
@@ -323,17 +329,61 @@ describe("selected-step dataflow projection", () => {
|
||||
});
|
||||
|
||||
describe("selected-step schema helpers", () => {
|
||||
it("returns canonical schema path suggestions", () => {
|
||||
expect(workflowSourceSuggestions(schema, {
|
||||
type: "object",
|
||||
properties: { fallback: { type: "string" } },
|
||||
})).toEqual([
|
||||
"input.items",
|
||||
"input.items.0",
|
||||
"input.account",
|
||||
"input.account.name",
|
||||
"state.fallback",
|
||||
const inventoryOptions: ReadonlyArray<AuthoringPathOption> = [
|
||||
{
|
||||
availability: "available",
|
||||
label: "Input title",
|
||||
origin: "workflow_input",
|
||||
path: "input.title",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
uses: ["step_input"],
|
||||
},
|
||||
{
|
||||
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([
|
||||
".",
|
||||
"items",
|
||||
@@ -344,15 +394,26 @@ describe("selected-step schema helpers", () => {
|
||||
expect(stateTargetSuggestions(schema)).toEqual(["state.items", "state.items.0", "state.account", "state.account.name"]);
|
||||
});
|
||||
|
||||
it("combines workflow input and state source suggestions with nested local targets", () => {
|
||||
expect(workflowSourceSuggestions(
|
||||
{ type: "object", properties: { request: { type: "object", properties: { id: { type: "string" } } } } },
|
||||
{ type: "object", properties: { session: { type: "object", properties: { token: { type: "string" } } } } },
|
||||
)).toEqual(["input.request", "input.request.id", "state.session", "state.session.token"]);
|
||||
it("keeps nested local capability targets and state targets schema-backed", () => {
|
||||
expect(capabilityLocalPathSuggestions({
|
||||
type: "object",
|
||||
properties: { profile: { type: "object", properties: { name: { type: "string" } } } },
|
||||
})).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", () => {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
AuthoringPathOption,
|
||||
AuthoringPathUse,
|
||||
} from "../domain/authoring-contract-models.js";
|
||||
import type {
|
||||
DraftDiagnostic,
|
||||
DraftWorkspace,
|
||||
@@ -402,13 +406,39 @@ const prefixedSchemaPaths = (prefix: string, schema: unknown): ReadonlyArray<str
|
||||
return parts === null ? [] : [formatTOMLPath([prefix, ...parts])];
|
||||
});
|
||||
|
||||
export const workflowSourceSuggestions = (
|
||||
inputSchema: unknown,
|
||||
stateSchema: unknown,
|
||||
): ReadonlyArray<string> => [
|
||||
...prefixedSchemaPaths("input", inputSchema),
|
||||
...prefixedSchemaPaths("state", stateSchema),
|
||||
];
|
||||
export const authoringOptionsForUse = (
|
||||
options: ReadonlyArray<AuthoringPathOption>,
|
||||
use: AuthoringPathUse,
|
||||
): ReadonlyArray<AuthoringPathOption> => options.filter((option) => option.uses.includes(use));
|
||||
|
||||
/**
|
||||
* 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> => [
|
||||
".",
|
||||
|
||||
Reference in New Issue
Block a user