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.
Intégration complète de HTMX
Configurer les propriétés des nœuds dans un panneau latéral
Stack technologiquePourquoi ce Stack ?
Réduit la complexité côté clientServeur maintient la source de la vérité
x-dataParfait pour les composants interactifs de l'interface utilisateurSimpledirectives
Mode noir/léger hors de la boîteComposants préconstruits (cartes, boutons, badges)
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│ │ │
│ │ └─────┘ │ │
└─────────┴───────────────────────────┴───────────────┘
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()
});
}
}
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>
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
};
}
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>
dragStart(event, nodeType, icon, color) {
this.draggedNodeType = { type: nodeType, icon, color };
}
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 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;
}
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
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>
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
}
}
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 };
}
}
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);
}
}
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);
}
}
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.
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>
Notre mise en page à trois panneaux s'adapte aux différentes tailles d'écran :
Déroulement virtuel
let dragTimeout;
dragNode(event, node) {
clearTimeout(dragTimeout);
dragTimeout = setTimeout(() => {
// Update position
}, 16); // ~60fps
}
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>
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
{
"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"
}
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
Traitement de l'emploi de base
Tableau de bord de surveillance
fournit juste assez de réactivité
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.