Skip to content
D
Documentation

Build a workflow editor

how-to
4 min readUpdated

Use this pattern when your application needs to edit a branching graph and test which steps a condition reaches. You get a Trigger → Condition workflow with Yes and No branches, an inspector for the condition's threshold, an insertion button, and a test log computed from the mounted diagram's live links. The test is a local simulation; it does not deploy anything or send messages.

Grafloria owns the graph and its interactions. Your application owns the condition, the inspector and the execution policy, as in the computing flows demo.

1. Define the steps and their ports

Put the shared code in workflow.ts. Describe the initial drawing with NodeSpec and EdgeSpec. Each condition output has its own port ID: the test chooses a port, not a destination's position on the canvas.

PortSpec carries direction, data type and connection gates. These ports carry numbers. Explicit start/end gates prevent an input from starting a wire or an output from accepting one.

ts
import type {
  NodeSpec, EdgeSpec, PortSpec, NodeInput, EdgeInput,
} from '@grafloria/renderer';
import type { DiagramModel } from '@grafloria/engine';

let nextId = 0;
function newId(): string {
  return `workflow-edit-${++nextId}`;
}

export function port(id: string, type: 'input' | 'output', index = 0): PortSpec {
  return {
    id, type, index, side: type === 'input' ? 'left' : 'right',
    dataType: 'number',
    gating: {
      isConnectableStart: type === 'output',
      isConnectableEnd: type === 'input',
      allowSelfLink: false,
      allowDuplicateLinks: false,
    },
  };
}

export const nodes: NodeSpec[] = [
  { id: 'trigger', label: 'Trigger', position: { x: 40, y: 150 },
    size: { width: 120, height: 60 }, ports: [port('trigger.out', 'output')] },
  { id: 'condition', label: 'Value ≥ threshold', position: { x: 230, y: 150 },
    size: { width: 160, height: 80 }, data: { threshold: 10 },
    ports: [port('condition.in', 'input'), port('condition.yes', 'output'),
      port('condition.no', 'output', 1)] },
  { id: 'yes', label: 'Approve', position: { x: 610, y: 60 },
    size: { width: 120, height: 60 }, ports: [port('yes.in', 'input')] },
  { id: 'no', label: 'Review', position: { x: 610, y: 260 },
    size: { width: 120, height: 60 }, ports: [port('no.in', 'input')] },
];

export const edges: EdgeSpec[] = [
  { id: 'start', source: 'trigger', target: 'condition',
    sourceHandle: 'trigger.out', targetHandle: 'condition.in' },
  { id: 'yes-edge', source: 'condition', target: 'yes', label: 'Yes',
    sourceHandle: 'condition.yes', targetHandle: 'yes.in' },
  { id: 'no-edge', source: 'condition', target: 'no', label: 'No',
    sourceHandle: 'condition.no', targetHandle: 'no.in' },
];

export class WorkflowEditor {
  constructor(
    private readonly model: DiagramModel,
    private readonly apply: (nodes: NodeInput[], edges: EdgeInput[]) => void,
  ) {}

  inspect(id: string): string {
    const node = this.model.getNode(id);
    if (!node) return 'Select a step';
    return `${node.getLabel() ?? id}: ${node.getIncomingLinks().length} incoming, `
      + `${node.getOutgoingLinks().length} outgoing`;
  }

  threshold(): number {
    return Number(this.model.getNode('condition')?.data.threshold ?? 10);
  }

  setThreshold(value: number): void {
    if (!Number.isFinite(value)) return;
    const next: NodeInput[] = this.model.getNodes().map(node =>
      node.id === 'condition'
        ? { id: node.id, data: { threshold: value } }
        : node,
    );
    this.apply(next, this.model.getLinks());
  }

  insert(): void {
    const condition = this.model.getNode('condition');
    const old = condition?.getOutgoingLinks().find(link =>
      link.sourcePortId === 'condition.yes');
    if (!old?.targetNodeId) return;

    const id = newId();
    const step: NodeSpec = {
      id, label: 'Audit', position: { x: 430, y: 60 },
      size: { width: 120, height: 60 },
      ports: [port(`${id}.in`, 'input'), port(`${id}.out`, 'output')],
    };
    const nextEdges: EdgeInput[] = [
      ...this.model.getLinks().filter(link => link.id !== old.id),
      { id: newId(), source: 'condition', target: id, label: 'Yes',
        sourceHandle: 'condition.yes', targetHandle: `${id}.in` },
      { id: newId(), source: id, target: old.targetNodeId,
        sourceHandle: `${id}.out`, targetHandle: old.targetPortId },
    ];
    this.apply([...this.model.getNodes(), step], nextEdges);
  }

  test(value: number): string {
    if (!Number.isFinite(value)) return 'Enter a finite number';
    const queue = ['trigger'];
    const visited = new Set<string>();
    const log: string[] = [];
    while (queue.length) {
      const id = queue.shift();
      if (!id || visited.has(id)) continue;
      visited.add(id);
      const node = this.model.getNode(id);
      if (!node) continue;
      log.push(`${node.getLabel() ?? id}: completed`);
      const chosen = value >= this.threshold() ? 'condition.yes' : 'condition.no';
      for (const link of node.getOutgoingLinks()) {
        if (id === 'condition' && link.sourcePortId !== chosen) continue;
        if (link.targetNodeId) queue.push(link.targetNodeId);
      }
    }
    return log.join('\n');
  }
}

The inspector reads NodeModel incoming and outgoing links from the owning DiagramModel. Insertion replaces the first Yes link with two links through an Audit step. Existing live models pass through unchanged via NodeInput and EdgeInput; only the new step and links need specs.

2. Mount the editor in your framework

Install the line for your framework in your own project:

