Bouwen van een workflow systeem met HTMX en ASP.NET Core - Deel 3: Bouwen van de visuele editor (Nederlands (Dutch))

Bouwen van een workflow systeem met HTMX en ASP.NET Core - Deel 3: Bouwen van de visuele editor

Wednesday, 15 January 2025

//

12 minute read

Inleiding

InDeel 2, bouwden we een krachtige workflow motor.

Maar workflows gedefinieerd in JSON zijn niet erg gebruiksvriendelijk.

  • In deze post maken we een prachtige visuele workflow editor - denk "dummy's Node-RED" - met behulp van HTMX, Alpine.js, TailwindCSS, en DaisyUI.
  • Tegen het einde van deze post heb je:
  • Een sleep-and-drop workflow canvas
  • Visuele knooppuntverbindingen met SVG
  • Real-time knooppuntconfiguratie

Mooie, thema-schakelbare UI

Volledige HTMX-integratie

  1. De visie
  2. We willen dat gebruikers:
  3. Sleep knooppunten vanuit een palet naar een canvas
  4. Knooppunten visueel verbinden

Eigenschappen node instellen in een zijpaneel

Werkstromen opslaan en uitvoeren

Allemaal zonder één lijn van JSON te schrijven!

Technologie StackWaarom deze Stack?

  • HTMX 2.0
  • : Servergestuurde interacties zonder JavaScript te schrijven
  • Perfect voor opslaan/laden

Vermindert de complexiteit van client-sideServer behoudt de bron van de waarheid

  • Alpine.jsunit synonyms for matching user input
  • : Lichtgewicht reactiviteit voor het doek
  • Alleen ~15KB verminktx-dataPerfect voor interactieve UI-componenten

Eenvoudigrichtlijnen

  • TailwindCSS + DaisyUI
  • : Mooie, thematische UI
  • Utility-first CSS

Donker/lichtmodus uit de doosVoorgebouwde onderdelen (kaarten, knoppen, badges)

  • SVG
  • : Native connection rendering
  • Schaalbare, scherpe lijnen

CSS-styleable

Geen externe bibliotheken nodig

┌─────────────────────────────────────────────────────┐
│  Top Toolbar (Name, Save, Run, Cancel)             │
├─────────┬───────────────────────────┬───────────────┤
│  Node   │      Canvas               │    Node       │
│ Palette │    (Drag & Drop)          │  Inspector    │
│         │                            │               │
│ [HTTP]  │   ┌─────┐                 │  Name: ___    │
│ [Xform] │   │Node1│─┐               │  Type: ___    │
│ [Delay] │   └─────┘ │               │  Config: ___  │
│ [Cond]  │           ▼               │  Style: ___   │
│         │      ┌─────┐              │               │
│         │      │Node2│              │               │
│         │      └─────┘              │               │
└─────────┴───────────────────────────┴───────────────┘

Overzicht architectuur

Onze redacteur heeft drie hoofdpanelen:

[Route("workflow")]
public class WorkflowController : BaseController
{
    private readonly WorkflowService _workflowService;
    private readonly WorkflowExecutionService _executionService;

    [HttpGet]
    public async Task<IActionResult> Index()
    {
        var workflows = await _workflowService.GetAllAsync();
        return View("Index", workflows);
    }

    [HttpGet("create")]
    public IActionResult Create()
    {
        var workflow = new WorkflowDefinition
        {
            Name = "New Workflow",
            Nodes = new List<WorkflowNode>(),
            Connections = new List<NodeConnection>()
        };
        return View("Editor", workflow);
    }

    [HttpGet("edit/{id}")]
    public async Task<IActionResult> Edit(string id)
    {
        var workflow = await _workflowService.GetByIdAsync(id);
        return workflow == null ? NotFound() : View("Editor", workflow);
    }

