Skip to content

Plugin API

The Modeler exposes a JavaScript Plugin API that allows other Drupal modules to extend the modeler UI at runtime. Modules can register toolbar widgets (buttons, toggles) and panels (sidebar or bottom sections) that interact with the modeler's state through a comprehensive public API that supports both reading state and modifying the workflow graph.

Concepts

The plugin system has three main parts:

Concept Description
Global object window.WorkflowModeler -- the entry point for all plugin operations. Available as soon as the modeler bundle loads.
Widgets Small inline elements (typically buttons) rendered inside the toolbar. Registered with registerWidget().
Panels Larger content areas rendered alongside the canvas. Registered with registerPanel().
Public API window.WorkflowModeler.api -- provides access to nodes, edges, selection state, model metadata, available components, contexts, and more. Includes event subscriptions and methods to add, update, and remove nodes and edges. Available once the modeler has mounted.

Lifecycle

Registrations can happen before or after the modeler mounts. This is important because Drupal module scripts may load in any order relative to the modeler bundle.

Modeler bundle loads   -->  window.WorkflowModeler created
                             Drupal.behaviors.workflowModelerReact registered
Drupal.attachBehaviors -->  React app starts mounting
                             Plugin behaviors attach and listen for ready event
React app mounted      -->  WorkflowModeler.api becomes available
                             onReady() callbacks fire
                             workflow-modeler:ready DOM event dispatched
                             queued widgets and panels render
React app unmounts     -->  WorkflowModeler.api set to null
(HTMX navigation)          registrations survive for re-mount

The workflow-modeler:ready DOM event

When the React app has fully mounted and the plugin API is available, the modeler dispatches a workflow-modeler:ready custom event on document. This is the recommended way for external Drupal behaviors to know when it is safe to call WorkflowModeler.registerWidget() and friends.

The event is dispatched asynchronously (via setTimeout(…, 0)) to guarantee it fires after Drupal.attachBehaviors() has completed, even when the modeler mounts synchronously during HTMX navigation.

document.addEventListener('workflow-modeler:ready', () => {
  // WorkflowModeler and WorkflowModeler.api are now available.
  console.log('Modeler is ready!');
}, { once: true });

Toolbar widgets

Toolbar widgets are inline elements rendered inside the modeler toolbar. They are ideal for toggle buttons, status indicators, or action triggers.

Registering a widget

const modeler = window.WorkflowModeler;

modeler.registerWidget({
  id: 'my_module_toggle',
  label: 'My Feature',
  position: 'right',   // 'left' or 'right'
  weight: 5,            // lower = rendered first
  render(container, api) {
    const btn = document.createElement('button');
    btn.className = 'toolbar-btn';
    btn.title = 'Toggle My Feature';
    btn.textContent = 'MF';
    btn.addEventListener('click', () => {
      btn.classList.toggle('active');
    });
    container.appendChild(btn);
  },
  destroy(container) {
    container.innerHTML = '';
  },
});

Widget descriptor reference

Property Type Default Description
id string required Unique identifier for the widget.
label string required Human-readable label (used for aria-label).
position 'left' \| 'right' 'right' Where to render in the toolbar. left places it near the event button and search bar; right places it near the docs link, save button, and kebab menu.
weight number 0 Ordering weight within the position. Lower values appear first.
render (container, api) => void required Called once when the widget mounts. Receives a DOM element to render into and the public API.
destroy (container) => void -- Optional cleanup callback called before the widget is removed.

Styling

For visual consistency, render a <button> with the toolbar-btn CSS class. Add the active class to indicate a toggled-on state:

btn.className = 'toolbar-btn';       // normal state
btn.className = 'toolbar-btn active'; // toggled on

Removing a widget

window.WorkflowModeler.unregisterWidget('my_module_toggle');

Panels

Panels are larger content areas rendered alongside the canvas. They can be positioned on the left, right, or bottom of the modeler and support resize and collapse.

Registering a panel

const modeler = window.WorkflowModeler;

