Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions studio/docs/specs/react-studio-preview/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,22 @@ Preview descriptions/tags, notices, all three frameworks and embedded assets;
an actual 3.10.0 pack produces no React packages. Existing publication-selection
and workflow-gate checks still pass. The try guide and release preparation notes
are under Studio docs. This prepares packages without publishing them.

## BPMN interchange

Workflow definitions now imports BPMN through the existing server interchange API,
shows Info/Degraded/Dropped findings and requires process selection for multi-process
documents. Structured capability and expression-condition refusals retain server
messages and offending IDs. Row actions export after a binding/expression warning,
with absent/unknown-version/stale-source refusals. Browser tests cover the flow,
cancellation and download/reimport. A disposable real Core check verifies process
and flow preservation, stable canonical XML after reimport, structured condition
refusal and cleanup. Server export can normalize XML and add missing layout, so
original byte equality is not asserted. Editable BPMN canvas work remains #8929.

BPMN review also verifies the native `Elsa.BpmnProcess` route. It opens a dedicated
read-only view outside the generic designer, preventing normalization and accidental
autosave before #8929 provides a native canvas. An actual Core-imported process
retains its exact root after opening and advancing beyond the autosave interval,
and still exports. Browser cases abort pending analysis/export on cancellation
and reject late navigation/download effects.
1 change: 1 addition & 0 deletions studio/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ New to the project? Start with the [Getting started guide](docs/getting-started.
| [Configuration](docs/configuration.md) | **Adding servers**, authentication providers, feature flags, theming, environment variables. |
| [Modules](docs/modules.md) | What every module does, how to enable it, and how to build a new one. |
| [Module authoring](docs/module-authoring.md) | Generate, package and verify an independent extension. |
| [BPMN interchange](docs/bpmn.md) | Import findings/refusals and export imported workflows. |
| [Parity audit](docs/parity.md) | Verified editor behavior, evidence boundaries and linked Preview gaps. |
| [Activity settings](docs/activity-settings.md) | Commit, log persistence, resilience, task and version settings; instance retry history. |
| [Designer](docs/designer.md) | Containers, the quick-add palette, data flow, call context, keyboard shortcuts. |
Expand Down
48 changes: 48 additions & 0 deletions studio/react/docs/bpmn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# BPMN import and export

From Workflow definitions, choose **Import BPMN**, select a `.bpmn` file up to
10 MB, and review the server's Info, Degraded and Dropped findings. Element and
process IDs identify affected parts. Dropped elements will not run; Degraded
elements behave differently from their authored definition. A document with more
than one process requires an explicit process selection before **Import** enables.
Canceling the dialog persists nothing. Confirming imports the same selected file
and opens a read-only view of the new draft. This view preserves the native
BPMN root and never normalizes or autosaves it. Export remains available; canvas
editing is separate work in #8929.

The API remains the source of truth for supported BPMN capabilities. Capability
refusals list missing capabilities and offending elements. Unsupported expression
conditions list sequence-flow IDs and explain why unconditional execution would
change routing. These messages use stable server error codes and retain the
server's explanation. Unknown codes and older-server errors show the ordinary
server error rather than guessing their meaning.

Use a definition row's **Actions → Export BPMN** to download the latest imported
document. The confirmation warns that bindings and expressions are included.
Export is refused when BPMN source is absent, its recorded version is unknown,
or it has become stale after workflow edits. The browser downloads the server's
XML bytes; the server may normalize XML and add missing diagram layout. Reimport
preserves the process/flow model, and a subsequent export is stable. It does not
promise byte equality with an original document that omitted layout.

Import requires `workflows/definitions:write`, export/analyze require `:view`,
and the backend must have BPMN interchange enabled. Existing read-only server
configuration hides the import action. Requests share the configured API origin,
authentication and tenant headers; closing a dialog cancels its outstanding request.

The public API client exposes `client.bpmn.analyze(file, signal)`,
`client.bpmn.import(file, options, signal)` and
`client.bpmn.export(definitionId, signal)`, with typed analysis/results and optional
process/name/definition IDs for API consumers. The UI imports new drafts, so it
does not overwrite an existing definition.

Browser tests cover findings, process choice, cancellation of pending analysis and
export requests, native-root preservation without autosave, capability/condition
refusals, stale/missing-source refusals, unknown error fallback and download/reimport.
To check a disposable real backend, set `ELSA_TEST_API_URL`, `ELSA_TEST_USERNAME`
and `ELSA_TEST_PASSWORD`, then run `pnpm test:bpmn`. This checks analysis,
import/export/reimport, stable canonical XML, structured expression-condition
refusal and cleanup of its temporary definitions.

This implements interchange for [#8853](https://github.com/elsa-workflows/elsa-core/issues/8853).
An editable BPMN diagram canvas is separate work in #8929.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useState } from "react";
import Link from "@elsa-workflows/react-studio-sdk/navigation/link";
import type { WorkflowDefinition } from "@elsa-workflows/react-studio-api-client/types/workflow-definitions";
import { Button } from "@elsa-workflows/react-studio-ui/button";
import { PageHeader } from "@elsa-workflows/react-studio-ui/common/page-header";
import { JsonViewer } from "@elsa-workflows/react-studio-ui/common/json-viewer";
import { BpmnExportDialog } from "./bpmn-dialogs";

/** The generic designer normalizes unknown roots. Keep BPMN outside that workspace
* until the dedicated canvas in #8929 can edit its native process model. */
export function BpmnDefinitionView({ definition }: { definition: WorkflowDefinition }) {
const [exporting, setExporting] = useState(false);
return <div className="p-4">
<PageHeader title={definition.name ?? definition.definitionId} eyebrow="Read-only BPMN definition"
description="This Preview preserves the BPMN process without editing or autosaving it. BPMN canvas editing is not available yet."
actions={<><Link href="/workflows/definitions">Workflow definitions</Link>{definition.isLatest && <Button onClick={() => setExporting(true)}>Export BPMN</Button>}</>} />
<p>Version {definition.version}</p>
<JsonViewer value={definition} />
{exporting && <BpmnExportDialog definitionId={definition.definitionId} onClose={() => setExporting(false)} />}
</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from "react";
import { useElsaClient } from "@elsa-workflows/react-studio-api-client/react";
import { ApiError } from "@elsa-workflows/react-studio-api-client/http";
import type { BpmnImportAnalysis, BpmnImportResult } from "@elsa-workflows/react-studio-api-client/types/bpmn";
import { Button } from "@elsa-workflows/react-studio-ui/button";
import { Input } from "@elsa-workflows/react-studio-ui/input";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@elsa-workflows/react-studio-ui/dialog";
import { ErrorAlert } from "@elsa-workflows/react-studio-ui/common/error-alert";
import { downloadBlob } from "@elsa-workflows/react-studio-sdk/utils/download";

/** Stable server codes select explanations; unknown envelopes retain the server message. */
function Refusal({ error }: { error: unknown }) {
const raw = error instanceof ApiError ? error.problem.raw : undefined;
const envelope = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
const data = envelope.data && typeof envelope.data === "object" ? envelope.data as Record<string, unknown> : {};
const strings = (value: unknown) => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const capabilities = strings(data.capabilities);
const elements = strings(data.elementIds);
const messages: Record<string, string> = {
"bpmn.import.capability-unsupported": "This deployment does not support every BPMN capability the document requires. Import was refused.",
"bpmn.import.condition-unsupported": "These sequence flows carry expression conditions that Elsa does not evaluate. Import was refused to prevent unconditional routing. Remove the conditions or use conditionOutcome.",
"bpmn.export.not-imported": "This workflow has no recorded BPMN source to export.",
"bpmn.export.source-version-unknown": "The recorded BPMN source version is unknown, so a current export cannot be verified.",
"bpmn.export.source-stale": "The workflow changed after import. Export was refused because the recorded BPMN source is stale.",
};
const message = typeof envelope.code === "string" ? messages[envelope.code] : undefined;
return <>
{message && <p role="alert">{message}</p>}
{!!capabilities.length && <div><h3>Missing capabilities</h3><ul>{capabilities.map(item => <li key={item}>{item}</li>)}</ul></div>}
{!!elements.length && <div><h3>{envelope.code === "bpmn.import.condition-unsupported" ? "Conditional sequence flows" : "Offending elements"}</h3><ul>{elements.map(item => <li key={item}>{item}</li>)}</ul></div>}
<ErrorAlert error={error} title="BPMN request failed" />
</>;
}

function useRequest() {
const request = useRef<AbortController | undefined>(undefined);
useEffect(() => () => request.current?.abort(), []);
return () => { request.current?.abort(); return (request.current = new AbortController()).signal; };
}

/** Mounted only while open: closing cancels work and discards the selected document. */
export function BpmnImportDialog({ onClose, onImported }: { onClose: () => void; onImported: (result: BpmnImportResult) => void }) {
const client = useElsaClient();
const begin = useRequest();
const [file, setFile] = useState<File>();
const [analysis, setAnalysis] = useState<BpmnImportAnalysis>();
const [processId, setProcessId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>();
const analyze = async (document: File | undefined) => {
setAnalysis(undefined); setError(undefined); setFile(undefined); setProcessId("");
if (!document) return;
if (!document.name.toLowerCase().endsWith(".bpmn") || document.size > 10 * 1024 * 1024) { setError(new Error("Select a .bpmn document no larger than 10 MB.")); return; }
setBusy(true);
try {
const result = await client.bpmn.analyze(document, begin());
setFile(document); setAnalysis(result); setProcessId(result.processIds.length === 1 ? result.processIds[0] : "");
} catch (failure) { setError(failure); } finally { setBusy(false); }
};
const submit = async () => {
if (!file || !analysis || (analysis.processIds.length > 1 && !processId)) return;
setBusy(true); setError(undefined);
try { onImported(await client.bpmn.import(file, { processId: processId || undefined }, begin())); }
catch (failure) { setError(failure); } finally { setBusy(false); }
};
return <Dialog open onOpenChange={open => { if (!open) onClose(); }}><DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader><DialogTitle>Import BPMN</DialogTitle><DialogDescription>Review findings before importing. Dropped elements will not run; Degraded elements do not run exactly as authored.</DialogDescription></DialogHeader>
<label htmlFor="bpmn-file">BPMN document (up to 10 MB)</label>
<Input id="bpmn-file" type="file" accept=".bpmn" disabled={busy} onChange={event => void analyze(event.target.files?.[0])} />
{busy && <p role="status">Processing BPMN document…</p>}
<Refusal error={error} />
{analysis && <>
{analysis.processIds.length > 1 && <label>Process<select aria-label="Process" value={processId} disabled={busy} onChange={event => setProcessId(event.target.value)}><option value="">Select a process</option>{analysis.processIds.map(id => <option key={id} value={id}>{id}</option>)}</select></label>}
{analysis.issues.length === 0 ? <p>No findings. This document reads without loss.</p> : [...new Set(analysis.issues.map(issue => issue.severity))].map(severity => <section key={severity}><h3>{severity}</h3><ul>{analysis.issues.filter(issue => issue.severity === severity).map((issue, index) => <li key={index}>{issue.elementId && <strong>[{issue.elementId}] </strong>}{issue.message}{issue.processId && <span> (process {issue.processId})</span>}</li>)}</ul></section>)}
</>}
<DialogFooter><Button variant="outline" onClick={onClose}>Cancel</Button><Button disabled={busy || !analysis || (analysis.processIds.length > 1 && !processId)} onClick={() => void submit()}>Import</Button></DialogFooter>
</DialogContent></Dialog>;
}

export function BpmnExportDialog({ definitionId, onClose }: { definitionId: string; onClose: () => void }) {
const client = useElsaClient();
const begin = useRequest();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>();
const submit = async () => {
setBusy(true); setError(undefined);
try { downloadBlob(await client.bpmn.export(definitionId, begin()), `${definitionId}.bpmn`); onClose(); }
catch (failure) { setError(failure); } finally { setBusy(false); }
};
return <Dialog open onOpenChange={open => { if (!open) onClose(); }}><DialogContent>
<DialogHeader><DialogTitle>Export BPMN</DialogTitle><DialogDescription>The exported file contains this workflow&apos;s binding configuration and expressions. Only current, unchanged imported BPMN source can be exported.</DialogDescription></DialogHeader>
<Refusal error={error} />
<DialogFooter><Button variant="outline" onClick={onClose}>Cancel</Button><Button disabled={busy} onClick={() => void submit()}>{busy ? "Exporting…" : "Export"}</Button></DialogFooter>
</DialogContent></Dialog>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useUrlState } from "@elsa-workflows/react-studio-sdk/utils/url-state";
import { Network } from "lucide-react";
import { ActivityToolbox, childActivities, createDesignerStore, DesignerProvider, DesignerSurface, emptyFlowchart, indexDataFlow, isDesignableContainer, useActivityRegistry } from "@elsa-workflows/react-studio-sdk/designer/index";
import { CodeView } from "../components/code-view";
import { BpmnDefinitionView } from "../components/bpmn-definition-view";
import { EditorHeader, type EditorViewState } from "../components/editor-header";
import { EditorSidePane } from "../components/editor-side-pane";
import { RunWorkflowDialog } from "../components/run-workflow-dialog";
Expand Down Expand Up @@ -48,7 +49,9 @@ export function WorkflowDefinitionEditorPage({ params }: { params: RouteParams }
const fromId = url.get(FROM_PARAM);
const { registry, isLoading: registryLoading, error: registryError } = useActivityRegistry();

if (definition.isLoading || registryLoading) return <FullPageSpinner label="Loading workflow…" />;
if (definition.isLoading) return <FullPageSpinner label="Loading workflow…" />;
if (!definition.error && definition.data?.root?.type === "Elsa.BpmnProcess") return <BpmnDefinitionView definition={definition.data} />;
if (registryLoading) return <FullPageSpinner label="Loading workflow…" />;
if (definition.error || registryError) return <ErrorAlert error={definition.error ?? registryError} title="Could not load the workflow" />;
if (!definition.data) return null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { Resources, usePermissions, Verbs } from "@elsa-workflows/react-studio-a
import { downloadBlob, pickFiles } from "@elsa-workflows/react-studio-sdk/utils/download";
import { useUrlState } from "@elsa-workflows/react-studio-sdk/utils/url-state";
import { useElsaClient } from "@elsa-workflows/react-studio-api-client/react";
import { BpmnExportDialog, BpmnImportDialog } from "../components/bpmn-dialogs";
import { CreateWorkflowDialog } from "../components/create-workflow-dialog";
import { RunWorkflowDialog } from "../components/run-workflow-dialog";
import { useWorkflowDefinitionMutations, useWorkflowDefinitions } from "../hooks";
Expand All @@ -56,6 +57,8 @@ export function WorkflowDefinitionsListPage() {

const [selected, setSelected] = useState<Set<string>>(new Set());
const [createOpen, setCreateOpen] = useState(() => url.get("create") === "1");
const [bpmnImportOpen, setBpmnImportOpen] = useState(false);
const [bpmnExportId, setBpmnExportId] = useState<string>();
const [runTarget, setRunTarget] = useState<WorkflowDefinitionSummary | null>(null);

const latest = useWorkflowDefinitions({
Expand Down Expand Up @@ -222,6 +225,7 @@ export function WorkflowDefinitionsListPage() {
<DropdownMenuItem onClick={() => void exportDefinition(d)}>
<Download /> Export
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setBpmnExportId(d.definitionId)}><Download /> Export BPMN</DropdownMenuItem>
{!listReadOnly && (canPublish || canRetract) && <DropdownMenuSeparator />}
{!listReadOnly && canPublish && !d.isPublished && (
<DropdownMenuItem onClick={() => void run("Workflow published", () => mutations.publish.mutateAsync(d.definitionId))}>
Expand Down Expand Up @@ -280,6 +284,7 @@ export function WorkflowDefinitionsListPage() {
</DropdownMenuContent>
</DropdownMenu>
)}
{permissions.has(Resources.workflowDefinitions, Verbs.write) && !listReadOnly && <Button variant="outline" onClick={() => setBpmnImportOpen(true)}><FileUp /> Import BPMN</Button>}
{canCreate && !listReadOnly && (
<div className="flex">
<Button className="rounded-r-none" onClick={() => setCreateOpen(true)}>
Expand Down Expand Up @@ -335,6 +340,8 @@ export function WorkflowDefinitionsListPage() {
onRowClick={(d) => router.push(href(WorkflowDefinitionRoutes.edit, { definitionId: d.definitionId }))}
/>

{bpmnImportOpen && <BpmnImportDialog onClose={() => setBpmnImportOpen(false)} onImported={result => { setBpmnImportOpen(false); void latest.refetch(); router.push(href(WorkflowDefinitionRoutes.edit, { definitionId: result.definitionId })); }} />}
Comment thread
sfmskywalker marked this conversation as resolved.
{bpmnExportId && <BpmnExportDialog definitionId={bpmnExportId} onClose={() => setBpmnExportId(undefined)} />}
<CreateWorkflowDialog open={createOpen} onOpenChange={setCreateOpen} />
<RunWorkflowDialog definition={runTarget} onOpenChange={(o) => !o && setRunTarget(null)} />
</div>
Expand Down
1 change: 1 addition & 0 deletions studio/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"docs:api": "pnpm build:packages && typedoc",
"test:packages": "pnpm build:packages && node scripts/test-packed-packages.mjs",
"test:module": "pnpm build:packages && node scripts/test-module-template.mjs",
"test:bpmn": "pnpm build:packages && node scripts/test-bpmn-roundtrip.mjs",
"check:boundaries": "node scripts/check-module-boundaries.mjs"
},
"dependencies": {
Expand Down
8 changes: 8 additions & 0 deletions studio/react/packages/api-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,11 @@ returns paged retry records; the ID is an activity execution record ID, not a gr
activity ID. Both methods share the HTTP client's current authentication and tenant.
`CommitStrategyDescriptor` models the commit catalogs' `name` field; other strategy
catalogs retain their `typeName` field.

## BPMN interchange

`client.bpmn.analyze(file, signal)` returns process IDs, element counts and typed
findings. `client.bpmn.import(file, { processId, name, definitionId }, signal)`
uses multipart upload; every option is optional. `client.bpmn.export(id, signal)`
returns an XML Blob. Requests use the shared auth, tenant and cancellation behavior.
`ApiError.problem.raw` preserves the server's stable refusal code/data envelope.
Loading
Loading