feat(dashboard): workflow editor dialogs, inline rename, and dirty-state guard

This commit is contained in:
gsxdsm
2026-06-04 20:51:24 -07:00
parent f67a2542b9
commit 5a499ea212
10 changed files with 852 additions and 26 deletions

View File

@@ -533,6 +533,84 @@
color: var(--bg);
}
/* Inline name + description strip (KTD-10). */
.wf-name-strip {
display: flex;
align-items: baseline;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.wf-workflow-name,
.wf-workflow-name--readonly {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 2px var(--space-xs);
cursor: pointer;
text-align: left;
}
.wf-workflow-name:hover {
background: var(--bg-tertiary);
}
.wf-workflow-name--readonly {
cursor: default;
}
.wf-workflow-name-input {
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 2px var(--space-xs);
}
.wf-workflow-description,
.wf-workflow-description--readonly {
font-size: 0.78rem;
color: var(--text-tertiary);
background: none;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 2px var(--space-xs);
cursor: pointer;
text-align: left;
}
.wf-workflow-description:hover {
background: var(--bg-tertiary);
}
.wf-workflow-description--readonly {
cursor: default;
}
.wf-workflow-description-input {
font-size: 0.78rem;
color: var(--text);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 2px var(--space-xs);
min-width: 220px;
}
/* Create-workflow dialog (KTD-7). */
.wf-create-error {
margin: var(--space-xs) 0 0;
font-size: 0.8rem;
color: var(--ws-error);
}
.wf-column-panel {
display: flex;
flex-direction: column;

View File

@@ -32,6 +32,7 @@ import type { Agent } from "../api";
import type { DiscoveredSkill } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useConfirm } from "../hooks/useConfirm";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext";
@@ -86,6 +87,29 @@ function parseModelDropdownValue(value: string): { provider: string; modelId: st
return { provider: value.slice(0, slashIndex), modelId: value.slice(slashIndex + 1) };
}
/** Normalized serialization of the editor's authoring state for dirty tracking
* (U4). Serializes nodes/edges through flowToIr (so mapping-layer defaults are
* materialized identically on the loaded and live sides) plus the editor-owned
* name/description and the resulting layout (auto-layout/drag position changes
* count as dirty). Returns a stable JSON string for cheap equality. */
function serializeGraph(
name: string,
description: string,
nodes: FlowNode<WorkflowFlowNodeData>[],
edges: FlowEdge[],
columns: WorkflowIrColumn[],
fields: WorkflowFieldDefinition[],
): string {
const { ir, layout } = flowToIr(
name,
nodes,
edges,
columns.length ? columns : undefined,
fields.length ? fields : undefined,
);
return JSON.stringify({ name, description, ir, layout });
}
interface WorkflowNodeEditorProps {
isOpen: boolean;
onClose: () => void;
@@ -124,6 +148,128 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
{ kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } },
];
/** Local create-workflow dialog (KTD-7). Built on the shared `.modal` primitives
* (precedent: NewTaskModal). Owns its own name/description/error state; the
* parent supplies an async `onCreate` that performs the createWorkflow call and
* throws on failure so the dialog can surface server rejections inline without
* losing the typed input. Escape/overlay close (no dirty state of its own). */
function CreateWorkflowDialog({
onCreate,
onClose,
}: {
onCreate: (name: string, description: string) => Promise<void>;
onClose: () => void;
}) {
const { t } = useTranslation("app");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
nameRef.current?.focus();
}, []);
const overlayProps = useOverlayDismiss(onClose);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) {
setError(t("workflows.createNameRequired", "Enter a workflow name"));
return;
}
setSubmitting(true);
setError(null);
try {
await onCreate(trimmed, description.trim());
// Success path closes the dialog from the parent.
} catch (err) {
setError(getErrorMessage(err) || t("workflows.createFailed", "Failed to create workflow"));
setSubmitting(false);
}
},
[name, description, onCreate, t],
);
return (
<div className="modal-overlay open wf-create-overlay" {...overlayProps}>
<div
className="modal wf-create-modal"
data-testid="wf-create-dialog"
role="dialog"
aria-modal="true"
aria-label={t("workflows.createTitle", "New workflow")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
}}
>
<div className="modal-header">
<h3>{t("workflows.createTitle", "New workflow")}</h3>
<button
type="button"
className="modal-close"
onClick={onClose}
aria-label={t("actions.close", "Close")}
>
<X size={16} />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<label className="wf-field">
<span>{t("workflows.createName", "Name")}</span>
<input
ref={nameRef}
data-testid="wf-create-name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (error) setError(null);
}}
/>
</label>
<label className="wf-field">
<span>{t("workflows.createDescription", "Description (optional)")}</span>
<textarea
rows={2}
data-testid="wf-create-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
{error && (
<p className="wf-create-error" role="alert" data-testid="wf-create-error">
{error}
</p>
)}
</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
{t("common.cancel", "Cancel")}
</button>
<button
type="submit"
className="btn btn-primary"
data-testid="wf-create-submit"
disabled={submitting}
>
{submitting ? <Loader2 size={13} className="wf-spin" /> : null}{" "}
{t("workflows.createSubmit", "Create")}
</button>
</div>
</form>
</div>
</div>
);
}
function InnerEditor({
onClose,
addToast,
@@ -144,6 +290,23 @@ function InnerEditor({
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const { t } = useTranslation("app");
const { confirm } = useConfirm();
// Create-workflow dialog (KTD-7) open state + focus-return ref to the
// "New workflow" button (NewTaskModal focus pattern).
const [createOpen, setCreateOpen] = useState(false);
const newWorkflowBtnRef = useRef<HTMLButtonElement>(null);
// Inline-editable name/description (KTD-10). `name`/`description` mirror the
// active workflow and are persisted through handleSave; `editingName`/
// `editingDescription` flag the active inline input.
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [editingName, setEditingName] = useState(false);
const [editingDescription, setEditingDescription] = useState(false);
// Snapshot of the workflow as loaded, serialized through flowToIr AFTER
// irToFlow so mapping-layer defaults (e.g. condition: "success", config
// materialization) are present on both sides of the dirty comparison. Set by
// the load effect; compared against the live serialization in `isDirty`.
const loadedSnapshotRef = useRef<string | null>(null);
// v2 columns the editor is authoring for the active workflow.
const [columns, setColumns] = useState<WorkflowIrColumn[]>([]);
// v2 custom field definitions the editor is authoring (KTD-13/14, U13).
@@ -202,6 +365,18 @@ function InnerEditor({
const unplaced = useMemo(() => unplacedNodeIds(nodes, columns), [nodes, columns]);
const blockingViolationCount = columnViolations.filter((v) => v.severity === "error").length;
// Dirty = the normalized live serialization differs from the loaded snapshot
// (U4). Built-ins are never dirty (read-only). Memoized over the inputs that
// feed serializeGraph; the loaded snapshot is a ref set by the load effect.
const isDirty = useMemo(() => {
if (isBuiltin) return false;
if (!activeWorkflow || loadedSnapshotRef.current === null) return false;
return (
serializeGraph(name, description, nodes, edges, columns, fields) !==
loadedSnapshotRef.current
);
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields]);
const loadWorkflows = useCallback(async () => {
setLoading(true);
try {
@@ -226,13 +401,32 @@ function InnerEditor({
setEdges([]);
setColumns([]);
setFields([]);
setName("");
setDescription("");
loadedSnapshotRef.current = null;
return;
}
const flow = irToFlow(activeWorkflow);
setNodes(flow.nodes);
setEdges(flow.edges);
setColumns(columnsOf(activeWorkflow));
setFields(fieldsOf(activeWorkflow));
const loadedColumns = columnsOf(activeWorkflow);
const loadedFields = fieldsOf(activeWorkflow);
setColumns(loadedColumns);
setFields(loadedFields);
setName(activeWorkflow.name);
setDescription(activeWorkflow.description ?? "");
setEditingName(false);
setEditingDescription(false);
// Compute the normalized loaded snapshot from the materialized flow (so
// mapping defaults match the live side) plus name/description.
loadedSnapshotRef.current = serializeGraph(
activeWorkflow.name,
activeWorkflow.description ?? "",
flow.nodes,
flow.edges,
loadedColumns,
loadedFields,
);
setSelectedNodeId(null);
setSelectedEdgeId(null);
setValidationError(null);
@@ -462,35 +656,55 @@ function InnerEditor({
canvasRef.current?.focus();
}, []);
const handleCreateWorkflow = useCallback(async () => {
const name = window.prompt("New workflow name");
if (!name?.trim()) return;
try {
// Close the create dialog and return focus to its trigger (NewTaskModal
// focus-return pattern). Used by both the success and cancel paths.
const closeCreateDialog = useCallback(() => {
setCreateOpen(false);
newWorkflowBtnRef.current?.focus();
}, []);
// Perform the createWorkflow call. Throws on failure so the dialog surfaces
// the server error (e.g. duplicate name) inline without losing the input.
const handleCreateWorkflow = useCallback(
async (workflowName: string, workflowDescription: string) => {
const created = await createWorkflow(
{ name: name.trim(), ir: emptyWorkflowIr(name.trim()), layout: emptyWorkflowLayout() },
{
name: workflowName,
description: workflowDescription || undefined,
ir: emptyWorkflowIr(workflowName),
layout: emptyWorkflowLayout(),
},
projectId,
);
setWorkflows((ws) => [...ws, created]);
setActiveId(created.id);
addToast(`Created workflow "${created.name}"`, "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to create workflow", "error");
}
}, [projectId, addToast]);
addToast(t("workflows.created", 'Created workflow "{{name}}"', { name: created.name }), "success");
closeCreateDialog();
},
[projectId, addToast, t, closeCreateDialog],
);
const handleDeleteWorkflow = useCallback(async () => {
if (!activeWorkflow) return;
if (isBuiltinWorkflowId(activeWorkflow.id)) return; // built-ins are read-only
if (!window.confirm(`Delete workflow "${activeWorkflow.name}"?`)) return;
const ok = await confirm({
title: t("workflows.deleteTitle", "Delete workflow?"),
message: t("workflows.deleteMessage", 'Delete workflow "{{name}}"? This cannot be undone.', {
name: activeWorkflow.name,
}),
confirmLabel: t("common.delete", "Delete"),
danger: true,
});
if (!ok) return;
try {
await deleteWorkflow(activeWorkflow.id, projectId);
setWorkflows((ws) => ws.filter((w) => w.id !== activeWorkflow.id));
setActiveId(null);
addToast("Workflow deleted", "success");
addToast(t("workflows.deleted", "Workflow deleted"), "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to delete workflow", "error");
addToast(getErrorMessage(err) || t("workflows.deleteFailed", "Failed to delete workflow"), "error");
}
}, [activeWorkflow, projectId, addToast]);
}, [activeWorkflow, projectId, addToast, confirm, t]);
const handleDuplicate = useCallback(async () => {
if (!activeWorkflow) return;
@@ -544,15 +758,41 @@ function InnerEditor({
setInterpreterOnly(false);
setServerNodeError(null);
try {
const trimmedName = name.trim() || activeWorkflow.name;
const { ir, layout } = flowToIr(
activeWorkflow.name,
trimmedName,
nodes,
edges,
columns.length ? columns : undefined,
fields.length ? fields : undefined,
);
const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId);
// Include name/description in the PATCH only when they changed from the
// loaded workflow (KTD-10 inline rename/description persist here).
const nameChanged = trimmedName !== activeWorkflow.name;
const descChanged = description !== (activeWorkflow.description ?? "");
const updated = await updateWorkflow(
activeWorkflow.id,
{
ir,
layout,
...(nameChanged ? { name: trimmedName } : {}),
...(descChanged ? { description } : {}),
},
projectId,
);
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
// Re-baseline the dirty snapshot to the just-saved state so the editor is
// clean immediately after a successful save.
loadedSnapshotRef.current = serializeGraph(
updated.name,
updated.description ?? "",
nodes,
edges,
columns,
fields,
);
setName(updated.name);
setDescription(updated.description ?? "");
// Validate by compiling — surfaces non-linear graphs as a banner.
try {
await compileWorkflow(updated.id, projectId);
@@ -586,7 +826,7 @@ function InnerEditor({
} finally {
setSaving(false);
}
}, [activeWorkflow, nodes, edges, columns, fields, unplaced, blockingViolationCount, projectId, addToast, t]);
}, [activeWorkflow, name, description, nodes, edges, columns, fields, unplaced, blockingViolationCount, projectId, addToast, t]);
// Stamp the shared error-state badge onto offending nodes: unplaced step
// nodes and any node the server flagged (seam-in-branch). One component
@@ -722,22 +962,86 @@ function InnerEditor({
skills.length,
]);
const overlayProps = useOverlayDismiss(onClose);
// ── Dirty-state dismissal guard (U4, R7) ────────────────────────────────────
// One synchronous decision point for every dismissal path. If the editor is
// clean (or built-in), the action runs immediately; if dirty, the discard
// confirm opens and the action runs only in the .then(true) callback. Used by
// the X button, overlay click (via useOverlayDismiss), the Escape keydown
// handler, and the sidebar workflow switch.
const guardedDismiss = useCallback(
(proceed: () => void) => {
if (!isDirty) {
proceed();
return;
}
void confirm({
title: t("workflows.discardTitle", "Discard unsaved changes?"),
message: t(
"workflows.discardMessage",
"You have unsaved changes to this workflow. Discard them?",
),
confirmLabel: t("workflows.discardConfirm", "Discard"),
danger: true,
}).then((ok) => {
if (ok) proceed();
});
},
[isDirty, confirm, t],
);
const requestClose = useCallback(() => {
guardedDismiss(onClose);
}, [guardedDismiss, onClose]);
// Sidebar workflow switch: route through the guard so dirty edits prompt
// before the active workflow changes (cancel keeps the current selection).
const requestSwitch = useCallback(
(id: string) => {
if (id === activeId) return;
guardedDismiss(() => setActiveId(id));
},
[guardedDismiss, activeId],
);
const overlayProps = useOverlayDismiss(requestClose);
return (
<div className="modal-overlay open wf-editor-overlay" {...overlayProps}>
<div className="modal wf-editor-modal" ref={modalRef} onClick={(e) => e.stopPropagation()}>
<div
className="modal wf-editor-modal"
ref={modalRef}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
// Dedicated Escape handler (useOverlayDismiss does not cover Escape).
// Ignore Escape originating from inputs/textareas/selects so inline
// editors (name/description) keep their own Escape-to-cancel behavior.
if (e.key !== "Escape") return;
// The create dialog (rendered as a child) owns its own Escape; if it's
// open, let it handle the event (it stops propagation already).
if (createOpen) return;
const target = e.target as HTMLElement;
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
e.stopPropagation();
requestClose();
}}
>
<header className="wf-editor-header">
<h2>Workflows</h2>
<button className="wf-editor-close" onClick={onClose} aria-label="Close workflow editor">
<button className="wf-editor-close" onClick={requestClose} aria-label="Close workflow editor">
<X size={18} />
</button>
</header>
<div className="wf-editor-body">
<aside className="wf-editor-sidebar">
<button className="wf-editor-new" onClick={handleCreateWorkflow}>
<Plus size={14} /> New workflow
<button
className="wf-editor-new"
ref={newWorkflowBtnRef}
data-testid="wf-new-workflow"
onClick={() => setCreateOpen(true)}
>
<Plus size={14} /> {t("workflows.newWorkflow", "New workflow")}
</button>
{loading ? (
<div className="wf-editor-empty">
@@ -751,7 +1055,7 @@ function InnerEditor({
<li key={w.id}>
<button
className={`wf-editor-list-item${w.id === activeId ? " active" : ""}`}
onClick={() => setActiveId(w.id)}
onClick={() => requestSwitch(w.id)}
>
{w.name}
</button>
@@ -764,6 +1068,88 @@ function InnerEditor({
<section className="wf-editor-canvas-wrap">
{activeWorkflow ? (
<>
{/* Inline name + description strip (KTD-10). Built-ins render as
plain text (no click affordance); user-owned workflows are
click-to-edit (Enter commits, Escape cancels, blur commits). */}
<div className="wf-name-strip">
{isBuiltin ? (
<span className="wf-workflow-name wf-workflow-name--readonly" data-testid="wf-workflow-name">
{activeWorkflow.name}
</span>
) : editingName ? (
<input
className="wf-workflow-name-input"
data-testid="wf-workflow-name-input"
autoFocus
value={name}
aria-label={t("workflows.nameLabel", "Workflow name")}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (!name.trim()) setName(activeWorkflow.name);
setEditingName(false);
} else if (e.key === "Escape") {
e.preventDefault();
setName(activeWorkflow.name);
setEditingName(false);
}
}}
onBlur={() => {
if (!name.trim()) setName(activeWorkflow.name);
setEditingName(false);
}}
/>
) : (
<button
type="button"
className="wf-workflow-name"
data-testid="wf-workflow-name"
onClick={() => setEditingName(true)}
title={t("workflows.clickToRename", "Click to rename")}
>
{name || activeWorkflow.name}
</button>
)}
{isBuiltin ? (
activeWorkflow.description ? (
<span className="wf-workflow-description wf-workflow-description--readonly" data-testid="wf-workflow-description">
{activeWorkflow.description}
</span>
) : null
) : editingDescription ? (
<input
className="wf-workflow-description-input"
data-testid="wf-workflow-description-input"
autoFocus
value={description}
aria-label={t("workflows.descriptionLabel", "Workflow description")}
placeholder={t("workflows.descriptionPlaceholder", "Add a description")}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
setEditingDescription(false);
} else if (e.key === "Escape") {
e.preventDefault();
setDescription(activeWorkflow.description ?? "");
setEditingDescription(false);
}
}}
onBlur={() => setEditingDescription(false)}
/>
) : (
<button
type="button"
className="wf-workflow-description"
data-testid="wf-workflow-description"
onClick={() => setEditingDescription(true)}
title={t("workflows.clickToEditDescription", "Click to edit description")}
>
{description || t("workflows.descriptionPlaceholder", "Add a description")}
</button>
)}
</div>
{isBuiltin ? (
// Read-only built-in: a banner *replaces* the save/edit toolbar
// (not an overlay); the canvas below stays inspectable.
@@ -1551,6 +1937,9 @@ function InnerEditor({
</aside>
)}
</div>
{createOpen && (
<CreateWorkflowDialog onCreate={handleCreateWorkflow} onClose={closeCreateDialog} />
)}
</div>
</div>
);

View File

@@ -18,9 +18,10 @@ vi.mock("../../api", () => ({
}));
import { fireEvent } from "@testing-library/react";
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, fetchModels } from "../../api";
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels } from "../../api";
import type { TraitCatalogEntry } from "../../api";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
const TRAIT_CATALOG: TraitCatalogEntry[] = [
{ id: "intake", name: "Intake", builtin: true, flags: { intake: true } },
@@ -741,3 +742,221 @@ describe("WorkflowNodeEditor — U2 interpreter-only banner", () => {
expect(screen.queryByTestId("wf-interpreter-only-banner")).not.toBeInTheDocument();
});
});
// ── U4: dialogs, inline rename/description, dirty guard ─────────────────────
/** Render the editor wrapped in a ConfirmDialogProvider so confirm()/discard
* prompts mount their ConfirmDialog (the app mounts this provider globally in
* App.tsx). The ConfirmDialog's primary button carries the supplied label. */
function renderWithConfirm(ui: import("react").ReactElement) {
return render(<ConfirmDialogProvider>{ui}</ConfirmDialogProvider>);
}
describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dirty guard", () => {
beforeEach(() => {
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// ── Create dialog (KTD-7) ──────────────────────────────────────────────────
it("opens the create dialog and blocks an empty name with an inline error", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
expect(await screen.findByTestId("wf-create-dialog")).toBeInTheDocument();
// Submitting with a whitespace-only name shows the inline error and does NOT
// call createWorkflow or close the dialog.
fireEvent.change(screen.getByTestId("wf-create-name"), { target: { value: " " } });
fireEvent.click(screen.getByTestId("wf-create-submit"));
expect(await screen.findByTestId("wf-create-error")).toBeInTheDocument();
expect(createWorkflow).not.toHaveBeenCalled();
expect(screen.getByTestId("wf-create-dialog")).toBeInTheDocument();
});
it("creates and activates a workflow on a valid submit", async () => {
const addToast = vi.fn();
vi.mocked(fetchWorkflows).mockResolvedValue([]);
vi.mocked(createWorkflow).mockResolvedValue({ ...v2Def(), id: "WF-NEW", name: "Pipeline" });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={addToast} />);
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
fireEvent.change(await screen.findByTestId("wf-create-name"), { target: { value: "Pipeline" } });
fireEvent.click(screen.getByTestId("wf-create-submit"));
await waitFor(() => expect(createWorkflow).toHaveBeenCalled());
const [input] = vi.mocked(createWorkflow).mock.calls[0];
expect((input as { name: string }).name).toBe("Pipeline");
// Dialog closes and the new workflow is active (its name shows in the strip).
await waitFor(() => expect(screen.queryByTestId("wf-create-dialog")).not.toBeInTheDocument());
await waitFor(() => expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Pipeline"));
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/Pipeline/), "success");
});
it("surfaces a server rejection inline and keeps the dialog open with input preserved", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([]);
vi.mocked(createWorkflow).mockRejectedValue(new Error("A workflow named 'Dup' already exists"));
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
const nameInput = await screen.findByTestId("wf-create-name");
fireEvent.change(nameInput, { target: { value: "Dup" } });
fireEvent.click(screen.getByTestId("wf-create-submit"));
await waitFor(() => expect(screen.getByTestId("wf-create-error")).toHaveTextContent(/already exists/i));
// Dialog stays open; the typed name is preserved.
expect(screen.getByTestId("wf-create-dialog")).toBeInTheDocument();
expect((nameInput as HTMLInputElement).value).toBe("Dup");
});
// ── Delete confirm ─────────────────────────────────────────────────────────
it("does not delete when no ConfirmDialogProvider is mounted (fallback cancels)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click((await screen.findByText("Delete")).closest("button")!);
// The no-op fallback resolves false → deleteWorkflow is never called.
await new Promise((r) => setTimeout(r, 20));
expect(deleteWorkflow).not.toHaveBeenCalled();
});
it("deletes after confirming in the ConfirmDialog (with provider)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(deleteWorkflow).mockResolvedValue(undefined);
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click((await screen.findByText("Delete")).closest("button")!);
// The confirm dialog's primary (danger) button carries the "Delete" label.
const dialog = await screen.findByRole("dialog", { name: /Delete workflow\?/i });
const confirmBtn = within(dialog).getByRole("button", { name: "Delete" });
fireEvent.click(confirmBtn);
await waitFor(() => expect(deleteWorkflow).toHaveBeenCalledWith("WF-002", undefined));
});
// ── Inline rename (KTD-10) ─────────────────────────────────────────────────
it("renames the workflow inline: click → input prefilled → Enter commits", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const nameBtn = await screen.findByTestId("wf-workflow-name");
expect(nameBtn).toHaveTextContent("Custom");
fireEvent.click(nameBtn);
const input = (await screen.findByTestId("wf-workflow-name-input")) as HTMLInputElement;
expect(input.value).toBe("Custom");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Renamed"));
});
it("cancels an inline rename on Escape (value reverts)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByTestId("wf-workflow-name"));
const input = (await screen.findByTestId("wf-workflow-name-input")) as HTMLInputElement;
fireEvent.change(input, { target: { value: "Throwaway" } });
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Custom"));
});
it("shows a built-in workflow name as plain text (no rename input on click)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const nameEl = await screen.findByTestId("wf-workflow-name");
// Built-in renders a plain <span>, not a clickable button.
expect(nameEl.tagName).toBe("SPAN");
fireEvent.click(nameEl);
expect(screen.queryByTestId("wf-workflow-name-input")).not.toBeInTheDocument();
});
it("persists a renamed name through the save PATCH", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
fireEvent.click(screen.getByTestId("wf-workflow-name"));
const input = await screen.findByTestId("wf-workflow-name-input");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
expect((updates as { name?: string }).name).toBe("Renamed");
});
// ── Dirty guard ────────────────────────────────────────────────────────────
it("closes immediately with no confirm when there are no edits", async () => {
const onClose = vi.fn();
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={onClose} addToast={() => {}} />);
// Wait for the workflow to load (clean snapshot established).
await screen.findByTestId("wf-workflow-name");
fireEvent.click(screen.getByLabelText("Close workflow editor"));
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
// No discard confirm dialog appeared.
expect(screen.queryByRole("dialog", { name: /Discard unsaved changes/i })).not.toBeInTheDocument();
});
it("load → immediately close produces no spurious dirty prompt", async () => {
// Regression for mapping-default asymmetry: the loaded snapshot is computed
// through flowToIr(irToFlow(...)) so default-materialization matches the live
// side and a freshly-loaded workflow is never dirty.
const onClose = vi.fn();
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={onClose} addToast={() => {}} />);
await screen.findByTestId("wf-node-foreach");
fireEvent.click(screen.getByLabelText("Close workflow editor"));
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("dialog", { name: /Discard unsaved changes/i })).not.toBeInTheDocument();
});
it("prompts to discard on close when dirty; confirming closes, cancelling keeps it open", async () => {
const onClose = vi.fn();
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={onClose} addToast={() => {}} />);
// Make an edit: inline rename.
fireEvent.click(await screen.findByTestId("wf-workflow-name"));
const input = await screen.findByTestId("wf-workflow-name-input");
fireEvent.change(input, { target: { value: "Edited" } });
fireEvent.keyDown(input, { key: "Enter" });
// Close → discard confirm appears. Cancel keeps the editor open.
fireEvent.click(screen.getByLabelText("Close workflow editor"));
const dialog = await screen.findByRole("dialog", { name: /Discard unsaved changes/i });
fireEvent.click(within(dialog).getByRole("button", { name: /Cancel/i }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: /Discard unsaved changes/i })).not.toBeInTheDocument());
expect(onClose).not.toHaveBeenCalled();
// Close again → confirm → onClose fires.
fireEvent.click(screen.getByLabelText("Close workflow editor"));
const dialog2 = await screen.findByRole("dialog", { name: /Discard unsaved changes/i });
fireEvent.click(within(dialog2).getByRole("button", { name: /Discard/i }));
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});
it("prompts to discard when switching workflows while dirty", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([
v2Def(),
{ ...v2Def(), id: "WF-OTHER", name: "Other" },
]);
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
// Edit the active workflow.
fireEvent.click(await screen.findByTestId("wf-workflow-name"));
const input = await screen.findByTestId("wf-workflow-name-input");
fireEvent.change(input, { target: { value: "Edited" } });
fireEvent.keyDown(input, { key: "Enter" });
// Switch to the other workflow in the sidebar → discard confirm.
fireEvent.click(screen.getByText("Other"));
const dialog = await screen.findByRole("dialog", { name: /Discard unsaved changes/i });
// Cancel keeps the current workflow (name still "Edited").
fireEvent.click(within(dialog).getByRole("button", { name: /Cancel/i }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: /Discard unsaved changes/i })).not.toBeInTheDocument());
expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Edited");
});
});