modeler.registerPanel({
  id: 'my_module_info',
  label: 'Model Info',
  position: 'right',
  weight: 10,
  width: 350,
  render(container, api) {
    const heading = document.createElement('h3');
    heading.textContent = 'Model Information';
    container.appendChild(heading);

    const info = document.createElement('div');
    const data = api.getModelData();
    info.textContent = data?.metadata?.label || 'Untitled';
    container.appendChild(info);

    // Subscribe to live updates
    api.onSelectionChange((node, edge) => {
      if (node) {
        info.textContent = 'Selected: ' + (node.data.label || node.id);
      } else if (edge) {
        info.textContent = 'Edge: ' + edge.source + ' -> ' + edge.target;
      } else {
        info.textContent = data?.metadata?.label || 'Nothing selected';
      }
    });
  },
  destroy(container) {
    container.innerHTML = '';
  },
});

Panel descriptor reference

Property Type Default Description
id string required Unique identifier for the panel.
label string required Human-readable label shown in the collapsed tab.
position 'left' \| 'right' \| 'bottom' 'right' Where to render the panel. right places it after the property panel; left places it before the canvas; bottom places it below the canvas.
weight number 0 Ordering weight within the position. Lower values appear first.
width number 320 Initial width in pixels (min: 200, max: 600).
render (container, api) => void required Called once when the panel mounts. Receives a DOM element to render into and the public API.
destroy (container) => void -- Optional cleanup callback called before the panel is removed.
onResize (width, height) => void -- Optional callback fired after the user finishes resizing the panel.

Panel features

Each registered panel automatically receives:

  • Collapse/expand toggle -- a button in the panel header that collapses the panel to a narrow vertical tab with a rotated label.
  • Resize handle -- drag the panel edge to adjust its width.
  • Error boundary -- if the panel's render function throws, only that panel shows an error; the rest of the modeler continues working.

Removing a panel

window.WorkflowModeler.unregisterPanel('my_module_info');

Public API reference

The public API is available as window.WorkflowModeler.api after the modeler mounts. It is also passed as the second argument to every render() callback.

Data safety

All data returned by API getters is a deep-cloned snapshot. Mutating the returned objects will not affect the modeler's internal state.

Mutation methods and read-only mode

All mutation methods (addNode, updateNode, removeNode, addEdge, updateEdge, removeEdge, setCondition, removeCondition, autoLayout) are no-ops in read-only mode. They return false or null to indicate the operation was rejected. Always check the return value if your plugin needs to know whether the operation succeeded.

Mutations automatically integrate with the modeler's undo/redo history and unsaved-changes tracking -- there is no need to manage this manually.

Getters

Graph data

Method Returns Description
getNodes() PluginNode[] All current nodes (id, type, data, position).
getEdges() PluginEdge[] All current edges (id, source, target, type, data).
getNodeById(nodeId) PluginNode \| null A single node by ID, or null if not found.
getEdgeById(edgeId) PluginEdge \| null A single edge by ID, or null if not found.
getSelectedNode() PluginNode \| null The currently selected node, or null.
getSelectedEdge() PluginEdge \| null The currently selected edge, or null.
getModelData() PluginModelData \| null Model metadata (id, version, label, description, tags).

State information

Method Returns Description
isReadOnly() boolean Whether the modeler is in read-only mode.
isDarkMode() boolean Whether dark mode is active.
getHistoryState() PluginHistoryState Current undo/redo availability ({ canUndo, canRedo }).
getErrors() PluginError[] Current error log entries ({ id, message, dismissed }).

Components and contexts

Method Returns Description
getComponents() PluginComponent[] All available component definitions (events, actions, conditions, gateways, subprocesses). Each entry contains plugin, label, type, provider, description, documentationUrl, and componentType.
getComponentLabels() PluginComponentLabels Model-owner-provided terminology (e.g. { start: 'Event', element: 'Action', link: 'Condition' }).
getContexts() PluginContext[] Available contexts ({ id, topic, model_owner }).
getSelectedContextId() string \| null The currently selected context ID, or null when no filter is active.
getFilteredNodeIds() string[] \| null IDs of start nodes whose flows are currently visible, or null when all flows are shown.

Event subscriptions

All subscription methods return an unsubscribe function. Call it to stop receiving updates.

Graph events

Method Callback signature Description
onSelectionChange(cb) (node, edge) => void Fires when the selected node or edge changes.
onNodesChange(cb) (nodes) => void Fires when nodes are added, removed, or reordered.
onEdgesChange(cb) (edges) => void Fires when edges are added, removed, or reordered.
onModelDataChange(cb) (data) => void Fires when model metadata changes.

