@uubato/uubato-dmn-modeller
v0.2.2
Published
A React component for building DMN Decision Requirements Diagrams (DRDs) against Camunda 7's DMN engine — `DrdCanvas`, a full `@maxgraph/core`-based canvas with decision/input data/business knowledge model/knowledge source nodes and their requirement conn
Readme
DMN Modeler
A React component for building DMN Decision Requirements Diagrams (DRDs)
against Camunda 7's DMN engine — DrdCanvas, a full @maxgraph/core-based
canvas with decision/input data/business knowledge model/knowledge source
nodes and their requirement connections. A decision node's own logic
(decision table or literal expression) is edited in a docked panel right
next to the canvas, not a separate component — there's no standalone
single-decision editor in this package anymore (see "Formerly:
DmnModeler" below). See
docs/dmn-package-plan.md for the full
phase-by-phase plan, scope decisions, and what's explicitly deferred.
This package lives at DMN/ in the bpmn-modeler monorepo,
alongside BPMN/ (the sibling BPMN modeler this
package's architecture mirrors) and CMMN/ (a
placeholder for a future CMMN package).
DrdCanvas is a persistence-free, controlled component, same contract
as BpmnModeler: you give it xml and get xml back via onChange. It
has no import/export UI, an "open recent" list, or storage of its own.
Published on npm as
@uubato/uubato-dmn-modeller. See Developing against source below if you want to work against this monorepo's checkout directly instead of the published package.
Installation
npm install @uubato/uubato-dmn-modellerPeer dependencies (install alongside it):
npm install [email protected] [email protected]Usage
import { useState } from 'react'
import { DrdCanvas, createEmptyDrdXml } from '@uubato/uubato-dmn-modeller'
import '@uubato/uubato-dmn-modeller/style.css'
function DiagramEditor() {
const [xml, setXml] = useState(createEmptyDrdXml())
return (
<div style={{ height: '100vh' }}>
<DrdCanvas xml={xml} onChange={setXml} theme="light" />
</div>
)
}Selecting a decision node on the canvas opens a docked panel combining its identity fields (Name/Question/Allowed Answers/Type) with its nested decision-table/literal-expression editor, switched via a "Logic Type" selector inside that panel — no separate prop or component needed.
DrdCanvas has no default export — same MIXED_EXPORTS-avoidance
reasoning as @uubato/uubato-bpmn-modeller. @uubato/uubato-dmn-modeller/style.css
includes the component's layout rules and the color/shadow custom
properties it relies on — that's the only stylesheet it needs. It fills
whatever height its container gives it and includes no page-wide reset,
same conventions as BpmnModeler.
Props
| Prop | Type | Description |
|---|---|---|
| xml | string \| null \| undefined | The DRD XML. The component is controlled: null/undefined means "leave the current content alone." Setting it to a new string imports it. |
| onChange | (xml: string) => void | Called with the freshly-exported XML on every mutation. No built-in debounce. |
| theme | 'light' \| 'dark' | Defaults to 'light'. No toggle UI of its own. |
| readOnly | boolean | Defaults to false. Disables every user-driven mutation: placing/moving/connecting/deleting nodes, renaming, undo/redo, and locks the decision-editing panel's fields. The toolbar unmounts entirely rather than just disabling. The Validate panel stays available in either mode — checking validity isn't a mutation. |
| availableDecisionSources | Array<{id, name, xml}> | Defaults to []. Feeds a decision node's "Import from existing decision" picker — see "Cross-package and cross-diagram linking" below. |
createEmptyDrdXml()
Returns a well-formed, blank DRD XML string with a freshly generated id —
pass it as xml to reset to a blank diagram:
import { createEmptyDrdXml } from '@uubato/uubato-dmn-modeller'
<button onClick={() => setXml(createEmptyDrdXml())}>New Decision Model</button>Cross-package and cross-diagram linking
Three functions support two different linking scenarios, both built on the
fact that every decision — in any DRD XML string this package produces —
is a <decision id="..." name="..."> sibling under <definitions>:
listDecisions(xml)— given any DRD XML string, returns every top-level decision's{ id, name }. For a host that also embeds@uubato/uubato-bpmn-modellerand wants to feedBpmnModeler'savailableDecisionsprop, turning a business-rule task's Decision Ref field into a dropdown over real, currently-open decisions instead of a free-text key:import { listDecisions } from '@uubato/uubato-dmn-modeller' const availableDecisions = listDecisions(drdXml) // [{ id, name }, ...]getDecisionContent(xml, id)— resolves one decision's full logic content (table or literal expression) out of a DRD XML string, plus its source<definitions>id/name. Used internally byDrdCanvas's "Import from existing decision" picker, fed by theavailableDecisionSourcesprop above (built the same way asavailableDecisions, just keeping each entry's sourcexmltoo):const availableDecisionSources = listDecisions(drdXml).map((d) => ({ ...d, xml: drdXml })) // multiple open DRD documents (e.g. one per tab in a multi-tab host) // can be concatenated into this same arrayOnce a node imports another decision this way, it stays live-linked to that source for as long as the source XML is still passed in via
availableDecisionSources— the node locks read-only and shows the source's current content automatically, with no action needed. A "Detach" action in the panel converts a linked node back into an independent, freely-editable one.resolveLinkedDecisionRef(linkedDecisionRef, availableDecisionSources)— re-resolves a node's stored link against a currentavailableDecisionSourceslist, matching on both the source document's id and the decision's own id (an id alone can collide across independently-authored documents). This is what powers the live-sync above; most hosts never need to call it directly.
If a linked node's source is no longer present in availableDecisionSources
(e.g. that tab was closed), the node falls back to its last-known content,
editable, with a validation warning. XML export always resolves every
linked node to real inlined content — a .dmn file exported from this
package never contains a bare reference.
What's covered
- Decision tables: add/remove rules (rows) and inputs/outputs
(columns), inline cell editing, all 7 standard hit policies (
UNIQUE,FIRST,PRIORITY,ANY,RULE_ORDER,OUTPUT_ORDER,COLLECTwith itsSUM/MIN/MAX/COUNTaggregators) - Literal expressions: a single FEEL text field plus an optional expression-language override, as an alternative decision-logic type
- DRD canvas: all four DRG node types (decision, input data, business
knowledge model, knowledge source —
DecisionServiceisn't modeled, see "What's not covered" below) and their requirement connections (information/knowledge/authority), click-to-place, validated connection dragging, inline rename, Ctrl+Scroll zoom, an on-canvas zoom-controls widget, fit-to-screen, and a placement ghost preview - Undo/redo, graph-mutation-tracking (same mechanism as
BpmnModeler's) - A validation panel — structural/textual checks (no rules, an unnamed
output on a multi-output table, blank input expressions,
literally-duplicated rules under
UNIQUE, unnamed/logic-less/orphaned DRD nodes, cyclic requirement chains, an orphaned decision link), not FEEL-semantic rule-overlap detection, which would need a FEEL parser this package doesn't have - A "Show XML" panel, live-synced with the graph, mirroring
BpmnModeler's own readOnlyview-only mode,themelight/dark support- Cross-package and cross-diagram linking — see above
Advanced usage
Most consumers only need the props above. Hosts that need to trigger
undo/redo, fit-to-screen, or the Validate/XML panels from outside a
specific <DrdCanvas> instance — e.g. wiring a native OS menu in an
Electron app with several open tabs — can inject a store created via
createDrdStore() through an _internalStore prop, the same mechanism
@uubato/uubato-bpmn-modeller's BpmnModeler uses:
import { useStore } from 'zustand'
import { DrdCanvas, createDrdStore } from '@uubato/uubato-dmn-modeller'
const store = createDrdStore() // one per open diagram/tab
// <DrdCanvas xml={xml} onChange={onChange} _internalStore={store} />
// elsewhere, outside that component's subtree:
const undoManager = useStore(store, (s) => s.undoManager)createDrdStore, DrdStoreContext, and useDrdStore are all exported
from the package root. See the monorepo root CLAUDE.md's "Advanced /
low-level API" section for the full mechanism.
Browser/ and Desktop/
in this monorepo are reference host implementations of exactly this.
Formerly: DmnModeler
Earlier versions of this package (through 0.1.x) also exported a second,
simpler component — DmnModeler, for editing one decision (table or
literal expression) with no canvas — plus createEmptyDmnXml() and its
own createModelerStore/ModelerStoreContext/useModelerStore advanced
tier. All of that was retired in 0.2.0: a decision's logic already
lived inline on its DrdCanvas node, so DmnModeler was a second
authoring surface for content DrdCanvas could already edit, without a
data-model reason to keep it separate. Its double-click-to-edit modal
(DecisionLogicModal) was replaced by the docked panel described above.
This is a real breaking change for anyone updating from 0.1.x — there is
no drop-in replacement component; use DrdCanvas and, if you only need a
single decision, simply don't add a second node.
What's not covered
DecisionService as a fifth DRD node type, RuleAnnotationClause
rule-annotation columns (a single free-text description per rule is
supported instead), DecisionTable.preferredOrientation (rules-as-rows
only, no crosstab), and boxed expressions beyond Literal Expression
(Context, Relation, Function Definition, Invocation, List, Conditional,
Filter, Iterator) — verified against Camunda 7's own DMN engine source,
none of those are executable as top-level decision logic there, so they're
not planned rather than deferred. See the plan doc's "Explicitly deferred"
sections for the full reasoning on each.
Developing against source
If you're working inside this monorepo (or a checkout/symlink of it) and
want to iterate on DMN/src without going through the published version,
import the component by relative path instead of npm installing it:
import DrdCanvas from '<relative-path>/bpmn-modeler/DMN/src/DrdCanvas.jsx'
import { createEmptyDrdXml } from '<relative-path>/bpmn-modeler/DMN/src/drd/xmlExport.js'
// No separate CSS import needed — DrdCanvas.jsx imports its own styles
// (src/styles/tokens.css) directly.Browser/ and Desktop/ in this monorepo do this via a dev-time Vite
alias instead (@uubato/uubato-dmn-modeller → DMN/src/index.js), so
editing DMN/src hot-reloads there instantly without a build:lib step —
see Browser/vite.config.js's comment for how that coexists with the real
published dependency both apps also depend on.
Contributing / local development
Prerequisites
- Node.js v18 or later
- npm v9 or later
Setup
Run from the repo root (this is an npm workspace, alongside BPMN/):
npm installDevelopment
Like BPMN/, this package is developed against
Browser/ — it embeds DrdCanvas via a dev-time
Vite alias (see Browser/vite.config.js), with a mode switcher to flip
between BPMN and DRD editing in the same running app. From the repo root:
npm run devthen use the "BPMN"/"DRD" buttons in the header to switch modes.
Testing
Run this package's unit tests (Vitest):
npm test -w DMN(equivalent to npm test from inside DMN/). Covers the decision-table
and DRD data models, XML round-trip, validation logic, undo/redo history,
per-instance store isolation, cross-diagram decision linking, and
DrdCanvas's xml/onChange/readOnly wiring — component-rendering
tests use react-dom/client + React's act() directly (no
@testing-library/react dependency), matching BPMN/'s existing
modelerStoreIsolation.test.js pattern. End-to-end tests for the demo app
live in Browser/tests/e2e/ — see that package's README.
A pre-push git hook (managed by Husky, installed automatically via npm install
at the repo root) runs npm run test:all before every git push and blocks
the push if any test fails. To push despite a failure use git push --no-verify.
Library build
Build the installable package (ESM + CJS + CSS) to dist-lib/:
npm run build:lib -w DMNThis is what npm publish ships — see the exports field in
package.json. Always run this immediately before npm publish, in
the same terminal session: a stale dist-lib built before the last source
change has shipped before (see docs/dmn-package-plan.md's 0.2.0→0.2.1
entry) and there's no automated check that catches it.
CI / publishing
.gitlab-ci.yml (at the repo root) runs the unit + e2e suite on every
push/MR and on tag pushes. publish_dmn is a tag-triggered
(dmn-v*.*.*) job that verifies the tag matches this package's
package.json version, builds the library, previews the tarball with
npm pack --dry-run -w DMN, and publishes (npm publish -w DMN --access
public) — its own tag namespace, separate from @uubato/uubato-bpmn-modeller's
v*.*.*, since the two packages version independently. publish_dmn_dry_run
runs the same build/pack steps manually, without publishing, for
sanity-checking the pipeline. Not yet wired to actually run (missing
NPM_TOKEN/protected-tags GitLab settings — see root CLAUDE.md's "Known
stubs / open work"), so every release so far has been a manual
npm run build:lib -w DMN && npm publish -w DMN --access public --otp=...
from inside DMN/.
Project Structure
DMN/
├── package.json # the published package: name/exports/files/peerDeps
├── vite.config.js # library build only (dist-lib)
├── vitest.config.js
├── src/
│ ├── DrdCanvas.jsx # Package root component (only top-level export)
│ ├── DrdCanvas.module.css
│ ├── index.js # Package entry point — DrdCanvas, createEmptyDrdXml, listDecisions, getDecisionContent, resolveLinkedDecisionRef, plus the advanced tier, all named exports
│ ├── components/ # Shared by DecisionEditor below (the single-decision editing UI, docked inside DrdCanvas)
│ │ ├── DecisionTable/ # Root editor body for one decision — branches decision-table grid vs. literal-expression UI on decision.logicType
│ │ ├── LiteralExpressionEditor/ # FEEL textarea + expression-language override, for a literal-expression decision
│ │ ├── HitPolicySelector/ # Dropdown for the 7 hit policies + COLLECT's aggregation dropdown
│ │ ├── InputOutputEditor/ # Shared input/output column-header editor
│ │ ├── ValidationPanel/ # Bottom-drawer validation panel (single-decision, used inside DecisionEditor)
│ │ └── useBlurCommitField.js # Shared local-draft-state + commit-on-blur hook for free-text fields
│ ├── dmn/
│ │ ├── decisionTable.js # Pure logic: create/add/remove rule/input/output, hit-policy helpers, setLogicType()
│ │ ├── literalExpression.js # Pure logic: createEmptyLiteralExpression(), setText/setExpressionLanguage
│ │ ├── decisionRefs.js # listDecisions(xml), getDecisionContent(xml, id), resolveLinkedDecisionRef() — cross-package/cross-diagram linking support
│ │ ├── xmlExport.js # exportDecisionTableFragment()/exportLiteralExpressionFragment()/exportDecisionLogicFragment() — the <decisionTable>/<literalExpression> fragment only, reused by drd/xmlExport.js
│ │ ├── xmlImport.js # The inverse fragment parsers, reused by drd/xmlImport.js
│ │ └── validation.js # runValidation(decision) → structural/textual issue list
│ ├── drd/
│ │ ├── drd.js # Pure logic: DRD data model, node/requirement mutation, connection-rule matrix, cycle detection
│ │ ├── xmlExport.js # exportToDrd() → DRD XML string; createEmptyDrdXml(); hydrateLinkedNodes() — re-resolves linked nodes before export
│ │ ├── xmlImport.js # importFromDrd() → drd object
│ │ ├── validation.js # runValidation(drd, availableDecisionSources) → structural issue list, incl. cyclic-requirement-chain and orphaned-link detection
│ │ ├── graph/ # @maxgraph/core setup, custom shapes (BKM/knowledge source), styles, placement, connection validation, graph<->drd sync, link-icon canvas overlay
│ │ ├── store/drdStore.js # Zustand store: graph ref, undo manager, active tool, fitTrigger, theme, readOnly, drdMeta, xmlViewOpen, availableDecisionSources
│ │ └── components/
│ │ ├── GraphCanvas/ # The @maxgraph/core DOM host — placement, connections, zoom, keyboard shortcuts, ghost preview; on-canvas zoom controls only
│ │ ├── DrdMenuBar/ # Top-of-editor bar — click-to-rename diagram name, Undo/Redo, "Show XML", mirroring BPMN's MenuBar
│ │ ├── DrdToolbar/ # Floating draggable icon toolbar — select + one button per node type
│ │ ├── DrdPropertiesPanel/ # Per-node-type field editor for Input Data/BKM/Knowledge Source; delegates to DecisionEditor in full for a selected Decision node
│ │ ├── DecisionEditor/ # The entire panel shown for a selected decision node — identity fields + nested DecisionTable/LiteralExpressionEditor + the import-from-existing-decision picker
│ │ ├── DrdXmlView/ # "Show XML" panel, live-synced with the graph
│ │ └── DrdValidationPanel/ # Bottom-drawer validation panel (DRD)
│ ├── store/
│ │ └── modelerStore.js # Zustand store (single decision) — DecisionEditor's own ephemeral per-selected-node store; not exported from the package root
│ └── styles/
│ └── tokens.css # DMN-scoped duplicate of a trimmed subset of BPMN/'s design tokens (not a cross-package import)
├── tests/unit/
│ ├── xmlRoundtrip.test.js
│ ├── decisionTable.test.js
│ ├── literalExpression.test.js
│ ├── decisionRefs.test.js
│ ├── validation.test.js
│ ├── modelerStoreIsolation.test.js
│ ├── modelerStoreHistory.test.js
│ ├── drd.test.js
│ ├── drdXmlRoundtrip.test.js
│ ├── drdLinkedDecisions.test.js
│ ├── drdValidation.test.js
│ ├── drdGraphSync.test.js
│ ├── drdConnectionValidation.test.js
│ └── setupTests.js # IS_REACT_ACT_ENVIRONMENT for React 19 act()-driven tests
└── docs/
├── dmn-specification.pdf # Official DMN spec (OMG v1.5)
└── dmn-package-plan.md # Phase-by-phase plan + status/decisions log