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