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
Wednesday, 15 January 2025
InDeel 2, bouwden we een krachtige workflow motor.
Maar workflows gedefinieerd in JSON zijn niet erg gebruiksvriendelijk.
Volledige HTMX-integratie
Eigenschappen node instellen in een zijpaneel
Technologie StackWaarom deze Stack?
Vermindert de complexiteit van client-sideServer behoudt de bron van de waarheid
x-dataPerfect voor interactieve UI-componentenEenvoudigrichtlijnen
Donker/lichtmodus uit de doosVoorgebouwde onderdelen (kaarten, knoppen, badges)
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│ │ │
│ │ └─────┘ │ │
└─────────┴───────────────────────────┴───────────────┘
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()
});
}
}
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>
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
};
}
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>
dragStart(event, nodeType, icon, color) {
this.draggedNodeType = { type: nodeType, icon, color };
}
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>
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;
}
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
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>
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
}
}
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 };
}
}
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);
}
}
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);
}
}
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.
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>
Onze drie-panel lay-out past zich aan verschillende schermgroottes aan:
Virtueel scrollen
let dragTimeout;
dragNode(event, node) {
clearTimeout(dragTimeout);
dragTimeout = setTimeout(() => {
// Update position
}, 16); // ~60fps
}
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>
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
{
"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"
}
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
Achtergrondtaakverwerking
Controle van het dashboard
biedt net genoeg reactiviteit
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.