Construire un système de flux de travail avec HTMX et ASP.NET Core - Partie 3: Construire l'éditeur visuel (Français (French))

Construire un système de flux de travail avec HTMX et ASP.NET Core - Partie 3: Construire l'éditeur visuel

Wednesday, 15 January 2025

//

13 minute read

Présentation

DansDeuxième partie, nous avons construit un puissant moteur de flux de travail.

Mais les workflows définis dans JSON ne sont pas très conviviaux.

  • Dans ce post, nous allons créer un éditeur visuel de flux de travail étonnant - pensez « Node-RED de Dummy » - en utilisant HTMX, Alpine.js, TailwindCSS et DaisyUI.
  • À la fin de ce post, vous aurez :
  • Une toile de flux de travail glisser-déposer
  • Connexions visuelles avec SVG
  • Configuration des nœuds en temps réel

Jolie interface utilisateur commutable à thème

Intégration complète de HTMX

  1. La vision
  2. Nous voulons que les utilisateurs:
  3. Faites glisser les nœuds d'une palette sur une toile
  4. Connecter visuellement les nœuds

Configurer les propriétés des nœuds dans un panneau latéral

Enregistrer et exécuter les workflows

Tout cela sans écrire une seule ligne de JSON !

Stack technologiquePourquoi ce Stack ?

  • HTMX 2.0
  • : Interactions basées sur le serveur sans écrire JavaScript
  • Parfait pour les opérations d'économie/de chargement

Réduit la complexité côté clientServeur maintient la source de la vérité

  • Alpine.js
  • : Réactivité légère pour la toile
  • Seulement ~15Ko minifiéx-dataParfait pour les composants interactifs de l'interface utilisateur

Simpledirectives

  • TailwindCSS + DaisyUI
  • : Belle UI à thème
  • Utilitaire-première CSS

Mode noir/léger hors de la boîteComposants préconstruits (cartes, boutons, badges)

  • SVG
  • : rendu de connexion native
  • Lignes scalables et croustillantes

Style CSS

Aucune bibliothèque externe n'est nécessaire

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

Vue d'ensemble de l'architecture

Notre éditeur dispose de trois panneaux principaux:

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

Le Contrôleur

Tout d'abord, mettons en place nos paramètres de contrôle :

@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 vue de l'éditeur

Notre vue de l'éditeur utilise Alpine.js pour la gestion de l'état:workflowEditorAdministration d'État Alpine.js

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

Les

La composante alpine gère tout l'état de l'éditeur :

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

Faites glisser et déposez : la palette de nœuds

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

La barre latérale gauche contient des types de nœuds dragables:

Glissez la poignée de démarrage

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

La Toile

La toile est l'endroit où la magie se produit:

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

Déposez un handler

Quand un noeud est tombé sur la toile:

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

Rendement des connexions SVG

Les connexions entre nœuds sont tracées avec les chemins SVG :

Cela crée des connexions fluides et incurvées entre les nœuds !

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

L'inspecteur du nœud

Le panneau droit permet aux utilisateurs de configurer les nœuds sélectionnés :

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

Parsing JSON en direct

Comme les utilisateurs éditent les entrées JSON, nous les examinons en temps réel:

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

Noeuds de connexion

Les utilisateurs connectent les nœuds en cliquant sur les points de connexion :

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

Enregistrer les flux de travail avec HTMX

Lorsque l'utilisateur clique sur "Enregistrer", nous utilisons un simple appel de récupération (pourrait aussi être 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);
    }
}

Flux de travail en cours d'exécution

Exécuter les workflows directement depuis l'éditeur :

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

Changement de thème avec DaisyUIbg-base-200, text-base-contentDaisyUI fournit un changement de thème automatique.

Les utilisateurs peuvent basculer entre les modes lumineux et sombres, et tous nos nœuds de workflow s'adaptent automatiquement !

Toutes nos couleurs utilisent les classes sémantiques de DaisyUI (

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

), donc ils s'adaptent automatiquement au thème choisi !

Conception réceptive

Notre mise en page à trois panneaux s'adapte aux différentes tailles d'écran :

Optimisations des performances

Déroulement virtuel

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

Pour les workflows avec des centaines de nœuds, nous pourrions implémenter le défilement virtuel (rendre seulement les nœuds visibles).

Mais pour la plupart des cas d'utilisation, rendre 50-100 nœuds est parfaitement bien avec les navigateurs modernes.

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

Mises à jour débloquées

Lorsque nous traînons des nœuds, nous pourrions débonfler les mises à jour de la position :

✅ AccessibilitéNous avons ajouté les bonnes étiquettes ARIA et la navigation du clavier: ✅ Ce que nous avons construitDans ce billet, nous avons créé : ✅ Palette des nœuds de glisser-déposer- Types de nœuds dragables ✅ Toile visuelle- arrière-plan de grille avec nœuds positionnés ✅ Connexions SVG- Chemins courbes entre les nœuds ✅ Inspecteur des nœuds- Panneau de configuration en temps réel ✅ Appui thématique- Fonctionne avec tous les thèmes DaisyUI ✅ Mise en page réceptive- S'adapte à la taille de l'écran

Intégration HTMX

  • Exécuter/charger avec le serveur
{
  "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"
}

Réactivité Alpine.js

  • UI doux et réactifExemple de flux de travailVoici un workflow complet que vous pouvez construire dans l'éditeur :

  • Qu'est-ce qu'il y a ?

  • Dans

  • Quatrième partie

  • , nous intégrerons Hangfire pour:

  • Exécution programmée du flux de travail

Déclencheurs de sondages API

Déclenchement de la gestion de l'État

Traitement de l'emploi de base

Tableau de bord de surveillance

  • **Nous allons rendre nos workflows vraiment autonomes !**Le présent règlement entre en vigueur le vingtième jour suivant celui de sa publication au Journal officiel de l'Union européenne.
  • **Nous avons construit un éditeur de flux de travail visuel prêt à la production avec un minimum de JavaScript!**En combinant HTMX pour la communication serveur, Alpine.js pour l'interactivité, et TailwindCSS/DaisyUI pour le style, nous avons créé quelque chose qui se sent moderne et est durable.
  • **Les principaux points de vue :**SVG
  • est parfait pour le rendu de connexionAlpine.js

fournit juste assez de réactivité

Finding related posts...
logo

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