Back to "Construyendo un sistema de flujo de trabajo con HTMX y ASP.NET Core - Parte 3: Construyendo el editor visual"

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

Construyendo un sistema de flujo de trabajo con HTMX y ASP.NET Core - Parte 3: Construyendo el editor visual

Wednesday, 15 January 2025

Introducción

InParte 2, construimos un potente motor de flujo de trabajo.

Pero los flujos de trabajo definidos en JSON no son muy fáciles de usar.

  • En este post, crearemos un impresionante editor de flujo de trabajo visual - piensa "dummy's Node-RED" - usando HTMX, Alpine.js, TailwindCSS y DaisyUI.
  • Al final de este post, tendrás:
  • Un lienzo de flujo de trabajo de arrastrar y soltar
  • Conexiones de nodos visuales con SVG
  • Configuración de nodos en tiempo real

Hermosa, tema-intercambiable interfaz de usuario

Integración HTMX completa

  1. La visión
  2. Queremos que los usuarios:
  3. Arrastre los nodos de una paleta a un lienzo
  4. Conectar los nodos visualmente

Configurar las propiedades del nodo en un panel lateral

Guardar y ejecutar flujos de trabajo

¡Todo sin escribir una sola línea de JSON!

Apilador de tecnología¿Por qué esta pila?

  • HTMX 2.0
  • : Interacciones impulsadas por el servidor sin escribir JavaScript
  • Perfecto para guardar/cargar operaciones

Reduce la complejidad del clienteServidor mantiene la fuente de la verdad

  • Alpine.js
  • : Reactividad ligera para el lienzo
  • Sólo ~15KB minificadox-dataPerfecto para componentes interactivos de interfaz de usuario

Sencillodirectivas

  • CoilwindCSS + DaisyUI
  • : Hermosa interfaz de usuario, temable
  • Utilidad-primera CSS

Modo oscuro/luz fuera de la cajaComponentes preconstruidos (tarjetas, botones, insignias)

  • SVG
  • : Renderización de conexión nativa
  • Líneas nítidas escalables y nítidas

CSS-estilable

No se necesitan bibliotecas externas

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

Descripción general de la arquitectura

Nuestro editor tiene tres paneles principales:

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

El Contralor

En primer lugar, vamos a configurar nuestros puntos finales del controlador:

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

La vista del editor

Nuestra vista de editor utiliza Alpine.js para la gestión del estado:workflowEditorAlpine.js Gestión del Estado

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

Los

El componente Alpine gestiona todo el estado del editor:

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

Arrastrar y soltar: La paleta de nodo

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

La barra lateral izquierda contiene tipos de nodos arrastrables:

Arrastrar el controlador de inicio

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

El lienzo

El lienzo es donde sucede la magia:

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

Controlador de caídas

Cuando se cae un nodo sobre el lienzo:

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

Renderización de la conexión SVG

Las conexiones entre nodos se dibujan con rutas SVG:

¡Esto crea conexiones suaves y curvas entre los nodos!

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

El Inspector del Nodo

El panel derecho permite a los usuarios configurar los nodos seleccionados:

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

El análisis de JSON en vivo

A medida que los usuarios editan las entradas JSON, las analizamos en tiempo real:

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

Conectando nodos

Los usuarios conectan nodos haciendo clic en los puntos de conexión:

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

Ahorro de flujos de trabajo con HTMX

Cuando el usuario hace clic en "Guardar", utilizamos una simple llamada de búsqueda (también podría ser HTMX):

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

Flujos de trabajo en ejecución

Ejecutar flujos de trabajo desde el 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>

Cambio de tema con DaisyUIbg-base-200, text-base-contentDaisyUI proporciona conmutación automática del tema.

Los usuarios pueden alternar entre modos claros y oscuros, ¡y todos nuestros nodos de flujo de trabajo se adaptan automáticamente!

Todos nuestros colores utilizan las clases semánticas de DaisyUI (

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

), por lo que se adaptan automáticamente al tema elegido!

Diseño sensible

Nuestro diseño de tres paneles se adapta a diferentes tamaños de pantalla:

Optimización del rendimiento

Desplazamiento virtual

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

Para flujos de trabajo con cientos de nodos, podríamos implementar desplazamiento virtual (sólo renderizar nodos visibles).

Pero para la mayoría de los casos de uso, la representación de 50-100 nodos está perfectamente bien con los navegadores modernos.

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

Actualizaciones denunciadas

Al arrastrar nodos, podríamos desbloquear actualizaciones de posición:

✅ AccesibilidadHemos añadido etiquetas ARIA adecuadas y navegación de teclado: ✅ Lo que hemos construidoEn este post, creamos: ✅ paleta de nodos de arrastrar y soltar- Tipos de nodos arrastrables ✅ Lona visual- Fondo de cuadrícula con nodos posicionados ✅ Conexiones SVG- Rutas curvadas entre nodos ✅ Inspector de nodos- Panel de configuración en tiempo real ✅ Apoyo temático- Funciona con todos los temas de DaisyUI ✅ Diseño responsivo- Se adapta al tamaño de la pantalla

Integración HTMX

  • Guardar/carga impulsado por el servidor
{
  "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 reactividad

  • Interfaz de usuario suave y reactivaEjemplo de flujo de trabajoAquí hay un flujo de trabajo completo que puede crear en el editor:

  • ¿Qué sigue?

  • In

  • Parte 4

  • , vamos a integrar Hangfire para:

  • Ejecución programada del flujo de trabajo

Activadores de votación API

Administración del estado de activación

Procesamiento de los trabajos de base

Panel de control

  • **¡Haremos que nuestros flujos de trabajo sean verdaderamente autónomos!**Conclusión
  • **¡Hemos construido un editor de flujo de trabajo visual listo para la producción con JavaScript mínimo!**Combinando HTMX para la comunicación del servidor, Alpine.js para la interactividad y TailwindCSS/DaisyUI para el estilo, hemos creado algo que se siente moderno y se puede mantener.
  • **Los puntos de vista clave:**SVG
  • es perfecto para renderizar la conexiónAlpine.js

proporciona la reactividad suficiente

logo

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