Back to "Aufbau eines Workflow-Systems mit HTMX und ASP.NET Core - Teil 3: Aufbau des Visual Editors"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

Alpine.js ASP.NET HTMX TailwindCSS Workflow

Aufbau eines Workflow-Systems mit HTMX und ASP.NET Core - Teil 3: Aufbau des Visual Editors

Wednesday, 15 January 2025

Einleitung

ImTeil 2Wir haben eine leistungsstarke Workflow-Engine gebaut.

Aber Workflows, die in JSON definiert sind, sind nicht sehr benutzerfreundlich.

  • In diesem Beitrag erstellen wir einen atemberaubenden visuellen Workflow-Editor - denken Sie "Dummy's Node-RED" - mit HTMX, Alpine.js, TailwindCSS und DaisyUI.
  • Am Ende dieses Postens haben Sie:
  • Eine Drag-and-Drop-Workflow-Leinwand
  • Visuelle Knotenverbindungen mit SVG
  • Konfiguration des Knotens in Echtzeit

Schöne, theme-schaltbare Benutzeroberfläche

Vollständige HTMX-Integration

  1. Die Vision
  2. Wir möchten, dass die Nutzer:
  3. Ziehen Sie Knoten aus einer Palette auf eine Leinwand
  4. Knoten visuell verbinden

Konfigurieren der Knoteneigenschaften in einem Seitenpanel

Workflows speichern und ausführen

Alles ohne eine einzige Zeile von JSON zu schreiben!

Technologie StackWarum dieser Stack?

  • HTMX 2.0
  • : Server-gesteuerte Interaktionen ohne Schreiben von JavaScript
  • Perfekt für den Spar-/Lastbetrieb

Reduziert die Komplexität auf der Client-SeiteServer pflegt die Quelle der Wahrheit

  • Alpine.js
  • : Leichte Reaktivität für die Leinwand
  • Nur ~15KB minifiziertx-dataPerfekt für interaktive UI-Komponenten

EinfachRichtlinien

  • EndwindCSS + DaisyUI
  • : Schöne, thematische Benutzeroberfläche
  • Dienstprogramm-erste CSS

Dunkler/heller Modus aus dem Kasten herausVorgefertigte Komponenten (Karten, Knöpfe, Abzeichen)

  • SVG
  • : Native Verbindung Rendering
  • Skalierbare, knackige Linien

CSS-Styleable

Keine externen Bibliotheken erforderlich

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

Überblick über die Architektur

Unser Editor hat drei Hauptpanels:

[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()
        });
    }
}

Der Controller

Zuerst richten wir unsere Controller-Endpunkte ein:

@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>

Die Editoransicht

Unsere Redaktionsansicht verwendet Alpine.js für die Landesverwaltung:workflowEditorAlpine.js Staatsverwaltung

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
    };
}

Das

Alpine Komponente verwaltet alle Redaktionszustände:

<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>

Drag and Drop: Die Knotenpalette

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

Die linke Seitenleiste enthält schleppbare Node-Typen:

Drag Start Handler

<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>

Die Leinwand

Die Leinwand ist, wo die Magie geschieht:

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

Wenn ein Knoten auf der Leinwand fallen gelassen wird:

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-Verbindung Rendering

Verbindungen zwischen Knoten werden mit SVG-Pfaden gezeichnet:

Dadurch entstehen glatte, gekrümmte Verbindungen zwischen den Knoten!

<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>

Der Node-Inspektor

Mit dem rechten Panel können Benutzer ausgewählte Knoten konfigurieren:

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 Parsing

Wenn Benutzer die JSON-Eingänge bearbeiten, analysieren wir sie in Echtzeit:

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 };
    }
}

Knoten verbinden

Benutzer verbinden Knoten, indem Sie auf Verbindungspunkte klicken:

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);
    }
}

Einsparung von Workflows mit HTMX

Wenn der Benutzer auf "Speichern" klickt, verwenden wir einen einfachen Abruf (kann auch HTMX sein):

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);
    }
}

Durchführen von Workflows

Ausführen von Workflows direkt vom Editor:

<!-- 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-Umschaltung mit DaisyUIbg-base-200, text-base-contentDaisyUI bietet eine automatische Themenumschaltung.

Benutzer können zwischen hellen und dunklen Modi umschalten, und alle unsere Workflow-Knoten passen sich automatisch an!

Alle unsere Farben verwenden DaisyUI semantische Klassen (

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

), so dass sie sich automatisch an das gewählte Thema anpassen!

Responsives Design

Unser Drei-Panel-Layout passt sich unterschiedlichen Bildschirmgrößen an:

Leistungsoptimierungen

Virtuelles Scrollen

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

Für Workflows mit Hunderten von Knoten konnten wir virtuelles Scrollen implementieren (nur sichtbare Knoten darstellen).

Aber für die meisten Anwendungsfälle, Rendering 50-100 Knoten ist völlig in Ordnung mit modernen Browsern.

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

Debounced-Updates

Beim Ziehen von Knoten könnten wir die Positionsaktualisierungen debounce:

✅ ZugänglichkeitWir haben die richtigen ARIA-Etiketten und Tastatur-Navigation hinzugefügt: ✅ Was wir aufgebaut habenIn diesem Beitrag haben wir erstellt: ✅ Drag-and-Drop-Node-Palette- Draggable Knotentypen ✅ Visuelle Leinwand- Grid-Hintergrund mit positionierten Knoten ✅ SVG-Verbindungen- Gebogene Pfade zwischen Knoten ✅ Node-Inspektor- Konfigurationspanel in Echtzeit ✅ Unterstützung für Themen- Arbeitet mit allen DaisyUI Themen ✅ Responsives Layout- Passt sich der Bildschirmgröße an

HTMX-Integration

  • Server-gesteuerte Einsparung/Last
{
  "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-Reaktivität

  • Glatte, reaktive BenutzeroberflächeBeispiel WorkflowHier ist ein vollständiger Workflow, den Sie im Editor erstellen können:

  • Was kommt als Nächstes?

  • Im

  • Teil 4

  • Wir integrieren Hangfire für:

  • Geplante Ausführung des Workflows

API-Abfrageauslöser

Auslösung des Zustandsmanagements

Hintergrund der Auftragsverarbeitung

Überwachung des Dashboards

  • **Wir machen unsere Arbeitsabläufe wirklich autonom!**Schlußfolgerung
  • **Wir haben einen produktionsfähigen visuellen Workflow-Editor mit minimalem JavaScript aufgebaut!**Durch die Kombination von HTMX für Serverkommunikation, Alpine.js für Interaktivität und TailwindCSS/DaisyUI für Styling haben wir etwas geschaffen, das sich modern anfühlt und aufrechtzuerhalten ist.
  • **Die wichtigsten Erkenntnisse:**SVG
  • ist perfekt für die Verbindung RenderingAlpine.js

bietet gerade genug Reaktivität

logo

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