State events

Method Callback signature Description
onDarkModeChange(cb) (isDarkMode) => void Fires when dark mode is toggled.
onContextChange(cb) (contextId) => void Fires when the active context filter changes. contextId is null when no filter is active.
onComponentsChange(cb) (components) => void Fires when the list of available components changes.
onReadOnlyChange(cb) (isReadOnly) => void Fires when the read-only state changes.
const unsubscribe = api.onSelectionChange((node, edge) => {
  console.log('Selection changed:', node, edge);
});

// Later, stop listening:
unsubscribe();

Selection and viewport actions

Method Description
selectNode(nodeId) Select a node by ID. Pass null to clear. No-op in read-only mode.
selectEdge(edgeId) Select an edge by ID. Pass null to clear. No-op in read-only mode.
clearSelection() Clear the current selection (node, edge, and multi-selection).
focusNode(nodeId) Animate the viewport to center on a node.
fitView() Fit all nodes into the viewport with animation.

Node mutations

Method Returns Description
addNode(descriptor) string \| null Add a new node. Returns the generated node ID, or null in read-only mode.
updateNode(nodeId, updates) boolean Update a node's data. Returns true on success.
removeNode(nodeId) boolean Remove a node and all its connected edges. Returns true on success.

AddNodeDescriptor

Property Type Required Description
plugin string yes Plugin identifier (e.g. 'eca_content_entity:create').
componentType number yes Integer component-type constant: 1 = start, 2 = subprocess, 4 = element, 6 = gateway.
label string no Human-readable label. Defaults to the plugin ID suffix.
position { x, y } no Canvas position. If omitted, placed automatically to the right of existing nodes.
configuration object no Initial configuration key/value pairs.
description string no Short description.
documentationUrl string no URL to plugin documentation.

UpdateNodeDescriptor

Property Type Description
label string New human-readable label.
position { x, y } New canvas position.
configuration object Configuration values (shallow-merged with existing).
locked boolean Whether the node is locked.
annotation string Annotation text.
// Add a new action node
const nodeId = api.addNode({
  plugin: 'eca_content_entity:create',
  componentType: 4, // element
  label: 'Create Article',
});

// Update its configuration
if (nodeId) {
  api.updateNode(nodeId, {
    configuration: { type: 'article', status: '1' },
  });
}

// Remove it
api.removeNode(nodeId);

Edge mutations

Method Returns Description
addEdge(sourceNodeId, targetNodeId) string \| null Connect two nodes. Returns the generated edge ID, or null in read-only mode or if either node does not exist.
updateEdge(edgeId, updates) boolean Update an edge's data. Returns true on success.
removeEdge(edgeId) boolean Remove an edge. Returns true on success.
setCondition(edgeId, condition) boolean Attach a condition to an edge (converts it to a condition edge). Returns true on success.
removeCondition(edgeId) boolean Remove the condition from an edge (converts it back to a default edge). Returns true on success.

UpdateEdgeDescriptor

Property Type Description
locked boolean Whether the edge is locked.
annotation string Annotation text (only meaningful for condition edges).

SetConditionDescriptor

Property Type Required Description
plugin string yes Condition plugin identifier.
label string no Human-readable condition label. Defaults to the plugin ID suffix.
configuration object no Initial condition configuration values.
// Connect two nodes
const edgeId = api.addEdge('event_1', 'action_1');

// Attach a condition
if (edgeId) {
  api.setCondition(edgeId, {
    plugin: 'eca_base:eca_scalar_comparison',
    label: 'Status is Published',
    configuration: { left: '[node:status]', right: '1' },
  });
}

// Remove the condition (edge remains)
api.removeCondition(edgeId);

// Remove the edge entirely
api.removeEdge(edgeId);

Canvas actions

Method Description
autoLayout() Trigger automatic layout of all unlocked nodes. No-op in read-only mode.
setDarkMode(enabled) Toggle dark mode on or off.
setFlowFilter(startNodeIds) Set the flow filter to show only the specified start node flows. Pass null to show all flows.

Ready callback (in-bundle)

Use onReady() to defer logic until the API is available. This is intended for code that runs inside the modeler bundle or scripts that are guaranteed to load before the modeler mounts:

window.WorkflowModeler.onReady(() => {
  const api = window.WorkflowModeler.api;
  console.log('Model has', api.getNodes().length, 'nodes');
});

If the modeler is already mounted when onReady() is called, the callback fires synchronously.

Ready event (Drupal behaviors)

For external Drupal modules that use Drupal.behaviors, listen for the workflow-modeler:ready DOM event instead. This avoids timing issues where onReady() might fire before the behavior has attached its listener. See the Drupal integration section below for the full pattern.

Complete example: toolbar button that toggles a panel

This example shows the recommended pattern for a Drupal module that adds a toolbar button which opens and closes a custom panel. It uses a proper Drupal behavior that listens for the workflow-modeler:ready event, with public methods exposed on a Drupal.* namespace.

/**
 * @file
 * My Module - Modeler plugin.
 */

(function (Drupal, once) {

  'use strict';

  Drupal.myModuleModelerPlugin = Drupal.myModuleModelerPlugin || {};

  const PANEL_ID = 'my_module_panel';
  const WIDGET_ID = 'my_module_toggle';
  let panelActive = false;

  /**
   * Drupal behavior for the modeler plugin.
   *
   * Listens for the `workflow-modeler:ready` event and registers the
   * widget once the modeler is available.
   *
   * @type {Drupal~behavior}
   */
  Drupal.behaviors.myModuleModelerPlugin = {
    attach: function (context) {
      once('my-module-modeler-plugin', 'html', context).forEach(function () {
        document.addEventListener(
          'workflow-modeler:ready',
          Drupal.myModuleModelerPlugin.registerToggleWidget,
          { once: true }
        );
      });
    },
  };

  /**
   * Registers the toolbar widget with the WorkflowModeler.
   */
  Drupal.myModuleModelerPlugin.registerToggleWidget = function () {
    const modeler = window.WorkflowModeler;
    if (!modeler) {
      return;
    }

    try {
      modeler.registerWidget({
        id: WIDGET_ID,
        label: 'My Feature',
        position: 'right',
        weight: 5,
        render: function (container) {
          const btn = document.createElement('button');
          btn.className = 'toolbar-btn';
          btn.title = 'Toggle My Feature';
          btn.textContent = 'MF';

          btn.addEventListener('click', function () {
            if (panelActive) {
              modeler.unregisterPanel(PANEL_ID);
              btn.classList.remove('active');
              panelActive = false;
            }
            else {
              modeler.registerPanel({
                id: PANEL_ID,
                label: 'My Feature',
                position: 'right',
                width: 400,
                render: function (panelContainer, panelApi) {
                  renderPanel(panelContainer, panelApi);
                },
                destroy: function (panelContainer) {
                  panelContainer.innerHTML = '';
                },
              });
              btn.classList.add('active');
              panelActive = true;
            }
          });

          container.appendChild(btn);
        },
        destroy: function (container) {
          if (panelActive) {
            modeler.unregisterPanel(PANEL_ID);
            panelActive = false;
          }
          container.innerHTML = '';
        },
      });
    }
    catch (err) {
      Drupal.throwError(err);
    }
  };

  /**
   * Renders the panel contents.
   *
   * @param {HTMLElement} container
   *   The panel container element.
   * @param {object} api
   *   The WorkflowModeler plugin API.
   */
  function renderPanel(container, api) {
    container.innerHTML = '<h3>My Feature</h3>';

    const status = document.createElement('p');
    status.textContent = 'Select a node to analyze.';
    container.appendChild(status);

    api.onSelectionChange(function (node, edge) {
      if (node) {
        status.textContent = 'Selected: ' + (node.data.label || node.id);
      }
      else {
        status.textContent = 'Select a node to analyze.';
      }
    });

    const stats = document.createElement('p');
    stats.textContent = 'Nodes: ' + api.getNodes().length;
    container.appendChild(stats);

    api.onNodesChange(function (nodes) {
      stats.textContent = 'Nodes: ' + nodes.length;
    });
  }

})(Drupal, once);

Drupal integration

Library definition

Define a library in your module's *.libraries.yml with core/drupal and core/once as dependencies. The library does not need to depend on modeler/react-ui directly -- instead, use hook_library_info_alter() to inject it as a dependency of the modeler library (see below).