View File

@@ -6809,7 +6809,27 @@
"stepExecuteLabel": "Step execute"
},
"workflows": {
"clickToEditDescription": "Click to edit description",
"clickToRename": "Click to rename",
"created": "Created workflow \"{{name}}\"",
"createDescription": "Description (optional)",
"createFailed": "Failed to create workflow",
"createName": "Name",
"createNameRequired": "Enter a workflow name",
"createSubmit": "Create",
"createTitle": "New workflow",
"deleted": "Workflow deleted",
"deleteFailed": "Failed to delete workflow",
"deleteMessage": "Delete workflow \"{{name}}\"? This cannot be undone.",
"deleteTitle": "Delete workflow?",
"descriptionLabel": "Workflow description",
"descriptionPlaceholder": "Add a description",
"discardConfirm": "Discard",
"discardMessage": "You have unsaved changes to this workflow. Discard them?",
"discardTitle": "Discard unsaved changes?",
"duplicateToCustomize": "Duplicate to customize",
"nameLabel": "Workflow name",
"newWorkflow": "New workflow",
"readOnlyBuiltin": "Read-only built-in workflow",
"saved": "Workflow saved",
"savedNotCompilable": "Workflow saved but cannot be compiled",

View File

@@ -6809,7 +6809,27 @@
"stepExecuteLabel": "Step execute"
},
"workflows": {
"clickToEditDescription": "",
"clickToRename": "",
"created": "",
"createDescription": "",
"createFailed": "",
"createName": "",
"createNameRequired": "",
"createSubmit": "",
"createTitle": "",
"deleted": "",
"deleteFailed": "",
"deleteMessage": "",
"deleteTitle": "",
"descriptionLabel": "",
"descriptionPlaceholder": "",
"discardConfirm": "",
"discardMessage": "",
"discardTitle": "",
"duplicateToCustomize": "",
"nameLabel": "",
"newWorkflow": "",
"readOnlyBuiltin": "",
"saved": "",
"savedNotCompilable": "",

View File

@@ -6809,7 +6809,27 @@
"stepExecuteLabel": "Step execute"
},
"workflows": {
"clickToEditDescription": "",
"clickToRename": "",
"created": "",
"createDescription": "",
"createFailed": "",
"createName": "",
"createNameRequired": "",
"createSubmit": "",
"createTitle": "",
"deleted": "",
"deleteFailed": "",
"deleteMessage": "",
"deleteTitle": "",
"descriptionLabel": "",
"descriptionPlaceholder": "",
"discardConfirm": "",
"discardMessage": "",
"discardTitle": "",
"duplicateToCustomize": "",
"nameLabel": "",
"newWorkflow": "",
"readOnlyBuiltin": "",
"saved": "",
"savedNotCompilable": "",

View File

@@ -6809,7 +6809,27 @@
"stepExecuteLabel": "Step execute"
},
"workflows": {
"clickToEditDescription": "",
"clickToRename": "",
"created": "",
"createDescription": "",
"createFailed": "",
"createName": "",
"createNameRequired": "",
"createSubmit": "",
"createTitle": "",
"deleted": "",
"deleteFailed": "",
"deleteMessage": "",
"deleteTitle": "",
"descriptionLabel": "",
"descriptionPlaceholder": "",
"discardConfirm": "",
"discardMessage": "",
"discardTitle": "",
"duplicateToCustomize": "",
"nameLabel": "",
"newWorkflow": "",
"readOnlyBuiltin": "",
"saved": "",
"savedNotCompilable": "",

View File

@@ -6809,7 +6809,27 @@
"stepExecuteLabel": "Step execute"
},
"workflows": {
"clickToEditDescription": "",
"clickToRename": "",
"created": "",
"createDescription": "",
"createFailed": "",
"createName": "",
"createNameRequired": "",
"createSubmit": "",
"createTitle": "",
"deleted": "",
"deleteFailed": "",
"deleteMessage": "",
"deleteTitle": "",
"descriptionLabel": "",
"descriptionPlaceholder": "",
"discardConfirm": "",
"discardMessage": "",
"discardTitle": "",
"duplicateToCustomize": "",
"nameLabel": "",
"newWorkflow": "",
"readOnlyBuiltin": "",
"saved": "",
"savedNotCompilable": "",

View File

@@ -6809,7 +6809,27 @@
"stepExecuteLabel": "Step execute"
},
"workflows": {
"clickToEditDescription": "",
"clickToRename": "",
"created": "",
"createDescription": "",
"createFailed": "",
"createName": "",
"createNameRequired": "",
"createSubmit": "",
"createTitle": "",
"deleted": "",
"deleteFailed": "",
"deleteMessage": "",
"deleteTitle": "",
"descriptionLabel": "",
"descriptionPlaceholder": "",
"discardConfirm": "",
"discardMessage": "",
"discardTitle": "",
"duplicateToCustomize": "",
"nameLabel": "",
"newWorkflow": "",
"readOnlyBuiltin": "",
"saved": "",
"savedNotCompilable": "",

View File

@@ -6817,7 +6817,27 @@ export default interface Resources {
"switchConfirm": "Switch and abort"
},
"workflows": {
"clickToEditDescription": "Click to edit description",
"clickToRename": "Click to rename",
"createDescription": "Description (optional)",
"createFailed": "Failed to create workflow",
"createName": "Name",
"createNameRequired": "Enter a workflow name",
"createSubmit": "Create",
"createTitle": "New workflow",
"created": "Created workflow \"{{name}}\"",
"deleteFailed": "Failed to delete workflow",
"deleteMessage": "Delete workflow \"{{name}}\"? This cannot be undone.",
"deleteTitle": "Delete workflow?",
"deleted": "Workflow deleted",
"descriptionLabel": "Workflow description",
"descriptionPlaceholder": "Add a description",
"discardConfirm": "Discard",
"discardMessage": "You have unsaved changes to this workflow. Discard them?",
"discardTitle": "Discard unsaved changes?",
"duplicateToCustomize": "Duplicate to customize",
"nameLabel": "Workflow name",
"newWorkflow": "New workflow",
"readOnlyBuiltin": "Read-only built-in workflow",
"saveFailed": "Failed to save workflow",
"saved": "Workflow saved",