feat: vollständige Verdrahtung erzwingen und unvollständige Elemente markieren

- Workflow-Validierung verschärft: jedes Element muss alle vorhandenen Ein- und Ausgänge verbunden haben (Start nur Ausgang, Abschluss nur Eingang)
- Unvollständig verdrahtete Elemente werden im Editor live hellrot markiert
- Fix: tsconfig ignoreDeprecations zurück auf "5.0" (TS 5.7.3 lehnt "6.0" ab -> Vite-Build brach ab)
- MainView: Beschriftungen auf "Workflow" angepasst

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 16:07:44 +02:00
parent cf9c73635b
commit 23b403711d
5 changed files with 89 additions and 35 deletions

View File

@@ -194,6 +194,34 @@ if (!window.__taskListEditorDragInit) {
}, true); }, true);
} }
/**
* Markiert alle Knoten, deren Ein- oder Ausgänge nicht vollständig verdrahtet
* sind, mit der CSS-Klasse {@code node-incomplete} (hellroter Hintergrund).
* Start-Knoten (kein Eingang) und Abschluss-Knoten (kein Ausgang) werden anhand
* ihrer tatsächlich vorhandenen Ports bewertet.
*/
function refreshValidity(state) {
if (!state) return;
const editor = state.editor;
const data = editor.drawflow.drawflow[editor.module].data;
for (const id in data) {
const node = data[id];
const incomplete = hasOpenPort(node.inputs) || hasOpenPort(node.outputs);
const el = state.container.querySelector('#node-' + id);
if (el) el.classList.toggle('node-incomplete', incomplete);
}
}
/** Liefert true, wenn mindestens einer der vorhandenen Ports keine Verbindung hat. */
function hasOpenPort(ports) {
if (!ports) return false;
for (const key in ports) {
const connections = ports[key] && ports[key].connections;
if (!connections || connections.length === 0) return true;
}
return false;
}
function buildNodeHtml(label, color) { function buildNodeHtml(label, color) {
const safe = String(label).replace(/[<>&"']/g, c => ({ const safe = String(label).replace(/[<>&"']/g, c => ({
'<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;'
@@ -325,6 +353,15 @@ function attach(host) {
} }
}); });
// Unvollständig verdrahtete Elemente (offener Ein- oder Ausgang) hellrot
// markieren. Nach jeder strukturellen Änderung neu bewerten.
const refresh = () => requestAnimationFrame(() => refreshValidity(state));
editor.on('connectionCreated', refresh);
editor.on('connectionRemoved', refresh);
editor.on('nodeCreated', refresh);
editor.on('nodeRemoved', refresh);
editor.on('import', refresh);
// Klick in die Nähe einer Verbindung (auf leerem Canvas-Hintergrund) wählt // Klick in die Nähe einer Verbindung (auf leerem Canvas-Hintergrund) wählt
// diese aus robuster als das exakte Treffen der dünnen Linie. Läuft in der // diese aus robuster als das exakte Treffen der dünnen Linie. Läuft in der
// Capture-Phase vor Drawflow, um das Verschieben/Deselektieren zu verhindern. // Capture-Phase vor Drawflow, um das Verschieben/Deselektieren zu verhindern.
@@ -543,6 +580,7 @@ window.taskListEditor = {
editor.removeNodeOutput(nodeId, `output_${current}`); editor.removeNodeOutput(nodeId, `output_${current}`);
current--; current--;
} }
requestAnimationFrame(() => refreshValidity(state));
}, },
updateNodeLabel(host, nodeId, label) { updateNodeLabel(host, nodeId, label) {
const state = editors.get(host); const state = editors.get(host);

View File

@@ -261,6 +261,12 @@ body, .tasklist-app {
flex-direction: column; flex-direction: column;
} }
/* Unvollständig verdrahtetes Element (offener Ein- oder Ausgang): hellrot. */
.drawflow-node.node-incomplete .task-node {
background: #ffdede;
border-color: #ef4444;
}
.task-node-gear { .task-node-gear {
position: absolute; position: absolute;
top: 12px; top: 12px;

View File

@@ -86,53 +86,63 @@ public class TaskListExporter {
* Meldung, falls die Bedingung verletzt ist * Meldung, falls die Bedingung verletzt ist
*/ */
private void validateWorkflow(JsonNode data, Map<Integer, BlockInstance> instances) { private void validateWorkflow(JsonNode data, Map<Integer, BlockInstance> instances) {
List<Integer> startNodes = new ArrayList<>(); // Es muss mindestens ein Start- und ein Abschluss-Element geben.
List<Integer> endNodes = new ArrayList<>(); boolean hasStart = false;
for (Map.Entry<Integer, BlockInstance> entry : instances.entrySet()) { boolean hasEnd = false;
int taskType = entry.getValue().type().taskType(); for (BlockInstance instance : instances.values()) {
int taskType = instance.type().taskType();
if (taskType == BlockTypeRegistry.START_TASK_TYPE) { if (taskType == BlockTypeRegistry.START_TASK_TYPE) {
startNodes.add(entry.getKey()); hasStart = true;
} else if (taskType == BlockTypeRegistry.END_TASK_TYPE) { } else if (taskType == BlockTypeRegistry.END_TASK_TYPE) {
endNodes.add(entry.getKey()); hasEnd = true;
} }
} }
if (!hasStart) {
if (startNodes.isEmpty()) {
throw new IllegalStateException("Es muss ein Start-Element vorhanden sein."); throw new IllegalStateException("Es muss ein Start-Element vorhanden sein.");
} }
if (endNodes.isEmpty()) { if (!hasEnd) {
throw new IllegalStateException("Es muss ein Abschluss-Element vorhanden sein."); throw new IllegalStateException("Es muss ein Abschluss-Element vorhanden sein.");
} }
for (int id : startNodes) {
if (!hasConnectedOutput(data, id)) { // Jedes Element muss vollständig verdrahtet sein: jeder vorhandene Ein-
throw new IllegalStateException("Das Start-Element muss mit dem Ablauf verbunden sein."); // und Ausgang muss mit mindestens einer Linie belegt sein. Start besitzt
} // nur einen Ausgang, Abschluss nur einen Eingang nicht vorhandene Ports
} // werden dabei nicht gefordert.
for (int id : endNodes) { List<String> nodeIds = new ArrayList<>();
if (!hasConnectedInput(data, id)) { data.fieldNames().forEachRemaining(nodeIds::add);
throw new IllegalStateException("Das Abschluss-Element muss mit dem Ablauf verbunden sein."); for (String nodeId : nodeIds) {
JsonNode node = data.path(nodeId);
if (!allPortsConnected(node.path("inputs")) || !allPortsConnected(node.path("outputs"))) {
throw new IllegalStateException(
"Jedes Element muss vollständig verbunden sein "
+ nodeLabel(instances, nodeId)
+ "“ hat einen offenen Ein- oder Ausgang.");
} }
} }
} }
private boolean hasConnectedOutput(JsonNode data, int nodeId) { /** Prüft, ob alle vorhandenen Ports (Ein- oder Ausgänge) mindestens eine Verbindung haben. */
JsonNode outputs = data.path(String.valueOf(nodeId)).path("outputs"); private boolean allPortsConnected(JsonNode ports) {
for (JsonNode output : outputs) { for (JsonNode port : ports) {
if (output.path("connections").size() > 0) { if (port.path("connections").size() == 0) {
return true; return false;
} }
} }
return false; return true;
} }
private boolean hasConnectedInput(JsonNode data, int nodeId) { /** Liefert einen sprechenden Namen eines Knotens für Fehlermeldungen. */
JsonNode inputs = data.path(String.valueOf(nodeId)).path("inputs"); private String nodeLabel(Map<Integer, BlockInstance> instances, String nodeId) {
for (JsonNode input : inputs) { try {
if (input.path("connections").size() > 0) { BlockInstance instance = instances.get(Integer.parseInt(nodeId));
return true; if (instance != null) {
String topic = instance.topic();
return (topic == null || topic.isBlank()) ? instance.type().label() : topic;
} }
} catch (NumberFormatException ignored) {
// Fällt auf die Knoten-ID zurück.
} }
return false; return "#" + nodeId;
} }
private List<Integer> determineOrder(JsonNode data) { private List<Integer> determineOrder(JsonNode data) {

View File

@@ -61,7 +61,7 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
@Route("") @Route("")
@PageTitle("TaskList Editor") @PageTitle("Workflow Editor")
public class MainView extends VerticalLayout implements BeforeEnterObserver { public class MainView extends VerticalLayout implements BeforeEnterObserver {
/** Query-Parameter des Deep-Links, der das Übergabe-Token enthält. */ /** Query-Parameter des Deep-Links, der das Übergabe-Token enthält. */
@@ -143,13 +143,13 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
header.setPadding(true); header.setPadding(true);
header.setSpacing(true); header.setSpacing(true);
H2 title = new H2("TaskList Editor"); H2 title = new H2("Workflow Editor");
title.addClassName("tasklist-title"); title.addClassName("tasklist-title");
Span subtitle = new Span("Funktionsblöcke per Drag & Drop in eine Reihenfolge bringen"); Span subtitle = new Span("Funktionsblöcke per Drag & Drop in eine Reihenfolge bringen");
subtitle.addClassName("tasklist-subtitle"); subtitle.addClassName("tasklist-subtitle");
Button clear = new Button("Canvas leeren", new Icon(VaadinIcon.TRASH)); Button clear = new Button("Workflow löschen", new Icon(VaadinIcon.TRASH));
clear.addThemeVariants(ButtonVariant.LUMO_TERTIARY); clear.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
clear.addClickListener(e -> { clear.addClickListener(e -> {
nodeEditor.clear(); nodeEditor.clear();
@@ -157,11 +157,11 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver {
Notification.show("Canvas geleert"); Notification.show("Canvas geleert");
}); });
Button saveCanvas = new Button("Canvas speichern", new Icon(VaadinIcon.HARDDRIVE)); Button saveCanvas = new Button("Workflow speichern", new Icon(VaadinIcon.HARDDRIVE));
saveCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY); saveCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
saveCanvas.addClickListener(e -> saveCanvas()); saveCanvas.addClickListener(e -> saveCanvas());
Button loadCanvas = new Button("Canvas laden", new Icon(VaadinIcon.FOLDER_OPEN)); Button loadCanvas = new Button("Worklow laden", new Icon(VaadinIcon.FOLDER_OPEN));
loadCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY); loadCanvas.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
loadCanvas.addClickListener(e -> openLoadCanvasDialog()); loadCanvas.addClickListener(e -> openLoadCanvasDialog());

View File

@@ -22,7 +22,7 @@
"noUnusedParameters": false, "noUnusedParameters": false,
"experimentalDecorators": true, "experimentalDecorators": true,
"useDefineForClassFields": false, "useDefineForClassFields": false,
"ignoreDeprecations": "6.0", "ignoreDeprecations": "5.0",
"baseUrl": "src/main/frontend", "baseUrl": "src/main/frontend",
"paths": { "paths": {
"@vaadin/flow-frontend": ["generated/jar-resources"], "@vaadin/flow-frontend": ["generated/jar-resources"],