# Build a workflow editor

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](https://grafloria.com/demos/interaction/computing-flows.html).

## 1. Define the steps and their ports

Put the shared code in `workflow.ts`. Describe the initial drawing with [`NodeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-nodespec) and [`EdgeSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-edgespec). Each condition output has its own port ID: the test chooses a port, not a destination's position on the canvas.

[`PortSpec`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance-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 title="workflow.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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-nodemodel) incoming and outgoing links from the owning [`DiagramModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-diagrammodel). Insertion replaces the first Yes link with two links through an Audit step. Existing live models pass through unchanged via [`NodeInput`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance#nodeinput) and [`EdgeInput`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-instance#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](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/edit-nodes) for framework mounting and instance access.

Use the shipped [`LIGHT_THEME`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-renderer-themes-constants). 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.

:::code-group
```ts title="JavaScript"
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(); };
}
```
```ts title="Angular"
import { AfterViewInit, Component, signal, viewChild } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import { LIGHT_THEME, type NodeInput, type EdgeInput } from '@grafloria/renderer';
import { nodes, edges, WorkflowEditor } from './workflow';

@Component({
  selector: 'app-workflow', standalone: true, imports: [DiagramCanvasComponent],
  template: `
    <grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
      [theme]="theme" style="display:block;height:400px"
      (selectionChange)="inspect($event.nodes.length ? $event.nodes[0].id : '')" />
    <aside>
      <p>{{ description() }}</p>
      <label>Threshold <input #threshold type="number" value="10"
        (change)="editor?.setThreshold(threshold.valueAsNumber)"></label>
      <button (click)="editor?.insert()">Insert Audit on Yes</button>
      <label>Test value <input #value type="number" value="12"></label>
      <button (click)="test(value.valueAsNumber)">Test workflow</button>
      <pre>{{ log() }}</pre>
    </aside>`,
})
export class WorkflowComponent implements AfterViewInit {
  readonly canvas = viewChild.required(DiagramCanvasComponent);
  nodes: NodeInput[] = [...nodes];
  edges: EdgeInput[] = [...edges];
  readonly theme = LIGHT_THEME;
  readonly description = signal('Select a step');
  readonly log = signal('Not run');
  editor?: WorkflowEditor;

  ngAfterViewInit(): void {
    const canvas = this.canvas();
    const model = canvas.activeEngine()?.getDiagram();
    if (!model) return;
    this.editor = new WorkflowEditor(model, (nextNodes, nextEdges) => {
      this.nodes = nextNodes;
      this.edges = nextEdges;
    });
    this.inspect('condition');
  }
  inspect(id: string): void {
    this.description.set(this.editor?.inspect(id) ?? 'Select a step');
  }
  test(value: number): void {
    this.log.set(this.editor?.test(value) ?? 'Canvas not ready');
  }
}
```
```tsx title="Qwik"
import { component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import { LIGHT_THEME } from '@grafloria/renderer';
import { nodes, edges, WorkflowEditor } from './workflow';

export default component$(() => {
  const editor = useSignal<NoSerialize<WorkflowEditor>>();
  const description = useSignal('Select a step');
  const value = useSignal(12);
  const log = useSignal('Not run');
  return <section>
    <div style={{ height: '400px' }}>
      <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} theme={LIGHT_THEME}
        onInit$={instance => {
          editor.value = noSerialize(new WorkflowEditor(instance.getModel(), (ns, es) => {
            instance.setNodes(ns); instance.setEdges(es); instance.renderNow();
          }));
          description.value = editor.value?.inspect('condition') ?? 'Select a step';
        }}
        onSelectionChange$={change => {
          description.value = editor.value?.inspect(change.nodes[0]?.id ?? '') ?? 'Select a step';
        }} />
    </div>
    <aside>
      <p>{description.value}</p>
      <label>Threshold <input type="number" value={10}
        onChange$={(_, input) => editor.value?.setThreshold(input.valueAsNumber)} /></label>
      <button onClick$={() => editor.value?.insert()}>Insert Audit on Yes</button>
      <label>Test value <input type="number" value={value.value}
        onInput$={(_, input) => { value.value = input.valueAsNumber; }} /></label>
      <button onClick$={() => { log.value = editor.value?.test(value.value) ?? 'Canvas not ready'; }}>
        Test workflow
      </button>
      <pre>{log.value}</pre>
    </aside>
  </section>;
});
```
```tsx title="React"
import { useRef, useState } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import { LIGHT_THEME } from '@grafloria/renderer';
import { nodes, edges, WorkflowEditor } from './workflow';

export default function Workflow() {
  const editor = useRef<WorkflowEditor | null>(null);
  const [description, setDescription] = useState('Select a step');
  const [value, setValue] = useState(12);
  const [log, setLog] = useState('Not run');
  return <section>
    <div style={{ height: 400 }}>
      <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} theme={LIGHT_THEME}
        onInit={instance => {
          editor.current = new WorkflowEditor(instance.getModel(), (ns, es) => {
            instance.setNodes(ns); instance.setEdges(es);
          });
          setDescription(editor.current.inspect('condition'));
        }}
        onSelectionChange={change => {
          setDescription(editor.current?.inspect(change.nodes[0]?.id ?? '') ?? 'Select a step');
        }} />
    </div>
    <aside>
      <p>{description}</p>
      <label>Threshold <input type="number" defaultValue={10}
        onChange={event => editor.current?.setThreshold(event.currentTarget.valueAsNumber)} /></label>
      <button onClick={() => editor.current?.insert()}>Insert Audit on Yes</button>
      <label>Test value <input type="number" value={value}
        onChange={event => setValue(event.currentTarget.valueAsNumber)} /></label>
      <button onClick={() => setLog(editor.current?.test(value) ?? 'Canvas not ready')}>
        Test workflow
      </button>
      <pre>{log}</pre>
    </aside>
  </section>;
}
```
```vue title="Vue"
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import { GrafloriaFlow } from '@grafloria/vue';
import { LIGHT_THEME, type DiagramInstance } from '@grafloria/renderer';
import { nodes, edges, WorkflowEditor } from './workflow';

const editor = shallowRef<WorkflowEditor>();
const description = ref('Select a step');
const value = ref(12);
const threshold = ref(10);
const log = ref('Not run');
function init(instance: DiagramInstance): void {
  editor.value = new WorkflowEditor(instance.getModel(), (ns, es) => {
    instance.setNodes(ns); instance.setEdges(es); instance.renderNow();
  });
  inspect('condition');
}
function inspect(id: string): void {
  description.value = editor.value?.inspect(id) ?? 'Select a step';
}
</script>

<template>
  <section>
    <div style="height:400px">
      <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" :theme="LIGHT_THEME"
        @init="init" @selection-change="inspect($event.nodes[0]?.id ?? '')" />
    </div>
    <aside>
      <p>{{ description }}</p>
      <label>Threshold <input v-model.number="threshold" type="number"
        @change="editor?.setThreshold(Number(threshold))"></label>
      <button @click="editor?.insert()">Insert Audit on Yes</button>
      <label>Test value <input v-model.number="value" type="number"></label>
      <button @click="log = editor?.test(Number(value)) ?? 'Canvas not ready'">Test workflow</button>
      <pre>{{ log }}</pre>
    </aside>
  </section>
</template>
```
:::

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.](https://atloriaassets.blob.core.windows.net/assets/a438221c-c1af-407c-b9e6-a56c986db846/e30aabc6325126d10838438a78d2f041.png)

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`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-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](https://grafloria.com/demos/interaction/computing-flows.html) demonstrates that policy; Grafloria supplies topology, not your arithmetic.

## Connection options that matter

Set these on authored ports. [`PortModel`](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/grafloria-engine-models-portmodel) holds their live values after mounting.

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `PortSpec.dataType` | `string` | Unset | Declares the carried type for connection validity and glyph colour. |
| `PortSpec.gating.isConnectableStart` | `boolean` | `true` | Allows or refuses a link starting here. |
| `PortSpec.gating.isConnectableEnd` | `boolean` | `true` | Allows or refuses a link ending here. |
| `PortSpec.gating.fromMaxLinks` | `number \| null` | Unlimited | Caps outgoing links. |
| `PortSpec.gating.toMaxLinks` | `number \| null` | Unlimited | Caps incoming links. |
| `PortSpec.gating.allowSelfLink` | `boolean` | `false` | Allows links back to the same node. |
| `PortSpec.gating.allowDuplicateLinks` | `boolean` | `true` | Allows 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](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/commands-and-history).

Repeated insertions use the same Audit position to keep this example focused on topology. Add [explicit layout](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/lay-out-a-diagram) to arrange a larger workflow after insertion. Save the live document using [save and restore documents](https://bench-grafloria-61d.atloria.app/p/bench-grafloria-61d-mQCmWpDrxv/developer/save-and-restore-documents), rather than treating the initial specs as the edited workflow.

Try the [workflow automation builder](https://grafloria.com/demos/interaction/workflow-builder.html) for output-mounted + buttons, action-specific inspectors and animated execution. Its [source](https://github.com/grafloria/grafloria/blob/cccb505e76c7af26c8bafecaba8146db7d8df928/demos/interaction/workflow-builder.html) shows a larger application built around the same model-owned steps and port-selected branches.
