Skip to content

BPMN Modeler Implementation

The BPMN Modeler wraps the bpmn-js library in a three-panel React component. This page covers the component structure, the canvas setup decisions, and known rendering issues with their fixes.


Component structure

packages/frontend/src/components/BpmnModeler/
├── BpmnModeler.tsx            main orchestrator, manages selected process state
├── BpmnCanvas.tsx             bpmn-js canvas wrapper (modeler lifecycle, badge overlays, deploy trigger)
├── BpmnProperties.tsx         properties panel (right), includes DmnTemplateSelector
├── ProcessList.tsx            process list (left), CRUD operations
├── DmnTemplateSelector.tsx    DMN/DRD dropdown for BusinessRuleTask linking
├── FormTemplateSelector.tsx   Form dropdown for UserTask / StartEvent linking
├── DocumentTemplateSelector.tsx  Document template dropdown for UserTask linking  ← new in v1.1.0
└── BpmnModeler.css            custom styles for canvas rendering fixes and badge overlays

packages/frontend/src/
├── services/
│   ├── bpmnService.ts         localStorage CRUD for BpmnProcess records
│   └── formService.ts         localStorage CRUD for FormSchema records (shared with FormEditor)
└── utils/
    └── bpmnTemplates.ts       default BPMN XML templates (new process, example)

Canvas initialisation

BpmnCanvas.tsx manages the bpmn-js modeler instance lifecycle:

const modeler = new Modeler({
  container: containerRef.current,
  moddleExtensions: {
    camunda: camundaModdleDescriptor,
  },
});

await modeler.importXML(xml);
const canvas = modeler.get('canvas');
canvas.zoom('fit-viewport');

camunda-bpmn-moddle is used instead of an Operaton equivalent because no operaton-bpmn-moddle package exists. Operaton accepts both camunda: and operaton: namespace attributes, so camunda: is safe to use and ensures compatibility with the broader Camunda 7 tooling ecosystem.


Scroll-to-zoom override

The bpmn-js default requires Ctrl+Scroll to zoom. This was overridden to plain scroll for consistency with the rest of the application:

const handleWheel = (e: WheelEvent) => {
  e.preventDefault();
  const canvas = modelerRef.current?.get('canvas') as any;
  const currentZoom = canvas.zoom();
  const delta = e.deltaY > 0 ? -0.1 : 0.1;
  canvas.zoom(Math.max(0.2, Math.min(4, currentZoom + delta)));
};

container.addEventListener('wheel', handleWheel, { passive: false });

passive: false is required so preventDefault() is effective on wheel events.


Rendering artifact fix

bpmn-js produces black circles and stray lines during drag operations when SVG layer pointer events conflict. The fix in BpmnModeler.css:

.bpmn-container .djs-overlay-container,
.bpmn-container .djs-hit-container,
.bpmn-container .djs-outline-container {
  pointer-events: none;
}

.bpmn-container .djs-element {
  pointer-events: all;
}

This separates hit detection (on elements) from overlay rendering (no pointer events), eliminating the visual artifacts.


FormTemplateSelector — form linking for UserTask and StartEvent

FormTemplateSelector is a React component injected into the bpmn-js properties panel when a UserTask or StartEvent is selected. It reads available forms from FormService and writes camunda:formRef / camunda:formRefBinding to the element's BPMN extension attributes via the bpmn-js modeling API.

Injection

The injection follows the same pattern as DmnTemplateSelector. Inside BpmnCanvas.tsx, the selectionChanged listener distinguishes element type and mounts the appropriate selector:

} else if (elementType === 'bpmn:UserTask' || elementType === 'bpmn:StartEvent') {
  const selectorContainer = document.createElement('div');
  selectorContainer.id = `form-template-custom-${selectedElement.id}`;
  propertiesPanel.appendChild(selectorContainer);

  const root = ReactDOM.createRoot(selectorContainer);
  root.render(
    <FormTemplateSelector
      element={selectedElement}
      modeling={modeling}
      selectedFormRef={businessObject.get('camunda:formRef')}
    />
  );
}

The cleanupReactRoots() helper unmounts the previous React root whenever the selection changes, preventing stale instances.

Writing attributes

When the user selects a form:

modeling.updateProperties(element, {
  'camunda:formRef': schemaId,        // the schema.id from the FormSchema JSON
  'camunda:formRefBinding': 'latest',
  'camunda:formKey': undefined,       // clears any legacy HTML formKey
});