    [HttpPost("save")]
    public async Task<IActionResult> Save([FromBody] WorkflowDefinition workflow)
    {
        var saved = await _workflowService.CreateOrUpdateAsync(workflow);
        return Json(new { success = true, workflowId = saved.Id });
    }

    [HttpPost("execute/{id}")]
    public async Task<IActionResult> Execute(string id,
        [FromBody] Dictionary<string, object>? inputData = null)
    {
        var execution = await _executionService.ExecuteWorkflowAsync(
            id, inputData, User.Identity?.Name ?? "Anonymous");

        return Json(new
        {
            success = true,
            executionId = execution.Id,
            status = execution.Status.ToString()
        });
    }
}

De controller

Eerst, laten we onze controller eindpunten instellen:

@model Mostlylucid.Workflow.Shared.Models.WorkflowDefinition

<div class="container-fluid"
     x-data="workflowEditor(@Html.Raw(JsonSerializer.Serialize(Model)))"
     x-init="init()">

    <!-- Top Toolbar -->
    <div class="navbar bg-base-200 rounded-box shadow-lg mb-6">
        <div class="flex-1">
            <input type="text"
                   x-model="workflow.name"
                   placeholder="Workflow Name"
                   class="input input-ghost text-2xl font-bold w-96" />
        </div>
        <div class="flex-none gap-2">
            <button @click="saveWorkflow()" class="btn btn-success">
                Save
            </button>
            <button @click="runWorkflow()" class="btn btn-primary">
                Run
            </button>
        </div>
    </div>

    <!-- Three-panel layout -->
    <div class="flex gap-4 h-[calc(100vh-200px)]">
        <!-- Left: Node Palette -->
        <!-- Center: Canvas -->
        <!-- Right: Node Inspector -->
    </div>
</div>

De Editor-weergave

Onze editor view maakt gebruik van Alpine.js voor staatsmanagement:workflowEditorAlpine.js State Management

function workflowEditor(initialWorkflow) {
    return {
        workflow: initialWorkflow || {
            id: crypto.randomUUID(),
            name: 'New Workflow',
            nodes: [],
            connections: [],
            isEnabled: true
        },
        selectedNode: null,
        draggedNodeType: null,
        connecting: false,

        init() {
            // Initialize node inputs as JSON for editing
            this.workflow.nodes.forEach(node => {
                node.inputsJson = JSON.stringify(node.inputs || {}, null, 2);
            });
        },

        // ... methods below
    };
}

De

Alpine component beheert alle editor staat:

<div class="w-64 bg-base-200 rounded-box p-4 overflow-y-auto">
    <h3 class="text-lg font-bold mb-4">📦 Available Nodes</h3>

    <div class="space-y-3">
        <div class="card bg-base-100 shadow cursor-pointer"
             draggable="true"
             @dragstart="dragStart($event, 'HttpRequest', '🌐', '#10B981')">
            <div class="card-body p-4">
                <h4 class="font-semibold">🌐 HTTP Request</h4>
                <p class="text-xs">Make API calls</p>
            </div>
        </div>

        <div class="card bg-base-100 shadow cursor-pointer"
             draggable="true"
             @dragstart="dragStart($event, 'Transform', '🔄', '#3B82F6')">
            <div class="card-body p-4">
                <h4 class="font-semibold">🔄 Transform</h4>
                <p class="text-xs">Process data</p>
            </div>
        </div>

        <!-- More node types... -->
    </div>
</div>

Slepen en neerzetten: Het Knooppunt Palet

dragStart(event, nodeType, icon, color) {
    this.draggedNodeType = { type: nodeType, icon, color };
}

De linker zijbalk bevat sleepbare knooppunttypes:

Sleepstarthandler

