Implement native BPMN workflows and guided modes
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent
|
||||
} from "react";
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
CopyPlus,
|
||||
Download,
|
||||
GitFork,
|
||||
ListChecks,
|
||||
Plus,
|
||||
@@ -16,9 +18,11 @@ import {
|
||||
RotateCcw,
|
||||
Save,
|
||||
Settings2,
|
||||
Trash2
|
||||
Trash2,
|
||||
Upload
|
||||
} from "lucide-react";
|
||||
import { ReactFlowProvider } from "@xyflow/react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import {
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
@@ -34,23 +38,27 @@ import {
|
||||
isApiError,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
useEffectiveView,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
activateWorkflowDefinition,
|
||||
archiveWorkflowDefinition,
|
||||
compileWorkflowBpmn,
|
||||
createWorkflowDefinition,
|
||||
deleteWorkflowDefinition,
|
||||
deriveWorkflowDefinition,
|
||||
listWorkflowDefinitions,
|
||||
listWorkflowNodeTypes,
|
||||
listWorkflowRevisions,
|
||||
renderWorkflowBpmn,
|
||||
updateWorkflowDefinition,
|
||||
validateWorkflowDefinition,
|
||||
workflowScopeReferenceProvider,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowDiagnostic,
|
||||
type WorkflowGraphEdge,
|
||||
type WorkflowNodeType,
|
||||
type WorkflowRevision
|
||||
} from "../../api/workflow";
|
||||
@@ -69,12 +77,12 @@ import {
|
||||
} from "./model";
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"trigger",
|
||||
"activity",
|
||||
"decision",
|
||||
"wait",
|
||||
"integration",
|
||||
"outcome"
|
||||
"bpmn_event",
|
||||
"bpmn_activity",
|
||||
"bpmn_gateway",
|
||||
"bpmn_data",
|
||||
"bpmn_collaboration",
|
||||
"bpmn_artifact"
|
||||
];
|
||||
|
||||
export default function WorkflowPage({
|
||||
@@ -85,6 +93,9 @@ export default function WorkflowPage({
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const { 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);
|
||||
@@ -96,6 +107,7 @@ export default function WorkflowPage({
|
||||
);
|
||||
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);
|
||||
@@ -106,6 +118,7 @@ export default function WorkflowPage({
|
||||
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
|
||||
const [deriveOpen, setDeriveOpen] = useState(false);
|
||||
const [runsOpen, setRunsOpen] = useState(false);
|
||||
const bpmnFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const canWrite = hasScope(auth, "workflow:definition:write")
|
||||
|| hasScope(auth, "workflow:instance:admin");
|
||||
@@ -133,10 +146,15 @@ export default function WorkflowPage({
|
||||
&& 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;
|
||||
@@ -162,6 +180,7 @@ export default function WorkflowPage({
|
||||
setSavedDraft(structuredClone(next));
|
||||
setHistoricalRevision(null);
|
||||
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
||||
setSelectedEdgeId(null);
|
||||
setDiagnostics([]);
|
||||
}, []);
|
||||
|
||||
@@ -189,7 +208,7 @@ export default function WorkflowPage({
|
||||
}, [applyDefinition, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
void reload(requestedDefinitionId);
|
||||
let cancelled = false;
|
||||
void listWorkflowNodeTypes(settings)
|
||||
.then((library) => {
|
||||
@@ -203,7 +222,17 @@ export default function WorkflowPage({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reload, settings]);
|
||||
}, [reload, requestedDefinitionId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
requestedRunId
|
||||
&& draft?.id
|
||||
&& (!requestedDefinitionId || draft.id === requestedDefinitionId)
|
||||
) {
|
||||
setRunsOpen(true);
|
||||
}
|
||||
}, [draft?.id, requestedDefinitionId, requestedRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft?.id) {
|
||||
@@ -232,6 +261,7 @@ export default function WorkflowPage({
|
||||
setDraft(next);
|
||||
setHistoricalRevision(null);
|
||||
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
||||
setSelectedEdgeId(null);
|
||||
setDiagnostics([]);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
@@ -305,6 +335,7 @@ export default function WorkflowPage({
|
||||
setRevisions([]);
|
||||
setHistoricalRevision(null);
|
||||
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
|
||||
setSelectedEdgeId(null);
|
||||
setDiagnostics([]);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
@@ -315,10 +346,11 @@ export default function WorkflowPage({
|
||||
if (!displayedGraph) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const result = await validateWorkflowDefinition(settings, displayedGraph);
|
||||
setDiagnostics(result.diagnostics);
|
||||
setSuccess(result.valid ? "Workflow definition is valid." : "");
|
||||
setSuccess(result.valid ? "BPMN workflow graph is valid." : "");
|
||||
} catch (validationError) {
|
||||
setError(apiErrorMessage(validationError));
|
||||
} finally {
|
||||
@@ -326,6 +358,73 @@ export default function WorkflowPage({
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -382,14 +481,14 @@ export default function WorkflowPage({
|
||||
};
|
||||
|
||||
const updateGraph = (graph: WorkflowDraft["graph"]) => {
|
||||
if (readOnly) return;
|
||||
if (graphReadOnly) return;
|
||||
setDraft((current) => current ? { ...current, graph } : current);
|
||||
setDiagnostics([]);
|
||||
setSuccess("");
|
||||
};
|
||||
|
||||
const removeNode = (nodeId: string) => {
|
||||
if (!draft || readOnly) return;
|
||||
if (!draft || graphReadOnly) return;
|
||||
updateGraph({
|
||||
...draft.graph,
|
||||
nodes: draft.graph.nodes.filter((node) => node.id !== nodeId),
|
||||
@@ -400,6 +499,25 @@ export default function WorkflowPage({
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="workflow-page">
|
||||
<div className="workflow-shell">
|
||||
@@ -501,21 +619,9 @@ export default function WorkflowPage({
|
||||
?? 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([]);
|
||||
}}
|
||||
onChange={(event) => selectRevision(
|
||||
Number(event.target.value)
|
||||
)}
|
||||
aria-label="Workflow revision"
|
||||
>
|
||||
{revisions.map((revision) => (
|
||||
@@ -533,7 +639,11 @@ export default function WorkflowPage({
|
||||
onClick={() => {
|
||||
setDraft({
|
||||
...draft,
|
||||
graph: structuredClone(historicalRevision.graph)
|
||||
graph: structuredClone(historicalRevision.graph),
|
||||
executionMode: historicalRevision.execution_mode,
|
||||
viewId: historicalRevision.view_id ?? "",
|
||||
viewRevisionId:
|
||||
historicalRevision.view_revision_id ?? ""
|
||||
});
|
||||
setHistoricalRevision(null);
|
||||
setDiagnostics([]);
|
||||
@@ -543,6 +653,30 @@ export default function WorkflowPage({
|
||||
<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>
|
||||
@@ -600,7 +734,9 @@ export default function WorkflowPage({
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveDraft()}
|
||||
disabled={!canEdit || !dirty || working || readOnly}
|
||||
disabled={
|
||||
!canEdit || !dirty || working || readOnly
|
||||
}
|
||||
>
|
||||
<Save size={16} /> Save
|
||||
</Button>
|
||||
@@ -628,93 +764,98 @@ export default function WorkflowPage({
|
||||
) : 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>
|
||||
<aside className="workflow-palette">
|
||||
<div className="workflow-panel-heading">
|
||||
<span>
|
||||
<strong>Node library</strong>
|
||||
<small>Drag nodes onto the canvas</small>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<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={!graphReadOnly}
|
||||
disabled={graphReadOnly}
|
||||
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}
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
{working ? (
|
||||
<div className="workflow-working-indicator" role="status">
|
||||
Working...
|
||||
@@ -775,9 +916,17 @@ export default function WorkflowPage({
|
||||
open={runsOpen}
|
||||
settings={settings}
|
||||
definition={selectedDefinition}
|
||||
initialInstanceId={requestedRunId}
|
||||
canStart={canStart}
|
||||
canTransition={canTransition}
|
||||
onClose={() => setRunsOpen(false)}
|
||||
onClose={() => {
|
||||
setRunsOpen(false);
|
||||
if (requestedRunId) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("run");
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -799,6 +948,7 @@ function WorkflowDefinitionSettingsDialog({
|
||||
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),
|
||||
@@ -881,6 +1031,61 @@ function WorkflowDefinitionSettingsDialog({
|
||||
<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."
|
||||
>
|
||||
<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."
|
||||
>
|
||||
<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>
|
||||
<div className="workflow-definition-toggles">
|
||||
<ToggleSwitch
|
||||
label="Visible to lower scopes"
|
||||
@@ -903,7 +1108,7 @@ function WorkflowDefinitionSettingsDialog({
|
||||
<ToggleSwitch
|
||||
label="Allow automation"
|
||||
checked={draft.allowAutomation}
|
||||
disabled={!editable}
|
||||
disabled={!editable || draft.executionMode === "guided"}
|
||||
onChange={(value) => onChange({ allowAutomation: value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -1094,6 +1299,14 @@ function startPaletteDrag(
|
||||
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.";
|
||||
|
||||
Reference in New Issue
Block a user