1572 lines
53 KiB
TypeScript
1572 lines
53 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type DragEvent
|
|
} from "react";
|
|
import {
|
|
Archive,
|
|
CheckCircle2,
|
|
CopyPlus,
|
|
Download,
|
|
GitFork,
|
|
ListChecks,
|
|
Plus,
|
|
RotateCcw,
|
|
Save,
|
|
Settings2,
|
|
Trash2,
|
|
Upload
|
|
} from "lucide-react";
|
|
import { ReactFlowProvider } from "@xyflow/react";
|
|
import { useSearchParams } from "react-router";
|
|
import { ActionToolbar,
|
|
Button,
|
|
ConfirmDialog,
|
|
ContentGrid,
|
|
ContentSection,
|
|
Dialog,
|
|
DocumentationHelpLink,
|
|
DismissibleAlert,
|
|
DefinitionPalette,
|
|
DefinitionPaletteGroup,
|
|
DefinitionPaletteItem,
|
|
FilterBar,
|
|
FloatingStatus,
|
|
FormField,
|
|
IconButton,
|
|
LoadingFrame,
|
|
ReferenceSelect,
|
|
SelectionList,
|
|
SelectionListItem,
|
|
SelectionListItemContent,
|
|
StatePanel,
|
|
StatusBadge,
|
|
ToggleSwitch,
|
|
WorkspaceActionBar,
|
|
WorkspaceFrame,
|
|
WorkspaceLayout,
|
|
hasScope,
|
|
isApiError,
|
|
useUnsavedChanges,
|
|
useUnsavedDraftGuard,
|
|
useEffectiveView,
|
|
type ApiSettings,
|
|
type AuthInfo
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
activateWorkflowDefinition,
|
|
archiveWorkflowDefinition,
|
|
compareWorkflowDefinitionToStandard,
|
|
compileWorkflowBpmn,
|
|
createWorkflowDefinition,
|
|
deleteWorkflowDefinition,
|
|
deriveWorkflowDefinition,
|
|
listWorkflowDefinitions,
|
|
listWorkflowNodeTypes,
|
|
listWorkflowRevisions,
|
|
reconcileWorkflowStandards,
|
|
renderWorkflowBpmn,
|
|
resetWorkflowDefinitionToStandard,
|
|
updateWorkflowDefinition,
|
|
validateWorkflowDefinition,
|
|
workflowScopeReferenceProvider,
|
|
type WorkflowDefinition,
|
|
type WorkflowDiagnostic,
|
|
type WorkflowGraphEdge,
|
|
type WorkflowNodeType,
|
|
type WorkflowRevision,
|
|
type WorkflowStandardDiff
|
|
} from "../../api/workflow";
|
|
import WorkflowCanvas, {
|
|
updateWorkflowGraphNode
|
|
} from "./WorkflowCanvas";
|
|
import WorkflowInspector from "./WorkflowInspector";
|
|
import WorkflowRunsDialog from "./WorkflowRunsDialog";
|
|
import {
|
|
FALLBACK_WORKFLOW_LIBRARY,
|
|
draftFromDefinition,
|
|
newWorkflowNode,
|
|
sampleWorkflowDraft,
|
|
workflowFingerprint,
|
|
workflowPayload,
|
|
type WorkflowDraft
|
|
} from "./model";
|
|
|
|
const CATEGORY_ORDER = [
|
|
"bpmn_event",
|
|
"bpmn_activity",
|
|
"bpmn_gateway",
|
|
"bpmn_data",
|
|
"bpmn_collaboration",
|
|
"bpmn_artifact"
|
|
];
|
|
|
|
export default function WorkflowPage({
|
|
settings,
|
|
auth
|
|
}: {
|
|
settings: ApiSettings;
|
|
auth: AuthInfo;
|
|
}) {
|
|
const { requestDiscard, requestNavigation } = useUnsavedChanges();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const requestedDefinitionId = searchParams.get("definition");
|
|
const requestedRunId = searchParams.get("run");
|
|
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 [selectedEdgeId, setSelectedEdgeId] = 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 [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
|
|
const [deriveOpen, setDeriveOpen] = useState(false);
|
|
const [runsOpen, setRunsOpen] = useState(false);
|
|
const [resetOpen, setResetOpen] = useState(false);
|
|
const [compareOpen, setCompareOpen] = useState(false);
|
|
const bpmnFileInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const canWrite = hasScope(auth, "workflow:definition:write")
|
|
|| hasScope(auth, "workflow:instance:admin");
|
|
const canEdit = canWrite && (
|
|
!draft?.id || draft.governance?.actions.edit?.allowed !== false
|
|
);
|
|
const canActivate = Boolean(
|
|
canWrite
|
|
&& (draft?.standard?.kind === "baseline" || canEdit)
|
|
);
|
|
const canReuse = Boolean(
|
|
draft?.id
|
|
&& canWrite
|
|
&& draft.governance?.actions.derive?.allowed
|
|
);
|
|
const canStart = Boolean(
|
|
draft?.id
|
|
&& (hasScope(auth, "workflow:instance:start")
|
|
|| hasScope(auth, "workflow:instance:admin"))
|
|
&& draft.governance?.actions.start?.allowed !== false
|
|
);
|
|
const canTransition = hasScope(auth, "workflow:instance:transition")
|
|
|| hasScope(auth, "workflow:instance:admin");
|
|
const selectedDefinition = useMemo(
|
|
() => definitions.find((item) => item.id === draft?.id) ?? null,
|
|
[definitions, draft?.id]
|
|
);
|
|
const dirty = Boolean(draft)
|
|
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
|
|
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
|
|
const readOnly = !canEdit || historicalRevision !== null;
|
|
const graphReadOnly = readOnly;
|
|
const selectedNode = useMemo(
|
|
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
|
|
[displayedGraph, selectedNodeId]
|
|
);
|
|
const selectedEdge = useMemo(
|
|
() => displayedGraph?.edges.find((edge) => edge.id === selectedEdgeId) ?? null,
|
|
[displayedGraph, selectedEdgeId]
|
|
);
|
|
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);
|
|
setSelectedEdgeId(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(requestedDefinitionId);
|
|
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, requestedDefinitionId, settings]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
requestedRunId
|
|
&& draft?.id
|
|
&& (!requestedDefinitionId || draft.id === requestedDefinitionId)
|
|
) {
|
|
setRunsOpen(true);
|
|
}
|
|
}, [draft?.id, requestedDefinitionId, requestedRunId]);
|
|
|
|
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);
|
|
setSelectedEdgeId(null);
|
|
setDiagnostics([]);
|
|
setError("");
|
|
setSuccess("");
|
|
}, [savedDraft]);
|
|
|
|
const saveDraft = useCallback(async (): Promise<boolean> => {
|
|
if (!draft || !canEdit || !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, canEdit, 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);
|
|
setSelectedEdgeId(null);
|
|
setDiagnostics([]);
|
|
setError("");
|
|
setSuccess("");
|
|
});
|
|
};
|
|
|
|
const validate = async () => {
|
|
if (!displayedGraph) return;
|
|
setWorking(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
const result = await validateWorkflowDefinition(settings, displayedGraph);
|
|
setDiagnostics(result.diagnostics);
|
|
setSuccess(result.valid ? "BPMN workflow graph is valid." : "");
|
|
} catch (validationError) {
|
|
setError(apiErrorMessage(validationError));
|
|
} finally {
|
|
setWorking(false);
|
|
}
|
|
};
|
|
|
|
const importBpmnFile = async (file: File | null) => {
|
|
if (!file || readOnly) return;
|
|
setWorking(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
const xml = await file.text();
|
|
const imported = await compileWorkflowBpmn(settings, {
|
|
xml,
|
|
adapter_id: "govoplan.native.bpmn",
|
|
adapter_version: "1.0.0"
|
|
});
|
|
setDraft((current) => current ? {
|
|
...current,
|
|
graph: imported.graph
|
|
} : current);
|
|
setSelectedNodeId(imported.graph.nodes[0]?.id ?? null);
|
|
setSelectedEdgeId(null);
|
|
setDiagnostics([]);
|
|
setSuccess("Imported BPMN XML into the native graph.");
|
|
} catch (fileError) {
|
|
setError(apiErrorMessage(fileError));
|
|
} finally {
|
|
setWorking(false);
|
|
}
|
|
};
|
|
|
|
const exportBpmnFile = async () => {
|
|
if (!displayedGraph) return;
|
|
setWorking(true);
|
|
setError("");
|
|
try {
|
|
const rendered = await renderWorkflowBpmn(settings, {
|
|
graph: displayedGraph,
|
|
name: draft?.name ?? "Workflow"
|
|
});
|
|
const url = URL.createObjectURL(
|
|
new Blob([rendered.xml], { type: "application/xml" })
|
|
);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${fileName(draft?.name || "workflow")}.bpmn`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (exportError) {
|
|
setError(apiErrorMessage(exportError));
|
|
} finally {
|
|
setWorking(false);
|
|
}
|
|
};
|
|
|
|
const selectRevision = (revisionNumber: number) => {
|
|
if (!draft?.id) return;
|
|
setDiagnostics([]);
|
|
setSelectedEdgeId(null);
|
|
if (revisionNumber === draft.currentRevision) {
|
|
setHistoricalRevision(null);
|
|
setSelectedNodeId(draft.graph.nodes[0]?.id ?? null);
|
|
return;
|
|
}
|
|
const historical = revisions.find(
|
|
(item) => item.revision === revisionNumber
|
|
) ?? null;
|
|
setHistoricalRevision(historical);
|
|
setSelectedNodeId(historical?.graph.nodes[0]?.id ?? null);
|
|
};
|
|
|
|
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 reconcileStandards = async () => {
|
|
setWorking(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
const result = await reconcileWorkflowStandards(settings);
|
|
await reload(draft?.id);
|
|
setSuccess(
|
|
`Module standards: ${result.created} installed, ${result.updated} updated, ${result.blocked} blocked.`
|
|
);
|
|
} catch (reconcileError) {
|
|
setError(apiErrorMessage(reconcileError));
|
|
} finally {
|
|
setWorking(false);
|
|
}
|
|
};
|
|
|
|
const resetToStandard = async () => {
|
|
if (!draft?.id) return;
|
|
setWorking(true);
|
|
setError("");
|
|
try {
|
|
const baseline = await resetWorkflowDefinitionToStandard(
|
|
settings,
|
|
draft.id
|
|
);
|
|
setResetOpen(false);
|
|
await reload(baseline.id);
|
|
setSuccess("The local override was archived and the module standard restored.");
|
|
} catch (resetError) {
|
|
setError(apiErrorMessage(resetError));
|
|
} finally {
|
|
setWorking(false);
|
|
}
|
|
};
|
|
|
|
const updateGraph = (graph: WorkflowDraft["graph"]) => {
|
|
if (graphReadOnly) return;
|
|
setDraft((current) => current ? { ...current, graph } : current);
|
|
setDiagnostics([]);
|
|
setSuccess("");
|
|
};
|
|
|
|
const removeNode = (nodeId: string) => {
|
|
if (!draft || graphReadOnly) 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);
|
|
};
|
|
|
|
const updateEdge = (updatedEdge: WorkflowGraphEdge) => {
|
|
if (!draft || graphReadOnly) return;
|
|
updateGraph({
|
|
...draft.graph,
|
|
edges: draft.graph.edges.map((edge) =>
|
|
edge.id === updatedEdge.id ? updatedEdge : edge
|
|
)
|
|
});
|
|
};
|
|
|
|
const removeEdge = (edgeId: string) => {
|
|
if (!draft || graphReadOnly) return;
|
|
updateGraph({
|
|
...draft.graph,
|
|
edges: draft.graph.edges.filter((edge) => edge.id !== edgeId)
|
|
});
|
|
setSelectedEdgeId(null);
|
|
};
|
|
|
|
const addNodeFromPalette = (nodeType: string) => {
|
|
if (!draft || graphReadOnly) return;
|
|
const index = draft.graph.nodes.length;
|
|
const node = newWorkflowNode(
|
|
nodeType,
|
|
{
|
|
x: 80 + (index % 4) * 220,
|
|
y: 80 + Math.floor(index / 4) * 150
|
|
},
|
|
nodeLibrary
|
|
);
|
|
updateGraph({ ...draft.graph, nodes: [...draft.graph.nodes, node] });
|
|
setSelectedNodeId(node.id);
|
|
};
|
|
|
|
return (
|
|
<WorkspaceFrame as="main" height="viewport" surface="plain" className="workflow-page" label="Workflow workspace">
|
|
<WorkspaceLayout
|
|
variant="split"
|
|
primarySize="compact"
|
|
surface="contained"
|
|
primaryScrollable={false}
|
|
contentScrollable={false}
|
|
primaryLabel="Workflows"
|
|
contentLabel="Workflow editor"
|
|
contentClassName="workflow-workspace"
|
|
primary={<>
|
|
<WorkspaceActionBar
|
|
scope="collection-pane"
|
|
variant="collection"
|
|
refreshable
|
|
reloadAction={{ onReload: () => void reload(draft?.id), loading: loading || working }}
|
|
contextActions={<>
|
|
<strong>Workflows</strong>
|
|
{hasScope(auth, "workflow:instance:admin") ? (
|
|
<IconButton
|
|
label="Reconcile module standards"
|
|
icon={<GitFork size={16} />}
|
|
variant="ghost"
|
|
onClick={() => void reconcileStandards()}
|
|
disabled={loading || working}
|
|
/>
|
|
) : null}
|
|
</>}
|
|
helpAction={<DocumentationHelpLink
|
|
reference={{ topicId: "workflow.editor", documentationType: "user" }}
|
|
label="Open Workflow documentation"
|
|
/>}
|
|
createAction={<IconButton
|
|
label="New workflow"
|
|
icon={<Plus size={17} />}
|
|
variant="primary"
|
|
onClick={createNew}
|
|
disabled={!canWrite}
|
|
disabledReason={!canWrite ? "Workflow definition write permission is required." : undefined}
|
|
/>}
|
|
/>
|
|
<FilterBar surface="panel">
|
|
<input
|
|
type="search"
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder="Search workflows"
|
|
aria-label="Search workflows"
|
|
/>
|
|
</FilterBar>
|
|
<LoadingFrame loading={loading} className="workflow-definition-list-frame">
|
|
<SelectionList variant="navigation" label="Workflows">
|
|
{visibleDefinitions.map((definition) => (
|
|
<SelectionListItem
|
|
key={definition.id}
|
|
selected={definition.id === draft?.id}
|
|
onClick={() => selectDefinition(definition)}
|
|
>
|
|
<SelectionListItemContent
|
|
title={definition.name}
|
|
description={[
|
|
definition.key,
|
|
`revision ${definition.current_revision}`,
|
|
definition.governance.scope_type,
|
|
definition.governance.definition_kind,
|
|
definition.standard
|
|
? `${definition.standard.kind === "baseline" ? "Standard" : "Override"} · ${definition.standard.origin_module_id}${definition.standard.update_available ? " · update available" : ""}`
|
|
: ""
|
|
].filter(Boolean).join(" · ")}
|
|
/>
|
|
<StatusBadge
|
|
status={definition.status}
|
|
label={definition.status}
|
|
/>
|
|
</SelectionListItem>
|
|
))}
|
|
{!visibleDefinitions.length ? (
|
|
<StatePanel size="compact" description="No workflow definitions" />
|
|
) : null}
|
|
</SelectionList>
|
|
</LoadingFrame>
|
|
</>}
|
|
>
|
|
{draft && displayedGraph ? (
|
|
<>
|
|
<WorkspaceActionBar
|
|
scope="editor-pane"
|
|
variant="editor"
|
|
state={working ? "saving" : dirty && !draft.name.trim() ? "invalid" : dirty ? "dirty" : "clean"}
|
|
className="workflow-workspace-toolbar"
|
|
contextActions={<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>}
|
|
primaryActions={<span className="workflow-command-bar">
|
|
{draft.id ? (
|
|
<select
|
|
className="workflow-revision-select"
|
|
value={
|
|
historicalRevision?.revision
|
|
?? draft.currentRevision
|
|
?? 1
|
|
}
|
|
onChange={(event) => selectRevision(
|
|
Number(event.target.value)
|
|
)}
|
|
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}
|
|
{draft.standard?.kind === "override"
|
|
&& draft.standard.update_available ? (
|
|
<Button
|
|
onClick={() => setCompareOpen(true)}
|
|
disabled={working}
|
|
>
|
|
<GitFork size={16} /> Compare update
|
|
</Button>
|
|
) : null}
|
|
{draft.standard?.kind === "override"
|
|
&& draft.standard.reset_available ? (
|
|
<Button
|
|
onClick={() => setResetOpen(true)}
|
|
disabled={working || dirty}
|
|
>
|
|
<RotateCcw size={16} /> Reset
|
|
</Button>
|
|
) : null}
|
|
{historicalRevision ? (
|
|
<Button
|
|
onClick={() => {
|
|
setDraft({
|
|
...draft,
|
|
graph: structuredClone(historicalRevision.graph),
|
|
executionMode: historicalRevision.execution_mode,
|
|
viewId: historicalRevision.view_id ?? "",
|
|
viewRevisionId:
|
|
historicalRevision.view_revision_id ?? ""
|
|
});
|
|
setHistoricalRevision(null);
|
|
setDiagnostics([]);
|
|
}}
|
|
disabled={!canEdit}
|
|
>
|
|
<RotateCcw size={16} /> Restore
|
|
</Button>
|
|
) : null}
|
|
<IconButton
|
|
label="Import BPMN XML"
|
|
icon={<Upload size={16} />}
|
|
variant="ghost"
|
|
onClick={() => bpmnFileInputRef.current?.click()}
|
|
disabled={readOnly || working}
|
|
/>
|
|
<input
|
|
ref={bpmnFileInputRef}
|
|
className="workflow-bpmn-file-input"
|
|
type="file"
|
|
accept=".bpmn,.xml,application/xml,text/xml"
|
|
onChange={(event) => {
|
|
void importBpmnFile(event.target.files?.[0] ?? null);
|
|
event.target.value = "";
|
|
}}
|
|
/>
|
|
<IconButton
|
|
label="Export BPMN XML"
|
|
icon={<Download size={16} />}
|
|
variant="ghost"
|
|
onClick={() => void exportBpmnFile()}
|
|
disabled={!displayedGraph || working}
|
|
/>
|
|
<Button onClick={() => void validate()} disabled={working}>
|
|
<CheckCircle2 size={16} /> Validate
|
|
</Button>
|
|
{draft.id ? (
|
|
<Button
|
|
onClick={() => setRunsOpen(true)}
|
|
disabled={dirty || historicalRevision !== null}
|
|
>
|
|
<ListChecks size={16} /> Runs
|
|
</Button>
|
|
) : null}
|
|
<IconButton
|
|
label="Definition settings"
|
|
icon={<Settings2 size={16} />}
|
|
variant="ghost"
|
|
onClick={() => setDefinitionSettingsOpen(true)}
|
|
/>
|
|
{draft.id ? (
|
|
<IconButton
|
|
label="Reuse as scoped copy"
|
|
icon={<CopyPlus size={16} />}
|
|
variant="ghost"
|
|
onClick={() => setDeriveOpen(true)}
|
|
disabled={!canReuse}
|
|
/>
|
|
) : null}
|
|
{draft.id
|
|
&& draft.status !== "archived"
|
|
&& draft.standard?.kind !== "baseline" ? (
|
|
<Button
|
|
onClick={() => void archiveDefinition()}
|
|
disabled={!canEdit || dirty || working || readOnly}
|
|
>
|
|
<Archive size={16} /> Archive
|
|
</Button>
|
|
) : null}
|
|
{draft.id && (
|
|
draft.status !== "active"
|
|
|| draft.activeRevision !== draft.currentRevision
|
|
) ? (
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => void activate()}
|
|
disabled={
|
|
!canActivate
|
|
|| dirty
|
|
|| working
|
|
|| historicalRevision !== null
|
|
|| draft.definitionKind === "template"
|
|
}
|
|
disabledReason={
|
|
draft.definitionKind === "template"
|
|
? "Templates cannot be activated or started."
|
|
: undefined
|
|
}
|
|
>
|
|
<CheckCircle2 size={16} /> Activate
|
|
</Button>
|
|
) : null}
|
|
</span>}
|
|
destructiveActions={draft.id && draft.standard?.kind !== "baseline" ? (
|
|
<IconButton
|
|
label="Delete workflow"
|
|
icon={<Trash2 size={16} />}
|
|
variant="danger"
|
|
onClick={() => setDeleteOpen(true)}
|
|
disabled={!canEdit || working}
|
|
/>
|
|
) : undefined}
|
|
discardAction={{
|
|
label: <><RotateCcw size={16} /> Discard</>,
|
|
onClick: () => requestDiscard(() => void reload(draft.id))
|
|
}}
|
|
saveAction={{
|
|
label: <><Save size={16} /> Save</>,
|
|
onClick: () => void saveDraft(),
|
|
disabled: !canEdit || readOnly
|
|
}}
|
|
/>
|
|
<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">
|
|
<DefinitionPalette label="Node library" description="Drag nodes onto the canvas">
|
|
{paletteGroups.map((group) => (
|
|
<DefinitionPaletteGroup key={group.category} label={group.label}>
|
|
{group.nodes.map((nodeType) => (
|
|
<DefinitionPaletteItem
|
|
key={nodeType.type}
|
|
icon={<GitFork size={16} />}
|
|
label={nodeType.label}
|
|
draggable={!graphReadOnly}
|
|
disabled={graphReadOnly}
|
|
onDragStart={(event) =>
|
|
startPaletteDrag(event, nodeType.type)
|
|
}
|
|
onClick={() => addNodeFromPalette(nodeType.type)}
|
|
title={nodeType.description}
|
|
/>
|
|
))}
|
|
</DefinitionPaletteGroup>
|
|
))}
|
|
</DefinitionPalette>
|
|
<div className="definition-editor-surface workflow-editor-surface">
|
|
<ReactFlowProvider>
|
|
<WorkflowCanvas
|
|
graph={displayedGraph}
|
|
diagnostics={diagnostics}
|
|
nodeLibrary={nodeLibrary}
|
|
selectedNodeId={selectedNodeId}
|
|
selectedEdgeId={selectedEdgeId}
|
|
readOnly={graphReadOnly}
|
|
allowsCycles={allowsCycles}
|
|
onGraphChange={updateGraph}
|
|
onSelectNode={setSelectedNodeId}
|
|
onSelectEdge={setSelectedEdgeId}
|
|
/>
|
|
</ReactFlowProvider>
|
|
</div>
|
|
<div className="workflow-inspector-column">
|
|
<WorkflowInspector
|
|
node={selectedNode}
|
|
edge={selectedEdge}
|
|
nodeLibrary={nodeLibrary}
|
|
readOnly={graphReadOnly}
|
|
onChange={(node) => {
|
|
if (!draft || graphReadOnly) return;
|
|
updateGraph(updateWorkflowGraphNode(draft.graph, node));
|
|
}}
|
|
onDelete={removeNode}
|
|
onEdgeChange={updateEdge}
|
|
onEdgeDelete={removeEdge}
|
|
/>
|
|
{diagnostics.length ? (
|
|
<div className="workflow-diagnostics">
|
|
<ActionToolbar surface="section-header" className="workflow-panel-heading">
|
|
<strong>Diagnostics</strong>
|
|
<StatusBadge
|
|
status={
|
|
diagnostics.some((item) => item.severity === "error")
|
|
? "error"
|
|
: "warning"
|
|
}
|
|
label={String(diagnostics.length)}
|
|
/>
|
|
</ActionToolbar>
|
|
<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 ? <FloatingStatus>Working...</FloatingStatus> : null}
|
|
</>
|
|
) : (
|
|
<StatePanel
|
|
size="fill"
|
|
icon={<GitFork size={34} />}
|
|
title="No workflow selected"
|
|
actions={canWrite ? (
|
|
<Button variant="primary" onClick={createNew}>
|
|
<Plus size={16} /> New workflow
|
|
</Button>
|
|
) : undefined}
|
|
/>
|
|
)}
|
|
</WorkspaceLayout>
|
|
<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()}
|
|
/>
|
|
<ConfirmDialog
|
|
open={resetOpen}
|
|
title="Reset to module standard"
|
|
message={`Archive ${draft?.name ?? "this override"} and restore the latest module standard? Historical revisions and running instances remain unchanged.`}
|
|
confirmLabel="Reset"
|
|
busy={working}
|
|
onCancel={() => setResetOpen(false)}
|
|
onConfirm={() => void resetToStandard()}
|
|
/>
|
|
<WorkflowStandardComparisonDialog
|
|
open={compareOpen}
|
|
settings={settings}
|
|
override={selectedDefinition}
|
|
onClose={() => setCompareOpen(false)}
|
|
/>
|
|
<WorkflowDefinitionSettingsDialog
|
|
open={definitionSettingsOpen}
|
|
settings={settings}
|
|
draft={draft}
|
|
editable={canEdit}
|
|
onChange={(patch) => setDraft((current) => current ? { ...current, ...patch } : current)}
|
|
onClose={() => setDefinitionSettingsOpen(false)}
|
|
/>
|
|
<DeriveWorkflowDialog
|
|
open={deriveOpen}
|
|
settings={settings}
|
|
definition={draft?.id ? {
|
|
id: draft.id,
|
|
name: draft.name,
|
|
revision: draft.currentRevision ?? 1
|
|
} : null}
|
|
onClose={() => setDeriveOpen(false)}
|
|
onDerived={(definition) => {
|
|
applyDefinition(definition);
|
|
setDefinitions((current) => [
|
|
definition,
|
|
...current.filter((item) => item.id !== definition.id)
|
|
]);
|
|
setDeriveOpen(false);
|
|
setSuccess("Created a pinned scoped copy.");
|
|
}}
|
|
/>
|
|
<WorkflowRunsDialog
|
|
open={runsOpen}
|
|
settings={settings}
|
|
definition={selectedDefinition}
|
|
initialInstanceId={requestedRunId}
|
|
canStart={canStart}
|
|
canTransition={canTransition}
|
|
onClose={() => {
|
|
setRunsOpen(false);
|
|
if (requestedRunId) {
|
|
const next = new URLSearchParams(searchParams);
|
|
next.delete("run");
|
|
setSearchParams(next, { replace: true });
|
|
}
|
|
}}
|
|
/>
|
|
</WorkspaceFrame>
|
|
);
|
|
}
|
|
|
|
function WorkflowStandardComparisonDialog({
|
|
open,
|
|
settings,
|
|
override,
|
|
onClose
|
|
}: {
|
|
open: boolean;
|
|
settings: ApiSettings;
|
|
override: WorkflowDefinition | null;
|
|
onClose: () => void;
|
|
}) {
|
|
const [comparison, setComparison] = useState<WorkflowStandardDiff | null>(null);
|
|
const [error, setError] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!open || !override) return;
|
|
let cancelled = false;
|
|
setError("");
|
|
setComparison(null);
|
|
void compareWorkflowDefinitionToStandard(settings, override.id).then((result) => {
|
|
if (cancelled) return;
|
|
setComparison(result);
|
|
}).catch((loadError) => {
|
|
if (!cancelled) setError(apiErrorMessage(loadError));
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open, override, settings]);
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title="Compare module standard update"
|
|
className="workflow-standard-comparison-dialog"
|
|
onClose={onClose}
|
|
footer={<Button onClick={onClose}>Close</Button>}
|
|
>
|
|
{error ? (
|
|
<DismissibleAlert tone="danger" resetKey={error}>
|
|
{error}
|
|
</DismissibleAlert>
|
|
) : null}
|
|
{!error && !comparison ? (
|
|
<LoadingFrame loading label="Comparing workflow revisions...">
|
|
<div className="workflow-standard-comparison" />
|
|
</LoadingFrame>
|
|
) : null}
|
|
{comparison ? (
|
|
<div className="workflow-standard-comparison">
|
|
<div className="workflow-standard-comparison-summary">
|
|
<span>
|
|
Pinned revision <strong>{comparison.pinned_baseline_revision}</strong>
|
|
</span>
|
|
<span>
|
|
Local revision <strong>{comparison.local_revision}</strong>
|
|
</span>
|
|
<span>
|
|
Latest standard <strong>{comparison.latest_baseline_revision}</strong>
|
|
</span>
|
|
<StatusBadge
|
|
status={comparison.auto_mergeable ? "ready" : "warning"}
|
|
label={comparison.auto_mergeable
|
|
? "No semantic conflicts"
|
|
: `${comparison.conflict_count} conflicts`}
|
|
/>
|
|
</div>
|
|
<div className="workflow-standard-diff-list">
|
|
{comparison.items.map((item) => (
|
|
<section
|
|
className={`workflow-standard-diff-item state-${item.state}`}
|
|
key={`${item.resource_type}:${item.resource_id}`}
|
|
>
|
|
<header>
|
|
<span>
|
|
<strong>{item.resource_id}</strong>
|
|
<small>{item.resource_type} · {item.changed_fields.join(", ")}</small>
|
|
</span>
|
|
<StatusBadge
|
|
status={item.state === "conflict"
|
|
? "warning"
|
|
: item.state === "upstream_only"
|
|
? "queued"
|
|
: "active"}
|
|
label={standardDiffLabel(item.state)}
|
|
/>
|
|
</header>
|
|
<p>{standardDiffAction(item.recommended_action)}</p>
|
|
</section>
|
|
))}
|
|
{!comparison.items.length ? (
|
|
<p className="workflow-standard-diff-empty">
|
|
The local override and latest standard contain no semantic changes.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function standardDiffLabel(state: string): string {
|
|
return ({
|
|
local_only: "Local change",
|
|
upstream_only: "Standard update",
|
|
same_change: "Same change",
|
|
conflict: "Conflict",
|
|
unchanged: "Unchanged"
|
|
} as Record<string, string>)[state] ?? state;
|
|
}
|
|
|
|
function standardDiffAction(action: string): string {
|
|
return ({
|
|
keep_local: "The local change can be retained automatically.",
|
|
adopt_upstream: "The standard update can be adopted automatically.",
|
|
either: "Both revisions made the same semantic change.",
|
|
manual_resolution: "Local and upstream changes overlap and need a decision.",
|
|
none: "No action is required."
|
|
} as Record<string, string>)[action] ?? action;
|
|
}
|
|
|
|
function WorkflowDefinitionSettingsDialog({
|
|
open,
|
|
settings,
|
|
draft,
|
|
editable,
|
|
onChange,
|
|
onClose
|
|
}: {
|
|
open: boolean;
|
|
settings: ApiSettings;
|
|
draft: WorkflowDraft | null;
|
|
editable: boolean;
|
|
onChange: (patch: Partial<WorkflowDraft>) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const provenance = draft?.governance?.actions.edit?.source_path ?? [];
|
|
const effectiveView = useEffectiveView();
|
|
const referenceScopeType = draft?.scopeType === "group" ? "group" : "user";
|
|
const scopeProvider = useMemo(
|
|
() => workflowScopeReferenceProvider(settings, referenceScopeType),
|
|
[
|
|
referenceScopeType,
|
|
settings.accessToken,
|
|
settings.apiBaseUrl,
|
|
settings.apiKey
|
|
]
|
|
);
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title="Definition settings"
|
|
className="workflow-definition-dialog"
|
|
onClose={onClose}
|
|
footer={<Button onClick={onClose}>Close</Button>}
|
|
>
|
|
{draft ? (
|
|
<div className="workflow-definition-fields">
|
|
<FormField
|
|
label="Scope"
|
|
help="The scope determines ownership, visibility, and the Policy inheritance path."
|
|
documentation={{ topicId: "workflow.editor", documentationType: "admin" }}
|
|
>
|
|
<select
|
|
value={draft.scopeType}
|
|
disabled={!editable || Boolean(draft.id)}
|
|
onChange={(event) => onChange({
|
|
scopeType: event.target.value as WorkflowDraft["scopeType"],
|
|
scopeId: ""
|
|
})}
|
|
>
|
|
<option value="system">System</option>
|
|
<option value="tenant">Tenant</option>
|
|
<option value="group">Group</option>
|
|
<option value="user">User</option>
|
|
</select>
|
|
</FormField>
|
|
{draft.scopeType === "user" || draft.scopeType === "group" ? (
|
|
<FormField
|
|
label={draft.scopeType === "user" ? "User" : "Group"}
|
|
help={
|
|
draft.scopeType === "user"
|
|
? "The stable account ID is stored; directory labels are presentation-only."
|
|
: "Only groups available in the active tenant can be selected."
|
|
}
|
|
>
|
|
<ReferenceSelect
|
|
value={draft.scopeId}
|
|
onChange={(value) => onChange({ scopeId: value })}
|
|
provider={scopeProvider}
|
|
aria-label={
|
|
draft.scopeType === "user"
|
|
? "Definition user"
|
|
: "Definition group"
|
|
}
|
|
placeholder={
|
|
draft.scopeType === "user"
|
|
? "Select a user"
|
|
: "Select a group"
|
|
}
|
|
disabled={!editable || Boolean(draft.id)}
|
|
required
|
|
/>
|
|
</FormField>
|
|
) : null}
|
|
<FormField
|
|
label="Definition kind"
|
|
help="Templates can be reused or derived, but cannot be activated or started."
|
|
documentation={{ topicId: "workflow.editor", documentationType: "user" }}
|
|
>
|
|
<select
|
|
value={draft.definitionKind}
|
|
disabled={!editable || Boolean(draft.id)}
|
|
onChange={(event) => onChange({
|
|
definitionKind: event.target.value as WorkflowDraft["definitionKind"],
|
|
status: "draft"
|
|
})}
|
|
>
|
|
<option value="flow">Complete flow</option>
|
|
<option value="template">Template</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField
|
|
label="Execution mode"
|
|
help="Guided flows require a user; automated flows cannot contain human handoffs; hybrid flows can combine both."
|
|
documentation={{ topicId: "workflow.editor", documentationType: "user" }}
|
|
>
|
|
<select
|
|
value={draft.executionMode}
|
|
disabled={!editable}
|
|
onChange={(event) => onChange({
|
|
executionMode:
|
|
event.target.value as WorkflowDraft["executionMode"],
|
|
allowAutomation:
|
|
event.target.value === "guided"
|
|
? false
|
|
: draft.allowAutomation
|
|
})}
|
|
>
|
|
<option value="guided">Guided UI workflow</option>
|
|
<option value="automated">Automated workflow</option>
|
|
<option value="hybrid">Hybrid workflow</option>
|
|
</select>
|
|
</FormField>
|
|
<FormField
|
|
label="Workflow View"
|
|
help="The selected immutable View revision is applied for active runs. Individual steps may narrow it further."
|
|
documentation={{ topicId: "workflow.editor", documentationType: "user" }}
|
|
>
|
|
<select
|
|
value={draft.viewId}
|
|
disabled={!editable || !effectiveView}
|
|
onChange={(event) => {
|
|
const viewId = event.target.value;
|
|
const option = effectiveView?.availableViews.find(
|
|
(item) => item.id === viewId
|
|
);
|
|
onChange({
|
|
viewId,
|
|
viewRevisionId: option?.revisionId ?? ""
|
|
});
|
|
}}
|
|
>
|
|
<option value="">Use the current interface</option>
|
|
{draft.viewId
|
|
&& !effectiveView?.availableViews.some(
|
|
(item) => item.id === draft.viewId
|
|
) ? (
|
|
<option value={draft.viewId}>
|
|
Stored View (currently unavailable)
|
|
</option>
|
|
) : null}
|
|
{(effectiveView?.availableViews ?? []).map((view) => (
|
|
<option key={view.id} value={view.id}>
|
|
{view.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
<ContentGrid columns={2} gap="default" collapseAt="narrow">
|
|
<ToggleSwitch
|
|
label="Visible to lower scopes"
|
|
checked={draft.inheritToLowerScopes}
|
|
disabled={!editable}
|
|
onChange={(value) => onChange({ inheritToLowerScopes: value })}
|
|
/>
|
|
<ToggleSwitch
|
|
label="Allow starts"
|
|
checked={draft.allowStart}
|
|
disabled={!editable}
|
|
onChange={(value) => onChange({ allowStart: value })}
|
|
/>
|
|
<ToggleSwitch
|
|
label="Allow reuse"
|
|
checked={draft.allowReuse}
|
|
disabled={!editable}
|
|
onChange={(value) => onChange({ allowReuse: value })}
|
|
/>
|
|
<ToggleSwitch
|
|
label="Allow automation"
|
|
checked={draft.allowAutomation}
|
|
disabled={!editable || draft.executionMode === "guided"}
|
|
onChange={(value) => onChange({ allowAutomation: value })}
|
|
/>
|
|
</ContentGrid>
|
|
{draft.governance?.automation_runtime_reason ? (
|
|
<DismissibleAlert
|
|
tone="warning"
|
|
resetKey={draft.governance.automation_runtime_reason}
|
|
>
|
|
{draft.governance.automation_runtime_reason}
|
|
</DismissibleAlert>
|
|
) : null}
|
|
{draft.governance?.derived_from_definition_id ? (
|
|
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="workflow-provenance">
|
|
<strong>Derived from</strong>
|
|
<span>
|
|
{draft.governance.derived_from_definition_id}
|
|
{" · revision "}
|
|
{draft.governance.derived_from_revision}
|
|
</span>
|
|
<code>{draft.governance.derived_from_hash}</code>
|
|
</ContentSection>
|
|
) : null}
|
|
{provenance.length ? (
|
|
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="workflow-provenance">
|
|
<strong>Effective Policy path</strong>
|
|
{provenance.map((item, index) => (
|
|
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
|
|
{String(item.label ?? item.path ?? item.scope_type)}
|
|
</span>
|
|
))}
|
|
{!editable && draft.governance?.actions.edit?.reason ? (
|
|
<small>{draft.governance.actions.edit.reason}</small>
|
|
) : null}
|
|
</ContentSection>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function DeriveWorkflowDialog({
|
|
open,
|
|
settings,
|
|
definition,
|
|
onClose,
|
|
onDerived
|
|
}: {
|
|
open: boolean;
|
|
settings: ApiSettings;
|
|
definition: { id: string; name: string; revision: number } | null;
|
|
onClose: () => void;
|
|
onDerived: (definition: WorkflowDefinition) => void;
|
|
}) {
|
|
const [name, setName] = useState("");
|
|
const [kind, setKind] = useState<"flow" | "template">("flow");
|
|
const [scopeType, setScopeType] = useState<"tenant" | "group" | "user">("tenant");
|
|
const [scopeId, setScopeId] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const scopeProvider = useMemo(
|
|
() =>
|
|
workflowScopeReferenceProvider(
|
|
settings,
|
|
scopeType === "group" ? "group" : "user"
|
|
),
|
|
[
|
|
scopeType,
|
|
settings.accessToken,
|
|
settings.apiBaseUrl,
|
|
settings.apiKey
|
|
]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setName(`${definition?.name ?? "Workflow"} copy`);
|
|
setKind("flow");
|
|
setScopeType("tenant");
|
|
setScopeId("");
|
|
setError("");
|
|
}, [open, definition?.id]);
|
|
|
|
const derive = async () => {
|
|
if (!definition || !name.trim()) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
onDerived(await deriveWorkflowDefinition(settings, definition.id, {
|
|
name: name.trim(),
|
|
source_revision: definition.revision,
|
|
metadata: {},
|
|
scope_type: scopeType,
|
|
scope_id: scopeId.trim() || null,
|
|
definition_kind: kind,
|
|
inherit_to_lower_scopes: false,
|
|
allow_start: true,
|
|
allow_reuse: false,
|
|
allow_automation: false
|
|
}));
|
|
} catch (deriveError) {
|
|
setError(apiErrorMessage(deriveError));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
title="Reuse as scoped copy"
|
|
className="workflow-definition-dialog"
|
|
closeDisabled={busy}
|
|
onClose={onClose}
|
|
footer={(
|
|
<>
|
|
<Button onClick={onClose} disabled={busy}>Cancel</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => void derive()}
|
|
disabled={
|
|
busy
|
|
|| !definition
|
|
|| !name.trim()
|
|
|| (scopeType !== "tenant" && !scopeId)
|
|
}
|
|
>
|
|
<CopyPlus size={16} /> Create copy
|
|
</Button>
|
|
</>
|
|
)}
|
|
>
|
|
<div className="workflow-definition-fields">
|
|
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
|
<FormField label="Name">
|
|
<input value={name} onChange={(event) => setName(event.target.value)} />
|
|
</FormField>
|
|
<FormField label="Target scope">
|
|
<select
|
|
value={scopeType}
|
|
onChange={(event) => {
|
|
setScopeType(event.target.value as typeof scopeType);
|
|
setScopeId("");
|
|
}}
|
|
>
|
|
<option value="tenant">Tenant</option>
|
|
<option value="group">Group</option>
|
|
<option value="user">User</option>
|
|
</select>
|
|
</FormField>
|
|
{scopeType !== "tenant" ? (
|
|
<FormField label={scopeType === "user" ? "User" : "Group"}>
|
|
<ReferenceSelect
|
|
value={scopeId}
|
|
onChange={(value) => setScopeId(value)}
|
|
provider={scopeProvider}
|
|
aria-label={
|
|
scopeType === "user" ? "Copy target user" : "Copy target group"
|
|
}
|
|
placeholder={
|
|
scopeType === "user" ? "Select a user" : "Select a group"
|
|
}
|
|
disabled={busy}
|
|
required
|
|
/>
|
|
</FormField>
|
|
) : null}
|
|
<FormField label="Copy kind">
|
|
<select value={kind} onChange={(event) => setKind(event.target.value as typeof kind)}>
|
|
<option value="flow">Complete flow</option>
|
|
<option value="template">Template</option>
|
|
</select>
|
|
</FormField>
|
|
<small>
|
|
The source graph revision, node-library version, content hash, and
|
|
effective ancestor limits are retained as provenance.
|
|
</small>
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function startPaletteDrag(
|
|
event: DragEvent<HTMLButtonElement>,
|
|
type: string
|
|
) {
|
|
event.dataTransfer.setData("application/x-govoplan-workflow-node", type);
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
}
|
|
|
|
function fileName(value: string): string {
|
|
return value
|
|
.normalize("NFKD")
|
|
.replace(/[^\w.-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.toLowerCase() || "workflow";
|
|
}
|
|
|
|
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;
|
|
}
|