<div class="flex-1 bg-base-300 rounded-box relative overflow-hidden"
     @drop="dropNode($event)"
     @dragover.prevent
     id="workflow-canvas">

    <div class="absolute inset-0 overflow-auto p-8">
        <!-- Grid background -->
        <div class="absolute inset-0 opacity-20"
             style="background-image: repeating-linear-gradient(...);
                    background-size: 20px 20px;">
        </div>

        <!-- Render nodes -->
        <template x-for="node in workflow.nodes" :key="node.id">
            <div :id="'node-' + node.id"
                 class="absolute card shadow-2xl cursor-move"
                 :style="`left: ${node.position.x}px;
                          top: ${node.position.y}px;
                          width: ${node.style.width}px;
                          background-color: ${node.style.backgroundColor};`"
                 @mousedown="selectNode(node)"
                 draggable="true"
                 @dragstart="dragNode($event, node)">

                <div class="card-body p-4">
                    <div class="flex items-center gap-2">
                        <span x-text="node.style.icon || '📦'"></span>
                        <h3 x-text="node.name"></h3>
                    </div>

                    <!-- Connection points -->
                    <div class="flex justify-between mt-2">
                        <div class="badge badge-primary"
                             @click.stop="startConnection(node, 'input')">
                            ◀
                        </div>
                        <div class="badge badge-success"
                             @click.stop="startConnection(node, 'output')">
                            ▶
                        </div>
                    </div>
                </div>
            </div>
        </template>

        <!-- SVG connections layer -->
        <svg class="absolute inset-0 pointer-events-none"
             style="width: 100%; height: 100%;">
            <template x-for="conn in workflow.connections" :key="conn.id">
                <path :d="getConnectionPath(conn)"
                      stroke="#94A3B8"
                      stroke-width="3"
                      fill="none"
                      marker-end="url(#arrowhead)"/>
            </template>
            <defs>
                <marker id="arrowhead" markerWidth="10" markerHeight="10">
                    <polygon points="0 0, 10 3, 0 6" fill="#94A3B8" />
                </marker>
            </defs>
        </svg>
    </div>
</div>

De Canvas

Het doek is waar de magie gebeurt:

dropNode(event) {
    if (!this.draggedNodeType) return;

    const canvas = document.getElementById('canvas-container');
    const rect = canvas.getBoundingClientRect();

    // Calculate drop position
    const x = event.clientX - rect.left + canvas.scrollLeft - 100;
    const y = event.clientY - rect.top + canvas.scrollTop - 50;

    // Create new node
    const newNode = {
        id: crypto.randomUUID(),
        type: this.draggedNodeType.type,
        name: this.draggedNodeType.type,
        inputs: {},
        outputs: {},
        position: { x, y },
        style: {
            backgroundColor: this.draggedNodeType.color,
            textColor: '#FFFFFF',
            borderColor: this.draggedNodeType.color,
            icon: this.draggedNodeType.icon,
            width: 200
        },
        inputsJson: '{}'
    };

    this.workflow.nodes.push(newNode);

    // Set as start node if first node
    if (this.workflow.nodes.length === 1) {
        this.workflow.startNodeId = newNode.id;
    }

    this.draggedNodeType = null;
}

Drop Handler

Als er een knooppunt op het doek valt:

getConnectionPath(conn) {
    const source = this.workflow.nodes.find(n => n.id === conn.sourceNodeId);
    const target = this.workflow.nodes.find(n => n.id === conn.targetNodeId);

    if (!source || !target) return '';

    // Calculate connection points
    const x1 = source.position.x + (source.style?.width || 200);
    const y1 = source.position.y + 50;
    const x2 = target.position.x;
    const y2 = target.position.y + 50;

    // Create curved Bezier path
    const midX = (x1 + x2) / 2;
    return `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
}

SVG-verbindingsrendering

Verbindingen tussen knooppunten worden getekend met SVG paden:

Dit creëert gladde, gebogen verbindingen tussen knooppunten!

