Skip to content

feat(workflows): bind BPMN tasks to Elsa activities from a Performed by section - #1031

Merged
sfmskywalker merged 11 commits into
mainfrom
claude/bpmn-w11-binding-ux
Sep 12, 2026
Merged

sfmskywalker merged 11 commits into
mainfrom
claude/bpmn-w11-binding-ux

Conversation

@sfmskywalker

Copy link
Copy Markdown
Member

Closes #1001. Part of elsa-workflows/elsa-core#7909 (W11, D5). Builds on elsa-workflows/elsa-core#8060 (document GET/PUT), #8063 (PUT preserves metadata), #8065 (content-based staleness), #8067 (refusal codes), and Studio #1029.

What changed

A BPMN task can now be bound to the Elsa activity that performs it without hand-editing XML.

  • Performed by. Selecting an authored task (task, serviceTask, userTask, scriptTask, manualTask, businessRuleTask, sendTask, and a receiveTask that names no message, following the library reader's own rule) shows a Performed-by section in place of the activity properties panel. The existing activity picker is filtered to leaf work, and the picked activity's own input editors render inline, exactly as in the flowchart panel. Elements the binder binds automatically (timers, message and signal waits, message publish, call activities, subprocesses) show their binding read-only.
  • Written into the document, in place. The section edits a working copy of the whole bpmnDefinitions document fetched with GET bpmn/definitions/{id}/document. It sets only that element's elsa:activityBinding, in the exact format elsa-core's BpmnActivityBindingFormat reads. Input JSON is what the flowchart's input editors already produce; there is no second encoder. A test compares the document before and after: only the target element's extension changes.
  • Saved only through the document PUT. Save, including toolbar Save and Ctrl+S, sends the document with If-Match set to the ETag it was read at, then reloads the definition. For a BPMN-imported workflow, WorkflowEditor never reads the designer into an ordinary save. It refuses any ordinary save whose root differs from the one the server last delivered, so a graph change cannot slip through the ordinary save and make the document stale. Property-only saves (name, variables) still go through the ordinary path. Publishing with unsaved binding changes is refused.
  • Refusals. A 412 keeps the working copy, explains that someone else changed the workflow, and offers Reload with no blind retry. A 428 shows a message and Reload. A 422 bpmn.import.binding-invalid shows the server's message against the edited tasks. A GET 422 for a stale source explains that the workflow must be re-imported and offers no editing.
  • Client: IBpmnInterchangeApi / IBpmnInterchangeService gain the document GET and PUT with ETag / If-Match.

Known limitations

  • Documents with subprocesses are read-only in Studio. elsa-core's document GET/PUT currently drops the content of subprocesses, transactions and event subprocesses: the document JSON lists only top-level processes, and nested bodies are not written back. Studio refuses to PUT such a document before sending anything, and explains why. The core fix is filed and is next in this run.
  • Publish-gate element ids are not clickable. W8 sends them only in message text, so the server's messages are shown as they are. Making them clickable needs the ids as structured data from core.
  • Out of scope: the optional vendor-extension suggestion on import, visual editing (W14), and whether import accepts unbound tasks (a pending maintainer decision; a save can never leave a task unbound).

Verification

cd src/modules/Elsa.Studio.Workflows.Designer/ClientLib && npm run check:generated && npm test && npm run build   # 253/253
dotnet build Elsa.Studio.sln --configuration Release                                                                   # 0 errors
dotnet test src/modules/Elsa.Studio.Workflows.Tests --configuration Release --no-build --framework net10.0            # 434/434
dotnet test src/modules/Elsa.Studio.Workflows.Designer.Tests --configuration Release --no-build --framework net10.0   # 83/83

Tests cover:

  • The ordinary editor save is never called for a binding edit on a BPMN root; disabling the guard fails 5 of 11 routing tests.
  • The PUT carries the GET's ETag.
  • Only the target element's extension changes.
  • An Input<string> expression and a plain [Input] collection round-trip through the binding format.
  • Each refusal produces its UI outcome.
  • The subprocess refusal happens before any request is sent.
  • A reload follows a successful PUT.

Offline, the real Bpmn.Interchange 0.2.0 reader and writer show a Studio-written binding reads back intact with the Camunda extensions untouched. The live end-to-end run against the sample server follows after merge.

Review: two iterations on the standards and spec axes. Resolved: a duplicate activity-tree lookup and a third copy of test stubs, now one shared set. Won't-fix: receiveTask is included, per the library's rule.

🤖 Generated with Claude Code

sfmskywalker and others added 5 commits September 12, 2026 15:50
…d element on selection

The "Performed by" section (elsa-studio#1001) has to know two things about a
clicked BPMN element that the canvas did not tell .NET: which element it is
even when no activity is bound to it, and whether its activity is authored on
it or derived by elsa-core's binder.

- The canvas-neutral view model classifies each binding's kind: `unboundTask`
  when the activity comes from an elsa:activityBinding on the element, else
  `automatic` (timer/message/signal waits, message publish, call activity,
  nested process). Mirrors Bpmn.Interchange's reader and elsa-core's publish
  gate: every task kind is authored unless a send or receive task resolved a
  message (`bpmn.messageName`). The kind rides on the cell data and on the
  selection sent to .NET (`BpmnElementSelection.BindingKind`).
- BpmnDesigner raises the selected element itself (ElementSelected), including
  a task nothing is bound to yet, which ActivitySelected cannot distinguish
  from a canvas click; the canvas raises null. BpmnDesignerWrapper forwards it
  from a named cascade an editor provides; the viewers provide none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ormat BPMN document

Binding a task means editing its elsa:activityBinding in place in the
bpmnDefinitions document the document endpoints exchange (D4, D5), without a
second encoder for activity inputs.

- BpmnActivityBindingFormat mirrors elsa-core's compatibility surface on the
  document JSON: the elsa namespace and names, Find (first binding wins),
  Create (one input per configured input, null ones skipped, ordered by name,
  as elsa-core's Write does), Read (elsa-core's refusals: no activityType, an
  unnamed or repeated input, input text that is not JSON) and Attach (replaces
  the binding in place, drops any second one, touches nothing else).
- BpmnDefinitionsDocument finds an element across the document's processes,
  and reports its subprocesses: the document carries no subprocess body, and
  elsa-core writes the XML from the document alone, so a PUT would empty them.
- BpmnActivityBindingDraft maps between the activity JSON Studio's own input
  editors write and the binding's inputs, node for node: an Input<T> keeps its
  {typeName, expression} wrapper, a plain [Input] its own JSON. Names are
  mapped through the descriptor (the server's camelCase and the editors'
  Humanizer spelling differ for acronyms), and an input Studio's catalogue
  does not describe is written back as it was read.

The document fixture is Bpmn.Interchange 0.2.0's own reader output for
camunda-order-process.bpmn, serialized as the document GET sends it; Studio
writes exactly the shape that reader produced for the same binding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extends the Studio-local interchange client and IBpmnInterchangeService with
elsa-core's document endpoints.

- The document body is read and written verbatim, with plain System.Text.Json
  defaults, never through the API-wide serializer conventions Bpmn.Model's
  reader does not accept.
- The strong ETag is read verbatim and sent back as If-Match. A document that
  arrives without one is reported (MissingETag) instead of being editable and
  failing every save with 428: across origins that is a CORS policy that does
  not expose the header.
- Refusals are classified by the envelope's code into BpmnDocumentFailureReason
  (precondition failed/required, binding invalid, capability unsupported,
  source stale, not imported, not found), falling back to the HTTP status and
  then to the server's own message, never to its wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nly through the document PUT

Implements the BPMN binding authoring flow (elsa-studio#1001, W11): select a
task, pick the Elsa activity that performs it, configure that activity's own
inputs, and save the result into the task's elsa:activityBinding through the
document write-back endpoint.

The invariant: a BPMN-imported workflow's activity graph changes only through
its BPMN document. An ordinary workflow-definition save that rewrote the graph
would leave the stored document stale, after which elsa-core refuses to read or
export it. So, for a definition whose root is an Elsa.BpmnProcess and that
carries Bpmn:SourceXml:

- The properties pane shows the Performed by section instead of the activity
  properties panel, whose edits only the ordinary save could persist. An
  authored task gets the existing activity picker (StateMachine's picker,
  given a leaf-work filter and its own context line) and the input editors the
  flowchart uses (InputsTab), bound to a draft that is never part of the graph;
  an automatically bound element shows what it is bound to, read-only.
- BpmnDocumentSession holds the GET'd document and its ETag, applies edits to a
  working copy, and PUTs it with If-Match; a successful PUT reads the document
  back and the editor reloads the definition. A 412 is reported with a Reload
  action and never retried; a refused binding names the edited task alongside
  the server's message; a stale document explains that the workflow must be
  re-imported. A document declaring a subprocess is refused before anything is
  sent, since the document carries no subprocess body and the PUT would empty it.
- The editor's ordinary save still carries the other properties, but refuses,
  loudly, any save whose graph differs from the root the server last delivered
  (the code view included), and never reads the designer for such a workflow.
  Save sends pending property changes first, then pending binding changes.
  Publishing with unsaved binding changes is refused; after an ordinary save
  the document is read again, so a save that left it stale is reported at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oubles

BpmnDesigner.FindActivityById duplicated the Core JsonObjectActivityTreeExtensions.FindActivity
extension with identical semantics, so the Designer now calls the shared extension instead.
The BPMN editing test services also introduced a third near-identical ILocalizer/IActivityRegistry
stub; StateMachineActivityPickerDialogTests and StateMachinePresentationTests now share one
TestLocalizer/TestActivityRegistry pair from Support instead of keeping private copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

Safe to merge.

Summary

  • This PR adds BPMN task-binding editing through document-backed GET/PUT saves with ETag concurrency control, protected in-flight editing, and clear outcomes when a save or reload cannot complete.

Reviews (3) · Last reviewed commit: "fix(workflows): never treat a completed ..."

Comment thread src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDocumentSession.cs Outdated
Comment thread src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDocumentSession.cs Outdated
sfmskywalker and others added 4 commits September 12, 2026 16:54
…it against a lost edit

The ordinary workflow save re-serializes the root and, after a publish, may open a new
draft version, either of which can move the BPMN document's ETag without changing what
it describes; the session now re-reads the document first and adopts the new ETag when
the content is unchanged, rather than sending a stale If-Match and being refused for a
change nobody made. A binding edit attempted while a save is in flight is now refused
rather than silently lost to the reload that follows a successful PUT, and a PUT that
succeeds but whose follow-up read fails is reported as saved-but-not-reloaded instead of
a plain success.

Also fixes two CodeQL findings: an empty `@if` branch in BpmnPerformedByPanel.razor, and
inconsistent null handling around IsBpmnDocumentBacked in WorkflowEditor.razor.cs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ent is saving

BpmnDocumentSession now raises a Changed event when IsSaving flips, so the
panel can re-render itself during a save instead of relying on the editor's
single before/after StateHasChanged, which never shows the disabled
picker or "Saving…" state. As a backstop, SetBinding refuses quietly
(returns false, leaves the working copy unchanged) instead of throwing when
called while a save is in flight, since a late edit can otherwise still
reach it despite the disabled UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sfmskywalker and others added 2 commits September 12, 2026 17:47
A second SaveAsync call while one was already in flight sent a second
PUT with the same stale ETag, and the first call's finally block reset
IsSaving while the second PUT/reload was still running, re-enabling
edits that could be lost to that second reload. SaveAsync now returns
the same in-flight task to a caller that calls it again, so at most one
PUT is sent, and RefreshRevisionAfterOrdinarySaveAsync awaits an
in-flight save before re-reading so it compares against the revision
that save reloaded rather than racing the PUT.

Also rewrite a bool? != false comparison as `is not false` in
BpmnPerformedByPanel.razor to satisfy CodeQL while keeping null meaning
browsable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BPMN W11: bind a task to an Elsa activity from the property panel

1 participant