bash
# JavaScript
npm install @grafloria/element @grafloria/engine @grafloria/renderer
# Angular
npm install @grafloria/angular @grafloria/element @grafloria/engine @grafloria/renderer @angular/common @angular/core @angular/forms @angular/platform-browser rxjs
# Qwik
npm install @grafloria/qwik @grafloria/element @grafloria/engine @grafloria/renderer @builder.io/qwik
# React
npm install @grafloria/react @grafloria/element @grafloria/engine @grafloria/renderer react react-dom
# Vue
npm install @grafloria/vue @grafloria/element @grafloria/engine @grafloria/renderer vue

Connect WorkflowEditor to the mounted graph so the inspector, insertion button and test log share its live topology; see edit nodes for framework mounting and instance access.

Use the shipped LIGHT_THEME. The canvas shows the four steps and two labelled branches. The adjacent inspector starts with the condition selected in application state; click a step to inspect its live connections.

Save the matching component beside workflow.ts. The JavaScript tab exports a mount function: call it with your application's host element, and call its returned cleanup function when that view unmounts.

ts
import { render } from '@grafloria/element';
import { LIGHT_THEME } from '@grafloria/renderer';
import { nodes, edges, WorkflowEditor } from './workflow';

export function mountWorkflow(host: HTMLElement): () => void {
  host.innerHTML = `
    <div data-canvas style="height:400px"></div>
    <aside>
      <p data-inspector></p>
      <label>Threshold <input data-threshold type="number" value="10"></label>
      <button data-insert>Insert Audit on Yes</button>
      <label>Test value <input data-value type="number" value="12"></label>
      <button data-test>Test workflow</button>
      <pre data-log>Not run</pre>
    </aside>`;
  const canvas = host.querySelector<HTMLElement>('[data-canvas]')!;
  const inspector = host.querySelector<HTMLElement>('[data-inspector]')!;
  const threshold = host.querySelector<HTMLInputElement>('[data-threshold]')!;
  const value = host.querySelector<HTMLInputElement>('[data-value]')!;
  const log = host.querySelector<HTMLElement>('[data-log]')!;
  const instance = render({ nodes, edges }, canvas, { theme: LIGHT_THEME });
  const editor = new WorkflowEditor(instance.getModel(), (nextNodes, nextEdges) => {
    instance.setNodes(nextNodes);
    instance.setEdges(nextEdges);
    instance.renderNow();
  });
  inspector.textContent = editor.inspect('condition');
  const off = instance.on('selection:change', change => {
    inspector.textContent = editor.inspect(change.nodes[0]?.id ?? '');
  });
  threshold.onchange = () => editor.setThreshold(threshold.valueAsNumber);
  host.querySelector<HTMLButtonElement>('[data-insert]')!.onclick = () => editor.insert();
  host.querySelector<HTMLButtonElement>('[data-test]')!.onclick = () => {
    log.textContent = editor.test(value.valueAsNumber);
  };
  return () => { off(); instance.dispose(); host.replaceChildren(); };
}

The Angular sample starts with the condition's connection counts beneath the branching canvas.

Angular: Trigger feeds the condition, with Yes leading to Approve and No to Review; threshold, insertion and test controls sit below.

The React sample displays the same graph and an initial Not run log.

The Vue sample also starts with threshold 10 and test value 12.

The Qwik sample shows the initial graph before a test runs.

The framework components own canvas teardown. The Qwik controller is created in the browser initialization callback and kept with noSerialize() because it holds a live model and a function, not resumable data.

3. Insert, inspect and test a branch

Click Insert Audit on Yes. The Yes path now passes through Audit before reaching Approve; the No path still reaches Review. Click Audit to see its one incoming and one outgoing link in the inspector.

Set Threshold to change the condition's model data. Press Test workflow with a value above or equal to that threshold to list the Yes path; use a smaller value to list the No path. After insertion, the Yes log includes Audit. The returned string lists completed steps in traversal order, and each framework displays it in its pre element.

The test reads getOutgoingLinks() afresh for every node. It follows each LinkModel's sourcePortId and targetNodeId, so rewiring changes the next test without rebuilding a private adjacency list. The visited set bounds the simulation when the graph contains a cycle. This is a reachability simulation, not a dependency scheduler: it does not wait for all inputs at a merge or execute remote actions.

For computed downstream values instead of a completion log, use getIncomingLinks() to read upstream values, apply your node's operation, and publish the result. The computing flows demo demonstrates that policy; Grafloria supplies topology, not your arithmetic.

Connection options that matter

Set these on authored ports. PortModel holds their live values after mounting.

OptionTypeDefaultWhat it does
PortSpec.dataTypestringUnsetDeclares the carried type for connection validity and glyph colour.
PortSpec.gating.isConnectableStartbooleantrueAllows or refuses a link starting here.
PortSpec.gating.isConnectableEndbooleantrueAllows or refuses a link ending here.
PortSpec.gating.fromMaxLinksnumber | nullUnlimitedCaps outgoing links.
PortSpec.gating.toMaxLinksnumber | nullUnlimitedCaps incoming links.
PortSpec.gating.allowSelfLinkbooleanfalseAllows links back to the same node.
PortSpec.gating.allowDuplicateLinksbooleantrueAllows another link between the same ports.

Editing boundaries and next steps

This example reconciles insertion and threshold changes through the binding's data surface. For undoable application edits on the same history as canvas gestures, use the command pattern in commands and history.

Repeated insertions use the same Audit position to keep this example focused on topology. Add explicit layout to arrange a larger workflow after insertion. Save the live document using save and restore documents, rather than treating the initial specs as the edited workflow.

Try the workflow automation builder for output-mounted + buttons, action-specific inspectors and animated execution. Its source shows a larger application built around the same model-owned steps and port-selected branches.

Was this page helpful?