<div class="w-80 bg-base-200 rounded-box p-4 overflow-y-auto"
     x-show="selectedNode">
    <h3 class="text-lg font-bold mb-4">⚙️ Node Settings</h3>

    <template x-if="selectedNode">
        <div class="space-y-4">
            <div class="form-control">
                <label class="label">Node Name</label>
                <input type="text"
                       x-model="selectedNode.name"
                       class="input input-bordered" />
            </div>

            <div class="form-control">
                <label class="label">Background Color</label>
                <input type="color"
                       x-model="selectedNode.style.backgroundColor"
                       class="input w-full h-12" />
            </div>

            <div class="form-control">
                <label class="label">Icon (emoji)</label>
                <input type="text"
                       x-model="selectedNode.style.icon"
                       class="input input-bordered"
                       maxlength="2" />
            </div>

            <div class="form-control">
                <label class="label">Inputs (JSON)</label>
                <textarea x-model="selectedNode.inputsJson"
                          @input="updateNodeInputs()"
                          class="textarea textarea-bordered font-mono"
                          rows="6"></textarea>
            </div>

            <button @click="setStartNode(selectedNode)"
                    class="btn btn-sm"
                    :class="workflow.startNodeId === selectedNode.id ?
                            'btn-success' : 'btn-outline'">
                Set as Start Node
            </button>
        </div>
    </template>
</div>

De Knooppunt Inspecteur

Het rechterpaneel laat gebruikers de geselecteerde knooppunten configureren:

updateNodeInputs() {
    if (!this.selectedNode) return;

    try {
        this.selectedNode.inputs = JSON.parse(this.selectedNode.inputsJson);
    } catch (e) {
        console.error('Invalid JSON:', e);
        // Could show error badge
    }
}

Live JSON ontleden

Als gebruikers de JSON ingangen bewerken, ontleden we ze in real-time:

startConnection(node, type) {
    if (this.connecting) {
        // Complete connection
        if (this.connectionStart.node.id !== node.id) {
            this.workflow.connections.push({
                id: crypto.randomUUID(),
                sourceNodeId: this.connectionStart.node.id,
                targetNodeId: node.id,
                sourceOutput: 'default',
                targetInput: 'default'
            });
        }
        this.connecting = false;
        this.connectionStart = null;
    } else {
        // Start connection
        this.connecting = true;
        this.connectionStart = { node, type };
    }
}

Knooppunten verbinden

Gebruikers verbinden knooppunten door te klikken op verbindingspunten:

async saveWorkflow() {
    try {
        const response = await fetch('/workflow/save', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(this.workflow)
        });

        const result = await response.json();
        if (result.success) {
            alert('Workflow saved!');
            window.location.href = '/workflow';
        } else {
            alert('Error: ' + result.message);
        }
    } catch (error) {
        alert('Error saving: ' + error.message);
    }
}

Werkstromen opslaan met HTMX

Wanneer de gebruiker op "Save" klikt, gebruiken we een eenvoudige fetch call (kan ook HTMX zijn):

async runWorkflow() {
    try {
        // Save first
        await this.saveWorkflow();

        // Then execute
        const response = await fetch(`/workflow/execute/${this.workflow.id}`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({})
        });

        const result = await response.json();
        if (result.success) {
            alert(`Workflow started! Execution ID: ${result.executionId}`);
        }
    } catch (error) {
        alert('Error: ' + error.message);
    }
}

Uitvoeren van werkstromen

workflows direct vanuit de editor uitvoeren:

<!-- In your layout -->
<select data-choose-theme class="select select-bordered">
    <option value="light">Light</option>
    <option value="dark">Dark</option>
    <option value="cupcake">Cupcake</option>
    <option value="synthwave">Synthwave</option>
</select>

Thema wisselen met DaisyUIbg-base-200, text-base-contentDaisyUI biedt automatische thema switching.

Gebruikers kunnen schakelen tussen licht en donker modi, en al onze workflow nodes automatisch aanpassen!

Al onze kleuren maken gebruik van DaisyUI's semantische lessen (

<div class="flex flex-col lg:flex-row gap-4">
    <!-- On mobile: stack vertically -->
    <!-- On desktop: side-by-side -->