schemaId is (form.schema as Record<string, unknown>).id — the ID embedded in the form's JSON schema, not the outer FormSchema.id used as the localStorage record key.

Selecting the blank option calls:

modeling.updateProperties(element, {
  'camunda:formRef': undefined,
  'camunda:formRefBinding': undefined,
});

Form badge overlay

When any UserTask or StartEvent has camunda:formRef set, BpmnCanvas.tsx renders a green badge overlay below the element using the bpmn-js overlays service. This is applied on import.done and on every element.changed event.

overlays.add(element.id, 'form-linked', {
  position: { bottom: -22, left: leftOffset },
  html: `<div class="form-linked-badge" title="${formRef}">📝 ${formRef}</div>`,
});

The badge offset uses leftOffset = Math.round((element.width - badgeWidth) / 2) to centre the badge horizontally beneath the element. A separate CSS class form-linked-badge--start applies smaller font/padding for StartEvent elements, which have a narrower default width.

Styles are defined in BpmnModeler.css:

.form-linked-badge {
  background: #16a34a;   /* green-600 */
  color: white;
  font-size: 10px;
  font-weight: 600;
  padding: 2px 6px;
  border-radius: 4px;
  max-width: 130px;
  overflow: hidden;
  text-overflow: ellipsis;
  pointer-events: none;
  box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}

pointer-events: none prevents the badge from interfering with element selection on the canvas.


DocumentTemplateSelector — document template linking for UserTask

DocumentTemplateSelector is a React component injected into the bpmn-js properties panel when a UserTask is selected (not StartEvent). It reads available document templates from DocumentService and writes ronl:documentRef to the element via the bpmn-js modeling API. It follows the identical injection pattern as FormTemplateSelector.

Injection

Inside the selectionChanged listener in BpmnCanvas.tsx, after the FormTemplateSelector is mounted for a UserTask, the document selector is appended immediately below it:

// UserTask only — not StartEvent
if (elementType === 'bpmn:UserTask') {
  const docSelectorContainer = document.createElement('div');
  docSelectorContainer.id = `document-template-custom-${selectedElement.id}`;
  propertiesPanel.appendChild(docSelectorContainer);

  const currentDocumentRef = businessObject.get('ronl:documentRef');

  const docRoot = ReactDOM.createRoot(docSelectorContainer);
  docRoot.render(

  );
}

cleanupReactRoots() unmounts all injected React roots (form and document) when the selection changes.

Writing and clearing the attribute

Selecting a template:

modeling.updateProperties(element, {
  'ronl:documentRef': templateId,
});

Selecting the blank option:

modeling.updateProperties(element, {
  'ronl:documentRef': undefined,
});

Document badge overlay

When a UserTask has ronl:documentRef set, BpmnCanvas.tsx renders a purple badge below the element. This is applied in refreshDmnOverlays() alongside the DMN and form badges:

overlays.remove({ type: 'document-linked' });

// ...inside the elementRegistry.forEach loop, after the form badge check:
if (element.type === 'bpmn:UserTask') {
  const documentRef = element.businessObject.get('ronl:documentRef');
  if (documentRef) {
    const badgeWidth = 130;
    const leftOffset = Math.round((element.width - badgeWidth) / 2);
    overlays.add(element.id, 'document-linked', {
      position: { bottom: -36, left: leftOffset }, // below the form badge at -22
      html: `📄 ${documentRef}`,
    });
  }
}

The badge offset is bottom: -36 (vs. bottom: -22 for the form badge), so both badges stack below the element without overlapping.

CSS

Defined in BpmnModeler.css:

.document-linked-badge {
  background: #7c3aed;   /* violet-700 */
  color: white;
  font-size: 10px;
  font-weight: 600;
  padding: 2px 6px;
  border-radius: 4px;
  white-space: nowrap;
  max-width: 130px;
  overflow: hidden;
  text-overflow: ellipsis;
  pointer-events: none;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}

Badge stacking order