# my_module.libraries.yml
modeler_plugin:
  js:
    js/modeler-plugin.js: {}
  dependencies:
    - core/drupal
    - core/once

Loading the plugin with the modeler

The recommended way to ensure your plugin loads together with the modeler is to use hook_library_info_alter() to add your library as a dependency of modeler/react-ui. This way your script is automatically included whenever the modeler is loaded -- no need for hook_page_attachments() or manual render array attachments.

<?php

namespace Drupal\my_module\Hook;

use Drupal\Core\Hook\Attribute\Hook;

class LibraryHooks {

  #[Hook('library_info_alter')]
  public function libraryInfoAlter(array &$libraries, string $extension): void {
    if ($extension === 'modeler' && isset($libraries['react-ui'])) {
      $libraries['react-ui']['dependencies'][] = 'my_module/modeler_plugin';
    }
  }

}

This approach has several advantages:

  • The plugin script only loads on pages where the modeler is present.
  • Load order is guaranteed: the modeler bundle loads first (since your library is a dependency of it, not the other way round), so window.WorkflowModeler is available by the time your script executes.
  • Multiple modules can each add their own plugin library the same way without conflicting.

Namespacing conventions

Public methods that need to be accessible outside the closure (e.g. as event listener callbacks) should be placed on a Drupal.* namespace object. The behavior key should match the namespace:

// Namespace for public methods
Drupal.myModuleModelerPlugin = Drupal.myModuleModelerPlugin || {};

// Behavior uses the same key
Drupal.behaviors.myModuleModelerPlugin = { ... };

// Registration function on the namespace
Drupal.myModuleModelerPlugin.registerToggleWidget = function () { ... };

This makes the registration function callable from outside the closure (e.g. for debugging or testing) and ensures the event listener reference is stable.

Why window.WorkflowModeler instead of the bare global?

Always access the modeler via window.WorkflowModeler (typically captured into a local variable). The bare identifier WorkflowModeler will not resolve inside a 'use strict' closure unless it is explicitly passed in.

Complete example: node statistics panel

This example registers a panel (without a toolbar button) that displays live statistics about the current model.

(function (Drupal, once) {

  'use strict';

  Drupal.myModuleStatsPlugin = Drupal.myModuleStatsPlugin || {};

  Drupal.behaviors.myModuleStatsPlugin = {
    attach: function (context) {
      once('my-module-stats-plugin', 'html', context).forEach(function () {
        document.addEventListener(
          'workflow-modeler:ready',
          Drupal.myModuleStatsPlugin.registerPanel,
          { once: true }
        );
      });
    },
  };

  Drupal.myModuleStatsPlugin.registerPanel = function () {
    const modeler = window.WorkflowModeler;
    if (!modeler) {
      return;
    }

    try {
      modeler.registerPanel({
        id: 'my_module_stats',
        label: 'Statistics',
        position: 'right',
        weight: 20,
        width: 280,
        render: function (container, api) {
          container.innerHTML = '';

          const table = document.createElement('table');
          table.style.width = '100%';
          table.style.borderCollapse = 'collapse';

          function updateStats() {
            const nodes = api.getNodes();
            const edges = api.getEdges();
            const types = {};
            nodes.forEach(function (n) {
              const t = n.type || 'unknown';
              types[t] = (types[t] || 0) + 1;
            });

            table.innerHTML =
              '<tr><th>Metric</th><th>Count</th></tr>' +
              '<tr><td>Total Nodes</td><td>' + nodes.length + '</td></tr>' +
              '<tr><td>Total Edges</td><td>' + edges.length + '</td></tr>' +
              Object.entries(types).map(function (entry) {
                return '<tr><td>' + entry[0] + '</td><td>' + entry[1] + '</td></tr>';
              }).join('');
          }

          container.appendChild(table);
          updateStats();

          api.onNodesChange(updateStats);
          api.onEdgesChange(updateStats);
        },
      });
    }
    catch (err) {
      Drupal.throwError(err);
    }
  };

})(Drupal, once);

Complete example: panel with mutation capabilities

This example shows a plugin panel that can add pre-configured action nodes to the workflow -- demonstrating the mutation API. It lists available components and lets the user click to add them with a default configuration.

