Implement native BPMN workflows and guided modes

This commit is contained in:
2026-07-31 02:48:57 +02:00
parent c505e81006
commit f4974b4949
40 changed files with 8203 additions and 489 deletions
@@ -23,8 +23,13 @@ import {
FormField,
IconButton,
LoadingFrame,
StageRail,
StatusBadge,
type ApiSettings
dispatchWorkflowViewChanged,
usePlatformUiCapability,
type StageRailTone,
type ApiSettings,
type ViewsRuntimeUiCapability
} from "@govoplan/core-webui";
import {
cancelWorkflowInstance,
@@ -50,6 +55,7 @@ export default function WorkflowRunsDialog({
open,
settings,
definition,
initialInstanceId,
canStart,
canTransition,
onClose
@@ -57,6 +63,7 @@ export default function WorkflowRunsDialog({
open: boolean;
settings: ApiSettings;
definition: WorkflowDefinition | null;
initialInstanceId?: string | null;
canStart: boolean;
canTransition: boolean;
onClose: () => void;
@@ -69,6 +76,9 @@ export default function WorkflowRunsDialog({
const [comment, setComment] = useState("");
const [evidence, setEvidence] = useState("");
const [cancelOpen, setCancelOpen] = useState(false);
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>(
"views.runtime"
);
const selected = useMemo(
() => instances.find((item) => item.id === selectedId) ?? instances[0] ?? null,
@@ -99,7 +109,10 @@ export default function WorkflowRunsDialog({
const items = await listWorkflowInstances(settings, definition.id);
setInstances(items);
setSelectedId((current) => (
items.some((item) => item.id === current)
initialInstanceId
&& items.some((item) => item.id === initialInstanceId)
? initialInstanceId
: items.some((item) => item.id === current)
? current
: items[0]?.id ?? null
));
@@ -108,7 +121,7 @@ export default function WorkflowRunsDialog({
} finally {
setLoading(false);
}
}, [definition?.id, open, settings]);
}, [definition?.id, initialInstanceId, open, settings]);
useEffect(() => {
if (!open) return;
@@ -117,6 +130,39 @@ export default function WorkflowRunsDialog({
void load();
}, [load, open]);
useEffect(() => {
if (!open || !selected) return;
const context = selected.view_context;
if (!context || !viewsRuntime) {
dispatchWorkflowViewChanged(null);
return;
}
let cancelled = false;
void viewsRuntime.resolveWorkflowView(settings, {
viewId: context.view_id,
revisionId: context.revision_id,
visibleSurfaceIds: context.visible_surface_ids
}).then((projection) => {
if (!cancelled) {
dispatchWorkflowViewChanged(projection, selected.id);
}
}).catch((viewError) => {
if (!cancelled) {
dispatchWorkflowViewChanged(null);
setError(errorMessage(viewError));
}
});
return () => {
cancelled = true;
};
}, [
open,
selected?.id,
selected?.updated_at,
settings,
viewsRuntime
]);
useEffect(() => {
if (
!open
@@ -236,6 +282,10 @@ export default function WorkflowRunsDialog({
const actionUrl = typeof currentStep?.handoff.action_url === "string"
? currentStep.handoff.action_url
: "";
const close = () => {
dispatchWorkflowViewChanged(null);
onClose();
};
return (
<>
@@ -244,8 +294,8 @@ export default function WorkflowRunsDialog({
title={`Runs${definition ? ` · ${definition.name}` : ""}`}
className="workflow-runs-dialog"
bodyClassName="workflow-runs-dialog-body"
onClose={onClose}
footer={<Button onClick={onClose}>Close</Button>}
onClose={close}
footer={<Button onClick={close}>Close</Button>}
>
<div className="workflow-runs-toolbar">
<span>
@@ -419,40 +469,26 @@ export default function WorkflowRunsDialog({
) : null}
<section className="workflow-run-history">
<h3>Progress</h3>
<div>
{selected.steps.map((step) => (
<div
key={step.id}
className="workflow-run-stage"
data-state={step.status}
data-current={
step.id === selected.current_step_id || undefined
}
>
<span className="workflow-run-stage-marker">
{step.status === "completed" ? (
<Check size={15} aria-hidden="true" />
) : step.status === "failed" ? (
<AlertTriangle size={15} aria-hidden="true" />
) : ["running", "waiting"].includes(step.status) ? (
<Clock3 size={15} aria-hidden="true" />
) : (
<Circle size={13} aria-hidden="true" />
)}
</span>
<span className="workflow-run-stage-copy">
<strong>{step.node_id}</strong>
<small>
{step.node_type} · attempt {step.attempt}
</small>
<StatusBadge
status={step.status}
label={step.status}
/>
</span>
</div>
))}
</div>
<StageRail
ariaLabel="Workflow instance progress"
items={selected.steps.map((step) => ({
id: step.id,
label: step.node_id,
detail: `${step.node_type} · attempt ${step.attempt}`,
statusLabel: step.status,
current: step.id === selected.current_step_id,
tone: stepTone(step.status),
icon: step.status === "completed" ? (
<Check size={15} aria-hidden="true" />
) : step.status === "failed" ? (
<AlertTriangle size={15} aria-hidden="true" />
) : ["running", "waiting"].includes(step.status) ? (
<Clock3 size={15} aria-hidden="true" />
) : (
<Circle size={13} aria-hidden="true" />
)
}))}
/>
</section>
<section className="workflow-run-events">
<h3>Evidence trail</h3>
@@ -530,6 +566,15 @@ function actionLabel(action: WorkflowAction): string {
}[action];
}
function stepTone(
status: WorkflowInstanceStep["status"]
): StageRailTone {
if (status === "completed") return "success";
if (status === "running" || status === "waiting") return "active";
if (status === "failed" || status === "cancelled") return "danger";
return "neutral";
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",