Overlay type CSS class Colour bottom offset
dmn-linked .dmn-linked-badge Blue (#2563eb) 8 (inside element)
form-linked .form-linked-badge Green (#16a34a) -22 (below element)
document-linked .document-linked-badge Violet (#7c3aed) -36 (below form badge)

refreshDmnOverlays() calls overlays.remove({ type: 'document-linked' }) before re-adding, so stale badges are cleared on every element.changed event.


Deploy modal

The deploy modal is triggered by the Deploy button in the canvas toolbar. BpmnCanvas.tsx assembles the resource bundle before opening the modal:

Resource collection

// 1. Save current BPMN to get latest XML
const { xml } = await modelerRef.current.saveXML({ format: true });

// 2. Extract subprocess calledElement references (recursive)
const calledElements = extractCalledElements(xml);
// → match against saved BpmnProcess records by process/@id

// 3. Extract all camunda:formRef values from main + subprocess XMLs
const allFormRefs = new Set([
  ...extractFormRefs(xml),
  ...subProcessXmls.flatMap(sp => extractFormRefs(sp.xml)),
]);

// 4. Match form refs against FormService.getForms() by schema.id
const forms = allFormRefs → matched FormSchema records

Unmatched form refs (referenced in BPMN but not in localStorage) are passed to the modal as unmatchedForms for display.

API call

On Deploy, BpmnCanvas.tsx sends one JSON request to the backend:

await fetch(`${API_BASE_URL}/api/dmns/process/deploy`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    bpmnXml: xml,
    deploymentName: processKey,       // from BPMN process/@id
    forms,                            // [{ id, schema }]
    documents,                        // [{ id, template }]
    subProcesses: subProcessXmls,     // [{ filename, xml }]
    boardOwner,
    organization: deployOrganization,
  }),
});

The request names no Operaton target and no credentials. Since v2026.09.5 the modal no longer offers an Operaton URL, username or password; it names the Operaton the backend deploys to instead.

The call still uses the legacy /api/dmns/... alias, which the backend serves through the /v1 handler with a Deprecation header.

The backend records the bundle in the same request. The response's data carries deploymentId and a bundleRecorded flag, with bundleRecordingError when the write did not land. A deploy Operaton has accepted is a success either way — it cannot be undone — so a failed recording shows as a warning, saying the process will not appear on the dashboard or the public site until it is saved and deployed again. Before v2026.09.5 the browser made that write itself, as a second request that was unawaited, whose failure was swallowed, and which was skipped altogether when local storage held no matching identifier. An error response is read with getProblemDetail(), since errors are RFC 9457 problem details.

Backend endpoint

POST /api/dmns/process/deploy (in dmn.routes.ts) delegates to operatonService.deployProcess(). That method builds a multipart/form-data request with each resource appended as a named field matching Camunda Modeler behaviour:

  • Main BPMN: field name = ${processKey}.bpmn
  • Subprocess BPMNs: field name = the subprocess filename
  • Forms: field name = ${formId}.form

Processes deploy only to the configured Operaton. deployProcess() always uses the shared client, built from OPERATON_BASE_URL and carrying OPERATON_API_KEY, so Operaton credentials stay on the backend. For older frontends, an operatonUrl equal to the configured one is still accepted; any other answers 400, and operatonUsername and operatonPassword are ignored. All three fields are deprecated. Until v2026.09.5 a URL in the request body made the backend build a new client for that host, with optional Basic Auth — an Operaton target, and credentials for it, chosen by whoever sent the request.

After Operaton accepts the deployment, the route finds the stored process by the id in the BPMN — or creates a minimal row when none exists — and stamps it as deployed, recording the Operaton actually used. Saving a process takes bpmnProcessId from the saved XML, so renaming the process id no longer leaves a stale value that makes the next deploy create a second row.


DmnTemplateSelector

DmnTemplateSelector.tsx loads from two sources in parallel when mounted for a BusinessRuleTask:

const loadOptions = async () => {
  // Remote: regular DMNs from backend
  const response = await fetch(`${API_BASE_URL}/v1/dmns?endpoint=${endpoint}`);
  const dmnArray: DmnModel[] = data.data.dmns;

  // Local: DRD templates from localStorage
  const userTemplates = getUserTemplates(endpoint);
  const drdOptions = userTemplates
    .filter(t => t.isDrd && t.drdEntryPointId)
    .map(t => ({
      identifier: t.drdEntryPointId!,
      title: `${t.name} (DRD)`,
      isDrd: true,
      originalChain: t.drdOriginalChain,
    }));

  setOptions({ drds: drdOptions, dmns: dmnArray });
};

