Skip to content

Document Composer Implementation

The Document Composer is a React feature for authoring structured government decision document templates (beschikkingen). It follows the same component, storage, and DnD patterns established by the BPMN Modeler and Form Editor.


Component structure

DocumentComposer/
โ”œโ”€โ”€ DocumentComposer.tsx       # Root โ€” owns template list state, DnD context, panel layout
โ”œโ”€โ”€ DocumentList.tsx           # Left panel โ€” document CRUD and Content library tab
โ”œโ”€โ”€ DocumentCanvas.tsx         # Centre panel โ€” zone rendering, block operations, toolbar
โ”œโ”€โ”€ ZonePanel.tsx              # Individual zone โ€” droppable container + block list
โ”œโ”€โ”€ TextBlockEditor.tsx        # TipTap rich-text block (inline editor)
โ”œโ”€โ”€ ImageBlock.tsx             # TriplyDB asset block
โ”œโ”€โ”€ VariableBlock.tsx          # Standalone variable-display block
โ””โ”€โ”€ BindingPanel.tsx           # Right panel โ€” {{placeholder}} โ†’ variableKey bindings

BpmnModeler/DocumentTemplateSelector.tsx is injected into the bpmn-js properties panel for UserTask elements (separate from the Composer itself).


Type model

Defined in packages/frontend/src/types/document.types.ts. Key interfaces:

interface DocumentTemplate {
  id: string;
  name: string;
  description?: string;
  processKey?: string;       // Operaton process definition key
  serviceId?: string;        // Chain Composer service identifier (informational)
  schemaVersion: number;     // currently 1
  zones: DocumentZones;
  bindings: VariableBinding[];
  assets: string[];          // TriplyDB asset URLs for dependency tracking
  createdAt: string;
  updatedAt: string;
  readonly?: boolean;
  status?: 'example' | 'wip' | 'e2e';
  language?: 'en' | 'nl' | 'de';
  organization?: string;
}

interface DocumentZones {
  letterhead: DocumentZone;
  contactInformation: DocumentZone;
  reference: DocumentZone;
  body: DocumentZone;
  closing: DocumentZone;
  signOff: DocumentZone;
  annex?: DocumentZone | null;
}

interface DocumentZone {
  blocks: DocumentBlock[];
}

type BlockType = 'text' | 'image' | 'variable' | 'separator' | 'spacer';

interface DocumentBlock {
  id: string;
  type: BlockType;
  content?: TipTapDoc;       // ProseMirror JSON โ€” for type === 'text'
  assetUrl?: string;         // for type === 'image'
  variableKey?: string;      // for type === 'variable'
  label?: string;
}

interface VariableBinding {
  id: string;
  placeholder: string;       // e.g. "{{permitDecision}}"
  variableKey: string;       // Operaton variable name
  source: 'process' | 'dmn_output';
  label?: string;
}

ZONE_META (also in document.types.ts) maps each ZoneId to a display label, English internal name, required flag, and description. ZONE_ORDER defines the fixed rendering sequence (annex always last).


Storage

DocumentService (services/documentService.ts) provides synchronous CRUD over localStorage. The storage key is linkedDataExplorer_documentTemplates.

DocumentService.getTemplates(): DocumentTemplate[]
DocumentService.getTemplate(id: string): DocumentTemplate | null
DocumentService.saveTemplate(template: DocumentTemplate): void
DocumentService.deleteTemplate(id: string): void

Example document seeding

The example templates are defined inline in DocumentComposer/defaultTemplates.ts and listed in DEFAULT_TEMPLATES, nine in all:

Template id Name
example_treefelling_beschikking Kapvergunning Beschikking (Example)
example_zorgtoeslag_provisional_beschikking Zorgtoeslag Voorlopige Beschikking (Example)
example_zorgtoeslag_final_beschikking Zorgtoeslag Definitieve Beschikking (Example)
example_dvtp_consent_receipt DvTP Toestemmingsbewijs (Example)
board-decision-notification-nl HR โ€” Directiebesluit-notificatie (Voorbeeld, NL)
capacity-claim-handover-nl HR โ€” Overdracht capaciteitsclaim (Voorbeeld, NL)
thuisbatterij_subsidie_beschikking Subsidie Thuisbatterij Beschikking
besluit-gb-besluit Besluit onder gedelegeerde bevoegdheid
besluit-gb-besluit-bestuur Besluit van de bevoegde bestuursautoriteit

On mount, DocumentComposer.tsx seeds by version, like the example forms and processes: it saves every entry of DEFAULT_TEMPLATES whose version in EXAMPLE_VERSIONS (utils/exampleVersions.ts, keyed by template id) is higher than the version this browser has recorded, and then records the new version. A template with no recorded version counts as version 0, so it is written on the next load even when a copy is already stored. A refresh overwrites the stored template, local edits included. exampleVersions.test.ts fails when a default template has no entry. example_treefelling_beschikking is at version 2; the other eight are at 1.

Every example carries status: 'example', which blocks deletion. All of them are readonly: false except example_dvtp_consent_receipt, which is readonly: true: Save is disabled for it, and Save As creates an editable copy.

