fix: close workflow console review remediation
This commit is contained in:
@@ -23,7 +23,7 @@ describe("CapabilityNodeForm", () => {
|
||||
await user.type(screen.getByRole("textbox", { name: "Step id" }), "enrich");
|
||||
await user.type(screen.getByRole("textbox", { name: "Description" }), "Enrich report");
|
||||
await user.type(screen.getByRole("textbox", { name: "Title" }), "Quarterly report");
|
||||
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveAttribute("inputmode", "decimal");
|
||||
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveAttribute("inputmode", "numeric");
|
||||
await user.click(screen.getByRole("button", { name: "Add node" }));
|
||||
|
||||
expect(submissions[0]).toMatchObject({
|
||||
@@ -56,6 +56,30 @@ describe("CapabilityNodeForm", () => {
|
||||
expect(submissions[0]).not.toHaveProperty("description");
|
||||
});
|
||||
|
||||
it("requires timeout seconds to be a positive whole number", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: unknown[] = [];
|
||||
render(
|
||||
<CapabilityNodeForm
|
||||
capabilityName="demo.enrich"
|
||||
inputSchema={{ type: "object", properties: {} }}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByRole("textbox", { name: "Step id" }), "enrich");
|
||||
const timeout = screen.getByRole("spinbutton", { name: "Timeout seconds" });
|
||||
expect(timeout).toHaveAttribute("min", "1");
|
||||
expect(timeout).toHaveAttribute("step", "1");
|
||||
await user.type(timeout, "1.5");
|
||||
await user.click(screen.getByRole("button", { name: "Add node" }));
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"Timeout must be a whole number greater than 0.",
|
||||
);
|
||||
expect(submissions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports local edits as dirty and keeps them when submission fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const dirtyStates: boolean[] = [];
|
||||
|
||||
@@ -134,8 +134,8 @@ export const CapabilityNodeForm = ({
|
||||
const timeout = timeoutSecondsRef.current?.value.trim() ?? "";
|
||||
if (timeout !== "") {
|
||||
const value = Number(timeout);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
issues.push("Timeout must be greater than 0.");
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
||||
issues.push("Timeout must be a whole number greater than 0.");
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
@@ -233,14 +233,14 @@ export const CapabilityNodeForm = ({
|
||||
? ""
|
||||
: String(initialValue.timeoutSeconds)
|
||||
}
|
||||
inputMode="decimal"
|
||||
min="0.000001"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
ref={timeoutSecondsRef}
|
||||
onChange={(event) => {
|
||||
timeoutTouchedRef.current = true;
|
||||
notifyMetadataChange();
|
||||
}}
|
||||
step="any"
|
||||
step={1}
|
||||
type="number"
|
||||
/>
|
||||
{metadataMessage("timeout_seconds") && <p role="alert">{metadataMessage("timeout_seconds")}</p>}
|
||||
|
||||
@@ -101,6 +101,15 @@ describe("SelectedCapabilityInspector", () => {
|
||||
expect(screen.getAllByRole("tab")).toHaveLength(3);
|
||||
expect(screen.getByRole("tab", { name: "Setup" })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
screen.getByRole("tab", { name: "Setup" }).focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Inputs" })).toHaveFocus();
|
||||
expect(screen.getByRole("tab", { name: "Inputs" })).toHaveAttribute("aria-selected", "true");
|
||||
await user.keyboard("{End}");
|
||||
expect(screen.getByRole("tab", { name: "Outputs" })).toHaveFocus();
|
||||
await user.keyboard("{Home}");
|
||||
expect(screen.getByRole("tab", { name: "Setup" })).toHaveFocus();
|
||||
|
||||
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" }));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useState, type KeyboardEvent, 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";
|
||||
@@ -64,6 +64,7 @@ const setupDiagnostics = (
|
||||
|
||||
const emptyStateSchema = { type: "object", properties: {} };
|
||||
const tabLabels: Record<InspectorTab, string> = { setup: "Setup", inputs: "Inputs", outputs: "Outputs" };
|
||||
const inspectorTabs = Object.keys(tabLabels) as InspectorTab[];
|
||||
const tabPanelId = (tab: InspectorTab): string => `selected-step-panel-${tab}`;
|
||||
const tabId = (tab: InspectorTab): string => `selected-step-tab-${tab}`;
|
||||
|
||||
@@ -98,6 +99,26 @@ export const SelectedCapabilityInspector = ({
|
||||
capabilityDetailMessage,
|
||||
}: SelectedCapabilityInspectorProps) => {
|
||||
const [activeTab, setActiveTab] = useState<InspectorTab>("setup");
|
||||
const activateTab = (tab: InspectorTab): void => {
|
||||
setActiveTab(tab);
|
||||
document.getElementById(tabId(tab))?.focus();
|
||||
};
|
||||
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, tab: InspectorTab): void => {
|
||||
const currentIndex = inspectorTabs.indexOf(tab);
|
||||
const targetIndex = event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? inspectorTabs.length - 1
|
||||
: event.key === "ArrowRight"
|
||||
? (currentIndex + 1) % inspectorTabs.length
|
||||
: event.key === "ArrowLeft"
|
||||
? (currentIndex - 1 + inspectorTabs.length) % inspectorTabs.length
|
||||
: null;
|
||||
if (targetIndex === null) return;
|
||||
event.preventDefault();
|
||||
const target = inspectorTabs[targetIndex];
|
||||
if (target !== undefined) activateTab(target);
|
||||
};
|
||||
const rawStep = selectedStep(draft, stepId);
|
||||
const projected = projectSelectedStepDataflow(draft, stepId);
|
||||
const preservedForm = controller.preservedCapabilityForm?.kind === "update" &&
|
||||
@@ -140,7 +161,7 @@ export const SelectedCapabilityInspector = ({
|
||||
{!isUnsupported && (
|
||||
<>
|
||||
<div aria-label="Selected step views" className="selected-capability-inspector__tabs" role="tablist">
|
||||
{(Object.keys(tabLabels) as InspectorTab[]).map((tab) => (
|
||||
{inspectorTabs.map((tab) => (
|
||||
<button
|
||||
aria-controls={tabPanelId(tab)}
|
||||
aria-selected={activeTab === tab}
|
||||
@@ -148,6 +169,7 @@ export const SelectedCapabilityInspector = ({
|
||||
id={tabId(tab)}
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
onKeyDown={(event) => handleTabKeyDown(event, tab)}
|
||||
role="tab"
|
||||
tabIndex={activeTab === tab ? 0 : -1}
|
||||
type="button"
|
||||
|
||||
@@ -40,4 +40,19 @@ describe("canonical capability form projection", () => {
|
||||
timeoutSeconds: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores negative and sparse array targets instead of creating array properties", () => {
|
||||
const projected = canonicalCapabilityFormData(
|
||||
draft({
|
||||
use: "wf.std.concat",
|
||||
input: [
|
||||
{ target: { root: "local", parts: ["items", "-1"] }, value: "negative" },
|
||||
{ target: { root: "local", parts: ["items", "2"] }, value: "sparse" },
|
||||
],
|
||||
}),
|
||||
"render",
|
||||
);
|
||||
|
||||
expect(projected?.initialInputValue).toEqual({ items: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ const setPath = (current: unknown, path: string, value: unknown): unknown => {
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number(head);
|
||||
if (!Number.isInteger(index)) return current;
|
||||
if (!Number.isInteger(index) || index < 0 || index > current.length) return current;
|
||||
const next = [...current];
|
||||
next[index] = setPath(next[index], formatTOMLPath(tail), value);
|
||||
return next;
|
||||
|
||||
@@ -70,4 +70,11 @@ describe("withDiagnosticKeys", () => {
|
||||
withDifferentHint,
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates equivalent nested details regardless of object key order", () => {
|
||||
const left = diagnostic({ details: { field: "title", context: { row: 1, source: "input" } } });
|
||||
const right = diagnostic({ details: { context: { source: "input", row: 1 }, field: "title" } });
|
||||
|
||||
expect(withDiagnosticKeys([left, right])).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,19 @@ export type DiagnosticEntry = {
|
||||
readonly key: string;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const canonicalize = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (!isRecord(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalize(item)]),
|
||||
);
|
||||
};
|
||||
|
||||
const diagnosticIdentity = (diagnostic: DraftDiagnostic): string =>
|
||||
JSON.stringify([
|
||||
diagnostic.code,
|
||||
@@ -12,7 +25,7 @@ const diagnosticIdentity = (diagnostic: DraftDiagnostic): string =>
|
||||
diagnostic.message,
|
||||
diagnostic.stepId,
|
||||
diagnostic.repairHint,
|
||||
diagnostic.details,
|
||||
canonicalize(diagnostic.details),
|
||||
]);
|
||||
|
||||
export const withDiagnosticKeys = (
|
||||
|
||||
Reference in New Issue
Block a user