The dropdown renders two <optgroup> elements: "🔗 DRDs (Unified Chains)" and "📋 Single DMNs". Selection auto-populates camunda:decisionRef and suggests a camunda:resultVariable value (derived from the decision title, camelCased).

DmnTemplateSelector pre-selection fix

Before v1.0.0, opening the properties panel for a BusinessRuleTask that already had camunda:decisionRef set would show an empty dropdown. The fix reads currentDecisionRef from businessObject.get('camunda:decisionRef') and passes it as selectedDecisionRef to DmnTemplateSelector, which initialises its useState from that prop.


Process persistence

bpmnService.ts stores processes as BpmnProcess records in PostgreSQL via the backend, using localStorage as a synchronous read cache. See Asset Storage for the full write-through cache and hydration architecture.

The BpmnProcess type includes three relationship fields added in v1.3.0:

interface BpmnProcess {
  // ... existing fields ...
  bpmnProcessId?: string;                               // <process id="..."> from XML
  processRole?: 'shell' | 'subprocess' | 'standalone';
  calledElement?: string;                               // parent shell's bpmnProcessId
}

bpmnProcessId is extracted from the XML on save using:

const extractBpmnProcessId = (xml: string): string => {
  const match = xml.match(/<(?:bpmn:)?process[^>]+\bid="([^"]+)"/);
  return match?.[1] ?? 'unknown';
};

ProcessList.tsx uses calledElement === shell.bpmnProcessId to group subprocesses under their parent shell in the hierarchical view.

Bundle assembly after migration

BpmnCanvas.tsx resolves subprocess XMLs for deployment by matching calledElement values from the active BPMN against stored BpmnProcess records. After the PostgreSQL migration the lookup continues to work identically — hydrateFromServer() ensures the local cache reflects the database state on mount, so the in-memory lookup in BpmnService.getProcesses() always has current data.

The backend additionally exposes GET /v1/assets/bpmn/by-bpmn-id/:bpmnProcessId for direct server-side subprocess lookup by BPMN process id.


RoPA linkage — moddleDescriptor and ProcessList

ronlModdleDescriptor.json

ronlModdleDescriptor.json registers all custom ronl: extensions against bpmn-js so the attributes survive saveXML() serialisation. Without registration, bpmn-js silently strips unknown namespaced attributes on every save.

The descriptor currently declares five type entries:

Type Extends Attribute Added in
DocumentRefMixin bpmn:UserTask documentRef v1.1.0
RopaRefMixin bpmn:Process ropaRef v1.4.0
DsoActiviteitMixin bpmn:Process dsoActiviteitUrn v1.5.0
LanguageMixin bpmn:Process language v1.6.0
OrganizationMixin bpmn:Process organization v1.6.0

Each entry has the same shape, e.g. for LanguageMixin:

{
  "name": "LanguageMixin",
  "extends": ["bpmn:Process"],
  "properties": [
    { "name": "language", "isAttr": true, "type": "String" }
  ]
}

When adding a new attribute on bpmn:Process or bpmn:UserTask, append a new mixin entry rather than altering existing ones — each entry is self-contained.

RopaSelector placement

RopaSelector.tsx is rendered as a sibling of the scrollable list container inside ProcessList.tsx, not as a child. The JSX structure is:

<div className="w-80 bg-white border-r ...">   ← outer wrapper
  <div className="h-14 ...">                   ← header
  <div className="flex-1 overflow-y-auto ..."> ← scrollable list
  </div>
  {activeProcess && (                          ← RopaSelector — outside scroll container
    <div className="border-t ... shrink-0">
      <RopaSelector ... />
    </div>
  )}
</div>

Placing the panel inside the scroll container caused it to scroll away with the list — it must be a sibling to stay pinned.

handleRopaRefChange

handleRopaRefChange in BpmnModeler.tsx handles three cases:

  • ropaRef is a non-empty string and ronl:ropaRef already exists → regex replace the existing value
  • ropaRef is a non-empty string and ronl:ropaRef is absent → inject into the <bpmn:process> opening tag before its > or />
  • ropaRef is undefined → remove the attribute entirely with a regex that also strips the preceding whitespace

In all cases it first checks for xmlns:ronl= in the XML and injects the namespace declaration on the <definitions> element if absent.


Example process seeding

