Build a workflow editor by mounting a diagram, representing each step as a node, constraining connections with ports and validators, grouping related steps, and writing execution status back to the live model. The result is a canvas that users can edit and a run action that visibly marks the steps it visits.
When to use this
Use this pattern when a workflow is a document rather than a fixed image. The diagram owns nodes, links, groups, and positions; your application owns the step data and the execution policy. The same document model works behind each framework binding.
Give the canvas a height. A diagram fills its container, so a zero-height wrapper produces a blank page.
1. Mount the workflow
The plain JavaScript front door is render. It takes a data spec, a target element, and optional render options. It returns the live DiagramInstance. The example below shows two workflow steps, directional ports, a connection, and a group around the action.
jsimport { render } from '@grafloria/element';
const host = document.getElementById('workflow');
if (!host) throw new Error('Missing #workflow');
host.style.height = '420px';
const nodes = [
{
id: 'trigger',
type: 'workflow-step',
position: { x: 60, y: 120 },
size: { width: 190, height: 78 },
data: { title: 'New signup', kind: 'Trigger', status: 'idle' },
ports: [{ id: 'trigger-out', side: 'right', type: 'output' }],
},
{
id: 'email',
type: 'workflow-step',
position: { x: 360, y: 120 },
size: { width: 190, height: 78 },
data: { title: 'Send welcome email', kind: 'Action', status: 'idle' },
ports: [
{ id: 'email-in', side: 'left', type: 'input' },
{ id: 'email-out', side: 'right', type: 'output' },
],
},
];
const edges = [{ id: 'signup-to-email', source: 'trigger', target: 'email' }];
const groups = [{
id: 'notifications',
label: 'Notifications',
children: ['email'],
padding: 24,
}];
const instance = render(
{ nodes, edges, groups },
host,
);
instance.fitView();
html<div id="workflow" style="height: 420px"></div>
You see a trigger connected to an action inside a labelled group. The groups value is a GroupSpec; its children move with the group. edges uses the host-facing EdgeSpec shape. fitView() frames all content after mounting.
Framework bindings
Use the binding's component when your application already uses a framework. These examples use the same data shape and give the mounted instance to onInit.
tsimport { render } from '@grafloria/element';
const host = document.getElementById('workflow');
if (!host) throw new Error('Missing #workflow');
host.style.height = '420px';
const nodes = [
{ id: 'trigger', position: { x: 40, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Trigger' } },
{ id: 'deploy', position: { x: 330, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Deploy' } },
];
const edges = [{ id: 'e1', source: 'trigger', target: 'deploy' }];
const instance = render({ nodes, edges }, host);
instance.fitView();
tsimport { Component } from '@angular/core';
import { GrafloriaDiagramComponent } from '@grafloria/angular';
import type { DiagramInstance } from '@grafloria/renderer';
@Component({
standalone: true,
imports: [GrafloriaDiagramComponent],
template: '<grafloria-diagram [spec]="spec" (ready)="onReady($event)" style="display:block;height:420px"></grafloria-diagram>',
})
export class WorkflowComponent {
readonly nodes = [
{ id: 'trigger', position: { x: 40, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Trigger' } },
{ id: 'deploy', position: { x: 330, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Deploy' } },
];
readonly edges = [{ id: 'e1', source: 'trigger', target: 'deploy' }];
readonly spec = { nodes: this.nodes, edges: this.edges };
onReady(instance: DiagramInstance): void { instance.fitView(); }
}
tsximport { component$, $ } from '@builder.io/qwik';
import { GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik';
export default component$(() => {
const nodes = [
{ id: 'trigger', position: { x: 40, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Trigger' } },
{ id: 'deploy', position: { x: 330, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Deploy' } },
];
const edges = [{ id: 'e1', source: 'trigger', target: 'deploy' }];
const onInit = $((api: DiagramInstance) => { api.fitView(); });
return <div style={{ height: '420px' }}><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} onInit$={onInit} /></div>;
});
tsximport { useCallback } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import type { DiagramInstance } from '@grafloria/react';
export function Workflow() {
const nodes = [
{ id: 'trigger', position: { x: 40, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Trigger' } },
{ id: 'deploy', position: { x: 330, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Deploy' } },
];
const edges = [{ id: 'e1', source: 'trigger', target: 'deploy' }];
const onInit = useCallback((instance: DiagramInstance) => { instance.fitView(); }, []);
return <div style={{ height: '420px' }}><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} onInit={onInit} /></div>;
}
vue<script setup lang="ts"> import { GrafloriaFlow } from '@grafloria/vue'; import type { DiagramInstance } from '@grafloria/vue'; const nodes = [ { id: 'trigger', position: { x: 40, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Trigger' } }, { id: 'deploy', position: { x: 330, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Deploy' } }, ]; const edges = [{ id: 'e1', source: 'trigger', target: 'deploy' }]; function onInit(instance: DiagramInstance): void { instance.fitView(); } </script> <template> <div style="height: 420px"><GrafloriaFlow :default-nodes="nodes" :default-edges="edges" @init="onInit" /></div> </template>
The JavaScript and Angular bindings mount into a host; Qwik, React, and Vue render their flow component as an element. Keep the instance in the framework's callback or signal rather than in a variable that a render replaces.
2. Enforce workflow connections
Ports express direction in the node data. Add a validator for domain rules. registerConnectionValidator gives every registered validator veto power: return true to allow a connection or a string to reject it with a user-facing reason.
tsimport { registerConnectionValidator } from '@grafloria/renderer';
import type { DiagramInstance } from '@grafloria/renderer';
let removeValidator: (() => void) | undefined;
function onInit(instance: DiagramInstance): void {
removeValidator = registerConnectionValidator(({ sourceNode, targetNode }) => {
if (targetNode?.getData('kind') === 'Trigger') return 'A trigger cannot receive input';
if (sourceNode?.getData('kind') === 'Action' && targetNode?.getData('kind') === 'Action') return 'Actions need a condition between them';
return true;
});
}
function disposeWorkflow(): void { removeValidator?.(); }
An input port cannot start an outgoing wire. The validator rejects an otherwise possible connection and leaves the diagram unchanged. Register the validator when the instance mounts and call the returned disposer when the workflow view unmounts; validators are process-wide.
3. Add groups and edit the document
Use the instance for reconciliation. setNodes(), setEdges(), and setGroups() update the rendered document; use NodeModel through getModel() when an execution step needs a live model mutation. batchUpdate() coalesces several mutations into one frame.
tsimport { render } from '@grafloria/element';
import type { DiagramInstance } from '@grafloria/renderer';
function addStep(instance: DiagramInstance): void {
instance.setNodes([
{ id: 'trigger', position: { x: 40, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Trigger' } },
{ id: 'check', position: { x: 300, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Check health' } },
{ id: 'deploy', position: { x: 560, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Deploy' } },
]);
instance.setEdges([
{ id: 'e1', source: 'trigger', target: 'check' },
{ id: 'e2', source: 'check', target: 'deploy' },
]);
instance.setGroups([{ id: 'release', label: 'Release', children: ['check', 'deploy'], padding: 24 }]);
instance.fitView();
instance.renderNow();
}
const host = document.createElement('div');
host.style.height = '420px';
document.body.append(host);
const instance = render({
nodes: [{ id: 'start', position: { x: 40, y: 100 }, size: { width: 180, height: 70 }, data: { title: 'Starting workflow' } }],
edges: [],
}, host);
addStep(instance);
The canvas now shows a three-step flow with the last two steps travelling inside the Release zone. Reconciliation removes entities no longer present in the arrays; removing a group keeps its boxes.
4. Show execution state
Keep execution state in node data so the node renderer can paint a spinner, success mark, or failure mark. The engine model is the live source for this update.
tsimport type { DiagramInstance } from '@grafloria/renderer';
async function run(instance: DiagramInstance): Promise<void> {
const model = instance.getModel();
const trigger = model.getNode('trigger');
const deploy = model.getNode('deploy');
if (!trigger || !deploy) return;
instance.batchUpdate(() => {
trigger.setData('status', 'success');
deploy.setData('status', 'running');
});
instance.renderNow();
await new Promise<void>((resolve) => window.setTimeout(resolve, 500));
instance.batchUpdate(() => { deploy.setData('status', 'success'); });
instance.renderNow();
}
After the first update, the trigger is complete and the deploy step is running; after the delay, both steps carry success. Your custom node view reads data.status and paints those states. renderNow() makes the repaint synchronous when code must measure the result immediately.
The repository's workflow automation builder combines this model with add-step menus, a side-panel editor, sticky-note groups, save/load, undo, and a branch-aware test run.
For item-count execution, branching, pause/step controls, and a run log, see the n8n-style workflow builder.
Options that matter
| Option | Type | Default | What it does |
|---|---|---|---|
interaction.portVisibility | string | binding default | Set to always to keep workflow ports visible while users connect steps. |
defaultNodes | NodeSpec[] | — | Supplies the initial nodes to an uncontrolled framework component. |
defaultEdges | EdgeSpec[] | — | Supplies the initial links to an uncontrolled framework component. |
groups | Array<GroupSpec | GroupModel> | — | Reconciles groups on a controlled binding. |
defaultGroups | Array<GroupSpec | GroupModel> | — | Supplies initial groups to an uncontrolled framework component. |
fitView | boolean | — | Enables initial fitting in bindings that expose this prop; the instance method is explicit and works in every binding. |
Pitfalls
- Do not pass Mermaid text to
render(). Itsspecis structured data; use the instance's text methods for Mermaid-compatible import and export. - Do not give the canvas an auto-sized wrapper with no height. Set a height on the host or its containing layout row.
- Dispose connection validators when the view unmounts. A validator registered for one workflow otherwise affects later workflows.
- Keep the live instance out of reactive document data. Store serializable nodes, edges, groups, and step data; use the instance for rendering and model operations.
- Use
batchUpdate()for a state change that touches several nodes, then callrenderNow()only when you need an immediate paint.
Was this page helpful?