Add shared source-preserving Table and Column authoring
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> 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)
|
||||
|
||||
@@ -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<Table> 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<Element> children(Element e) {
|
||||
var result = new ArrayList<Element>();
|
||||
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<Column> 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<Replacement> 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<Replacement> 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 =
|
||||
"<de.neitzel.fx.view.Table collection=\""
|
||||
+ escape(collection)
|
||||
+ "\" row=\""
|
||||
+ escape(row)
|
||||
+ "\"/>";
|
||||
return List.of(xml.append(parent, node, ""));
|
||||
}
|
||||
|
||||
List<Replacement> 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<Replacement> 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<String, String>();
|
||||
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),
|
||||
"<de.neitzel.fx.view.Column attribute=\""
|
||||
+ escape(attribute)
|
||||
+ "\" header=\""
|
||||
+ escape(header)
|
||||
+ "\""
|
||||
+ (width.isEmpty() ? "" : " width=\"" + escape(width) + "\"")
|
||||
+ "/>",
|
||||
""));
|
||||
}
|
||||
|
||||
List<Replacement> 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<Replacement> 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<Replacement> remove(int index) {
|
||||
var t = xml.source.tags.get(index);
|
||||
return List.of(new Replacement(t.start(), t.end(), ""));
|
||||
}
|
||||
|
||||
private List<Replacement> attributes(int index, Map<String, String> values) {
|
||||
var changed = new LinkedHashMap<String, String>();
|
||||
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<String> errors() {
|
||||
var errors = new ArrayList<String>();
|
||||
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<String>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Replacement> edits) {}
|
||||
|
||||
private final ListView<TableDocument.Table> tables = new ListView<>();
|
||||
private final ListView<TableDocument.Column> 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<Intent> edit;
|
||||
private String source = "";
|
||||
private TableDocument document;
|
||||
private boolean loading;
|
||||
private Integer table, column;
|
||||
|
||||
TableEditor(Consumer<Intent> edit, Consumer<String> 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<List<Replacement>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 =
|
||||
"<?neitzel-model app.Person?>\r\n"
|
||||
+ "<?import de.neitzel.fx.view.*?><?import javafx.scene.layout.VBox?><VBox"
|
||||
+ " xmlns:f='http://javafx.com/fxml/1'><!-- keep --><Table f:id='t' collection = 'visits'"
|
||||
+ " row='app.Row' custom='keep'><columns><!-- before --><Column header = 'Street'"
|
||||
+ " attribute='street' width='80' custom='keep'/><!-- between --><Column header='Number'"
|
||||
+ " attribute='number'/></columns></Table><Label text='untouched'/></VBox>";
|
||||
|
||||
private String apply(String text, List<EditBatch.Replacement> edits) throws Exception {
|
||||
var result =
|
||||
new EditBatch(1, "Table edit", edits)
|
||||
.applyTo(new DocumentSnapshot(URI.create("file:///view.fxml"), 1, text, true, false));
|
||||
new TableDocument(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Test
|
||||
void editsOnlyChangedRangesAndRemovesOptionalWidth() throws Exception {
|
||||
var d = new TableDocument(START);
|
||||
int t = d.tables.getFirst().index();
|
||||
int c = d.columns(t).getFirst().index();
|
||||
assertEquals(
|
||||
START.replace("collection = 'visits'", "collection = 'other'"),
|
||||
apply(START, d.configure(t, "other", "app.Row")));
|
||||
assertTrue(d.configure(t, "visits", "app.Row").isEmpty());
|
||||
var changed = apply(START, d.writeColumn(t, c, "street", "A'&<", ""));
|
||||
assertEquals(
|
||||
START.replace("header = 'Street'", "header = 'A'&<'").replace("width='80'", ""),
|
||||
changed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reordersExactElementsWithoutMovingCommentsOrRowSource() throws Exception {
|
||||
var d = new TableDocument(START);
|
||||
int t = d.tables.getFirst().index();
|
||||
int c = d.columns(t).getFirst().index();
|
||||
String moved = apply(START, d.moveColumn(t, c, 1));
|
||||
assertTrue(
|
||||
moved.contains(
|
||||
"<!-- before --><Column header='Number' attribute='number'/><!-- between --><Column"
|
||||
+ " header = 'Street' attribute='street' width='80' custom='keep'/>"));
|
||||
var next = new TableDocument(moved);
|
||||
assertEquals(START, apply(moved, next.moveColumn(t, next.columns(t).getFirst().index(), 1)));
|
||||
assertTrue(d.moveColumn(t, c, -1).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsAndRemovesTablesAndColumnsInSupportedContainers() throws Exception {
|
||||
for (String root :
|
||||
List.of(
|
||||
"<VBox/>",
|
||||
"<VBox><children/></VBox>",
|
||||
"<VBox><children><!--keep--></children></VBox>")) {
|
||||
var d = new TableDocument(root);
|
||||
String created = apply(root, d.create("visits", "app.Row"));
|
||||
var n = new TableDocument(created);
|
||||
int t = n.tables.getFirst().index();
|
||||
String withColumn = apply(created, n.writeColumn(t, null, "street", "Street", "120"));
|
||||
var c = new TableDocument(withColumn);
|
||||
assertTrue(c.errors().isEmpty());
|
||||
assertTrue(apply(withColumn, c.removeTable(t)).contains("VBox"));
|
||||
String removed = apply(withColumn, c.removeColumn(t, c.columns(t).getFirst().index()));
|
||||
assertTrue(
|
||||
new TableDocument(removed).errors().stream().anyMatch(e -> e.contains("at least one")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesNestedDeclarationsAndRejectsAmbiguousContainers() throws Exception {
|
||||
String text =
|
||||
START.replace("width='80' custom='keep'/>", "width='80'><header>Nested</header></Column>");
|
||||
var d = new TableDocument(text);
|
||||
int t = d.tables.getFirst().index();
|
||||
assertFalse(d.columns(t).getFirst().editable());
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> d.writeColumn(t, d.columns(t).getFirst().index(), "street", "X", ""));
|
||||
var mixed =
|
||||
new TableDocument(
|
||||
START.replace("<columns>", "<Column header='Other' attribute='other'/><columns>"));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class, () -> mixed.columns(mixed.tables.getFirst().index()));
|
||||
var root = new TableDocument("<de.neitzel.fx.view.Table collection='visits' row='app.Row'/>");
|
||||
assertThrows(IllegalArgumentException.class, () -> root.removeTable(0));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new TableDocument("<Label/>").create("visits", "app.Row"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void diagnosesInvalidDeclarationsWithoutPreventingEdits() throws Exception {
|
||||
for (String width : List.of("0", "-2", "NaN", "Infinity", "oops")) {
|
||||
var d = new TableDocument(START);
|
||||
int t = d.tables.getFirst().index();
|
||||
var changed =
|
||||
new TableDocument(
|
||||
apply(START, d.writeColumn(t, d.columns(t).getFirst().index(), "number", "", width)));
|
||||
assertTrue(changed.errors().stream().anyMatch(e -> e.contains("Duplicate")));
|
||||
assertTrue(changed.errors().stream().anyMatch(e -> e.contains("positive and finite")));
|
||||
assertTrue(changed.errors().stream().anyMatch(e -> e.contains("header")));
|
||||
}
|
||||
var invalid =
|
||||
new TableDocument(START.replace("collection = 'visits'", "collection='invalid path'"));
|
||||
assertFalse(invalid.errors().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresUnrelatedImportedNames() throws Exception {
|
||||
assertTrue(new TableDocument("<?import app.Table?><VBox><Table/></VBox>").tables.isEmpty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user