Some templates mirror a .document file that is deployed with a bundle. thuisbatterij_subsidie_beschikking is kept in step with public/examples/flevoland/thuisbatterij_subsidie_beschikking.document, besluit-gb-besluit with public/examples/flevoland/besluitvorming-gedelegeerd/besluit-gb-besluit.document โ€” the copy the LDE deploys and the RONL Business API renders for signing โ€” and besluit-gb-besluit-bestuur with besluit-gb-besluit-bestuur.document in the same folder, the besluit the competent authority takes after escalation. The copy is inline because the Vite dev server rejects imports from public/; defaultTemplates.test.ts pins each inline copy and its file as identical.

Developer workflow: edit the template in defaultTemplates.ts; where it mirrors a deployed .document file, change that file in the same commit. Bump the template's EXAMPLE_VERSIONS entry in the same change, or browsers that already hold it keep the old copy.


Drag-and-drop

Drag-and-drop is implemented with @dnd-kit/core and @dnd-kit/sortable. The DndContext lives in DocumentComposer.tsx and passes dragEndEvent down to DocumentCanvas.tsx via props (to keep business logic in the canvas component while the context wraps the full three-panel layout).

Two drag types are distinguished via DragData.type:

  • 'new-block' โ€” dragged from the Content library. Resolved in DocumentCanvas by calling createBlock(dragData) and appending to the target zone.
  • 'existing-block' โ€” dragged from an existing block within a zone. Resolved by either reordering within the zone (arrayMove) or moving to a different zone.

Zone droppable IDs use the prefix zone-{zoneId} so that DocumentCanvas can distinguish a drop onto a zone (append to end) from a drop onto a specific block (insert at position).


TextBlockEditor and TipTap readonly sync

TextBlockEditor wraps @tiptap/react. TipTap only reads the editable option at initialisation and does not react to prop changes. A useEffect syncs the readonly prop after mount:

useEffect(() => {
  editor?.setEditable(!readonly);
}, [editor, readonly]);

This mirrors the pattern in the BPMN Modeler's form editors.


BindingPanel โ€” variable discovery

BindingPanel calls fetchVariableHints(processKey) from assetService.ts, which hits:

GET /v1/process/:key/variable-hints

The backend queries the Operaton history API for all variables present in completed instances of the given process definition key, deduplicates by name, and returns an array of { name, type } objects. The response is typed as ProcessVariableHint[].


DocumentTemplateSelector โ€” BPMN integration

DocumentTemplateSelector (BpmnModeler/DocumentTemplateSelector.tsx) is injected into the bpmn-js properties panel alongside FormTemplateSelector whenever a UserTask is selected. It follows the identical injection pattern (see BPMN Modeler developer docs):

// BpmnCanvas.tsx โ€” inside selectionChanged, after FormTemplateSelector injection
if (elementType === 'bpmn:UserTask') {
  const docRoot = ReactDOM.createRoot(docSelectorContainer);
  docRoot.render(
    <DocumentTemplateSelector
      element={selectedElement}
      modeling={modeling}
      selectedDocumentRef={businessObject.get('ronl:documentRef')}
    />
  );
}

ronl:documentRef holds a comma-separated list of template ids, because one task can produce several documents. utils/documentRefs.ts reads and writes it: parseDocumentRefs splits on commas, trims whitespace and drops blanks; formatDocumentRefs joins the ids with a comma, or returns undefined for an empty list so that the attribute is removed rather than left empty. A BPMN with a single id needs no migration.

The selector, labelled Link decision templates, shows each attached template as a removable chip. The select below it is an add action: choosing a template appends its id (-- Add a template --), and once every template is attached the select is disabled (-- All templates attached --). Every change writes the whole list:

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

A chip for an id that has no template in this browser shows the id instead of a name, so an attachment from an imported BPMN stays visible.

Document badge overlay

The purple document badge is rendered in BpmnCanvas.tsx in the element.changed handler, immediately after the green form badge:

const documentRefs = parseDocumentRefs(element.businessObject.get('ronl:documentRef'));
if (documentRefs.length === 0) return;
const label =
  documentRefs.length === 1 ? documentRefs[0] : `${documentRefs.length} documents`;
overlays.add(element.id, 'document-linked', {
  position: { bottom: -36, left: leftOffset }, // below the form badge
  html: `<div class="document-linked-badge" title="${documentRefs.join(', ')}">๐Ÿ“„ ${label}</div>`,
});

With one document the badge names it; with several it reads N documents and the tooltip lists every id. The badge is positioned 36px below the element (vs. 22px for the form badge), so both badges are visible simultaneously without overlapping.

When a process is deployed, the bundle collects document ids from both ronl:documentRef and ronl:signatureRef, splitting each list, and includes the matching templates from local storage as .document resources. A referenced template that is not in local storage is reported in the deploy dialog and blocks the deploy.


Export format

Clicking Export .document serialises the DocumentTemplate object to JSON and downloads it with a .document extension. The file is identical to what is stored in localStorage and can be used as a seed in public/examples/.