fix: close Task 6 review findings
This commit is contained in:
@@ -1,41 +0,0 @@
|
|||||||
# Task 6 Report
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Complete. The implementation is based on Task 5 commit `eae1a62f`.
|
|
||||||
|
|
||||||
Implementation commit: `92a32df8` (`refactor: use canonical authoring choices`).
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
- Threaded `useAuthoringContract` inventory data through `SelectedCapabilityInspector`.
|
|
||||||
- Filtered inventory choices by `step_input`, `step_output_source`, and `state_target` use.
|
|
||||||
- Replaced input target/source datalists, recursive expression path datalists, and output schema-local controls with `AuthoringPathPicker`.
|
|
||||||
- Preserved canonical input and output binding serializers. Picker values translate canonical inventory paths to the existing local paths before submission.
|
|
||||||
- Kept unsupported rows and custom or malformed persisted paths editable. Non-catalog values initialize the picker Advanced section without replacing the persisted value.
|
|
||||||
- Runtime context choices are rendered only when supplied by the inventory; no runtime context field names are hardcoded in production authoring files.
|
|
||||||
- Preserved existing row diagnostic accessibility through `aria-invalid` and `aria-describedby` on Advanced custom inputs.
|
|
||||||
- Kept schema helpers required by validation and output preview; removed `workflowSourceSuggestions` as a production source of choices.
|
|
||||||
|
|
||||||
## TDD Evidence
|
|
||||||
|
|
||||||
The required RED command was run before production changes:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pnpm --dir web --filter @lda/console test -- src/workspace/authoring/StepInputBindingsForm.test.tsx src/workspace/authoring/InputExpressionControl.test.tsx src/workspace/authoring/StepOutputBindingsForm.test.tsx src/workspace/authoring/SelectedCapabilityInspector.test.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
RED result: 4 test files failed, 4 tests failed, and 60 tests passed because the forms still rendered the old datalists/selects.
|
|
||||||
|
|
||||||
The tests then covered inventory-backed source/target choices, recursive expression paths, conditional context visibility, canonical submission, custom-path repair, whole-payload path round-tripping, and the selected inspector inventory seam.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm --dir web --filter @lda/console test -- src/workspace/authoring`: 19 files passed, 197 tests passed.
|
|
||||||
- `pnpm --dir web --filter @lda/console typecheck`: passed.
|
|
||||||
- `npx react-doctor@latest --verbose --scope changed`: score 87/100; six non-blocking warnings were reported in existing picker/component patterns.
|
|
||||||
- `git diff --check`: passed before commit.
|
|
||||||
|
|
||||||
## Concerns
|
|
||||||
|
|
||||||
React Doctor reports six non-blocking warnings: mirrored picker state, array lookups, the existing large input form, and empty default props. These patterns were present in the existing authoring seams or are equivalent to the prior suggestion-array defaults; no new blocking diagnostic was reported.
|
|
||||||
@@ -134,7 +134,7 @@ describe("AuthoringPathPicker", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: /Title/ }));
|
await user.click(screen.getByRole("button", { name: /Title/ }));
|
||||||
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(1);
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
expect(onChange).toHaveBeenCalledWith("input.title");
|
expect(onChange).toHaveBeenCalledWith("input.title", "catalog");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives a conditional incompatible option one composed description node", () => {
|
it("gives a conditional incompatible option one composed description node", () => {
|
||||||
@@ -203,7 +203,7 @@ describe("AuthoringPathPicker", () => {
|
|||||||
const nested = screen.getByRole("button", { name: /Customer name/ });
|
const nested = screen.getByRole("button", { name: /Customer name/ });
|
||||||
nested.focus();
|
nested.focus();
|
||||||
await user.keyboard("{Enter}");
|
await user.keyboard("{Enter}");
|
||||||
expect(onChange).toHaveBeenCalledWith("input.customer.name");
|
expect(onChange).toHaveBeenCalledWith("input.customer.name", "catalog");
|
||||||
|
|
||||||
await user.click(screen.getByText("Advanced"));
|
await user.click(screen.getByText("Advanced"));
|
||||||
expect(screen.getByRole("textbox", { name: "Custom Source path" })).toBeInTheDocument();
|
expect(screen.getByRole("textbox", { name: "Custom Source path" })).toBeInTheDocument();
|
||||||
@@ -211,6 +211,6 @@ describe("AuthoringPathPicker", () => {
|
|||||||
|
|
||||||
await user.clear(screen.getByRole("textbox", { name: "Custom Source path" }));
|
await user.clear(screen.getByRole("textbox", { name: "Custom Source path" }));
|
||||||
await user.type(screen.getByRole("textbox", { name: "Custom Source path" }), "context.future");
|
await user.type(screen.getByRole("textbox", { name: "Custom Source path" }), "context.future");
|
||||||
expect(onChange).toHaveBeenLastCalledWith("context.future");
|
expect(onChange).toHaveBeenLastCalledWith("context.future", "custom");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import type {
|
|||||||
AuthoringPathUse,
|
AuthoringPathUse,
|
||||||
} from "../domain/authoring-contract-models.js";
|
} from "../domain/authoring-contract-models.js";
|
||||||
|
|
||||||
|
export type AuthoringPathSelection = "catalog" | "custom";
|
||||||
|
|
||||||
export type AuthoringPathPickerProps = {
|
export type AuthoringPathPickerProps = {
|
||||||
readonly options: ReadonlyArray<AuthoringPathOption>;
|
readonly options: ReadonlyArray<AuthoringPathOption>;
|
||||||
readonly uses: AuthoringPathUse | ReadonlyArray<AuthoringPathUse>;
|
readonly uses: AuthoringPathUse | ReadonlyArray<AuthoringPathUse>;
|
||||||
readonly value: string;
|
readonly value: string;
|
||||||
readonly onChange: (value: string) => void;
|
readonly onChange: (value: string, selection: AuthoringPathSelection) => void;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly allowCustom?: boolean;
|
readonly allowCustom?: boolean;
|
||||||
readonly describedBy?: string | undefined;
|
readonly describedBy?: string | undefined;
|
||||||
@@ -106,7 +108,7 @@ export const AuthoringPathPicker = ({
|
|||||||
key={option.path}
|
key={option.path}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCustomValue(option.path);
|
setCustomValue(option.path);
|
||||||
onChange(option.path);
|
onChange(option.path, "catalog");
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -138,7 +140,7 @@ export const AuthoringPathPicker = ({
|
|||||||
id={customId}
|
id={customId}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setCustomValue(event.target.value);
|
setCustomValue(event.target.value);
|
||||||
onChange(event.target.value);
|
onChange(event.target.value, "custom");
|
||||||
}}
|
}}
|
||||||
type="text"
|
type="text"
|
||||||
value={customValue}
|
value={customValue}
|
||||||
|
|||||||
@@ -1,16 +1,46 @@
|
|||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } 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 type { DraftAuthoringController } from "./useDraftAuthoring.js";
|
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
|
||||||
import { ContextInspector } from "./ContextInspector.js";
|
import { ContextInspector } from "./ContextInspector.js";
|
||||||
|
|
||||||
|
vi.mock("./useAuthoringContract.js", () => ({ useAuthoringContract: vi.fn() }));
|
||||||
|
|
||||||
|
import { useAuthoringContract } from "./useAuthoringContract.js";
|
||||||
|
|
||||||
|
const mockedUseAuthoringContract = vi.mocked(useAuthoringContract);
|
||||||
|
|
||||||
|
const emptyInventory: AuthoringContractInventory = {
|
||||||
|
workspaceId: "draft-report",
|
||||||
|
revision: 3,
|
||||||
|
selectedStepId: "read",
|
||||||
|
readableSources: [],
|
||||||
|
stepInputTargets: [],
|
||||||
|
stepOutputSources: [],
|
||||||
|
stateTargets: [],
|
||||||
|
workflowOutputTargets: [],
|
||||||
|
entrySteps: [],
|
||||||
|
workflowOutcomes: [],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedUseAuthoringContract.mockReturnValue({
|
||||||
|
phase: "ready",
|
||||||
|
inventory: emptyInventory,
|
||||||
|
message: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const draft: DraftWorkspace = {
|
const draft: DraftWorkspace = {
|
||||||
workspaceId: "draft-report",
|
workspaceId: "draft-report",
|
||||||
revision: 3,
|
revision: 3,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, screen, within } from "@testing-librar
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, 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 { DraftWorkbench } from "./DraftWorkbench.js";
|
import { DraftWorkbench } from "./DraftWorkbench.js";
|
||||||
|
|
||||||
@@ -9,9 +10,27 @@ vi.mock("./useAuthoringCapabilityDetail.js", () => ({
|
|||||||
useAuthoringCapabilityDetail: vi.fn(),
|
useAuthoringCapabilityDetail: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./useAuthoringContract.js", () => ({ useAuthoringContract: vi.fn() }));
|
||||||
|
|
||||||
import { useAuthoringCapabilityDetail } from "./useAuthoringCapabilityDetail.js";
|
import { useAuthoringCapabilityDetail } from "./useAuthoringCapabilityDetail.js";
|
||||||
|
import { useAuthoringContract } from "./useAuthoringContract.js";
|
||||||
|
|
||||||
const mockedUseAuthoringCapabilityDetail = vi.mocked(useAuthoringCapabilityDetail);
|
const mockedUseAuthoringCapabilityDetail = vi.mocked(useAuthoringCapabilityDetail);
|
||||||
|
const mockedUseAuthoringContract = vi.mocked(useAuthoringContract);
|
||||||
|
|
||||||
|
const emptyInventory: AuthoringContractInventory = {
|
||||||
|
workspaceId: "draft-review",
|
||||||
|
revision: 2,
|
||||||
|
selectedStepId: "collect",
|
||||||
|
readableSources: [],
|
||||||
|
stepInputTargets: [],
|
||||||
|
stepOutputSources: [],
|
||||||
|
stateTargets: [],
|
||||||
|
workflowOutputTargets: [],
|
||||||
|
entrySteps: [],
|
||||||
|
workflowOutcomes: [],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
const workspace: DraftWorkspace = {
|
const workspace: DraftWorkspace = {
|
||||||
workspaceId: "draft-review",
|
workspaceId: "draft-review",
|
||||||
@@ -86,6 +105,12 @@ beforeEach(() => {
|
|||||||
detail: null,
|
detail: null,
|
||||||
message: null,
|
message: null,
|
||||||
});
|
});
|
||||||
|
mockedUseAuthoringContract.mockReturnValue({
|
||||||
|
phase: "ready",
|
||||||
|
inventory: emptyInventory,
|
||||||
|
message: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("DraftWorkbench", () => {
|
describe("DraftWorkbench", () => {
|
||||||
|
|||||||
@@ -60,10 +60,18 @@ const inventory: AuthoringContractInventory = {
|
|||||||
warnings: [],
|
warnings: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyInventory: AuthoringContractInventory = {
|
||||||
|
...inventory,
|
||||||
|
readableSources: [],
|
||||||
|
stepInputTargets: [],
|
||||||
|
stepOutputSources: [],
|
||||||
|
stateTargets: [],
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockedUseAuthoringContract.mockReturnValue({
|
mockedUseAuthoringContract.mockReturnValue({
|
||||||
phase: "disconnected",
|
phase: "ready",
|
||||||
inventory: null,
|
inventory: emptyInventory,
|
||||||
message: null,
|
message: null,
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
});
|
});
|
||||||
@@ -147,6 +155,98 @@ const controllerFor = (workspace: DraftWorkspace): DraftAuthoringController => (
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("SelectedCapabilityInspector", () => {
|
describe("SelectedCapabilityInspector", () => {
|
||||||
|
it("shows loading feedback and gates binding editors without current inventory", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockedUseAuthoringContract.mockReturnValue({
|
||||||
|
phase: "loading",
|
||||||
|
inventory: null,
|
||||||
|
message: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
});
|
||||||
|
const workspace = draft("read", [], []);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SelectedCapabilityInspector
|
||||||
|
capabilityDetail={detail}
|
||||||
|
capabilityDetailMessage={null}
|
||||||
|
capabilityDetailPhase="ready"
|
||||||
|
controller={controllerFor(workspace)}
|
||||||
|
draft={workspace}
|
||||||
|
nodeKind="use"
|
||||||
|
nodeRef="demo.read"
|
||||||
|
stepId="read"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Loading canonical authoring choices...");
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Inputs" }));
|
||||||
|
expect(screen.getByText("Input bindings are unavailable until canonical authoring choices are ready.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Save inputs" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows initial inventory errors with a retry action and gates binding editors", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const refresh = vi.fn();
|
||||||
|
mockedUseAuthoringContract.mockReturnValue({
|
||||||
|
phase: "error",
|
||||||
|
inventory: null,
|
||||||
|
message: "Catalog request failed.",
|
||||||
|
refresh,
|
||||||
|
});
|
||||||
|
const workspace = draft("read", [], []);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SelectedCapabilityInspector
|
||||||
|
capabilityDetail={detail}
|
||||||
|
capabilityDetailMessage={null}
|
||||||
|
capabilityDetailPhase="ready"
|
||||||
|
controller={controllerFor(workspace)}
|
||||||
|
draft={workspace}
|
||||||
|
nodeKind="use"
|
||||||
|
nodeRef="demo.read"
|
||||||
|
stepId="read"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent("Catalog request failed.");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Retry canonical authoring choices" }));
|
||||||
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Outputs" }));
|
||||||
|
expect(screen.getByText("Output bindings are unavailable until canonical authoring choices are ready.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Save outputs" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains binding editors and exposes retry feedback when inventory refresh fails", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const refresh = vi.fn();
|
||||||
|
mockedUseAuthoringContract.mockReturnValue({
|
||||||
|
phase: "error",
|
||||||
|
inventory,
|
||||||
|
message: "Refresh failed.",
|
||||||
|
refresh,
|
||||||
|
});
|
||||||
|
const workspace = draft("read", [], []);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SelectedCapabilityInspector
|
||||||
|
capabilityDetail={detail}
|
||||||
|
capabilityDetailMessage={null}
|
||||||
|
controller={controllerFor(workspace)}
|
||||||
|
draft={workspace}
|
||||||
|
nodeKind="use"
|
||||||
|
nodeRef="demo.read"
|
||||||
|
stepId="read"
|
||||||
|
capabilityDetailPhase="ready"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent("Refresh failed.");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Retry canonical authoring choices" }));
|
||||||
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Inputs" }));
|
||||||
|
expect(screen.getByRole("button", { name: "Save inputs" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("threads inventory-backed pickers into the selected step editors", async () => {
|
it("threads inventory-backed pickers into the selected step editors", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
mockedUseAuthoringContract.mockReturnValue({
|
mockedUseAuthoringContract.mockReturnValue({
|
||||||
|
|||||||
@@ -70,6 +70,41 @@ const inspectorTabs = Object.keys(tabLabels) as InspectorTab[];
|
|||||||
const tabPanelId = (tab: InspectorTab): string => `selected-step-panel-${tab}`;
|
const tabPanelId = (tab: InspectorTab): string => `selected-step-panel-${tab}`;
|
||||||
const tabId = (tab: InspectorTab): string => `selected-step-tab-${tab}`;
|
const tabId = (tab: InspectorTab): string => `selected-step-tab-${tab}`;
|
||||||
|
|
||||||
|
const InventoryStatus = ({
|
||||||
|
inventoryAvailable,
|
||||||
|
message,
|
||||||
|
phase,
|
||||||
|
refresh,
|
||||||
|
}: {
|
||||||
|
readonly inventoryAvailable: boolean;
|
||||||
|
readonly message: string | null;
|
||||||
|
readonly phase: "disconnected" | "idle" | "loading" | "ready" | "error";
|
||||||
|
readonly refresh: () => void;
|
||||||
|
}) => {
|
||||||
|
if (phase === "loading") {
|
||||||
|
return <p role="status">
|
||||||
|
{inventoryAvailable
|
||||||
|
? "Refreshing canonical authoring choices..."
|
||||||
|
: "Loading canonical authoring choices..."}
|
||||||
|
</p>;
|
||||||
|
}
|
||||||
|
if (phase === "error") {
|
||||||
|
return (
|
||||||
|
<div role="alert">
|
||||||
|
<p>{message ?? "Canonical authoring choices failed to load."}</p>
|
||||||
|
<button onClick={refresh} type="button">Retry canonical authoring choices</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!inventoryAvailable && phase === "disconnected") {
|
||||||
|
return <p role="status">Canonical authoring choices are unavailable while disconnected.</p>;
|
||||||
|
}
|
||||||
|
if (!inventoryAvailable) {
|
||||||
|
return <p role="status">Waiting for canonical authoring choices...</p>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const TabPanel = ({
|
const TabPanel = ({
|
||||||
activeTab,
|
activeTab,
|
||||||
children,
|
children,
|
||||||
@@ -153,6 +188,7 @@ export const SelectedCapabilityInspector = ({
|
|||||||
};
|
};
|
||||||
const detailReady = capabilityDetailPhase === "ready" && capabilityDetail !== null;
|
const detailReady = capabilityDetailPhase === "ready" && capabilityDetail !== null;
|
||||||
const isUnsupported = nodeKind !== undefined && nodeKind !== "use";
|
const isUnsupported = nodeKind !== undefined && nodeKind !== "use";
|
||||||
|
const inventoryAvailable = inventory !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="selected-capability-inspector" aria-label="Selected step editor">
|
<section className="selected-capability-inspector" aria-label="Selected step editor">
|
||||||
@@ -198,6 +234,12 @@ export const SelectedCapabilityInspector = ({
|
|||||||
{!detailReady && capabilityDetailPhase === "disconnected" && (
|
{!detailReady && capabilityDetailPhase === "disconnected" && (
|
||||||
<p role="status">Connect to inspect the capability schema.</p>
|
<p role="status">Connect to inspect the capability schema.</p>
|
||||||
)}
|
)}
|
||||||
|
<InventoryStatus
|
||||||
|
inventoryAvailable={inventoryAvailable}
|
||||||
|
message={authoringContract.message}
|
||||||
|
phase={authoringContract.phase}
|
||||||
|
refresh={authoringContract.refresh}
|
||||||
|
/>
|
||||||
{detailReady && (
|
{detailReady && (
|
||||||
<>
|
<>
|
||||||
<TabPanel activeTab={activeTab} tab="setup">
|
<TabPanel activeTab={activeTab} tab="setup">
|
||||||
@@ -210,6 +252,7 @@ export const SelectedCapabilityInspector = ({
|
|||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel activeTab={activeTab} tab="inputs">
|
<TabPanel activeTab={activeTab} tab="inputs">
|
||||||
|
{inventoryAvailable ? (
|
||||||
<StepInputBindingsForm
|
<StepInputBindingsForm
|
||||||
canonicalVersion={`${stepId}:${controller.resetGeneration}`}
|
canonicalVersion={`${stepId}:${controller.resetGeneration}`}
|
||||||
initialRows={inputRows}
|
initialRows={inputRows}
|
||||||
@@ -220,8 +263,12 @@ export const SelectedCapabilityInspector = ({
|
|||||||
onSubmit={controller.setStepInputs}
|
onSubmit={controller.setStepInputs}
|
||||||
rowDiagnostics={inputDiagnostics.rowIssues}
|
rowDiagnostics={inputDiagnostics.rowIssues}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<p role="status">Input bindings are unavailable until canonical authoring choices are ready.</p>
|
||||||
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel activeTab={activeTab} tab="outputs">
|
<TabPanel activeTab={activeTab} tab="outputs">
|
||||||
|
{inventoryAvailable ? (
|
||||||
<StepOutputBindingsForm
|
<StepOutputBindingsForm
|
||||||
key={`outputs:${stepId}:${controller.resetGeneration}`}
|
key={`outputs:${stepId}:${controller.resetGeneration}`}
|
||||||
initialRows={outputRows}
|
initialRows={outputRows}
|
||||||
@@ -233,6 +280,9 @@ export const SelectedCapabilityInspector = ({
|
|||||||
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}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<p role="status">Output bindings are unavailable until canonical authoring choices are ready.</p>
|
||||||
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -85,6 +85,27 @@ describe("StepInputBindingsForm", () => {
|
|||||||
expect(submissions).toEqual([[{ path: "input.request.id", target: "profile.name" }]]);
|
expect(submissions).toEqual([[{ path: "input.request.id", target: "profile.name" }]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a custom target that collides with the canonical step-input namespace", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const submissions: ReadonlyArray<StepInputBinding>[] = [];
|
||||||
|
render(
|
||||||
|
<StepInputBindingsForm
|
||||||
|
inputSchema={schema}
|
||||||
|
sourceOptions={[authoringOption("input.title", "Title", "workflow_input", ["step_input"])]}
|
||||||
|
targetOptions={[authoringOption("step_input.text", "Text target", "step_input", ["step_input"])]}
|
||||||
|
initialBindings={[{ path: "input.title", target: "step_input.text" }]}
|
||||||
|
onSubmit={(value) => { submissions.push(value); }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const target = await editableCustomInput(user, "Target for row 1");
|
||||||
|
await user.clear(target);
|
||||||
|
await user.type(target, "step_input.text");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||||
|
|
||||||
|
expect(submissions).toEqual([[{ path: "input.title", target: "step_input.text" }]]);
|
||||||
|
});
|
||||||
|
|
||||||
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");
|
||||||
|
|||||||
@@ -481,9 +481,11 @@ const StepInputBindingsFormContent = ({
|
|||||||
describedBy={hasIssues ? errorId : undefined}
|
describedBy={hasIssues ? errorId : undefined}
|
||||||
invalid={hasIssues}
|
invalid={hasIssues}
|
||||||
label={`Target for row ${rowNumber}`}
|
label={`Target for row ${rowNumber}`}
|
||||||
onChange={(target) => editRow(row.id, (current) => ({
|
onChange={(target, selection) => editRow(row.id, (current) => ({
|
||||||
...current,
|
...current,
|
||||||
target: localPathFromPickerValue(target, targetOptions, "step_input"),
|
target: selection === "catalog"
|
||||||
|
? localPathFromPickerValue(target, targetOptions, "step_input")
|
||||||
|
: target,
|
||||||
}))}
|
}))}
|
||||||
options={targetOptions}
|
options={targetOptions}
|
||||||
uses="step_input"
|
uses="step_input"
|
||||||
|
|||||||
@@ -123,6 +123,28 @@ describe("StepOutputBindingsForm", () => {
|
|||||||
expect(submissions).toEqual([[{ source: "text", target: "state.existing" }]]);
|
expect(submissions).toEqual([[{ source: "text", target: "state.existing" }]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a custom source that collides with the canonical step-output namespace", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const submissions: ReadonlyArray<OutputBinding>[] = [];
|
||||||
|
render(
|
||||||
|
<StepOutputBindingsForm
|
||||||
|
outputSchema={outputSchema}
|
||||||
|
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: "step_output.text", target: "state.existing" }]}
|
||||||
|
onSubmit={(value) => { submissions.push(value); }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const source = await editableCustomInput(user, "Source path for output row 1");
|
||||||
|
await user.clear(source);
|
||||||
|
await user.type(source, "step_output.text");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save outputs" }));
|
||||||
|
|
||||||
|
expect(submissions).toEqual([[{ source: "step_output.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
|
||||||
|
|||||||
@@ -201,9 +201,11 @@ const OutputRowEditor = ({
|
|||||||
describedBy={hasIssues ? errorId : undefined}
|
describedBy={hasIssues ? errorId : undefined}
|
||||||
invalid={hasIssues}
|
invalid={hasIssues}
|
||||||
label={`Source path for output row ${rowNumber}`}
|
label={`Source path for output row ${rowNumber}`}
|
||||||
onChange={(source) => onEdit(row.id, (current) => ({
|
onChange={(source, selection) => onEdit(row.id, (current) => ({
|
||||||
...current,
|
...current,
|
||||||
sourcePath: localPathFromPickerValue(source, sourceOptions, "step_output"),
|
sourcePath: selection === "catalog"
|
||||||
|
? localPathFromPickerValue(source, sourceOptions, "step_output")
|
||||||
|
: source,
|
||||||
}))}
|
}))}
|
||||||
options={sourceOptions}
|
options={sourceOptions}
|
||||||
uses="step_output_source"
|
uses="step_output_source"
|
||||||
|
|||||||
Reference in New Issue
Block a user