diff --git a/src/main/frontend/node-editor/node-editor.js b/src/main/frontend/node-editor/node-editor.js index 0870154..98b8c93 100644 --- a/src/main/frontend/node-editor/node-editor.js +++ b/src/main/frontend/node-editor/node-editor.js @@ -181,6 +181,70 @@ if (Drawflow && Drawflow.prototype) { const editors = new WeakMap(); +// --- Undo-Historie --------------------------------------------------------- +// Nach jeder Canvas-Änderung wird ein Schnappschuss (Drawflow-Export) +// abgelegt; Strg+Z bzw. der Rückgängig-Button stellt den vorherigen +// Schnappschuss wieder her. Es sind maximal UNDO_LIMIT Schritte rücknehmbar. +const UNDO_LIMIT = 10; + +function currentSnapshot(state) { + return JSON.stringify(state.editor.export()); +} + +// Legt (um einen Tick verzögert) einen Schnappschuss ab. Mehrere Drawflow- +// Events derselben Benutzeraktion (z. B. Knoten löschen inkl. seiner +// Verbindungen) werden so zu einem einzigen Undo-Schritt zusammengefasst. +function recordHistory(state) { + if (state.undoSuppressed || state.undoScheduled) return; + state.undoScheduled = true; + setTimeout(() => { + state.undoScheduled = false; + if (state.undoSuppressed) return; + const snap = currentSnapshot(state); + const stack = state.history; + if (stack.length && stack[stack.length - 1] === snap) return; + stack.push(snap); + // Basiszustand + UNDO_LIMIT rücknehmbare Schritte behalten. + if (stack.length > UNDO_LIMIT + 1) stack.shift(); + }, 0); +} + +// Setzt die Historie auf den aktuellen Zustand als neuen Basiszustand zurück +// (z. B. nach dem Laden eines gespeicherten Canvas). +function resetHistory(state) { + state.history = [currentSnapshot(state)]; +} + +// Stellt den vorherigen Schnappschuss wieder her; liefert false, wenn es +// nichts zurückzunehmen gibt. +function undoLast(state) { + const stack = state.history; + if (!stack || stack.length < 2) return false; + stack.pop(); // aktueller Zustand + const previous = stack[stack.length - 1]; + state.undoSuppressed = true; + try { + state.editor.import(JSON.parse(previous)); + } finally { + state.undoSuppressed = false; + } + notifyCanvasRestored(state); + return true; +} + +// Meldet dem Server die nach einem Undo vorhandenen Knoten, damit dieser +// seine BlockInstances (inkl. der Einstellungen wiederhergestellter Blöcke) +// abgleichen kann. +function notifyCanvasRestored(state) { + if (!state.host || !state.host.$server) return; + const data = state.editor.drawflow.drawflow[state.editor.module].data; + const nodes = Object.keys(data).map(id => ({ + id: parseInt(id, 10), + typeId: data[id].data ? data[id].data.typeId : null + })); + state.host.$server.onCanvasRestored(JSON.stringify(nodes)); +} + if (!window.__taskListEditorDragInit) { window.__taskListEditorDragInit = true; document.addEventListener('dragstart', e => { @@ -261,9 +325,14 @@ function attach(host) { const state = { editor, container, - registry: new Map() + host, + registry: new Map(), + history: [], + undoSuppressed: false, + undoScheduled: false }; editors.set(host, state); + resetHistory(state); // Hindernis-bewusste Linienführung. Reroute-Teilsegmente // (type != 'openclose') nutzen die einfache vertikale Kurve. @@ -360,6 +429,14 @@ function attach(host) { editor.on('nodeRemoved', refresh); editor.on('import', refresh); + // Undo-Historie: jede strukturelle Änderung erzeugt einen Schnappschuss. + const record = () => recordHistory(state); + editor.on('nodeCreated', record); + editor.on('nodeRemoved', record); + editor.on('nodeMoved', record); + editor.on('connectionCreated', record); + editor.on('connectionRemoved', record); + // 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 // Capture-Phase vor Drawflow, um das Verschieben/Deselektieren zu verhindern. @@ -412,13 +489,20 @@ function attach(host) { const newTop = rightDrag.nodeEl.offsetTop + (e.clientY - rightDrag.lastY) / zoom; rightDrag.lastX = e.clientX; rightDrag.lastY = e.clientY; + rightDrag.moved = true; rightDrag.nodeEl.style.left = newLeft + 'px'; rightDrag.nodeEl.style.top = newTop + 'px'; const data = editor.drawflow.drawflow[editor.module].data[rightDrag.id]; if (data) { data.pos_x = newLeft; data.pos_y = newTop; } editor.updateConnectionNodes('node-' + rightDrag.id); }; - const onRightUp = () => { rightDrag = null; rightPan = null; }; + const onRightUp = () => { + // Ein per Rechts-Drag verschobener Knoten löst Drawflows nodeMoved + // nicht aus -> Undo-Schnappschuss hier ablegen. + if (rightDrag && rightDrag.moved) recordHistory(state); + rightDrag = null; + rightPan = null; + }; document.addEventListener('mousemove', onRightMove); document.addEventListener('mouseup', onRightUp); @@ -465,8 +549,25 @@ function attach(host) { editor.removeConnection(); }; document.addEventListener('keydown', deleteSelectedConnection); + + // Strg+Z / Cmd+Z: letzte Canvas-Änderung zurücknehmen. Eingaben in + // Textfeldern und geöffneten Dialogen bleiben unangetastet. + const undoKeyHandler = e => { + if (!(e.ctrlKey || e.metaKey) || e.shiftKey || (e.key !== 'z' && e.key !== 'Z')) return; + const active = document.activeElement; + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' + || active.isContentEditable + || (active.closest && active.closest('vaadin-dialog-overlay')))) { + return; + } + e.preventDefault(); + undoLast(state); + }; + document.addEventListener('keydown', undoKeyHandler); + state.cleanup = () => { document.removeEventListener('keydown', deleteSelectedConnection); + document.removeEventListener('keydown', undoKeyHandler); document.removeEventListener('mousemove', onRightMove); document.removeEventListener('mouseup', onRightUp); container.removeEventListener('wheel', onWheelZoom, { capture: true }); @@ -592,6 +693,7 @@ window.taskListEditor = { editor.removeNodeOutput(nodeId, `output_${current}`); current--; } + recordHistory(state); requestAnimationFrame(() => refreshValidity(state)); }, updateNodeLabel(host, nodeId, label) { @@ -601,12 +703,23 @@ window.taskListEditor = { if (!node) return; const html = buildNodeHtml(label, state.registry.get(node.data.typeId)?.color || '#444'); state.editor.updateNodeDataFromId(nodeId, { ...node.data, label }); + // Auch das im Graphen hinterlegte HTML aktualisieren, damit Export, + // Laden und Undo den neuen Titel anzeigen. + const nodeData = state.editor.drawflow.drawflow[state.editor.module].data[nodeId]; + if (nodeData) nodeData.html = html; const el = state.container.querySelector(`#node-${nodeId} .drawflow_content_node`); if (el) el.innerHTML = html; + recordHistory(state); + }, + undo(host) { + const state = editors.get(host); + return state ? undoLast(state) : false; }, clear(host) { const state = editors.get(host); - if (state) state.editor.clear(); + if (!state) return; + state.editor.clear(); + recordHistory(state); }, exportGraph(host) { const state = editors.get(host); @@ -646,6 +759,8 @@ window.taskListEditor = { state.editor.addConnection(ids[i], ids[i + 1], 'output_1', 'input_1'); } + // Frisch geladener Stand ist der neue Basiszustand der Undo-Historie. + resetHistory(state); return JSON.stringify(ids); }, importGraph(host, graphJson) { @@ -653,6 +768,8 @@ window.taskListEditor = { if (!graphJson) return; try { state.editor.import(JSON.parse(graphJson)); + // Frisch geladener Stand ist der neue Basiszustand der Undo-Historie. + resetHistory(state); } catch (e) { console.error('importGraph failed', e); } diff --git a/src/main/java/de/assecutor/tasklisteditor/component/NodeEditor.java b/src/main/java/de/assecutor/tasklisteditor/component/NodeEditor.java index c62076c..c03623b 100644 --- a/src/main/java/de/assecutor/tasklisteditor/component/NodeEditor.java +++ b/src/main/java/de/assecutor/tasklisteditor/component/NodeEditor.java @@ -62,6 +62,16 @@ public class NodeEditor extends Div { getElement().executeJs("window.taskListEditor.clear(this)"); } + /** + * Nimmt die letzte Canvas-Änderung zurück (maximal 10 Schritte). Der + * Callback erhält {@code true}, wenn ein Schritt zurückgenommen wurde, + * sonst {@code false}. + */ + public void undo(SerializableConsumer callback) { + getElement().executeJs("return window.taskListEditor.undo(this)") + .then(Boolean.class, callback::accept); + } + /** * Liest den aktuellen Drawflow-Graphen (Knoten + Verbindungen) als * JSON-String aus dem Client und übergibt ihn an den Callback. @@ -115,6 +125,10 @@ public class NodeEditor extends Div { return addListener(NodeRemovedEvent.class, listener); } + public Registration addCanvasRestoredListener(ComponentEventListener listener) { + return addListener(CanvasRestoredEvent.class, listener); + } + @ClientCallable private void onNodeAdded(int nodeId, String typeId) { fireEvent(new NodeAddedEvent(this, nodeId, typeId)); @@ -140,6 +154,11 @@ public class NodeEditor extends Div { fireEvent(new NodeRemovedEvent(this, nodeId)); } + @ClientCallable + private void onCanvasRestored(String nodesJson) { + fireEvent(new CanvasRestoredEvent(this, nodesJson)); + } + public static class NodeAddedEvent extends ComponentEvent { private final int nodeId; private final String typeId; @@ -192,4 +211,20 @@ public class NodeEditor extends Div { public int nodeId() { return nodeId; } } + + /** + * Wird nach einem Undo gefeuert und enthält die danach auf dem Canvas + * vorhandenen Knoten als JSON-Array aus Objekten mit {@code id} und + * {@code typeId}, damit der Server seinen Zustand abgleichen kann. + */ + public static class CanvasRestoredEvent extends ComponentEvent { + private final String nodesJson; + + public CanvasRestoredEvent(NodeEditor source, String nodesJson) { + super(source, true); + this.nodesJson = nodesJson; + } + + public String nodesJson() { return nodesJson; } + } } diff --git a/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java b/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java index 0474c49..f1b54a1 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/BlockTypeRegistry.java @@ -188,7 +188,7 @@ public class BlockTypeRegistry { 11, 1, 1, List.of( - PropertyDefinition.textArea("choices", "Statusoptionen (eine pro Zeile)"), + PropertyDefinition.choiceList("choices", "Statusoptionen"), PropertyDefinition.bool("opt", "Optionale Aufgabe", false), PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false) ) @@ -208,7 +208,6 @@ public class BlockTypeRegistry { 1, 1, List.of( PropertyDefinition.fieldList("fields", "Textfelder (Titel + Platzhalter)"), - PropertyDefinition.bool("opt", "Optionale Aufgabe", false), PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false) ) )); @@ -223,7 +222,6 @@ public class BlockTypeRegistry { 1, 1, List.of( PropertyDefinition.fieldList("fields", "Zahlenfelder (Titel + Platzhalter)"), - PropertyDefinition.bool("opt", "Optionale Aufgabe", false), PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false) ) )); @@ -238,7 +236,6 @@ public class BlockTypeRegistry { 1, 1, List.of( PropertyDefinition.nameList("entries", "Name der Checkboxen"), - PropertyDefinition.bool("opt", "Optionale Aufgabe", false), PropertyDefinition.bool("reenter", "Erneut bearbeitbar", false) ) )); diff --git a/src/main/java/de/assecutor/tasklisteditor/model/FieldDto.java b/src/main/java/de/assecutor/tasklisteditor/model/FieldDto.java index e281107..4674e99 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/FieldDto.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/FieldDto.java @@ -5,17 +5,18 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder; /** * Ein einzelnes Eingabefeld eines Listen-Text-Tasks (Typ 12). - * Definiert Titel und Platzhalter eines vom Bearbeiter auszufüllenden - * Textfeldes. + * Definiert Titel, Platzhalter und Pflichtfeld-Kennzeichen eines vom + * Bearbeiter auszufüllenden Textfeldes. * *

{@code null}-Felder werden ausgelassen. */ @JsonInclude(JsonInclude.Include.NON_NULL) -@JsonPropertyOrder({"title", "placeholder"}) +@JsonPropertyOrder({"title", "placeholder", "required"}) public class FieldDto { private String title; private String placeholder; + private Boolean required; public FieldDto() { } @@ -25,6 +26,14 @@ public class FieldDto { this.placeholder = placeholder; } + public Boolean getRequired() { + return required; + } + + public void setRequired(Boolean required) { + this.required = required; + } + public String getTitle() { return title; } diff --git a/src/main/java/de/assecutor/tasklisteditor/model/PropertyDefinition.java b/src/main/java/de/assecutor/tasklisteditor/model/PropertyDefinition.java index ec7d72a..e23f801 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/PropertyDefinition.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/PropertyDefinition.java @@ -26,4 +26,8 @@ public record PropertyDefinition(String id, String label, PropertyType type, Obj public static PropertyDefinition nameList(String id, String label) { return new PropertyDefinition(id, label, PropertyType.NAME_LIST, List.of()); } + + public static PropertyDefinition choiceList(String id, String label) { + return new PropertyDefinition(id, label, PropertyType.CHOICE_LIST, List.of()); + } } diff --git a/src/main/java/de/assecutor/tasklisteditor/model/PropertyType.java b/src/main/java/de/assecutor/tasklisteditor/model/PropertyType.java index 7d99f81..10e1df0 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/PropertyType.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/PropertyType.java @@ -8,5 +8,10 @@ public enum PropertyType { /** Wiederholbare Liste aus Eingabefeldern mit Titel und Platzhalter. */ FIELD_LIST, /** Wiederholbare Liste aus reinen Namens-Einträgen (ein Textfeld je Zeile). */ - NAME_LIST + NAME_LIST, + /** + * Wiederholbare Liste aus Auswahloptionen (ein Textfeld je Zeile, + * ohne Pflichtfeld-Kennzeichen). + */ + CHOICE_LIST } diff --git a/src/main/java/de/assecutor/tasklisteditor/model/TaskEntryDto.java b/src/main/java/de/assecutor/tasklisteditor/model/TaskEntryDto.java index 76e0abf..8269ff7 100644 --- a/src/main/java/de/assecutor/tasklisteditor/model/TaskEntryDto.java +++ b/src/main/java/de/assecutor/tasklisteditor/model/TaskEntryDto.java @@ -1,5 +1,6 @@ package de.assecutor.tasklisteditor.model; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; /** @@ -14,6 +15,10 @@ public class TaskEntryDto { @JsonProperty("query_remark") private boolean queryRemark; + /** Pflichtfeld-Kennzeichen (nur bei Listen-Tasks gesetzt, sonst ausgelassen). */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean required; + public TaskEntryDto() { } @@ -46,4 +51,12 @@ public class TaskEntryDto { public void setQueryRemark(boolean queryRemark) { this.queryRemark = queryRemark; } + + public Boolean getRequired() { + return required; + } + + public void setRequired(Boolean required) { + this.required = required; + } } diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java index 3f91c8a..ef94aad 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListExporter.java @@ -230,7 +230,10 @@ public class TaskListExporter { } if (v.containsKey("choices")) { - task.setChoices(parseChoices(asText(v.get("choices")))); + Object raw = v.get("choices"); + task.setChoices(raw instanceof List list + ? parseChoices(list) + : parseChoices(asText(raw))); } if (v.containsKey("fields")) { @@ -273,13 +276,30 @@ public class TaskListExporter { if (o instanceof Map m) { String text = m.get("title") == null ? "" : m.get("title").toString().trim(); if (!text.isEmpty()) { - entries.add(new TaskEntryDto(code++, text, queryRemark)); + TaskEntryDto entry = new TaskEntryDto(code++, text, queryRemark); + entry.setRequired(asBool(m.get("required"))); + entries.add(entry); } } } return entries.isEmpty() ? null : entries; } + /** Variante für den Zeilen-Editor: Liste aus {@code {title}}-Einträgen. */ + private List parseChoices(List raw) { + List choices = new ArrayList<>(); + int ret = 1; + for (Object o : raw) { + if (o instanceof Map m) { + String text = m.get("title") == null ? "" : m.get("title").toString().trim(); + if (!text.isEmpty()) { + choices.add(new ChoiceDto(text, ret++)); + } + } + } + return choices.isEmpty() ? null : choices; + } + private List parseChoices(String raw) { if (raw == null || raw.isBlank()) { return null; @@ -305,7 +325,9 @@ public class TaskListExporter { String title = m.get("title") == null ? "" : m.get("title").toString().trim(); String placeholder = m.get("placeholder") == null ? "" : m.get("placeholder").toString().trim(); if (!title.isEmpty() || !placeholder.isEmpty()) { - fields.add(new FieldDto(title, placeholder)); + FieldDto field = new FieldDto(title, placeholder); + field.setRequired(asBool(m.get("required"))); + fields.add(field); } } } diff --git a/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java b/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java index 7ad6e75..20b86db 100644 --- a/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java +++ b/src/main/java/de/assecutor/tasklisteditor/service/TaskListImporter.java @@ -2,6 +2,7 @@ package de.assecutor.tasklisteditor.service; import de.assecutor.tasklisteditor.model.BlockType; import de.assecutor.tasklisteditor.model.BlockTypeRegistry; +import de.assecutor.tasklisteditor.model.ChoiceDto; import de.assecutor.tasklisteditor.model.FieldDto; import de.assecutor.tasklisteditor.model.PropertyDefinition; import de.assecutor.tasklisteditor.model.TaskDto; @@ -69,6 +70,7 @@ public class TaskListImporter { Object value = switch (def.type()) { case FIELD_LIST -> fieldRows(task); case NAME_LIST -> entryRows(task); + case CHOICE_LIST -> choiceRows(task); default -> scalarValue(def, task); }; values.put(def.id(), value); @@ -103,6 +105,7 @@ public class TaskListImporter { Map row = new LinkedHashMap<>(); row.put("title", field.getTitle() == null ? "" : field.getTitle()); row.put("placeholder", field.getPlaceholder() == null ? "" : field.getPlaceholder()); + row.put("required", Boolean.TRUE.equals(field.getRequired())); rows.add(row); } return rows; @@ -117,11 +120,28 @@ public class TaskListImporter { for (TaskEntryDto entry : task.getEntries()) { Map row = new LinkedHashMap<>(); row.put("title", entry.getText() == null ? "" : entry.getText()); + row.put("required", Boolean.TRUE.equals(entry.getRequired())); rows.add(row); } return rows; } + /** {@code choices}-Array (Typ 11) zurück in Zeilen mit Namen ({@code title}). */ + private List> choiceRows(TaskDto task) { + List> rows = new ArrayList<>(); + if (task.getChoices() != null && !task.getChoices().isEmpty()) { + for (ChoiceDto choice : task.getChoices()) { + Map row = new LinkedHashMap<>(); + row.put("title", choice.getTxt() == null ? "" : choice.getTxt()); + rows.add(row); + } + return rows; + } + // Altdaten des früheren Typ 11 (Abhol-/Lieferstatus) liefern die + // Optionen als entries; diese als Zeilen übernehmen. + return entryRows(task); + } + private Object orDefault(Object value, Object fallback) { return value != null ? value : fallback; } diff --git a/src/main/java/de/assecutor/tasklisteditor/view/MainView.java b/src/main/java/de/assecutor/tasklisteditor/view/MainView.java index df87c89..e674c97 100644 --- a/src/main/java/de/assecutor/tasklisteditor/view/MainView.java +++ b/src/main/java/de/assecutor/tasklisteditor/view/MainView.java @@ -78,6 +78,18 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { private final ObjectMapper objectMapper; private final NodeEditor nodeEditor; private final Map instances = new HashMap<>(); + + /** + * Zuletzt vom Canvas entfernte Blöcke. Stellt ein Undo einen Knoten wieder + * her, werden seine Einstellungen von hier zurückgeholt (begrenzt, damit + * die Session nicht unbegrenzt wächst). + */ + private final Map removedInstances = new LinkedHashMap<>() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > 100; + } + }; private Div palettePanel; private String currentListName; private final String appVersion; @@ -135,7 +147,52 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { openSettingsDialog(instance); } }); - nodeEditor.addNodeRemovedListener(e -> instances.remove(e.nodeId())); + nodeEditor.addNodeRemovedListener(e -> { + BlockInstance removed = instances.remove(e.nodeId()); + if (removed != null) { + removedInstances.put(e.nodeId(), removed); + } + }); + nodeEditor.addCanvasRestoredListener(e -> reconcileAfterUndo(e.nodesJson())); + } + + /** + * Gleicht die serverseitigen BlockInstances nach einem Undo mit den auf dem + * Canvas vorhandenen Knoten ab: Verschwundene Knoten wandern in den + * Papierkorb, wieder aufgetauchte werden von dort (inkl. ihrer + * Einstellungen) wiederhergestellt. + */ + private void reconcileAfterUndo(String nodesJson) { + Map present = new LinkedHashMap<>(); + try { + JsonNode nodes = objectMapper.readTree(nodesJson); + for (JsonNode node : nodes) { + present.put(node.path("id").asInt(), node.path("typeId").asText()); + } + } catch (Exception ex) { + return; + } + instances.entrySet().removeIf(entry -> { + if (present.containsKey(entry.getKey())) { + return false; + } + removedInstances.put(entry.getKey(), entry.getValue()); + return true; + }); + present.forEach((id, typeId) -> { + if (instances.containsKey(id)) { + return; + } + BlockInstance restored = removedInstances.remove(id); + if (restored == null) { + BlockType type = registry.find(typeId).orElse(null); + if (type == null) { + return; + } + restored = new BlockInstance(id, type); + } + instances.put(id, restored); + }); } private Component buildHeader() { @@ -159,10 +216,21 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { title.add(version); } + Button undo = new Button("Rückgängig", new Icon(VaadinIcon.ARROW_BACKWARD)); + undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + undo.setTooltipText("Letzte Canvas-Änderung zurücknehmen (Strg+Z, max. 10 Schritte)"); + undo.addClickListener(e -> nodeEditor.undo(undone -> { + if (!Boolean.TRUE.equals(undone)) { + Notification.show("Nichts zum Rückgängigmachen."); + } + })); + Button clear = new Button("Workflow löschen", new Icon(VaadinIcon.TRASH)); clear.addThemeVariants(ButtonVariant.LUMO_TERTIARY); clear.addClickListener(e -> { nodeEditor.clear(); + // In den Papierkorb statt verwerfen: Undo kann das Leeren zurücknehmen. + removedInstances.putAll(instances); instances.clear(); Notification.show("Canvas geleert"); }); @@ -186,7 +254,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { Span spacer = new Span(); spacer.getStyle().set("flex", "1"); - header.add(title, spacer, clear, saveCanvas, loadCanvas, appJson, export); + header.add(title, spacer, undo, clear, saveCanvas, loadCanvas, appJson, export); return header; } @@ -461,7 +529,7 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { private void openBlockDialog(BlockInstance instance) { Dialog dialog = new Dialog(); dialog.setHeaderTitle(instance.type().label()); - dialog.setWidth("440px"); + dialog.setWidth("585px"); Paragraph desc = new Paragraph(instance.type().description()); desc.addClassName("properties-desc"); @@ -732,23 +800,48 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { return box; } case FIELD_LIST -> { - return buildRowList(instance, def, current, true); + return buildRowList(instance, def, current, true, true); } case NAME_LIST -> { - return buildRowList(instance, def, current, false); + return buildRowList(instance, def, current, false, true); + } + case CHOICE_LIST -> { + return buildRowList(instance, def, legacyLinesToRows(current), false, false); } } return new Span(); } + /** + * Wandelt den früheren Textarea-Wert der Statusauswahl (eine Option pro + * Zeile) in die vom Zeilen-Editor erwartete Struktur um. Listen werden + * unverändert durchgereicht. + */ + private Object legacyLinesToRows(Object current) { + if (!(current instanceof String s)) { + return current; + } + List> rows = new ArrayList<>(); + for (String line : s.split("\\r?\\n")) { + String text = line.trim(); + if (!text.isEmpty()) { + Map row = new LinkedHashMap<>(); + row.put("title", text); + rows.add(row); + } + } + return rows; + } + /** * Editor für eine wiederholbare Liste von Eingabefeldern. Jede Zeile besteht * aus einem Titel und – falls {@code withPlaceholder} – zusätzlich einem - * Platzhalter. Der Wert wird als {@code List>} unter der + * Platzhalter sowie – falls {@code withRequired} – einer Pflichtfeld- + * Checkbox. Der Wert wird als {@code List>} unter der * Property-ID abgelegt (rundläuft über Mongo und Export). */ private Component buildRowList(BlockInstance instance, PropertyDefinition def, Object current, - boolean withPlaceholder) { + boolean withPlaceholder, boolean withRequired) { List> rows = new ArrayList<>(); if (current instanceof List list) { for (Object o : list) { @@ -758,6 +851,11 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { if (withPlaceholder) { row.put("placeholder", m.get("placeholder") == null ? "" : m.get("placeholder").toString()); } + if (withRequired) { + row.put("required", m.get("required") instanceof Boolean b + ? b + : Boolean.parseBoolean(String.valueOf(m.get("required")))); + } rows.add(row); } } @@ -811,6 +909,15 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { line.add(placeholder); line.setFlexGrow(1, placeholder); } + if (withRequired) { + Checkbox required = new Checkbox("Pflicht"); + required.setValue(Boolean.TRUE.equals(row.get("required"))); + required.addValueChangeListener(e -> { + row.put("required", e.getValue()); + commit.run(); + }); + line.add(required); + } line.add(remove); line.setAlignItems(FlexComponent.Alignment.BASELINE); rowsBox.add(line); @@ -825,6 +932,9 @@ public class MainView extends VerticalLayout implements BeforeEnterObserver { if (withPlaceholder) { row.put("placeholder", ""); } + if (withRequired) { + row.put("required", false); + } rows.add(row); commit.run(); render[0].run();