(function (Drupal, once) {

  'use strict';

  Drupal.myModuleQuickActions = Drupal.myModuleQuickActions || {};

  Drupal.behaviors.myModuleQuickActions = {
    attach: function (context) {
      once('my-module-quick-actions', 'html', context).forEach(function () {
        document.addEventListener(
          'workflow-modeler:ready',
          Drupal.myModuleQuickActions.registerPanel,
          { once: true }
        );
      });
    },
  };

  Drupal.myModuleQuickActions.registerPanel = function () {
    var modeler = window.WorkflowModeler;
    if (!modeler) {
      return;
    }

    try {
      modeler.registerPanel({
        id: 'my_module_quick_actions',
        label: 'Quick Actions',
        position: 'left',
        weight: 5,
        width: 300,
        render: function (container, api) {
          container.innerHTML = '';

          var heading = document.createElement('h3');
          heading.textContent = 'Quick Actions';
          container.appendChild(heading);

          // Show read-only warning when applicable
          if (api.isReadOnly()) {
            var warning = document.createElement('p');
            warning.textContent = 'Read-only mode — actions disabled.';
            warning.style.color = 'var(--modeler-color-text-secondary)';
            container.appendChild(warning);
            return;
          }

          // List available action components (componentType 4 = element)
          var components = api.getComponents().filter(function (c) {
            return c.componentType === 4;
          });

          var list = document.createElement('ul');
          list.style.listStyle = 'none';
          list.style.padding = '0';

          components.slice(0, 10).forEach(function (comp) {
            var item = document.createElement('li');
            var btn = document.createElement('button');
            btn.className = 'toolbar-btn';
            btn.style.width = '100%';
            btn.style.textAlign = 'left';
            btn.style.marginBottom = '4px';
            btn.textContent = comp.label;
            btn.title = comp.description || comp.plugin;

            btn.addEventListener('click', function () {
              var nodeId = api.addNode({
                plugin: comp.plugin,
                componentType: comp.componentType,
                label: comp.label,
                description: comp.description,
                documentationUrl: comp.documentationUrl,
              });

              if (nodeId) {
                // Select and focus the new node
                api.selectNode(nodeId);
                api.focusNode(nodeId);
              }
            });

            item.appendChild(btn);
            list.appendChild(item);
          });

          container.appendChild(list);

          // Add a "Connect selected" button
          var connectBtn = document.createElement('button');
          connectBtn.className = 'toolbar-btn';
          connectBtn.style.width = '100%';
          connectBtn.style.marginTop = '12px';
          connectBtn.textContent = 'Connect selected to last added';
          connectBtn.disabled = true;

          var lastAddedId = null;

          api.onSelectionChange(function (node) {
            connectBtn.disabled = !node || !lastAddedId
              || node.id === lastAddedId;
          });

          connectBtn.addEventListener('click', function () {
            var selected = api.getSelectedNode();
            if (selected && lastAddedId) {
              api.addEdge(selected.id, lastAddedId);
            }
          });

          container.appendChild(connectBtn);

          // Track the last node we added
          api.onNodesChange(function (nodes) {
            if (nodes.length > 0) {
              lastAddedId = nodes[nodes.length - 1].id;
            }
          });

          // React to read-only changes
          api.onReadOnlyChange(function (readOnly) {
            list.querySelectorAll('button').forEach(function (b) {
              b.disabled = readOnly;
            });
            connectBtn.disabled = readOnly;
          });
        },
        destroy: function (container) {
          container.innerHTML = '';
        },
      });
    }
    catch (err) {
      Drupal.throwError(err);
    }
  };

})(Drupal, once);

Error handling

Plugin code runs inside error boundaries. If a panel's render() function throws, only that panel displays an error message -- the rest of the modeler continues working. The error boundary offers automatic retry with exponential backoff, plus a manual "Try Again" button.

Widget errors are caught and logged to the console but do not crash the toolbar.

API stability

The Plugin API is designed to be stable across minor versions of the modeler. The following guarantees apply:

  • Additive changes only: New methods and properties may be added to the API in minor releases, but existing ones will not be removed or changed in breaking ways.
  • Deep-cloned data: Plugins always receive copies, never internal references. Changes to internal data structures do not affect the plugin-facing shape.
  • Position values: The set of allowed positions (left, right, bottom for panels; left, right for widgets) may be extended but not reduced.