On mount, BpmnModeler.tsx runs a versioned seed effect. For each example defined in EXAMPLE_VERSIONS, if the stored version is lower than the current version the file is re-fetched from public/examples/ and the record is overwritten in localStorage.

Seeded records carry status: 'example', which is what the list badges key on. They are not read-only. Of the ten records the seed effect writes, exactly one — wip_asylum_migration — sets readonly: true; the other nine set readonly: false.

That distinction is load-bearing, because readonly — not status — is what gates the backend write: BpmnService.saveProcess persists to localStorage first and then returns early for a readonly record without ever POSTing to /v1/assets/bpmn. So nine of the ten seeded examples are written to the backend when saved, and hydrateFromServer merges the readonly one back from local storage rather than from the server. A user's edit to a seeded example also survives only until the next version bump: the seed overwrites the stored record whenever EXAMPLE_VERSIONS moves past it.

The current example processes and their roles:

Seed ID processRole bpmnProcessId calledElement
example_awb_process shell AwbShellProcess —
example_tree_felling subprocess TreeFellingPermitSubProcess AwbShellProcess
example_awb_zorgtoeslag shell AwbZorgtoeslagProcess —
example_zorgtoeslag_provisional subprocess ZorgtoeslagProvisionalSubProcess AwbZorgtoeslagProcess
example_zorgtoeslag_final subprocess ZorgtoeslagFinalSubProcess AwbZorgtoeslagProcess
example_hr_capacity_nl standalone ManagementCapacityClaimProcess —
example_thuisbatterij_aanvraag shell ThuisbatterijSubsidieAanvraagProcess —
example_thuisbatterij_decision subprocess ThuisbatterijSubsidieDecisionSubProcess ThuisbatterijSubsidieAanvraagProcess
wip_asylum_migration standalone Process_Migratie_en_Asiel —

The Thuisbatterij bundle joined the seed in v2026.09.6. Its files sat in public/examples/flevoland/ and stopped there — fetchable by URL, invisible in the app, with no entry in the version registry, no seeding block, no form definitions and no document template. It was the only bundle in public/examples with no way into the UI, which left deploying it a file-shuffling exercise rather than the Modeler flow kapvergunning and zorgtoeslag already have. Both records carry organization: 'flevoland', and the decision subprocess declares shellId alongside calledElement.

Both Thuisbatterij processes are drawn as a pool with lanes and carry Dutch element names; their element ids are the ones they always had, so nothing that references them by id changes. The main process sits in the pool Subsidie Thuisbatterij Flevoland - Hoofdproces with the lanes Aanvrager, Behandelaar and Systeem. The decision subprocess sits in the pool Thuisbatterijsubsidie - Beoordeling recht en hoogte with only Behandelaar and Systeem — it has no applicant-facing step.

The user task Aanvullende gegevens opvragen (Awb 4:5) (Task_RequestMissingInfo) opens the form-js form thuisbatterij-aanvullende-gegevens with camunda:formRefBinding="deployment". That form carries the supplementReceived checkbox the next gateway, Aanvulling ontvangen?, branches on. The task used to point at an embedded HTML form (embedded:deployment:awb-missing-info-form.html) that was never part of the bundle, so nothing could set supplementReceived. The form is seeded as the Form Editor example example_thuisbatterij_missing_info, and the e2e-fixtures/manifest.json entry for ThuisbatterijSubsidieAanvraagProcess lists it beside the start and notification forms. In utils/exampleVersions.ts, example_thuisbatterij_aanvraag and example_thuisbatterij_decision stand at version 2, so the Modeler replaces copies seeded before the redraw; example_thuisbatterij_missing_info starts at 1.

A tenanted process cannot see an untenanted DMN

Operaton resolves a business rule task's decisionRef inside the process instance's own tenant. A process deployed under tenant-id flevoland therefore cannot reach a DMN deployed without one, and the engine refuses to instantiate it at all — surfacing as a 500 from process start and an unexplained "De aanvraag kon niet worden ingediend" on the ACC citizen dashboard. Kapvergunning was broken this way; Thuisbatterij had the same defect one step further in, pinned to a tenant id. decisionRefTenantId="${null}" points them back at the shared untenanted DMNs.

EXAMPLE_VERSIONS was bumped for example_awb_process, example_tree_felling, example_awb_zorgtoeslag and example_zorgtoeslag_provisional in the same change — without the bump the seed skips re-saving, and every existing user keeps the broken copy.

