feat: persist and edit versioned workflow definitions
This commit is contained in:
@@ -0,0 +1,703 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type DragEvent
|
||||
} from "react";
|
||||
import {
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
GitFork,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { ReactFlowProvider } from "@xyflow/react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DismissibleAlert,
|
||||
IconButton,
|
||||
LoadingFrame,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
isApiError,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
activateWorkflowDefinition,
|
||||
archiveWorkflowDefinition,
|
||||
createWorkflowDefinition,
|
||||
deleteWorkflowDefinition,
|
||||
listWorkflowDefinitions,
|
||||
listWorkflowNodeTypes,
|
||||
listWorkflowRevisions,
|
||||
updateWorkflowDefinition,
|
||||
validateWorkflowDefinition,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowDiagnostic,
|
||||
type WorkflowNodeType,
|
||||
type WorkflowRevision
|
||||
} from "../../api/workflow";
|
||||
import WorkflowCanvas, {
|
||||
updateWorkflowGraphNode
|
||||
} from "./WorkflowCanvas";
|
||||
import WorkflowInspector from "./WorkflowInspector";
|
||||
import {
|
||||
FALLBACK_WORKFLOW_LIBRARY,
|
||||
draftFromDefinition,
|
||||
sampleWorkflowDraft,
|
||||
workflowFingerprint,
|
||||
workflowPayload,
|
||||
type WorkflowDraft
|
||||
} from "./model";
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"trigger",
|
||||
"activity",
|
||||
"decision",
|
||||
"wait",
|
||||
"integration",
|
||||
"outcome"
|
||||
];
|
||||
|
||||
export default function WorkflowPage({
|
||||
settings,
|
||||
auth
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
const [definitions, setDefinitions] = useState<WorkflowDefinition[]>([]);
|
||||
const [draft, setDraft] = useState<WorkflowDraft | null>(null);
|
||||
const [savedDraft, setSavedDraft] = useState<WorkflowDraft | null>(null);
|
||||
const [revisions, setRevisions] = useState<WorkflowRevision[]>([]);
|
||||
const [historicalRevision, setHistoricalRevision] =
|
||||
useState<WorkflowRevision | null>(null);
|
||||
const [nodeLibrary, setNodeLibrary] = useState<WorkflowNodeType[]>(
|
||||
FALLBACK_WORKFLOW_LIBRARY
|
||||
);
|
||||
const [allowsCycles, setAllowsCycles] = useState(true);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [diagnostics, setDiagnostics] = useState<WorkflowDiagnostic[]>([]);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const canWrite = hasScope(auth, "workflow:definition:write")
|
||||
|| hasScope(auth, "workflow:instance:admin");
|
||||
const dirty = Boolean(draft)
|
||||
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
|
||||
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
|
||||
const readOnly = !canWrite || historicalRevision !== null;
|
||||
const selectedNode = useMemo(
|
||||
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||
[displayedGraph, selectedNodeId]
|
||||
);
|
||||
const visibleDefinitions = useMemo(() => {
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
if (!query) return definitions;
|
||||
return definitions.filter((definition) =>
|
||||
`${definition.name} ${definition.key} ${definition.description ?? ""} ${definition.status}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
}, [definitions, search]);
|
||||
const paletteGroups = useMemo(
|
||||
() => CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
label: nodeLibrary.find((item) => item.category === category)
|
||||
?.category_label ?? category,
|
||||
nodes: nodeLibrary.filter((item) => item.category === category)
|
||||
})).filter((group) => group.nodes.length),
|
||||
[nodeLibrary]
|
||||
);
|
||||
|
||||
const applyDefinition = useCallback((definition: WorkflowDefinition) => {
|
||||
const next = draftFromDefinition(definition);
|
||||
setDraft(next);
|
||||
setSavedDraft(structuredClone(next));
|
||||
setHistoricalRevision(null);
|
||||
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
||||
setDiagnostics([]);
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(async (preferredId?: string | null) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const items = await listWorkflowDefinitions(settings);
|
||||
setDefinitions(items);
|
||||
const selected = items.find((item) => item.id === preferredId) ?? items[0];
|
||||
if (selected) {
|
||||
applyDefinition(selected);
|
||||
} else {
|
||||
setDraft(null);
|
||||
setSavedDraft(null);
|
||||
setRevisions([]);
|
||||
setHistoricalRevision(null);
|
||||
setSelectedNodeId(null);
|
||||
}
|
||||
} catch (loadError) {
|
||||
setError(apiErrorMessage(loadError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyDefinition, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
let cancelled = false;
|
||||
void listWorkflowNodeTypes(settings)
|
||||
.then((library) => {
|
||||
if (cancelled) return;
|
||||
if (library.nodes.length) setNodeLibrary(library.nodes);
|
||||
setAllowsCycles(library.allows_cycles);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setNodeLibrary(FALLBACK_WORKFLOW_LIBRARY);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reload, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft?.id) {
|
||||
setRevisions([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void listWorkflowRevisions(settings, draft.id)
|
||||
.then((items) => {
|
||||
if (!cancelled) setRevisions(items);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) setError(apiErrorMessage(loadError));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [draft?.id, draft?.currentRevision, settings]);
|
||||
|
||||
const discardDraft = useCallback(() => {
|
||||
if (!savedDraft) {
|
||||
setDraft(null);
|
||||
return;
|
||||
}
|
||||
const next = structuredClone(savedDraft);
|
||||
setDraft(next);
|
||||
setHistoricalRevision(null);
|
||||
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
||||
setDiagnostics([]);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
}, [savedDraft]);
|
||||
|
||||
const saveDraft = useCallback(async (): Promise<boolean> => {
|
||||
if (!draft || !canWrite || !draft.name.trim()) {
|
||||
setError(
|
||||
!draft?.name.trim()
|
||||
? "Workflow name is required."
|
||||
: "You cannot save this workflow."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
setWorking(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const saved = draft.id && draft.currentRevision
|
||||
? await updateWorkflowDefinition(settings, draft.id, {
|
||||
...workflowPayload(draft),
|
||||
expected_revision: draft.currentRevision
|
||||
})
|
||||
: await createWorkflowDefinition(settings, workflowPayload(draft));
|
||||
applyDefinition(saved);
|
||||
setDefinitions((current) => [
|
||||
saved,
|
||||
...current.filter((item) => item.id !== saved.id)
|
||||
]);
|
||||
setSuccess(`Saved revision ${saved.current_revision}.`);
|
||||
return true;
|
||||
} catch (saveError) {
|
||||
setError(apiErrorMessage(saveError));
|
||||
return false;
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}, [applyDefinition, canWrite, draft, settings]);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
title: "Unsaved workflow",
|
||||
message: "Save or discard the workflow changes before leaving this workspace.",
|
||||
onSave: saveDraft,
|
||||
onDiscard: discardDraft
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", beforeUnload);
|
||||
}, [dirty]);
|
||||
|
||||
const selectDefinition = (definition: WorkflowDefinition) => {
|
||||
requestNavigation(() => {
|
||||
applyDefinition(definition);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
});
|
||||
};
|
||||
|
||||
const createNew = () => {
|
||||
requestNavigation(() => {
|
||||
const next = sampleWorkflowDraft();
|
||||
setDraft(next);
|
||||
setSavedDraft(null);
|
||||
setRevisions([]);
|
||||
setHistoricalRevision(null);
|
||||
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
||||
setDiagnostics([]);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
});
|
||||
};
|
||||
|
||||
const validate = async () => {
|
||||
if (!displayedGraph) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await validateWorkflowDefinition(settings, displayedGraph);
|
||||
setDiagnostics(result.diagnostics);
|
||||
setSuccess(result.valid ? "Workflow definition is valid." : "");
|
||||
} catch (validationError) {
|
||||
setError(apiErrorMessage(validationError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activate = async () => {
|
||||
if (!draft?.id || dirty) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await activateWorkflowDefinition(
|
||||
settings,
|
||||
draft.id,
|
||||
draft.currentRevision
|
||||
);
|
||||
applyDefinition(saved);
|
||||
setDefinitions((current) => current.map((item) =>
|
||||
item.id === saved.id ? saved : item
|
||||
));
|
||||
setSuccess(`Activated revision ${saved.active_revision}.`);
|
||||
} catch (activationError) {
|
||||
setError(apiErrorMessage(activationError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const archiveDefinition = async () => {
|
||||
if (!draft?.id || dirty) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await archiveWorkflowDefinition(settings, draft.id);
|
||||
applyDefinition(saved);
|
||||
setDefinitions((current) => current.map((item) =>
|
||||
item.id === saved.id ? saved : item
|
||||
));
|
||||
setSuccess("Workflow archived.");
|
||||
} catch (archiveError) {
|
||||
setError(apiErrorMessage(archiveError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDefinition = async () => {
|
||||
if (!draft?.id) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteWorkflowDefinition(settings, draft.id);
|
||||
setDeleteOpen(false);
|
||||
await reload();
|
||||
} catch (deleteError) {
|
||||
setError(apiErrorMessage(deleteError));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateGraph = (graph: WorkflowDraft["graph"]) => {
|
||||
if (readOnly) return;
|
||||
setDraft((current) => current ? { ...current, graph } : current);
|
||||
setDiagnostics([]);
|
||||
setSuccess("");
|
||||
};
|
||||
|
||||
const removeNode = (nodeId: string) => {
|
||||
if (!draft || readOnly) return;
|
||||
updateGraph({
|
||||
...draft.graph,
|
||||
nodes: draft.graph.nodes.filter((node) => node.id !== nodeId),
|
||||
edges: draft.graph.edges.filter(
|
||||
(edge) => edge.source !== nodeId && edge.target !== nodeId
|
||||
)
|
||||
});
|
||||
setSelectedNodeId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="workflow-page">
|
||||
<div className="workflow-shell">
|
||||
<aside className="workflow-definition-panel">
|
||||
<div className="workflow-panel-toolbar">
|
||||
<strong>Workflows</strong>
|
||||
<span className="workflow-toolbar-actions">
|
||||
<IconButton
|
||||
label="Refresh"
|
||||
icon={<RefreshCw size={16} />}
|
||||
variant="ghost"
|
||||
onClick={() => void reload(draft?.id)}
|
||||
disabled={loading || working}
|
||||
/>
|
||||
<IconButton
|
||||
label="New workflow"
|
||||
icon={<Plus size={17} />}
|
||||
variant="primary"
|
||||
onClick={createNew}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="workflow-definition-search">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search workflows"
|
||||
aria-label="Search workflows"
|
||||
/>
|
||||
</div>
|
||||
<LoadingFrame loading={loading} className="workflow-definition-list-frame">
|
||||
<div className="workflow-definition-list">
|
||||
{visibleDefinitions.map((definition) => (
|
||||
<button
|
||||
key={definition.id}
|
||||
type="button"
|
||||
className={definition.id === draft?.id ? "is-selected" : ""}
|
||||
onClick={() => selectDefinition(definition)}
|
||||
>
|
||||
<span>
|
||||
<strong>{definition.name}</strong>
|
||||
<small>
|
||||
{definition.key} · revision {definition.current_revision}
|
||||
</small>
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={definition.status}
|
||||
label={definition.status}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{!visibleDefinitions.length ? (
|
||||
<div className="workflow-definition-empty">
|
||||
No workflow definitions
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</aside>
|
||||
|
||||
<section className="workflow-workspace">
|
||||
{draft && displayedGraph ? (
|
||||
<>
|
||||
<div className="workflow-workspace-toolbar">
|
||||
<span className="workflow-identity-fields">
|
||||
<input
|
||||
className="workflow-name-input"
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
name: event.target.value
|
||||
})}
|
||||
disabled={readOnly}
|
||||
aria-label="Workflow name"
|
||||
/>
|
||||
<input
|
||||
className="workflow-description-input"
|
||||
value={draft.description}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
description: event.target.value
|
||||
})}
|
||||
disabled={readOnly}
|
||||
placeholder="Description"
|
||||
aria-label="Workflow description"
|
||||
/>
|
||||
</span>
|
||||
<span className="workflow-command-bar">
|
||||
{draft.id ? (
|
||||
<select
|
||||
className="workflow-revision-select"
|
||||
value={
|
||||
historicalRevision?.revision
|
||||
?? draft.currentRevision
|
||||
?? 1
|
||||
}
|
||||
onChange={(event) => {
|
||||
const revision = Number(event.target.value);
|
||||
const historical = revisions.find(
|
||||
(item) => item.revision === revision
|
||||
) ?? null;
|
||||
setHistoricalRevision(
|
||||
revision === draft.currentRevision
|
||||
? null
|
||||
: historical
|
||||
);
|
||||
setSelectedNodeId(
|
||||
(historical?.graph ?? draft.graph).nodes[0]?.id ?? null
|
||||
);
|
||||
setDiagnostics([]);
|
||||
}}
|
||||
aria-label="Workflow revision"
|
||||
>
|
||||
{revisions.map((revision) => (
|
||||
<option key={revision.id} value={revision.revision}>
|
||||
Revision {revision.revision}
|
||||
{revision.revision === draft.activeRevision
|
||||
? " (active)"
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{historicalRevision ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDraft({
|
||||
...draft,
|
||||
graph: structuredClone(historicalRevision.graph)
|
||||
});
|
||||
setHistoricalRevision(null);
|
||||
setDiagnostics([]);
|
||||
}}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
<RotateCcw size={16} /> Restore
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={() => void validate()} disabled={working}>
|
||||
<CheckCircle2 size={16} /> Validate
|
||||
</Button>
|
||||
{draft.id && draft.status !== "archived" ? (
|
||||
<Button
|
||||
onClick={() => void archiveDefinition()}
|
||||
disabled={!canWrite || dirty || working || readOnly}
|
||||
>
|
||||
<Archive size={16} /> Archive
|
||||
</Button>
|
||||
) : null}
|
||||
{draft.id && draft.status !== "active" ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void activate()}
|
||||
disabled={!canWrite || dirty || working || readOnly}
|
||||
>
|
||||
<CheckCircle2 size={16} /> Activate
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveDraft()}
|
||||
disabled={!canWrite || !dirty || working || readOnly}
|
||||
>
|
||||
<Save size={16} /> Save
|
||||
</Button>
|
||||
{draft.id ? (
|
||||
<IconButton
|
||||
label="Delete workflow"
|
||||
icon={<Trash2 size={16} />}
|
||||
variant="danger"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={!canWrite || working}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="workflow-alerts">
|
||||
{error ? (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
{success ? (
|
||||
<DismissibleAlert tone="success" resetKey={success}>
|
||||
{success}
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="workflow-editor">
|
||||
<aside className="workflow-palette">
|
||||
<div className="workflow-panel-heading">
|
||||
<span>
|
||||
<strong>Node library</strong>
|
||||
<small>Drag nodes onto the canvas</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="workflow-palette-items">
|
||||
{paletteGroups.map((group) => (
|
||||
<section key={group.category} className="workflow-palette-group">
|
||||
<h3>{group.label}</h3>
|
||||
{group.nodes.map((nodeType) => (
|
||||
<button
|
||||
key={nodeType.type}
|
||||
type="button"
|
||||
draggable={!readOnly}
|
||||
disabled={readOnly}
|
||||
onDragStart={(event) =>
|
||||
startPaletteDrag(event, nodeType.type)
|
||||
}
|
||||
title={nodeType.description}
|
||||
>
|
||||
<GitFork size={16} />
|
||||
<span>{nodeType.label}</span>
|
||||
<Plus size={13} className="workflow-palette-add" />
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="workflow-editor-surface">
|
||||
<ReactFlowProvider>
|
||||
<WorkflowCanvas
|
||||
graph={displayedGraph}
|
||||
diagnostics={diagnostics}
|
||||
nodeLibrary={nodeLibrary}
|
||||
selectedNodeId={selectedNodeId}
|
||||
readOnly={readOnly}
|
||||
allowsCycles={allowsCycles}
|
||||
onGraphChange={updateGraph}
|
||||
onSelectNode={setSelectedNodeId}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
<div className="workflow-inspector-column">
|
||||
<WorkflowInspector
|
||||
node={selectedNode}
|
||||
nodeLibrary={nodeLibrary}
|
||||
readOnly={readOnly}
|
||||
onChange={(node) => {
|
||||
if (!draft || readOnly) return;
|
||||
updateGraph(updateWorkflowGraphNode(draft.graph, node));
|
||||
}}
|
||||
onDelete={removeNode}
|
||||
/>
|
||||
{diagnostics.length ? (
|
||||
<div className="workflow-diagnostics">
|
||||
<div className="workflow-panel-heading">
|
||||
<strong>Diagnostics</strong>
|
||||
<StatusBadge
|
||||
status={
|
||||
diagnostics.some((item) => item.severity === "error")
|
||||
? "error"
|
||||
: "warning"
|
||||
}
|
||||
label={String(diagnostics.length)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{diagnostics.map((item, index) => (
|
||||
<button
|
||||
key={`${item.code}-${item.node_id ?? "graph"}-${index}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.node_id) setSelectedNodeId(item.node_id);
|
||||
}}
|
||||
>
|
||||
<strong>{item.message}</strong>
|
||||
<small>{item.code}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{working ? (
|
||||
<div className="workflow-working-indicator" role="status">
|
||||
Working...
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="workflow-workspace-empty">
|
||||
<GitFork size={34} />
|
||||
<strong>No workflow selected</strong>
|
||||
{canWrite ? (
|
||||
<Button variant="primary" onClick={createNew}>
|
||||
<Plus size={16} /> New workflow
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
title="Delete workflow"
|
||||
message={`Delete ${draft?.name ?? "this workflow"} and all of its definition revisions?`}
|
||||
confirmLabel="Delete"
|
||||
tone="danger"
|
||||
busy={working}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
onConfirm={() => void removeDefinition()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function startPaletteDrag(
|
||||
event: DragEvent<HTMLButtonElement>,
|
||||
type: string
|
||||
) {
|
||||
event.dataTransfer.setData("application/x-govoplan-workflow-node", type);
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
if (!isApiError(error)) {
|
||||
return error instanceof Error ? error.message : "The request failed.";
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(error.body) as {
|
||||
detail?: string | { message?: string };
|
||||
};
|
||||
if (typeof parsed.detail === "string") return parsed.detail;
|
||||
if (parsed.detail?.message) return parsed.detail.message;
|
||||
} catch {
|
||||
// Fall through to the API error message.
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
Reference in New Issue
Block a user