feat: edit selected-step dataflow
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CapabilityDetail } from "../domain/capability-models.js";
|
||||
@@ -102,9 +102,9 @@ describe("ContextInspector", () => {
|
||||
expect(screen.getByRole("textbox", { name: "Step id" })).toHaveAttribute("readonly");
|
||||
expect(screen.getByRole("spinbutton", { name: "Retry" })).toHaveValue(2);
|
||||
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveValue(45);
|
||||
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue("Existing title");
|
||||
expect(screen.getByRole("textbox", { name: "Source path for Count" })).toHaveValue("input.count");
|
||||
expect(screen.getAllByText("Bind")).not.toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
expect(screen.getByRole("textbox", { name: "Source path for input row 2" })).toHaveValue("input.count");
|
||||
});
|
||||
|
||||
it("keeps deferred actions focusable while keyboard activation does not dispatch", async () => {
|
||||
@@ -162,7 +162,7 @@ describe("ContextInspector", () => {
|
||||
},
|
||||
{
|
||||
code: "invalid_node_input_field",
|
||||
path: "nodes[0].input[0].target",
|
||||
path: "bindings[0].target",
|
||||
message: "Destination field is not declared.",
|
||||
stepId: "read",
|
||||
repairHint: null,
|
||||
@@ -183,10 +183,11 @@ describe("ContextInspector", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
expect(screen.getAllByText("Title is not accepted.")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("Retry must be non-negative.")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("Destination field is not declared.")).not.toHaveLength(0);
|
||||
expect(screen.getByRole("textbox", { name: "Title" })).toHaveAttribute(
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveAttribute(
|
||||
"aria-invalid",
|
||||
"true",
|
||||
);
|
||||
@@ -231,6 +232,7 @@ describe("ContextInspector", () => {
|
||||
expect(screen.getByRole("textbox", { name: "Description" })).toHaveValue(
|
||||
"Locally edited description",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue(
|
||||
"Locally edited title",
|
||||
);
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import type { CapabilityDetail, CapabilitySummary } from "../domain/capability-models.js";
|
||||
import type { SchemaValueIssue } from "../schema-form/schema-values.js";
|
||||
import { projectAuthoringGraph, type WorkbenchSelection } from "./authoring-graph.js";
|
||||
import { withDiagnosticKeys } from "./diagnostic-key.js";
|
||||
import { formatBoundedJson } from "./format-bounded-json.js";
|
||||
import { CapabilityNodeForm } from "./CapabilityNodeForm.js";
|
||||
import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js";
|
||||
import { RouteForm } from "./RouteForm.js";
|
||||
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
|
||||
import {
|
||||
canonicalCapabilityFormData,
|
||||
capabilityFormDataFromValue,
|
||||
} from "./canonical-capability-form.js";
|
||||
import { parseTOMLPath } from "../schema-form/schema-paths.js";
|
||||
|
||||
type ContextInspectorProps = {
|
||||
readonly draft: DraftWorkspace;
|
||||
@@ -40,107 +35,6 @@ const diagnosticParts = (path: string): string[] => {
|
||||
return normalized.split(".").filter((part) => part.length > 0);
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const schemaPathParts = (value: unknown): ReadonlyArray<string | number> | null => {
|
||||
if (typeof value === "string") {
|
||||
const parts = parseTOMLPath(value);
|
||||
return parts?.map((part) => /^\d+$/.test(part) ? Number(part) : part) ?? null;
|
||||
}
|
||||
if (!isRecord(value) || value.root !== "local" || !Array.isArray(value.parts)) return null;
|
||||
return value.parts.every((part): part is string => typeof part === "string")
|
||||
? value.parts.map((part) => /^\d+$/.test(part) ? Number(part) : part)
|
||||
: null;
|
||||
};
|
||||
|
||||
const nodeIdForDiagnostic = (
|
||||
draft: DraftWorkspace,
|
||||
parts: ReadonlyArray<string>,
|
||||
): string | null => {
|
||||
const nodeIndexPart = parts[parts.indexOf("nodes") + 1];
|
||||
const nodeIndex = nodeIndexPart === undefined ? NaN : Number(nodeIndexPart);
|
||||
if (!Number.isInteger(nodeIndex) || nodeIndex < 0 || !isRecord(draft.draft)) return null;
|
||||
const nodes = draft.draft.nodes;
|
||||
if (Array.isArray(nodes)) {
|
||||
const node = nodes[nodeIndex];
|
||||
return isRecord(node) && typeof node.id === "string" ? node.id : null;
|
||||
}
|
||||
const steps = draft.draft.steps;
|
||||
if (!isRecord(steps)) return null;
|
||||
const stepIds = Object.keys(steps).toSorted();
|
||||
return stepIds[nodeIndex] ?? null;
|
||||
};
|
||||
|
||||
const inputBindingsForStep = (
|
||||
draft: DraftWorkspace,
|
||||
stepId: string,
|
||||
): ReadonlyArray<unknown> | null => {
|
||||
if (!isRecord(draft.draft)) return null;
|
||||
const steps = draft.draft.steps;
|
||||
if (isRecord(steps) && isRecord(steps[stepId]) && Array.isArray(steps[stepId].input)) {
|
||||
return steps[stepId].input;
|
||||
}
|
||||
const nodes = draft.draft.nodes;
|
||||
if (!Array.isArray(nodes)) return null;
|
||||
const node = nodes.find((candidate) => isRecord(candidate) && candidate.id === stepId);
|
||||
return isRecord(node) && Array.isArray(node.input) ? node.input : null;
|
||||
};
|
||||
|
||||
const fieldDiagnostic = (
|
||||
diagnostic: DraftDiagnostic,
|
||||
stepId: string,
|
||||
draft: DraftWorkspace,
|
||||
): SchemaValueIssue | null => {
|
||||
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) return null;
|
||||
const parts = diagnosticParts(diagnostic.path);
|
||||
const stepIndex = parts.findIndex((part) => part === stepId);
|
||||
if (stepIndex >= 0 && parts[stepIndex - 1] !== "steps" && parts[stepIndex - 1] !== "nodes") return null;
|
||||
const nodeIndex = parts.indexOf("nodes");
|
||||
const diagnosticStepId = diagnostic.stepId ?? (nodeIndex >= 0 ? nodeIdForDiagnostic(draft, parts) : null);
|
||||
if (diagnosticStepId !== null && diagnosticStepId !== stepId) return null;
|
||||
const inputIndex = parts.indexOf("input");
|
||||
if (inputIndex < 0) return null;
|
||||
const inputPath = parts.slice(inputIndex + 1);
|
||||
const bindingIndex = inputPath[0] === undefined ? NaN : Number(inputPath[0]);
|
||||
if (Number.isInteger(bindingIndex) && bindingIndex >= 0) {
|
||||
const bindings = inputBindingsForStep(draft, stepId);
|
||||
const binding = bindings?.[bindingIndex];
|
||||
if (!isRecord(binding)) return null;
|
||||
const target = schemaPathParts(binding.target);
|
||||
return target === null ? null : { path: target, message: diagnostic.message };
|
||||
}
|
||||
return { path: inputPath, message: diagnostic.message };
|
||||
};
|
||||
|
||||
const metadataDiagnostics = (
|
||||
diagnostics: ReadonlyArray<DraftDiagnostic>,
|
||||
stepId: string,
|
||||
draft: DraftWorkspace,
|
||||
): ReadonlyArray<SchemaValueIssue> => diagnostics.flatMap((diagnostic) => {
|
||||
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) return [];
|
||||
const parts = diagnosticParts(diagnostic.path);
|
||||
const stepIndex = parts.findIndex((part) => part === stepId);
|
||||
if (stepIndex >= 0 && parts[stepIndex - 1] !== "steps" && parts[stepIndex - 1] !== "nodes") return [];
|
||||
const nodeIndex = parts.indexOf("nodes");
|
||||
const diagnosticStepId = diagnostic.stepId ?? (nodeIndex >= 0 ? nodeIdForDiagnostic(draft, parts) : null);
|
||||
if (diagnosticStepId !== null && diagnosticStepId !== stepId) return [];
|
||||
if (parts.includes("input")) return [];
|
||||
const field = parts.at(-1);
|
||||
return field === "desc" || field === "retry" || field === "timeout_seconds"
|
||||
? [{ path: [field], message: diagnostic.message }]
|
||||
: [];
|
||||
});
|
||||
|
||||
const inputDiagnostics = (
|
||||
diagnostics: ReadonlyArray<DraftDiagnostic>,
|
||||
stepId: string,
|
||||
draft: DraftWorkspace,
|
||||
): ReadonlyArray<SchemaValueIssue> => diagnostics.flatMap((diagnostic) => {
|
||||
const issue = fieldDiagnostic(diagnostic, stepId, draft);
|
||||
return issue === null ? [] : [issue];
|
||||
});
|
||||
|
||||
const routeDiagnostics = (
|
||||
diagnostics: ReadonlyArray<DraftDiagnostic>,
|
||||
selection: Extract<WorkbenchSelection, { readonly kind: "edge" }>,
|
||||
@@ -323,49 +217,18 @@ export const ContextInspector = ({
|
||||
);
|
||||
} else {
|
||||
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
|
||||
const preservedForm =
|
||||
controller.preservedCapabilityForm?.kind === "update" &&
|
||||
controller.preservedCapabilityForm.input.stepId === selection.nodeId
|
||||
? capabilityFormDataFromValue(controller.preservedCapabilityForm.input)
|
||||
: null;
|
||||
const formData = canonicalCapabilityFormData(draft, selection.nodeId) ?? preservedForm;
|
||||
const unsupported = node?.data.kind === "unsupported";
|
||||
content = (
|
||||
<section className="authoring-inspector__selection" aria-labelledby="node-selection-heading">
|
||||
<p className="workspace-route-pending__eyebrow">Selected step</p>
|
||||
<h2 id="node-selection-heading">{selection.nodeId}</h2>
|
||||
<dl className="authoring-inspector__facts">
|
||||
<Fact label="Kind" value={node?.data.kind ?? "unknown"} />
|
||||
<Fact label="Reference" value={node?.data.nodeRef ?? formData?.capabilityName ?? "none"} />
|
||||
</dl>
|
||||
{unsupported && <p role="status">Read-only: unsupported step kind.</p>}
|
||||
{!unsupported && capabilityDetailPhase === "loading" && (
|
||||
<p role="status">Loading capability schema...</p>
|
||||
)}
|
||||
{!unsupported && capabilityDetailPhase === "error" && (
|
||||
<p role="alert">{capabilityDetailMessage ?? "Capability schema failed to load."}</p>
|
||||
)}
|
||||
{!unsupported && capabilityDetailPhase === "ready" && capabilityDetail !== null && (() => {
|
||||
if (formData === null) return <p role="status">Canonical node data is unavailable.</p>;
|
||||
return (
|
||||
<CapabilityNodeForm
|
||||
key={`node:${selection.nodeId}:${controller.resetGeneration}`}
|
||||
capabilityName={formData.capabilityName}
|
||||
diagnostics={inputDiagnostics(draft.diagnostics, selection.nodeId, draft)}
|
||||
initialInputSources={formData.initialInputSources}
|
||||
initialInputValue={formData.initialInputValue}
|
||||
initialValue={formData.initialValue}
|
||||
inputSchema={capabilityDetail.inputSchema}
|
||||
metadataDiagnostics={metadataDiagnostics(draft.diagnostics, selection.nodeId, draft)}
|
||||
onDirtyChange={controller.markDirty}
|
||||
onSubmit={controller.updateCapability}
|
||||
onValueChange={(value) => controller.rememberCapabilityForm("update", value)}
|
||||
stepIdReadOnly
|
||||
submitLabel="Apply changes"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
<SelectedCapabilityInspector
|
||||
key={selection.nodeId}
|
||||
capabilityDetail={capabilityDetail}
|
||||
capabilityDetailMessage={capabilityDetailMessage}
|
||||
capabilityDetailPhase={capabilityDetailPhase}
|
||||
controller={controller}
|
||||
draft={draft}
|
||||
nodeRef={node?.data.nodeRef ?? null}
|
||||
stepId={selection.nodeId}
|
||||
{...(node?.data.kind ? { nodeKind: node.data.kind } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,18 @@ const capabilityDetail: CapabilityDetail = {
|
||||
acceptsContext: false,
|
||||
};
|
||||
|
||||
const dataflowCapabilityDetail: CapabilityDetail = {
|
||||
...capabilityDetail,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { title: { type: "string" } },
|
||||
},
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string" } },
|
||||
},
|
||||
};
|
||||
|
||||
const setViewport = (width: number): void => {
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
@@ -202,6 +214,50 @@ describe("DraftWorkbench", () => {
|
||||
expect(container.querySelector(".draft-workbench")).toHaveAttribute("data-dirty", "true");
|
||||
});
|
||||
|
||||
it("keeps the selected dataflow tab and unsaved rows across mobile close and reopen", async () => {
|
||||
setViewport(390);
|
||||
mockedUseAuthoringCapabilityDetail.mockReturnValue({
|
||||
phase: "ready",
|
||||
detail: dataflowCapabilityDetail,
|
||||
message: null,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<DraftWorkbench
|
||||
draft={workspace}
|
||||
initialSelection={{ kind: "node", nodeId: "collect" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const inspector = container.querySelector("#draft-workbench-inspector") as HTMLElement;
|
||||
await user.click(
|
||||
within(inspector).getByRole("tab", { name: "Inputs", hidden: true }),
|
||||
);
|
||||
await user.click(
|
||||
within(inspector).getByRole("button", { name: "Add input row", hidden: true }),
|
||||
);
|
||||
await user.type(
|
||||
within(inspector).getByRole("textbox", { name: "Target for row 1", hidden: true }),
|
||||
"title",
|
||||
);
|
||||
await user.click(
|
||||
within(inspector).getByRole("tab", { name: "Outputs", hidden: true }),
|
||||
);
|
||||
|
||||
const inspectorTrigger = screen.getByRole("button", { name: "Open context inspector" });
|
||||
await user.click(inspectorTrigger);
|
||||
await user.click(screen.getByRole("button", { name: "Close context inspector" }));
|
||||
expect(inspectorTrigger).toHaveFocus();
|
||||
await user.click(inspectorTrigger);
|
||||
|
||||
expect(
|
||||
within(inspector).getByRole("tab", { name: "Outputs" }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(
|
||||
within(inspector).getByRole("textbox", { name: "Target for row 1", hidden: true }),
|
||||
).toHaveValue("title");
|
||||
});
|
||||
|
||||
it("reopens an open desktop sheet as a modal after resizing to mobile", async () => {
|
||||
setViewport(1024);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CapabilityDetail } from "../domain/capability-models.js";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { SelectedCapabilityInspector } from "./SelectedCapabilityInspector.js";
|
||||
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const detail: CapabilityDetail = {
|
||||
kind: "node_spec",
|
||||
name: "demo.read",
|
||||
sourceId: "demo",
|
||||
description: "Read a report",
|
||||
isAsync: false,
|
||||
outcomes: ["ok"],
|
||||
inputSchema: { type: "object", properties: { title: { type: "string" } } },
|
||||
outputSchema: { type: "object", properties: { text: { type: "string" } } },
|
||||
wrapperHints: {},
|
||||
acceptsContext: false,
|
||||
};
|
||||
|
||||
const draft = (stepId: string, input: unknown, output: unknown): DraftWorkspace => ({
|
||||
workspaceId: "draft-report",
|
||||
revision: 3,
|
||||
title: "Report",
|
||||
status: "invalid",
|
||||
diagnostics: [],
|
||||
summary: { name: "report", start: stepId, stepCount: 1, routeCount: 0, steps: [stepId] },
|
||||
draft: {
|
||||
state_schema: { type: "object", properties: { existing: { type: "string" } } },
|
||||
steps: {
|
||||
[stepId]: {
|
||||
use: "demo.read",
|
||||
input,
|
||||
output,
|
||||
desc: "Read the report",
|
||||
retry: 2,
|
||||
timeout_seconds: 45,
|
||||
},
|
||||
},
|
||||
routes: {},
|
||||
},
|
||||
});
|
||||
|
||||
const controllerFor = (workspace: DraftWorkspace): DraftAuthoringController => ({
|
||||
draft: workspace,
|
||||
selection: { kind: "node", nodeId: "read" },
|
||||
insertionContext: null,
|
||||
dirty: false,
|
||||
phase: "idle",
|
||||
message: null,
|
||||
resetGeneration: 0,
|
||||
addCapability: vi.fn(),
|
||||
updateCapability: vi.fn(),
|
||||
setStepInputs: vi.fn(),
|
||||
setStepOutputs: vi.fn(),
|
||||
updateSetup: vi.fn(),
|
||||
setRoute: vi.fn(),
|
||||
validate: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
reapply: vi.fn(),
|
||||
rememberCapabilityForm: vi.fn(),
|
||||
rememberRouteForm: vi.fn(),
|
||||
select: vi.fn(),
|
||||
markDirty: vi.fn(),
|
||||
preservedCapabilityForm: null,
|
||||
});
|
||||
|
||||
describe("SelectedCapabilityInspector", () => {
|
||||
it("composes setup, inputs, and outputs while preserving malformed rows and dispatching focused saves", async () => {
|
||||
const user = userEvent.setup();
|
||||
const workspace = draft(
|
||||
"read",
|
||||
[
|
||||
{ target: "title", value: "Existing title" },
|
||||
{ target: "broken", value: () => "not JSON" },
|
||||
],
|
||||
[{ source: "text", target: "state.existing" }],
|
||||
);
|
||||
const controller = controllerFor(workspace);
|
||||
|
||||
render(
|
||||
<SelectedCapabilityInspector
|
||||
capabilityDetail={detail}
|
||||
capabilityDetailMessage={null}
|
||||
capabilityDetailPhase="ready"
|
||||
controller={controller}
|
||||
draft={workspace}
|
||||
nodeKind="use"
|
||||
nodeRef="demo.read"
|
||||
stepId="read"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("tab")).toHaveLength(3);
|
||||
expect(screen.getByRole("tab", { name: "Setup" })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
expect(screen.getByRole("region", { name: "Raw unsupported input row 2" })).toHaveTextContent("broken");
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
expect(controller.setStepInputs).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByRole("alert").some((alert) =>
|
||||
alert.textContent?.includes("Remove or repair every unsupported input row before saving.") ?? false,
|
||||
)).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove unsupported input row 2" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
expect(controller.setStepInputs).toHaveBeenCalledWith([{ target: "title", value: "Existing title" }]);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Outputs" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save outputs" }));
|
||||
expect(controller.setStepOutputs).toHaveBeenCalledWith([
|
||||
{ source: "text", target: "state.existing" },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Setup" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
expect(controller.updateSetup).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("keeps diagnostic ids unique across failing setup and hidden binding forms", async () => {
|
||||
const user = userEvent.setup();
|
||||
const workspace = draft(
|
||||
"read",
|
||||
[{ target: "title", value: "Existing title" }],
|
||||
[{ source: "text", target: "state.existing" }],
|
||||
);
|
||||
const controller = controllerFor(workspace);
|
||||
|
||||
render(
|
||||
<SelectedCapabilityInspector
|
||||
capabilityDetail={detail}
|
||||
capabilityDetailMessage={null}
|
||||
capabilityDetailPhase="ready"
|
||||
controller={controller}
|
||||
draft={workspace}
|
||||
nodeKind="use"
|
||||
nodeRef="demo.read"
|
||||
stepId="read"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "Retry", hidden: true }), {
|
||||
target: { value: "-1" },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
await user.clear(screen.getByRole("textbox", { name: "Target for row 1" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Outputs" }));
|
||||
await user.clear(screen.getByRole("combobox", { name: "Target for output row 1" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save outputs" }));
|
||||
|
||||
const diagnosticIds = [...document.querySelectorAll('[id$="-error"], [id$="-errors"]')]
|
||||
.map((element) => element.id);
|
||||
expect(new Set(diagnosticIds).size).toBe(diagnosticIds.length);
|
||||
expect(diagnosticIds.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("rehydrates canonical rows when the selected step changes", async () => {
|
||||
const first = draft("first", [{ target: "title", value: "First" }], []);
|
||||
const second = draft("second", [{ target: "title", value: "Second" }], []);
|
||||
const controller = controllerFor(first);
|
||||
const { rerender } = render(
|
||||
<SelectedCapabilityInspector
|
||||
capabilityDetail={detail}
|
||||
capabilityDetailMessage={null}
|
||||
capabilityDetailPhase="ready"
|
||||
controller={controller}
|
||||
draft={first}
|
||||
nodeKind="use"
|
||||
nodeRef="demo.read"
|
||||
stepId="first"
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
rerender(
|
||||
<SelectedCapabilityInspector
|
||||
capabilityDetail={detail}
|
||||
capabilityDetailMessage={null}
|
||||
capabilityDetailPhase="ready"
|
||||
controller={{ ...controller, draft: second }}
|
||||
draft={second}
|
||||
key="second"
|
||||
nodeKind="use"
|
||||
nodeRef="demo.read"
|
||||
stepId="second"
|
||||
/>,
|
||||
);
|
||||
await userEvent.setup().click(screen.getByRole("tab", { name: "Inputs" }));
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue("Second");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["loading", "Loading capability schema..."],
|
||||
["error", "Capability schema failed to load."],
|
||||
] as const)("shows capability detail %s feedback", (phase, message) => {
|
||||
const workspace = draft("read", [], []);
|
||||
render(
|
||||
<SelectedCapabilityInspector
|
||||
capabilityDetail={phase === "loading" ? null : detail}
|
||||
capabilityDetailMessage={phase === "error" ? null : message}
|
||||
capabilityDetailPhase={phase}
|
||||
controller={controllerFor(workspace)}
|
||||
draft={workspace}
|
||||
nodeKind="use"
|
||||
nodeRef="demo.read"
|
||||
stepId="read"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole(phase === "error" ? "alert" : "status")).toHaveTextContent(message);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { CapabilityDetail } from "../domain/capability-models.js";
|
||||
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { CapabilitySetupForm } from "./CapabilitySetupForm.js";
|
||||
import { StepInputBindingsForm } from "./StepInputBindingsForm.js";
|
||||
import { StepOutputBindingsForm } from "./StepOutputBindingsForm.js";
|
||||
import {
|
||||
bindingDiagnosticsForStep,
|
||||
inputBindingRows,
|
||||
outputBindingRows,
|
||||
projectSelectedStepDataflow,
|
||||
} from "./selected-step-dataflow.js";
|
||||
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
|
||||
|
||||
type InspectorTab = "setup" | "inputs" | "outputs";
|
||||
|
||||
export type SelectedCapabilityInspectorProps = {
|
||||
readonly draft: DraftWorkspace;
|
||||
readonly stepId: string;
|
||||
readonly nodeKind?: string;
|
||||
readonly nodeRef: string | null;
|
||||
readonly controller: DraftAuthoringController;
|
||||
readonly capabilityDetail: CapabilityDetail | null;
|
||||
readonly capabilityDetailPhase: "disconnected" | "loading" | "ready" | "error";
|
||||
readonly capabilityDetailMessage: string | null;
|
||||
};
|
||||
|
||||
type JsonRecord = Readonly<Record<string, unknown>>;
|
||||
|
||||
const isRecord = (value: unknown): value is JsonRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const selectedStep = (draft: DraftWorkspace, stepId: string): JsonRecord | null => {
|
||||
if (!isRecord(draft.draft)) return null;
|
||||
if (Array.isArray(draft.draft.nodes)) {
|
||||
const node = draft.draft.nodes.find((candidate) => isRecord(candidate) && candidate.id === stepId);
|
||||
return isRecord(node) ? node : null;
|
||||
}
|
||||
const steps = draft.draft.steps;
|
||||
return isRecord(steps) && isRecord(steps[stepId]) ? steps[stepId] : null;
|
||||
};
|
||||
|
||||
const diagnosticParts = (path: string): ReadonlyArray<string> => {
|
||||
const normalized = path.startsWith("/")
|
||||
? path.slice(1).replaceAll("~1", "/").replaceAll("~0", "~")
|
||||
: path.replace(/\[([^\]]+)\]/g, ".$1");
|
||||
return normalized.split(".").filter((part) => part.length > 0);
|
||||
};
|
||||
|
||||
const setupDiagnostics = (
|
||||
diagnostics: ReadonlyArray<DraftDiagnostic>,
|
||||
stepId: string,
|
||||
): ReadonlyArray<{ readonly path: ReadonlyArray<string>; readonly message: string }> =>
|
||||
diagnostics.flatMap((diagnostic) => {
|
||||
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) return [];
|
||||
const parts = diagnosticParts(diagnostic.path);
|
||||
const stepIndex = parts.indexOf(stepId);
|
||||
if (stepIndex >= 0 && parts[stepIndex - 1] !== "steps" && parts[stepIndex - 1] !== "nodes") return [];
|
||||
const field = parts.at(-1);
|
||||
return field === "desc" || field === "retry" || field === "timeout_seconds"
|
||||
? [{ path: [field], message: diagnostic.message }]
|
||||
: [];
|
||||
});
|
||||
|
||||
const emptyStateSchema = { type: "object", properties: {} };
|
||||
const tabLabels: Record<InspectorTab, string> = { setup: "Setup", inputs: "Inputs", outputs: "Outputs" };
|
||||
const tabPanelId = (tab: InspectorTab): string => `selected-step-panel-${tab}`;
|
||||
const tabId = (tab: InspectorTab): string => `selected-step-tab-${tab}`;
|
||||
|
||||
const TabPanel = ({
|
||||
activeTab,
|
||||
children,
|
||||
tab,
|
||||
}: {
|
||||
readonly activeTab: InspectorTab;
|
||||
readonly children: ReactNode;
|
||||
readonly tab: InspectorTab;
|
||||
}) => (
|
||||
<section
|
||||
aria-labelledby={tabId(tab)}
|
||||
className="selected-capability-inspector__panel"
|
||||
hidden={activeTab !== tab}
|
||||
id={tabPanelId(tab)}
|
||||
role="tabpanel"
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
export const SelectedCapabilityInspector = ({
|
||||
draft,
|
||||
stepId,
|
||||
nodeKind,
|
||||
nodeRef,
|
||||
controller,
|
||||
capabilityDetail,
|
||||
capabilityDetailPhase,
|
||||
capabilityDetailMessage,
|
||||
}: SelectedCapabilityInspectorProps) => {
|
||||
const [activeTab, setActiveTab] = useState<InspectorTab>("setup");
|
||||
const rawStep = selectedStep(draft, stepId);
|
||||
const projected = projectSelectedStepDataflow(draft, stepId);
|
||||
const preservedForm = controller.preservedCapabilityForm?.kind === "update" &&
|
||||
controller.preservedCapabilityForm.input.stepId === stepId
|
||||
? controller.preservedCapabilityForm.input
|
||||
: null;
|
||||
// Forms receive raw-row projections so malformed persisted entries stay in order.
|
||||
const inputRows = inputBindingRows(rawStep?.input ?? preservedForm?.inputBindings);
|
||||
const outputRows = outputBindingRows(rawStep?.output);
|
||||
const inputDiagnostics = bindingDiagnosticsForStep(draft.diagnostics, stepId, "input", projected?.compiledNodeIndex ?? null);
|
||||
const outputDiagnostics = bindingDiagnosticsForStep(draft.diagnostics, stepId, "output", projected?.compiledNodeIndex ?? null);
|
||||
const setupInitialValue = projected === null
|
||||
? preservedForm ?? {}
|
||||
: {
|
||||
...(projected.description !== undefined ? { description: projected.description } : {}),
|
||||
...(projected.retry !== undefined ? { retry: projected.retry } : {}),
|
||||
...(projected.timeoutSeconds !== undefined ? { timeoutSeconds: projected.timeoutSeconds } : {}),
|
||||
};
|
||||
const detailReady = capabilityDetailPhase === "ready" && capabilityDetail !== null;
|
||||
const isUnsupported = nodeKind !== undefined && nodeKind !== "use";
|
||||
|
||||
return (
|
||||
<section className="selected-capability-inspector" aria-label="Selected step editor">
|
||||
<section className="authoring-inspector__selection" aria-labelledby="node-selection-heading">
|
||||
<p className="workspace-route-pending__eyebrow">Selected step</p>
|
||||
<h2 id="node-selection-heading">{stepId}</h2>
|
||||
<dl className="authoring-inspector__facts">
|
||||
<div><dt>Kind</dt><dd>{nodeKind ?? "use"}</dd></div>
|
||||
<div><dt>Reference</dt><dd>{nodeRef ?? projected?.capabilityName ?? "none"}</dd></div>
|
||||
</dl>
|
||||
<label className="selected-capability-inspector__step-id">
|
||||
Step id
|
||||
<input aria-label="Step id" readOnly value={stepId} />
|
||||
</label>
|
||||
{isUnsupported && <p role="status">Read-only: unsupported step kind.</p>}
|
||||
</section>
|
||||
|
||||
{!isUnsupported && (
|
||||
<>
|
||||
<div aria-label="Selected step views" className="selected-capability-inspector__tabs" role="tablist">
|
||||
{(Object.keys(tabLabels) as InspectorTab[]).map((tab) => (
|
||||
<button
|
||||
aria-controls={tabPanelId(tab)}
|
||||
aria-selected={activeTab === tab}
|
||||
className="selected-capability-inspector__tab"
|
||||
id={tabId(tab)}
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
role="tab"
|
||||
tabIndex={activeTab === tab ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
{tabLabels[tab]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!detailReady && capabilityDetailPhase === "loading" && <p role="status">Loading capability schema...</p>}
|
||||
{!detailReady && capabilityDetailPhase === "error" && (
|
||||
<p role="alert">{capabilityDetailMessage ?? "Capability schema failed to load."}</p>
|
||||
)}
|
||||
{!detailReady && capabilityDetailPhase === "disconnected" && (
|
||||
<p role="status">Connect to inspect the capability schema.</p>
|
||||
)}
|
||||
{detailReady && (
|
||||
<>
|
||||
<TabPanel activeTab={activeTab} tab="setup">
|
||||
<CapabilitySetupForm
|
||||
key={`setup:${stepId}:${controller.resetGeneration}`}
|
||||
diagnostics={setupDiagnostics(draft.diagnostics, stepId)}
|
||||
initialValue={setupInitialValue}
|
||||
onDirtyChange={controller.markDirty}
|
||||
onSubmit={controller.updateSetup}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel activeTab={activeTab} tab="inputs">
|
||||
<StepInputBindingsForm
|
||||
key={`inputs:${stepId}:${controller.resetGeneration}`}
|
||||
initialRows={inputRows}
|
||||
inputSchema={capabilityDetail.inputSchema}
|
||||
onDirtyChange={controller.markDirty}
|
||||
onSubmit={controller.setStepInputs}
|
||||
rowDiagnostics={inputDiagnostics.rowIssues}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel activeTab={activeTab} tab="outputs">
|
||||
<StepOutputBindingsForm
|
||||
key={`outputs:${stepId}:${controller.resetGeneration}`}
|
||||
initialRows={outputRows}
|
||||
onDirtyChange={controller.markDirty}
|
||||
onSubmit={controller.setStepOutputs}
|
||||
outputSchema={capabilityDetail.outputSchema}
|
||||
rowDiagnostics={outputDiagnostics.rowIssues}
|
||||
stateSchema={(isRecord(draft.draft) ? draft.draft.state_schema : null) ?? emptyStateSchema}
|
||||
/>
|
||||
</TabPanel>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -51,6 +51,47 @@ describe("projectAuthoringGraph", () => {
|
||||
|
||||
expect(projectAuthoringGraph(reordered)).toEqual(projectAuthoringGraph(draft));
|
||||
});
|
||||
|
||||
it("summarizes canonical selected-step bindings with truthful grammar", () => {
|
||||
const model = projectAuthoringGraph({
|
||||
...draft,
|
||||
steps: {
|
||||
collect: {
|
||||
use: "demo.collect",
|
||||
input: [
|
||||
{ target: "title", value: "Report" },
|
||||
{ target: "count", path: "input.count" },
|
||||
],
|
||||
output: [{ source: "text", target: "state.report" }],
|
||||
},
|
||||
review: draft.steps.review,
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.nodes.find((node) => node.id === "collect")?.data.summary).toBe(
|
||||
"2 inputs · 1 state write",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses singular labels and omits empty binding summaries", () => {
|
||||
const model = projectAuthoringGraph({
|
||||
...draft,
|
||||
steps: {
|
||||
collect: {
|
||||
use: "demo.collect",
|
||||
input: [{ target: "title", value: "Report" }],
|
||||
output: [{ source: ".", target: "state.report" }, { source: ".", target: "state.raw" }],
|
||||
},
|
||||
review: draft.steps.review,
|
||||
},
|
||||
});
|
||||
expect(model.nodes.find((node) => node.id === "collect")?.data.summary).toBe(
|
||||
"1 input · 2 state writes",
|
||||
);
|
||||
|
||||
const empty = projectAuthoringGraph({ ...draft, steps: { collect: { use: "demo.collect" } } });
|
||||
expect(empty.nodes.find((node) => node.id === "collect")?.data.summary).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkbenchSelection", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildWorkflowGraph, type WorkflowGraphModel } from "../../graph/graph-model.js";
|
||||
import { inputBindingRows, outputBindingRows } from "./selected-step-dataflow.js";
|
||||
|
||||
type JsonRecord = Readonly<Record<string, unknown>>;
|
||||
|
||||
@@ -67,6 +68,18 @@ const nodeForStep = (id: string, step: JsonRecord): JsonRecord => {
|
||||
detail: stringValue(step.desc),
|
||||
};
|
||||
|
||||
if (kind === "use") {
|
||||
const inputCount = inputBindingRows(step.input).filter((row) => row.kind === "canonical").length;
|
||||
const outputCount = outputBindingRows(step.output).filter((row) => row.kind === "canonical").length;
|
||||
if (inputCount > 0 || outputCount > 0) {
|
||||
const inputLabel = `${inputCount} input${inputCount === 1 ? "" : "s"}`;
|
||||
const outputLabel = `${outputCount} state write${outputCount === 1 ? "" : "s"}`;
|
||||
node.summary = [inputCount > 0 ? inputLabel : null, outputCount > 0 ? outputLabel : null]
|
||||
.filter((value): value is string => value !== null)
|
||||
.join(" · ");
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "use") node.node = stringValue(step.use) ?? id;
|
||||
if (kind === "interrupt") {
|
||||
node.kind = stringValue(payload?.kind) ?? "Interrupt";
|
||||
|
||||
Reference in New Issue
Block a user