After the seed effect, a separate hydration effect runs BpmnService.hydrateFromServer() to merge any user-authored processes stored in PostgreSQL into the local list.


Pending-until-Save editing model

Footer edits — language, organization, ropaRef, dsoActiviteitUrn — accumulate in a draft on BpmnModeler.tsx rather than persisting immediately. The pattern:

type FooterDraft = {
  language?: BpmnProcess['language'];
  organization?: string;
  ropaRef?: string;
  dsoActiviteitUrn?: string;
};

const [draft, setDraft] = useState<FooterDraft>({});
const [hasFooterChanges, setHasFooterChanges] = useState(false);
const [hasCanvasChanges, setHasCanvasChanges] = useState(false);

Footer handlers update only the draft:

const handleLanguageChange = (language: string | undefined) => {
  if (!activeProcessId) return;
  setDraft((d) => ({ ...d, language: language as BpmnProcess['language'] }));
  setHasFooterChanges(true);
};

handleSaveProcess flushes the draft into both the in-memory BpmnProcess object and the BPMN XML's ronl: attributes, then resets the draft. Distinguishing "user touched the field" from "user explicitly cleared it" matters — the 'language' in draft idiom preserves the difference between unset and cleared:

language: 'language' in draft ? draft.language : process.language,

The list panel reads from committed processes state so visible grouping doesn't shift while the user is typing in the footer. The footer reads its display value via a readEffective() helper: draft wins when the field has been touched, else the committed BpmnProcess field, else the ronl: attribute extracted from the XML.

Navigation guards (handleCreateProcess, handleLoadProcess, handleCloseProcess) check the combined hasUnsavedChanges = hasCanvasChanges || hasFooterChanges and prompt the user to confirm before discarding. BpmnCanvas exposes its own dirty state via onDirtyChange?: (dirty: boolean) => void so the parent can combine canvas content edits with footer edits into a single guard.

applyRonlAttr helper

A single helper consolidates the four duplicated XML-mutation snippets that previously lived in each footer handler. It ensures the xmlns:ronl= namespace is declared on <definitions>, then either rewrites an existing ronl:<attr>= value, injects a new attribute on the <bpmn:process> opening tag, or strips the attribute when the value is undefined:

function applyRonlAttr(xml: string, attr: string, value: string | undefined): string;

handleSaveProcess calls it once per draft field present (language, organization, ropaRef, dsoActiviteitUrn).

Shell → subprocess atomic save

After saving a shell, handleSaveProcess walks processes for subprocesses where processRole === 'subprocess' and the record links back to this shell. The link is matched on shellId where the record has one, falling back to calledElement === shell.bpmnProcessId for records saved before shellId existed — two shell records can share a bpmnProcessId (an e2e-fixtures copy deliberately keeps a seeded example's production Operaton key), so matching on that string alone would cascade one shell's language and organization onto an unrelated shell's subprocess. For each match, the shell's language and organization are applied to the subprocess XML (via applyRonlAttr) and to the in-memory BpmnProcess fields, then persisted via BpmnService.saveProcess in sequence. The propagation triggers on every shell save when the shell has either field set, regardless of whether the user touched the footer in this session — the architectural rule "shell wins" must hold across editing sessions.

Idempotent: subprocesses already aligned on both fields are skipped (no updatedAt bump, no backend write). Records marked readonly are skipped — which in practice means only wip_asylum_migration, since the seeded example subprocesses are not read-only and do receive the propagation. RoPA and DSO are NOT propagated — each subprocess has its own RoPA record and DSO context.


Testing checklist

After changes to any BPMN Modeler component:

  • Create new process — process appears in list, empty canvas with start event
  • Rename process — double-click list item, save on blur
  • Delete process — confirmation dialog, list updates
  • Cannot delete example — delete button disabled with tooltip
  • Save — process persists across hard refresh
  • Export — .bpmn file downloads with valid XML
  • Drag element from palette — element appears on canvas
  • Connect elements — arrow tool works
  • Select element — properties panel updates
  • BusinessRuleTask selected — DMN/DRD dropdown appears and loads
  • DRD selected — purple info card shows chain composition
  • Single DMN selected — blue info card shows identifier
  • Scroll to zoom — wheel event zooms without requiring Ctrl
  • Fit to viewport — canvas centres diagram
  • No rendering artifacts during drag — no black circles or stray lines