diff --git a/README.md b/README.md index 244aba4..4a4fd3b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits. -The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). Broader binding authoring, generation, structural editing and product packaging remain later work. +The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). The shared [Table/Column inspector](docs/table-column-inspector.md) adds source-preserving Table configuration, ordered Column authoring and explicit Row navigation. Broader discovery, generation, structural editing and product packaging remain later work. Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats and validation rules. diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingDocument.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingDocument.java index a7506ab..b973728 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingDocument.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingDocument.java @@ -104,7 +104,7 @@ final class BindingDocument { entries.add(new Entry(indexes.get(e), values(e), !hasElementChildren(e))); } - private boolean named(Element e, String type) { + boolean named(Element e, String type) { if (e.getTagName().equals(PACKAGE + type)) return true; if (!e.getTagName().equals(type)) return false; boolean own = imports.contains(PACKAGE + type) || imports.contains(PACKAGE + "*"); @@ -372,7 +372,7 @@ final class BindingDocument { return text.contains("\r\n") ? "\r\n" : "\n"; } - private Replacement append(Element container, String child, String attributes) { + Replacement append(Element container, String child, String attributes) { var t = source.tags.get(indexes.get(container)); String eol = newline(); if (t.selfClosing()) diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java index 3cf064a..39694bf 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java @@ -45,6 +45,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { if (bindingEditor != null) bindingEditor.selectControl(id); if (computedEditor != null) computedEditor.selectControl(id); if (repeatEditor != null) repeatEditor.selectControl(id); + if (tableEditor != null) tableEditor.selectControl(id); if (id == null || !projection.components().containsKey(id)) return; componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id)); componentInspector @@ -117,6 +118,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { private DesignerWorkflow bindingWorkflow; private BindingBrowser bindingBrowser; private BindingEditor bindingEditor, computedEditor, repeatEditor; + private TableEditor tableEditor; private String validatedSource, validatingSource; private boolean validationPreview; private java.util.List bindingProblems = @@ -211,6 +213,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { if (target != null) kit.editor().getSelection().select(target); }, values -> validateBindings(values, false)); + tableEditor = new TableEditor(this::editTable, this::navigateComponent); bindingBrowser = new BindingBrowser( this::discoverBindings, @@ -361,9 +364,12 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { computedTab.setClosable(false); var repeatTab = new Tab("Repeat bindings", repeatEditor); repeatTab.setClosable(false); + var tableTab = new Tab("Table / Columns", tableEditor); + tableTab.setClosable(false); var bindings = new TitledPane( - "Bindings", new TabPane(directBindingTab, computedTab, repeatTab, discoveryTab)); + "Bindings", + new TabPane(directBindingTab, computedTab, repeatTab, tableTab, discoveryTab)); bindings.setExpanded(false); center.setBottom(new VBox(properties, bindings)); output.setEditable(false); @@ -506,6 +512,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { bindingEditor.load(value.text(), value.writable()); computedEditor.load(value.text(), value.writable()); repeatEditor.load(value.text(), value.writable()); + tableEditor.load(value.text(), value.writable()); submittedSource = null; commitSource(); return; @@ -530,6 +537,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { bindingEditor.load(value.text(), value.writable()); computedEditor.load(value.text(), value.writable()); repeatEditor.load(value.text(), value.writable()); + tableEditor.load(value.text(), value.writable()); if (!source.getText().equals(value.text())) { int anchor = source.getAnchor(), caret = source.getCaretPosition(); var edits = @@ -685,6 +693,20 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { repeatEditor.invalidateDiscovery(); } + private void editTable(TableEditor.Intent intent) { + withDraft( + () -> { + try { + if (!draft.writable()) throw new IllegalStateException("Document is read-only"); + if (!draft.text().equals(intent.source())) + throw new IllegalStateException("Document changed; select the Table again"); + submit(intent.edits(), intent.label()); + } catch (Exception error) { + tableEditor.error(error.getMessage()); + } + }); + } + private void editBinding(BindingEditor.Intent intent) { withDraft( () -> { @@ -715,7 +737,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { private boolean bindingPreviewAllowed() { try { - var errors = new BindingDocument(draft.text()).errors(); + var errors = new ArrayList<>(new BindingDocument(draft.text()).errors()); + errors.addAll(new TableDocument(draft.text()).errors()); if (errors.isEmpty()) return true; status.setText("Preview blocked: " + String.join("; ", errors)); if (projection != null) diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/TableDocument.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/TableDocument.java new file mode 100644 index 0000000..fc91388 --- /dev/null +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/TableDocument.java @@ -0,0 +1,228 @@ +package de.neitzel.fxdesigner.kit; + +import de.neitzel.fxdesigner.contracts.EditBatch.Replacement; +import java.util.*; +import org.w3c.dom.*; + +/** Table/Column intents over the authoritative XML, never over Kit's projection. */ +final class TableDocument { + record Table(int index, String id, String collection, String row, boolean editable) { + public String toString() { + return (id.isEmpty() ? "Table #" + index : id) + " ← " + collection; + } + } + + record Column(int index, String attribute, String header, String width, boolean editable) { + public String toString() { + return header + " (" + attribute + ")"; + } + } + + final BindingDocument xml; + final List tables = new ArrayList<>(); + + TableDocument(String text) throws Exception { + xml = new BindingDocument(text); + for (int i = 0; i < xml.elements.size(); i++) { + var e = xml.elements.get(i); + if (xml.named(e, "Table")) + tables.add( + new Table( + i, + e.getAttributeNS(BindingDocument.FX, "id"), + e.getAttribute("collection"), + e.getAttribute("row"), + !property(e, "collection") && !property(e, "row"))); + } + } + + private boolean property(Element e, String name) { + return children(e).stream().anyMatch(c -> c.getTagName().equals(name)); + } + + private static List children(Element e) { + var result = new ArrayList(); + for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling()) + if (n instanceof Element c) result.add(c); + return result; + } + + private Element table(int index) { + if (tables.stream().noneMatch(t -> t.index() == index)) + throw new IllegalArgumentException("Select a Table again"); + return xml.elements.get(index); + } + + private Element columnsContainer(int index) { + var e = table(index); + var wrappers = children(e).stream().filter(c -> c.getTagName().equals("columns")).toList(); + if (wrappers.size() > 1 + || !wrappers.isEmpty() && children(e).stream().anyMatch(c -> xml.named(c, "Column"))) + throw new IllegalArgumentException("Mixed Column containers; edit in Source"); + return wrappers.isEmpty() ? e : wrappers.getFirst(); + } + + List columns(int index) { + return children(columnsContainer(index)).stream() + .filter(e -> xml.named(e, "Column")) + .map( + e -> + new Column( + xml.elements.indexOf(e), + e.getAttribute("attribute"), + e.getAttribute("header"), + e.getAttribute("width"), + children(e).isEmpty())) + .toList(); + } + + List configure(int index, String collection, String row) { + if (!tables.stream().filter(t -> t.index() == index).findFirst().orElseThrow().editable()) + throw new IllegalArgumentException("Nested Table properties must be edited in Source"); + return attributes(index, Map.of("collection", collection, "row", row)); + } + + List create(String collection, String row) { + var root = xml.document.getDocumentElement(); + String name = root.getTagName(); + if (BindingDocument.FX.equals(root.getNamespaceURI()) && root.getLocalName().equals("root")) + name = root.getAttribute("type"); + if (!Set.of( + "VBox", + "HBox", + "Pane", + "AnchorPane", + "StackPane", + "FlowPane", + "TilePane", + "GridPane", + "javafx.scene.layout.VBox", + "javafx.scene.layout.HBox", + "javafx.scene.layout.Pane", + "javafx.scene.layout.AnchorPane", + "javafx.scene.layout.StackPane", + "javafx.scene.layout.FlowPane", + "javafx.scene.layout.TilePane", + "javafx.scene.layout.GridPane") + .contains(name)) + throw new IllegalArgumentException( + "New Table requires a supported root Pane; insert it in Source for another parent"); + Element parent = + children(root).stream() + .filter(c -> c.getTagName().equals("children")) + .findFirst() + .orElse(root); + String node = + ""; + return List.of(xml.append(parent, node, "")); + } + + List removeTable(int index) { + if (table(index) == xml.document.getDocumentElement()) + throw new IllegalArgumentException("A root Table cannot be removed; edit in Source"); + return remove(index); + } + + List writeColumn( + int table, Integer index, String attribute, String header, String width) { + if (index != null) { + var c = columns(table).stream().filter(v -> v.index() == index).findFirst().orElseThrow(); + if (!c.editable()) + throw new IllegalArgumentException("Nested Column properties must be edited in Source"); + var values = new LinkedHashMap(); + values.put("attribute", attribute); + values.put("header", header); + values.put("width", width.isEmpty() ? null : width); + return attributes(index, values); + } + return List.of( + xml.append( + columnsContainer(table), + "", + "")); + } + + List removeColumn(int table, int index) { + if (columns(table).stream().noneMatch(c -> c.index() == index)) + throw new IllegalArgumentException("Select a Column again"); + return remove(index); + } + + List moveColumn(int table, int index, int delta) { + var columns = columns(table); + int from = -1; + for (int i = 0; i < columns.size(); i++) if (columns.get(i).index() == index) from = i; + if (from < 0) throw new IllegalArgumentException("Select a Column again"); + int to = from + delta; + if (to < 0 || to >= columns.size()) return List.of(); + var a = xml.source.tags.get(index); + var b = xml.source.tags.get(columns.get(to).index()); + return List.of( + new Replacement(a.start(), a.end(), xml.text.substring(b.start(), b.end())), + new Replacement(b.start(), b.end(), xml.text.substring(a.start(), a.end()))); + } + + private List remove(int index) { + var t = xml.source.tags.get(index); + return List.of(new Replacement(t.start(), t.end(), "")); + } + + private List attributes(int index, Map values) { + var changed = new LinkedHashMap(); + var e = xml.elements.get(index); + values.forEach( + (k, v) -> { + if (v == null ? e.hasAttribute(k) : !v.equals(e.getAttribute(k))) changed.put(k, v); + }); + return xml.source.attributes(index, changed); + } + + private static String escape(String v) { + return XmlSource.escape(v, '"'); + } + + private static boolean path(String v) { + return v.matches( + "[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(\\.[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)*"); + } + + List errors() { + var errors = new ArrayList(); + for (var t : tables) { + if (!t.editable()) continue; + if (!path(t.collection())) errors.add("Invalid Table collection: " + t); + if (!path(t.row())) errors.add("Invalid Table Row class: " + t); + try { + var columns = columns(t.index()); + if (columns.isEmpty()) errors.add("Table needs at least one Column: " + t); + var seen = new HashSet(); + for (var c : columns) { + if (!c.editable()) continue; + if (!path(c.attribute())) errors.add("Invalid Column Attribute: " + c.attribute()); + if (!seen.add(c.attribute())) errors.add("Duplicate Column Attribute: " + c.attribute()); + if (c.header().isBlank()) errors.add("Column needs a header: " + c.attribute()); + if (!c.width().isEmpty()) + try { + double w = Double.parseDouble(c.width()); + if (!Double.isFinite(w) || w <= 0) throw new NumberFormatException(); + } catch (NumberFormatException e) { + errors.add("Column width must be positive and finite: " + c.attribute()); + } + } + } catch (IllegalArgumentException e) { + errors.add(e.getMessage()); + } + } + return List.copyOf(errors); + } +} diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/TableEditor.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/TableEditor.java new file mode 100644 index 0000000..d209146 --- /dev/null +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/TableEditor.java @@ -0,0 +1,249 @@ +package de.neitzel.fxdesigner.kit; + +import de.neitzel.fxdesigner.contracts.EditBatch.Replacement; +import java.util.List; +import java.util.function.Consumer; +import javafx.scene.control.*; +import javafx.scene.layout.*; + +/** Shared Table and ordered Column form. Applying an intent is always a host document command. */ +final class TableEditor extends VBox { + record Intent(String source, String label, List edits) {} + + private final ListView tables = new ListView<>(); + private final ListView columns = new ListView<>(); + private final TextField collection = field("collection"), + row = field("row"), + attribute = field("attribute"), + header = field("header"), + width = field("width"); + private final Label status = new Label(); + private final Consumer edit; + private String source = ""; + private TableDocument document; + private boolean loading; + private Integer table, column; + + TableEditor(Consumer edit, Consumer navigate) { + super(5); + this.edit = edit; + setId("table-editor"); + tables.setId("table-bindings"); + columns.setId("table-columns"); + status.setId("table-status"); + status.setWrapText(true); + tables.setPrefHeight(65); + columns.setPrefHeight(65); + tables + .getSelectionModel() + .selectedItemProperty() + .addListener( + (o, a, b) -> { + if (!loading && b != null) select(b); + }); + columns + .getSelectionModel() + .selectedItemProperty() + .addListener( + (o, a, b) -> { + if (!loading && b != null) { + column = b.index(); + attribute.setText(b.attribute()); + header.setText(b.header()); + width.setText(b.width()); + } + }); + var tableFields = + new HBox(6, new Label("Collection"), collection, new Label("Row Component"), row); + var columnFields = + new HBox( + 6, + new Label("Attribute"), + attribute, + new Label("Header"), + header, + new Label("Width (optional)"), + width); + getChildren() + .addAll( + tables, + tableFields, + new HBox( + 6, + button( + "New Table", + () -> { + tables.getSelectionModel().clearSelection(); + table = null; + column = null; + columns.getItems().clear(); + collection.clear(); + row.clear(); + }), + button( + "Apply Table", + () -> + change( + "Edit Table", + () -> + table == null + ? document.create(collection.getText(), row.getText()) + : document.configure( + table, collection.getText(), row.getText()))), + button( + "Remove Table", + () -> change("Remove Table", () -> document.removeTable(requireTable()))), + button( + "Open selected Row Component", + () -> { + if (!row.getText().isBlank()) navigate.accept(row.getText()); + })), + columns, + columnFields, + new HBox( + 6, + button( + "New Column", + () -> { + column = null; + columns.getSelectionModel().clearSelection(); + attribute.clear(); + header.clear(); + width.clear(); + }), + button( + "Apply Column", + () -> + change( + "Edit Column", + () -> + document.writeColumn( + requireTable(), + column, + attribute.getText(), + header.getText(), + width.getText()))), + button( + "Remove Column", + () -> + change( + "Remove Column", + () -> document.removeColumn(requireTable(), requireColumn()))), + button( + "Column up", + () -> + change( + "Move Column", + () -> document.moveColumn(requireTable(), requireColumn(), -1))), + button( + "Column down", + () -> + change( + "Move Column", + () -> document.moveColumn(requireTable(), requireColumn(), 1)))), + status); + } + + private TextField field(String name) { + var f = new TextField(); + f.setId("table-" + name); + return f; + } + + private Button button(String text, Runnable action) { + var b = new Button(text); + b.setOnAction(e -> action.run()); + return b; + } + + private int requireTable() { + if (table == null) throw new IllegalArgumentException("Select a Table"); + return table; + } + + private int requireColumn() { + if (column == null) throw new IllegalArgumentException("Select a Column"); + return column; + } + + private void change(String label, java.util.concurrent.Callable> operation) { + try { + var edits = operation.call(); + if (!edits.isEmpty()) edit.accept(new Intent(source, label, edits)); + } catch (Exception e) { + error(e.getMessage()); + } + } + + private void select(TableDocument.Table value) { + table = value.index(); + collection.setText(value.collection()); + row.setText(value.row()); + column = null; + attribute.clear(); + header.clear(); + width.clear(); + try { + columns.getItems().setAll(document.columns(table)); + } catch (Exception e) { + columns.getItems().clear(); + error(e.getMessage()); + } + } + + void selectControl(String id) { + if (id == null || id.isEmpty()) return; + tables.getItems().stream() + .filter(t -> id.equals(t.id())) + .findFirst() + .ifPresent(t -> tables.getSelectionModel().select(t)); + } + + void load(String text, boolean writable) { + setDisable(!writable); + if (source.equals(text)) return; + source = text; + loading = true; + try { + document = new TableDocument(text); + tables.getItems().setAll(document.tables); + var selected = + document.tables.stream() + .filter(t -> table != null && t.index() == table) + .findFirst() + .orElse(null); + tables.getSelectionModel().select(selected); + columns.getItems().clear(); + column = null; + if (selected != null) select(selected); + else { + table = null; + collection.clear(); + row.clear(); + attribute.clear(); + header.clear(); + width.clear(); + } + var errors = document.errors(); + status.setText( + (errors.isEmpty() ? "No structural Table/Column errors." : String.join("\n", errors)) + + "\n" + + "Column/Row alignment and type compatibility are unverified. Open Row Component to" + + " review its positional fields; Column edits never rearrange the shared Row" + + " layout."); + } catch (Exception e) { + document = null; + tables.getItems().clear(); + columns.getItems().clear(); + table = null; + column = null; + error("Table editor unavailable: " + e.getMessage()); + } finally { + loading = false; + } + } + + void error(String message) { + status.setText(message); + } +} diff --git a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java index 26d4069..1577376 100644 --- a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java +++ b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java @@ -675,6 +675,146 @@ public final class ProjectionHostChecks { await(() -> fx(() -> source(pane).getText().equals(after))); } + private static void tableField(DesignerPane pane, String field, String value) throws Exception { + fx( + () -> { + ((TextField) pane.lookup("#table-" + field)).setText(value); + return null; + }); + } + + private static void tableSelect(DesignerPane pane, String list, int index) throws Exception { + fx( + () -> { + ((ListView) pane.lookup("#table-" + list)).getSelectionModel().select(index); + return null; + }); + } + + private static void tableEdits(DesignerPane pane, DocumentSession document, String full) + throws Exception { + set(document, full); + await(() -> fx(() -> source(pane).getText().equals(full))); + bindingTab(pane, "Table / Columns"); + tableSelect(pane, "bindings", 0); + tableSelect(pane, "columns", 0); + tableField(pane, "header", "Street & address"); + tableField(pane, "width", "180"); + check(documentText(document).equals(full), "Table form changed source before Apply"); + click(pane, "Apply Column"); + await(() -> !documentText(document).equals(full)); + String edited = documentText(document); + check( + edited.contains("header=\"Street & address\"") && edited.contains("width=\"180\""), + "Column fields missing"); + repeatHistory(pane, document, full, edited); + // A temporary duplicate is saveable, but cannot start a new preview. + tableSelect(pane, "bindings", 0); + click(pane, "New Column"); + tableField(pane, "attribute", "street"); + tableField(pane, "header", "Second"); + click(pane, "Apply Column"); + await(() -> !documentText(document).equals(edited)); + String duplicate = documentText(document); + repeatHistory(pane, document, edited, duplicate); + document + .save(document.snapshot().toCompletableFuture().get().revision()) + .toCompletableFuture() + .get(); + check( + java.nio.file.Files.readString( + java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document())) + .equals(duplicate), + "Table invalid draft not saved"); + click(pane, "Render drafts"); + await( + () -> + fx( + () -> + pane.lookupAll(".label").stream() + .anyMatch( + n -> + n instanceof Label l + && l.getText() != null + && l.getText().startsWith("Preview blocked:") + && l.getText().contains("Duplicate Column")))); + tableSelect(pane, "bindings", 0); + tableSelect(pane, "columns", 1); + click(pane, "Column up"); + await(() -> !documentText(document).equals(duplicate)); + String moved = documentText(document); + repeatHistory(pane, document, duplicate, moved); + check( + moved.indexOf("header=\"Second\"") < moved.indexOf("header=\"Street & address\""), + "Column order not changed"); + tableSelect(pane, "bindings", 0); + tableSelect(pane, "columns", 0); + click(pane, "Column down"); + await(() -> documentText(document).equals(duplicate)); + repeatHistory(pane, document, moved, duplicate); + tableSelect(pane, "bindings", 0); + tableSelect(pane, "columns", 1); + click(pane, "Remove Column"); + await(() -> !documentText(document).equals(duplicate)); + String removed = documentText(document); + repeatHistory(pane, document, duplicate, removed); + tableSelect(pane, "bindings", 0); + tableField(pane, "row", "fixture.components.AddressForm"); + click(pane, "Apply Table"); + await(() -> !documentText(document).equals(removed)); + String configured = documentText(document); + repeatHistory(pane, document, removed, configured); + tableSelect(pane, "bindings", 0); + click(pane, "Remove Table"); + await(() -> !documentText(document).equals(configured)); + String noTable = documentText(document); + repeatHistory(pane, document, configured, noTable); + click(pane, "New Table"); + tableField(pane, "collection", "visits"); + tableField(pane, "row", "fixture.components.VisitRow"); + click(pane, "Apply Table"); + await(() -> !documentText(document).equals(noTable)); + String created = documentText(document); + repeatHistory(pane, document, noTable, created); + tableSelect(pane, "bindings", 0); + click(pane, "New Column"); + tableField(pane, "attribute", "street"); + tableField(pane, "header", "Street"); + tableField(pane, "width", "160"); + click(pane, "Apply Column"); + await(() -> !documentText(document).equals(created)); + String valid = documentText(document); + repeatHistory(pane, document, created, valid); + document + .save(document.snapshot().toCompletableFuture().get().revision()) + .toCompletableFuture() + .get(); + check( + java.nio.file.Files.readString( + java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document())) + .equals(valid), + "Table save changed source"); + click(pane, "Interactive preview"); + await( + () -> + fx( + () -> + pane.lookupAll(".label").stream() + .anyMatch( + n -> + n instanceof Label l + && "Interactive preview running from captured drafts" + .equals(l.getText())))); + check(pane.interactivePid() > 0, "Table preview worker missing"); + click(pane, "Stop preview"); + await(() -> pane.interactivePid() < 0); + set(document, full); + await(() -> fx(() -> source(pane).getText().equals(full))); + System.out.println( + "PASS Table/Column inspector: CRUD, reorder, exact undo/redo, invalid save, preview gating" + + " and target load"); + } + private static void repeatBindingEdits(DesignerPane pane, DocumentSession document, String full) throws Exception { String base = @@ -1273,6 +1413,7 @@ public final class ProjectionHostChecks { directBindingEdits(pane, document, full); computedBindingEdits(pane, document, full); repeatBindingEdits(pane, document, full); + tableEdits(pane, document, full); document .save(document.snapshot().toCompletableFuture().get().revision()) .toCompletableFuture() diff --git a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/TableDocumentTest.java b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/TableDocumentTest.java new file mode 100644 index 0000000..e40c696 --- /dev/null +++ b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/TableDocumentTest.java @@ -0,0 +1,121 @@ +package de.neitzel.fxdesigner.kit; + +import static org.junit.jupiter.api.Assertions.*; + +import de.neitzel.fxdesigner.contracts.*; +import java.net.URI; +import java.util.*; +import org.junit.jupiter.api.Test; + +class TableDocumentTest { + private static final String START = + "\r\n" + + "