</div>

), zodat ze zich automatisch aanpassen aan het gekozen thema!

Responsief ontwerp

Onze drie-panel lay-out past zich aan verschillende schermgroottes aan:

Prestatieoptimalisaties

Virtueel scrollen

let dragTimeout;
dragNode(event, node) {
    clearTimeout(dragTimeout);
    dragTimeout = setTimeout(() => {
        // Update position
    }, 16); // ~60fps
}

Voor workflows met honderden knooppunten kunnen we virtueel scrollen implementeren (alleen zichtbare knooppunten weergeven).

Maar voor de meeste use cases, het renderen van 50-100 knooppunten is perfect prima met moderne browsers.

<button @click="deleteNode(node)"
        class="btn"
        aria-label="Delete node"
        @keydown.delete="deleteNode(node)">
    🗑️
</button>

Afgewezen updates

Bij het slepen van knooppunten, kunnen we positie-updates ontbouncen:

✅ ToegankelijkheidWe hebben de juiste ARIA labels en toetsenbord navigatie toegevoegd: ✅ Wat we gebouwd hebbenIn deze post, creëerden we: ✅ Knooppalet slepen en neerzetten- Sleepbare knooppunttypes ✅ Visuele canvas- Rasterachtergrond met gepositioneerde knooppunten ✅ SVG-verbindingen- Gebogen paden tussen knooppunten ✅ Knoopinspecteur- Real-time configuratiepaneel ✅ Themaondersteuning- Werkt met alle DaisyUI thema's ✅ Responsieve lay-out- Past zich aan schermgrootte aan

HTMX-integratie

  • Servergestuurde opslag/load
{
  "name": "GitHub Stars Monitor",
  "nodes": [
    {
      "id": "1",
      "type": "HttpRequest",
      "name": "Fetch Repo Data",
      "inputs": {
        "url": "https://api.github.com/repos/{{owner}}/{{repo}}",
        "method": "GET"
      },
      "position": { "x": 100, "y": 100 },
      "style": { "backgroundColor": "#10B981", "icon": "🌐" }
    },
    {
      "id": "2",
      "type": "Condition",
      "name": "Check Stars",
      "inputs": {
        "condition": "{{stargazers_count}} > 100"
      },
      "position": { "x": 100, "y": 250 },
      "style": { "backgroundColor": "#8B5CF6", "icon": "🔀" }
    },
    {
      "id": "3",
      "type": "Log",
      "name": "Log Success",
      "inputs": {
        "message": "Repo has {{stargazers_count}} stars!",
        "level": "info"
      },
      "position": { "x": 100, "y": 400 },
      "style": { "backgroundColor": "#3B82F6", "icon": "📝" }
    }
  ],
  "connections": [
    { "sourceNodeId": "1", "targetNodeId": "2" },
    { "sourceNodeId": "2", "targetNodeId": "3" }
  ],
  "startNodeId": "1"
}

Alpine.js reactiviteit

  • Gladde, reactieve UIVoorbeeld workflowHier is een complete workflow die je kunt bouwen in de editor:

  • Wat is het volgende?

  • In

  • Deel 4

  • , we zullen Hangfire integreren voor:

  • Geplande workflow-uitvoering

API polling triggers

Beheer van de triggerstaat

Achtergrondtaakverwerking

Controle van het dashboard

  • **We zullen onze workflows echt autonoom maken!**Conclusie
  • **We hebben een productie-ready visuele workflow editor gebouwd met minimale JavaScript!**Door het combineren van HTMX voor servercommunicatie, Alpine.js voor interactiviteit en TailwindCSS/DaisyUI voor styling, hebben we iets gemaakt dat modern aanvoelt en onderhoudbaar is.
  • **De belangrijkste inzichten:**SVG
  • is perfect voor het renderen van verbindingenAlpine.jsunit synonyms for matching user input

biedt net genoeg reactiviteit

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.