Add shared source-preserving Repeat binding inspector
This commit is contained in:
@@ -10,10 +10,15 @@ final class BindingDocument {
|
||||
|
||||
enum Kind {
|
||||
DIRECT,
|
||||
COMPUTED;
|
||||
COMPUTED,
|
||||
REPEAT;
|
||||
|
||||
String element() {
|
||||
return this == DIRECT ? "DirectBinding" : "ComputedBinding";
|
||||
return switch (this) {
|
||||
case DIRECT -> "DirectBinding";
|
||||
case COMPUTED -> "ComputedBinding";
|
||||
case REPEAT -> "Repeat";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,18 +28,31 @@ final class BindingDocument {
|
||||
String path,
|
||||
String direction,
|
||||
String converter,
|
||||
String method) {
|
||||
String method,
|
||||
String pane,
|
||||
String component,
|
||||
String collection) {
|
||||
Values(String control, String property, String path, String direction, String converter) {
|
||||
this(control, property, path, direction, converter, "");
|
||||
this(control, property, path, direction, converter, "", "", "", "");
|
||||
}
|
||||
|
||||
static Values repeat(String pane, String component, String collection) {
|
||||
return new Values("", "", "", "view", "", "", pane, component, collection);
|
||||
}
|
||||
|
||||
String target() {
|
||||
return pane.isEmpty() ? control : pane;
|
||||
}
|
||||
|
||||
static Values computed(String control, String property, String method) {
|
||||
return new Values(control, property, "", "view", "", method);
|
||||
return new Values(control, property, "", "view", "", method, "", "", "");
|
||||
}
|
||||
}
|
||||
|
||||
record Entry(int index, Values values, boolean editable) {
|
||||
public String toString() {
|
||||
if (!values.pane().isEmpty())
|
||||
return values.pane() + " ← " + values.collection() + " (" + values.component() + ")";
|
||||
return values.control()
|
||||
+ "."
|
||||
+ values.property()
|
||||
@@ -115,6 +133,9 @@ final class BindingDocument {
|
||||
}
|
||||
|
||||
private Values values(Element e) {
|
||||
if (kind == Kind.REPEAT)
|
||||
return Values.repeat(
|
||||
e.getAttribute("pane"), e.getAttribute("component"), e.getAttribute("collection"));
|
||||
if (kind == Kind.COMPUTED)
|
||||
return Values.computed(
|
||||
e.getAttribute("control"), e.getAttribute("property"), e.getAttribute("method"));
|
||||
@@ -170,9 +191,34 @@ final class BindingDocument {
|
||||
}
|
||||
}
|
||||
}
|
||||
var panes = new HashSet<String>();
|
||||
for (var e : elements)
|
||||
if (binding(e, "Repeat")) {
|
||||
if (hasElementChildren(e)) continue;
|
||||
String pane = e.getAttribute("pane");
|
||||
if (!panes.add(pane)) errors.add("Duplicate Repeat pane: " + pane);
|
||||
if (ids.getOrDefault(pane, 0) != 1) errors.add("Missing or ambiguous Repeat pane: " + pane);
|
||||
if (!qualifiedName(e.getAttribute("collection")))
|
||||
errors.add("Invalid or missing Repeat collection: " + pane);
|
||||
if (!qualifiedName(e.getAttribute("component")))
|
||||
errors.add("Invalid or missing Repeat Component class: " + pane);
|
||||
}
|
||||
return List.copyOf(errors);
|
||||
}
|
||||
|
||||
private static boolean qualifiedName(String value) {
|
||||
return value.matches(
|
||||
"[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(\\.[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)*");
|
||||
}
|
||||
|
||||
private boolean conflicts(Element e, Values value) {
|
||||
if (kind == Kind.REPEAT)
|
||||
return binding(e, "Repeat") && e.getAttribute("pane").equals(value.pane());
|
||||
return (binding(e, "DirectBinding") || binding(e, "ComputedBinding"))
|
||||
&& e.getAttribute("control").equals(value.control())
|
||||
&& e.getAttribute("property").equals(value.property());
|
||||
}
|
||||
|
||||
List<Replacement> remove(int index) {
|
||||
Element e = entry(index);
|
||||
var t = source.tags.get(indexes.get(e));
|
||||
@@ -180,23 +226,20 @@ final class BindingDocument {
|
||||
}
|
||||
|
||||
List<Replacement> write(Integer index, Values value, boolean replaceConflicts) {
|
||||
if (value.control().isBlank() || value.property().isBlank())
|
||||
if (kind == Kind.REPEAT && value.pane().isBlank())
|
||||
throw new IllegalArgumentException("Choose a pane");
|
||||
if (kind != Kind.REPEAT && (value.control().isBlank() || value.property().isBlank()))
|
||||
throw new IllegalArgumentException("Choose a control and property");
|
||||
Element original = index == null ? null : entry(index);
|
||||
if (original != null && hasElementChildren(original))
|
||||
throw new IllegalArgumentException(
|
||||
"This declaration uses nested property elements; edit it in Source.");
|
||||
var conflicts =
|
||||
elements.stream()
|
||||
.filter(
|
||||
e ->
|
||||
e != original
|
||||
&& (binding(e, "DirectBinding") || binding(e, "ComputedBinding"))
|
||||
&& e.getAttribute("control").equals(value.control())
|
||||
&& e.getAttribute("property").equals(value.property()))
|
||||
.toList();
|
||||
var conflicts = elements.stream().filter(e -> e != original && conflicts(e, value)).toList();
|
||||
if (!conflicts.isEmpty() && !replaceConflicts)
|
||||
throw new Conflict(value.control() + "." + value.property());
|
||||
throw new Conflict(
|
||||
kind == Kind.REPEAT
|
||||
? "Repeat pane " + value.pane()
|
||||
: value.control() + "." + value.property());
|
||||
var edits = new ArrayList<Replacement>();
|
||||
for (var e : conflicts) {
|
||||
var t = source.tags.get(indexes.get(e));
|
||||
@@ -205,25 +248,33 @@ final class BindingDocument {
|
||||
if (original != null) {
|
||||
var changed = new LinkedHashMap<String, String>();
|
||||
Map<String, String> fields =
|
||||
kind == Kind.COMPUTED
|
||||
kind == Kind.REPEAT
|
||||
? Map.of(
|
||||
"control",
|
||||
value.control(),
|
||||
"property",
|
||||
value.property(),
|
||||
"method",
|
||||
value.method())
|
||||
: Map.of(
|
||||
"control",
|
||||
value.control(),
|
||||
"property",
|
||||
value.property(),
|
||||
"path",
|
||||
value.path(),
|
||||
"direction",
|
||||
value.direction(),
|
||||
"converter",
|
||||
value.converter());
|
||||
"pane",
|
||||
value.pane(),
|
||||
"component",
|
||||
value.component(),
|
||||
"collection",
|
||||
value.collection())
|
||||
: kind == Kind.COMPUTED
|
||||
? Map.of(
|
||||
"control",
|
||||
value.control(),
|
||||
"property",
|
||||
value.property(),
|
||||
"method",
|
||||
value.method())
|
||||
: Map.of(
|
||||
"control",
|
||||
value.control(),
|
||||
"property",
|
||||
value.property(),
|
||||
"path",
|
||||
value.path(),
|
||||
"direction",
|
||||
value.direction(),
|
||||
"converter",
|
||||
value.converter());
|
||||
for (var f : fields.entrySet())
|
||||
if (!original.getAttribute(f.getKey()).equals(f.getValue()))
|
||||
changed.put(
|
||||
@@ -240,7 +291,19 @@ final class BindingDocument {
|
||||
+ "\" property=\""
|
||||
+ XmlSource.escape(value.property(), '"')
|
||||
+ "\"";
|
||||
if (kind == Kind.COMPUTED)
|
||||
if (kind == Kind.REPEAT)
|
||||
node =
|
||||
"<"
|
||||
+ PACKAGE
|
||||
+ kind.element()
|
||||
+ " pane=\""
|
||||
+ XmlSource.escape(value.pane(), '"')
|
||||
+ "\" component=\""
|
||||
+ XmlSource.escape(value.component(), '"')
|
||||
+ "\" collection=\""
|
||||
+ XmlSource.escape(value.collection(), '"')
|
||||
+ "\"";
|
||||
else if (kind == Kind.COMPUTED)
|
||||
node += " method=\"" + XmlSource.escape(value.method(), '"') + "\"";
|
||||
else
|
||||
node +=
|
||||
|
||||
@@ -20,7 +20,10 @@ final class BindingEditor extends VBox {
|
||||
property = field("property"),
|
||||
path = field("path"),
|
||||
converter = field("converter"),
|
||||
method = field("method");
|
||||
method = field("method"),
|
||||
pane = field("pane"),
|
||||
component = field("component"),
|
||||
collection = field("collection");
|
||||
private final ComboBox<BindingMetadata.Computed> methods = new ComboBox<>();
|
||||
private final BindingDocument.Kind kind;
|
||||
private final ComboBox<BindingMetadata.Converter> converters = new ComboBox<>();
|
||||
@@ -43,9 +46,11 @@ final class BindingEditor extends VBox {
|
||||
Consumer<BindingDocument.Values> discover) {
|
||||
this.kind = kind;
|
||||
boolean computed = kind == BindingDocument.Kind.COMPUTED;
|
||||
String prefix = computed ? "computed-" : "direct-";
|
||||
boolean repeat = kind == BindingDocument.Kind.REPEAT;
|
||||
String prefix = repeat ? "repeat-" : computed ? "computed-" : "direct-";
|
||||
this.navigate = navigate;
|
||||
for (var field : java.util.List.of(control, property, path, converter, method))
|
||||
for (var field :
|
||||
java.util.List.of(control, property, path, converter, method, pane, component, collection))
|
||||
field.setId(prefix + field.getId());
|
||||
methods.setId("computed-sources");
|
||||
methods.setPrefWidth(680);
|
||||
@@ -60,6 +65,12 @@ final class BindingEditor extends VBox {
|
||||
apply.setText("Apply Computed binding");
|
||||
remove.setText("Remove Computed binding");
|
||||
}
|
||||
if (repeat) {
|
||||
inspect.setText("Validate Repeat bindings");
|
||||
fresh.setText("New Repeat binding");
|
||||
apply.setText("Apply Repeat binding");
|
||||
remove.setText("Remove Repeat binding");
|
||||
}
|
||||
setSpacing(5);
|
||||
converters.setId("direct-converters");
|
||||
converters.setPrefWidth(680);
|
||||
@@ -83,9 +94,11 @@ final class BindingEditor extends VBox {
|
||||
fresh.setOnAction(
|
||||
e ->
|
||||
newBinding(
|
||||
computed
|
||||
? BindingDocument.Values.computed("", "", "")
|
||||
: new BindingDocument.Values("", "", "", "both", "")));
|
||||
repeat
|
||||
? BindingDocument.Values.repeat("", "", "")
|
||||
: computed
|
||||
? BindingDocument.Values.computed("", "", "")
|
||||
: new BindingDocument.Values("", "", "", "both", "")));
|
||||
apply.setOnAction(
|
||||
e ->
|
||||
edit.accept(new Intent(kind, source, selected, values(), false, replace.isSelected())));
|
||||
@@ -101,7 +114,7 @@ final class BindingEditor extends VBox {
|
||||
fill(b.values());
|
||||
replace.setSelected(false);
|
||||
buttons();
|
||||
if (!followingCanvas) navigate.accept(b.values().control());
|
||||
if (!followingCanvas) navigate.accept(b.values().target());
|
||||
if (!b.editable())
|
||||
status.setText(
|
||||
"Nested property elements are preserved. Edit this declaration in Source, or"
|
||||
@@ -110,7 +123,7 @@ final class BindingEditor extends VBox {
|
||||
var grid = new GridPane();
|
||||
grid.setHgap(8);
|
||||
grid.setVgap(4);
|
||||
if (!computed)
|
||||
if (!computed && !repeat)
|
||||
grid.addRow(
|
||||
0,
|
||||
new Label("Control (fx:id)"),
|
||||
@@ -119,19 +132,23 @@ final class BindingEditor extends VBox {
|
||||
property,
|
||||
new Label("Direction"),
|
||||
direction);
|
||||
if (!computed)
|
||||
if (!computed && !repeat)
|
||||
grid.addRow(1, new Label("Model path"), path, new Label("Converter (optional)"), converter);
|
||||
else {
|
||||
else if (computed) {
|
||||
grid.addRow(0, new Label("Control (fx:id)"), control, new Label("Property"), property);
|
||||
grid.addRow(1, new Label("Controller method"), method);
|
||||
for (var field : java.util.List.of(control, property))
|
||||
field.textProperty().addListener((o, a, b) -> methods.getItems().clear());
|
||||
}
|
||||
if (repeat) {
|
||||
grid.addRow(0, new Label("Pane (fx:id)"), pane, new Label("Collection path"), collection);
|
||||
grid.addRow(1, new Label("Component class"), component);
|
||||
}
|
||||
getChildren()
|
||||
.addAll(
|
||||
bindings,
|
||||
grid,
|
||||
new HBox(8, inspect, computed ? methods : converters),
|
||||
repeat ? new HBox(8, inspect) : new HBox(8, inspect, computed ? methods : converters),
|
||||
new HBox(8, fresh, apply, remove, replace),
|
||||
status);
|
||||
buttons();
|
||||
@@ -145,6 +162,9 @@ final class BindingEditor extends VBox {
|
||||
}
|
||||
|
||||
private BindingDocument.Values values() {
|
||||
if (kind == BindingDocument.Kind.REPEAT)
|
||||
return BindingDocument.Values.repeat(
|
||||
pane.getText().trim(), component.getText().trim(), collection.getText().trim());
|
||||
if (kind == BindingDocument.Kind.COMPUTED)
|
||||
return BindingDocument.Values.computed(
|
||||
control.getText().trim(), property.getText().trim(), method.getText().trim());
|
||||
@@ -163,6 +183,9 @@ final class BindingEditor extends VBox {
|
||||
direction.setValue(v.direction());
|
||||
converter.setText(v.converter());
|
||||
method.setText(v.method());
|
||||
pane.setText(v.pane());
|
||||
component.setText(v.component());
|
||||
collection.setText(v.collection());
|
||||
}
|
||||
|
||||
void newBinding(BindingDocument.Values v) {
|
||||
@@ -214,9 +237,9 @@ final class BindingEditor extends VBox {
|
||||
void selectControl(String id) {
|
||||
if (loading || id == null) return;
|
||||
var selected = bindings.getSelectionModel().getSelectedItem();
|
||||
if (selected != null && selected.values().control().equals(id)) return;
|
||||
if (selected != null && selected.values().target().equals(id)) return;
|
||||
bindings.getItems().stream()
|
||||
.filter(e -> e.values().control().equals(id))
|
||||
.filter(e -> e.values().target().equals(id))
|
||||
.findFirst()
|
||||
.ifPresent(
|
||||
e -> {
|
||||
@@ -250,6 +273,21 @@ final class BindingEditor extends VBox {
|
||||
+ ": "
|
||||
+ p.message())
|
||||
.collect(java.util.stream.Collectors.joining("\n"))));
|
||||
if (kind == BindingDocument.Kind.REPEAT) {
|
||||
try {
|
||||
var errors = new BindingDocument(source, kind).errors();
|
||||
status.setText(
|
||||
provenance
|
||||
+ "\n"
|
||||
+ (errors.isEmpty() ? "No structural binding errors." : String.join("\n", errors))
|
||||
+ "\n"
|
||||
+ "Collection and Component compatibility have not been checked. Render drafts to"
|
||||
+ " test them.");
|
||||
} catch (Exception error) {
|
||||
status.setText("Validation unavailable: " + error.getMessage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (requested == null || !values().equals(requested)) return;
|
||||
if (kind == BindingDocument.Kind.COMPUTED) {
|
||||
methods.getItems().setAll(metadata.computed());
|
||||
|
||||
@@ -44,6 +44,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
String id = hit.getFxId();
|
||||
if (bindingEditor != null) bindingEditor.selectControl(id);
|
||||
if (computedEditor != null) computedEditor.selectControl(id);
|
||||
if (repeatEditor != null) repeatEditor.selectControl(id);
|
||||
if (id == null || !projection.components().containsKey(id)) return;
|
||||
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
|
||||
componentInspector
|
||||
@@ -115,7 +116,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
private DesignerWorkflow interactiveWorkflow;
|
||||
private DesignerWorkflow bindingWorkflow;
|
||||
private BindingBrowser bindingBrowser;
|
||||
private BindingEditor bindingEditor, computedEditor;
|
||||
private BindingEditor bindingEditor, computedEditor, repeatEditor;
|
||||
private String validatedSource, validatingSource;
|
||||
private boolean validationPreview;
|
||||
private java.util.List<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> bindingProblems =
|
||||
@@ -200,6 +201,16 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
if (target != null) kit.editor().getSelection().select(target);
|
||||
},
|
||||
values -> validateBindings(values, false));
|
||||
repeatEditor =
|
||||
new BindingEditor(
|
||||
BindingDocument.Kind.REPEAT,
|
||||
this::editBinding,
|
||||
id -> {
|
||||
if (kit.editor().getFxomDocument() == null) return;
|
||||
var target = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(id);
|
||||
if (target != null) kit.editor().getSelection().select(target);
|
||||
},
|
||||
values -> validateBindings(values, false));
|
||||
bindingBrowser =
|
||||
new BindingBrowser(
|
||||
this::discoverBindings,
|
||||
@@ -348,8 +359,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
directBindingTab.setClosable(false);
|
||||
var computedTab = new Tab("Computed bindings", computedEditor);
|
||||
computedTab.setClosable(false);
|
||||
var repeatTab = new Tab("Repeat bindings", repeatEditor);
|
||||
repeatTab.setClosable(false);
|
||||
var bindings =
|
||||
new TitledPane("Bindings", new TabPane(directBindingTab, computedTab, discoveryTab));
|
||||
new TitledPane(
|
||||
"Bindings", new TabPane(directBindingTab, computedTab, repeatTab, discoveryTab));
|
||||
bindings.setExpanded(false);
|
||||
center.setBottom(new VBox(properties, bindings));
|
||||
output.setEditable(false);
|
||||
@@ -491,6 +505,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
draft = value;
|
||||
bindingEditor.load(value.text(), value.writable());
|
||||
computedEditor.load(value.text(), value.writable());
|
||||
repeatEditor.load(value.text(), value.writable());
|
||||
submittedSource = null;
|
||||
commitSource();
|
||||
return;
|
||||
@@ -514,6 +529,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
draft = value;
|
||||
bindingEditor.load(value.text(), value.writable());
|
||||
computedEditor.load(value.text(), value.writable());
|
||||
repeatEditor.load(value.text(), value.writable());
|
||||
if (!source.getText().equals(value.text())) {
|
||||
int anchor = source.getAnchor(), caret = source.getCaretPosition();
|
||||
var edits =
|
||||
@@ -686,7 +702,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
? "Remove " + intent.kind().element()
|
||||
: (intent.index() == null ? "Create " : "Edit ") + intent.kind().element());
|
||||
} catch (Exception error) {
|
||||
(intent.kind() == BindingDocument.Kind.COMPUTED ? computedEditor : bindingEditor)
|
||||
(intent.kind() == BindingDocument.Kind.REPEAT
|
||||
? repeatEditor
|
||||
: intent.kind() == BindingDocument.Kind.COMPUTED
|
||||
? computedEditor
|
||||
: bindingEditor)
|
||||
.error(error.getMessage());
|
||||
}
|
||||
});
|
||||
@@ -761,6 +781,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
: "Current successful build")
|
||||
+ " · draft revision "
|
||||
+ captured.revision());
|
||||
repeatEditor.metadata(
|
||||
metadata,
|
||||
values,
|
||||
(last
|
||||
? "Explicit retained build (outdated)"
|
||||
: "Current successful build")
|
||||
+ " · draft revision "
|
||||
+ captured.revision());
|
||||
status.setText(
|
||||
"Binding validation complete: "
|
||||
+ metadata.problems().size()
|
||||
|
||||
@@ -237,4 +237,108 @@ class BindingDocumentTest {
|
||||
.entries
|
||||
.isEmpty());
|
||||
}
|
||||
|
||||
private BindingDocument repeats(String text) throws Exception {
|
||||
return new BindingDocument(text, BindingDocument.Kind.REPEAT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatCreatesContainersAndEscapesFields() throws Exception {
|
||||
for (String root :
|
||||
List.of(
|
||||
"<VBox/>",
|
||||
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define/></VBox>",
|
||||
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define><"
|
||||
+ P
|
||||
+ "BindingList><elements/></"
|
||||
+ P
|
||||
+ "BindingList></f:define></VBox>")) {
|
||||
String text = "<?neitzel-model app.Person?>\r\n" + root;
|
||||
var value = BindingDocument.Values.repeat("pane", "app.Child", "a'&<");
|
||||
String result = apply(text, repeats(text).write(null, value, false));
|
||||
assertEquals(value, repeats(result).entries.getFirst().values());
|
||||
assertTrue(result.startsWith("<?neitzel-model app.Person?>\r\n"));
|
||||
assertFalse(result.contains(" control="));
|
||||
assertTrue(repeats(result).errors().stream().anyMatch(e -> e.contains("Repeat collection")));
|
||||
}
|
||||
}
|
||||
|
||||
private String repeatSource() {
|
||||
return existing()
|
||||
.replace(
|
||||
"<app.Unknown",
|
||||
"<Repeat pane = 'rows' component='app.Child' collection = 'visits' custom='keep'><!--"
|
||||
+ " inner --></Repeat><app.Unknown")
|
||||
.replace("</VBox>", "<VBox fx:id='rows'/><VBox fx:id='other'/></VBox>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatChangesAllFieldsWithoutRewritingOtherBindings() throws Exception {
|
||||
String text = repeatSource();
|
||||
var doc = repeats(text);
|
||||
int index = doc.entries.getFirst().index();
|
||||
var value = BindingDocument.Values.repeat("other", "app.OtherChild", "home.visits");
|
||||
String changed = apply(text, doc.write(index, value, false));
|
||||
assertEquals(
|
||||
text.replace("pane = 'rows'", "pane = 'other'")
|
||||
.replace("component='app.Child'", "component='app.OtherChild'")
|
||||
.replace("collection = 'visits'", "collection = 'home.visits'"),
|
||||
changed);
|
||||
assertTrue(repeats(changed).write(index, value, false).isEmpty());
|
||||
String removed = apply(changed, repeats(changed).remove(index));
|
||||
assertTrue(repeats(removed).entries.isEmpty());
|
||||
assertEquals(1, new BindingDocument(removed).entries.size());
|
||||
assertTrue(removed.contains("<!-- preserve -->"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatConflictsAreScopedToThePaneAndReplacedAtomically() throws Exception {
|
||||
String text =
|
||||
repeatSource()
|
||||
.replace(
|
||||
"<app.Unknown",
|
||||
"<Repeat pane='rows' component='app.Child' collection='visits'/><app.Unknown");
|
||||
var doc = repeats(text);
|
||||
var value = BindingDocument.Values.repeat("rows", "app.NewChild", "visits");
|
||||
assertTrue(doc.errors().stream().anyMatch(e -> e.contains("Duplicate Repeat pane")));
|
||||
assertThrows(BindingDocument.Conflict.class, () -> doc.write(null, value, false));
|
||||
String result = apply(text, doc.write(null, value, true));
|
||||
assertEquals(1, repeats(result).entries.size());
|
||||
assertEquals(1, new BindingDocument(result).entries.size());
|
||||
assertTrue(repeats(result).errors().isEmpty());
|
||||
// A DirectBinding to a property of the same pane remains independent.
|
||||
var direct = new BindingDocument.Values("rows", "visible", "name", "view", "");
|
||||
String withDirect = apply(result, new BindingDocument(result).write(null, direct, false));
|
||||
assertEquals(1, repeats(withDirect).entries.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatNestedSyntaxAndImportIdentityArePreserved() throws Exception {
|
||||
String text =
|
||||
repeatSource()
|
||||
.replace(
|
||||
"collection = 'visits' custom='keep'><!-- inner -->",
|
||||
"custom='keep'><collection>visits</collection>");
|
||||
var doc = repeats(text);
|
||||
var entry = doc.entries.getFirst();
|
||||
assertFalse(entry.editable());
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
doc.write(
|
||||
entry.index(),
|
||||
BindingDocument.Values.repeat("rows", "app.Child", "visits"),
|
||||
false));
|
||||
assertTrue(repeats(apply(text, doc.remove(entry.index()))).entries.isEmpty());
|
||||
assertTrue(
|
||||
repeats(text.replace("<?import de.neitzel.fx.view.*?>", "<?import app.Repeat?>"))
|
||||
.entries
|
||||
.isEmpty());
|
||||
assertTrue(
|
||||
repeats(repeatSource().replace("pane = 'rows'", "pane = 'missing'")).errors().stream()
|
||||
.anyMatch(e -> e.contains("Missing or ambiguous Repeat pane")));
|
||||
assertTrue(
|
||||
repeats(repeatSource().replace("component='app.Child'", "component='' ")).errors().stream()
|
||||
.anyMatch(e -> e.contains("Repeat Component")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +644,164 @@ public final class ProjectionHostChecks {
|
||||
await(() -> fx(() -> source(pane).getText().equals(full)));
|
||||
}
|
||||
|
||||
private static void repeatField(DesignerPane pane, String name, String value) throws Exception {
|
||||
fx(
|
||||
() -> {
|
||||
((TextField) pane.lookup("#repeat-" + name)).setText(value);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private static void selectRepeat(DesignerPane pane, String name) throws Exception {
|
||||
fx(
|
||||
() -> {
|
||||
var list = (ListView<Object>) pane.lookup("#repeat-bindings");
|
||||
list.getSelectionModel()
|
||||
.select(
|
||||
list.getItems().stream()
|
||||
.filter(e -> e.toString().startsWith(name + " ←"))
|
||||
.findFirst()
|
||||
.orElseThrow());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private static void repeatHistory(
|
||||
DesignerPane pane, DocumentSession document, String before, String after) throws Exception {
|
||||
click(pane, "Undo");
|
||||
await(() -> documentText(document).equals(before));
|
||||
click(pane, "Redo");
|
||||
await(() -> documentText(document).equals(after));
|
||||
await(() -> fx(() -> source(pane).getText().equals(after)));
|
||||
}
|
||||
|
||||
private static void repeatBindingEdits(DesignerPane pane, DocumentSession document, String full)
|
||||
throws Exception {
|
||||
String base =
|
||||
full.replace(
|
||||
"<VBox fx:id=\"visits\"/>",
|
||||
"<VBox fx:id=\"visits\"/><VBox fx:id=\"otherVisits\"/><!-- Repeat edit sentinel -->");
|
||||
check(!base.equals(full), "Repeat fixture pane missing");
|
||||
set(document, base);
|
||||
await(() -> fx(() -> source(pane).getText().equals(base)));
|
||||
bindingTab(pane, "Repeat bindings");
|
||||
selectRepeat(pane, "visits");
|
||||
click(pane, "Remove Repeat binding");
|
||||
String removed =
|
||||
base.replace(
|
||||
"<Repeat pane=\"visits\" component=\"fixture.components.AddressForm\""
|
||||
+ " collection=\"visits\"/>",
|
||||
"");
|
||||
await(() -> documentText(document).equals(removed));
|
||||
repeatHistory(pane, document, base, removed);
|
||||
click(pane, "New Repeat binding");
|
||||
repeatField(pane, "pane", "visits");
|
||||
repeatField(pane, "component", "fixture.components.AddressForm");
|
||||
repeatField(pane, "collection", "visits");
|
||||
check(documentText(document).equals(removed), "Repeat form changed source before Apply");
|
||||
click(pane, "Apply Repeat binding");
|
||||
await(() -> !documentText(document).equals(removed));
|
||||
String created = documentText(document);
|
||||
check(
|
||||
created.contains(
|
||||
"de.neitzel.fx.view.Repeat pane=\"visits\" component=\"fixture.components.AddressForm\""
|
||||
+ " collection=\"visits\""),
|
||||
"Repeat creation failed");
|
||||
repeatHistory(pane, document, removed, created);
|
||||
selectRepeat(pane, "visits");
|
||||
repeatField(pane, "pane", "otherVisits");
|
||||
repeatField(pane, "component", "fixture.components.VisitRow");
|
||||
click(pane, "Apply Repeat binding");
|
||||
String changed =
|
||||
created.replace(
|
||||
"Repeat pane=\"visits\" component=\"fixture.components.AddressForm\"",
|
||||
"Repeat pane=\"otherVisits\" component=\"fixture.components.VisitRow\"");
|
||||
await(() -> documentText(document).equals(changed));
|
||||
repeatHistory(pane, document, created, changed);
|
||||
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(changed),
|
||||
"Repeat save changed source");
|
||||
click(pane, "Validate Repeat bindings");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
((Label) pane.lookup("#repeat-status"))
|
||||
.getText()
|
||||
.contains("Collection and Component compatibility")));
|
||||
click(pane, "Render drafts");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
object(pane, "home").getSceneGraphObject() instanceof SnapshotRegion r
|
||||
&& r.image() != null
|
||||
&& r.statusText().equals("Target runtime")));
|
||||
click(pane, "New Repeat binding");
|
||||
repeatField(pane, "pane", "otherVisits");
|
||||
repeatField(pane, "component", "fixture.components.AddressForm");
|
||||
repeatField(pane, "collection", "visits");
|
||||
click(pane, "Apply Repeat binding");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
((Label) pane.lookup("#repeat-status"))
|
||||
.getText()
|
||||
.contains("Replace conflicting bindings")));
|
||||
check(documentText(document).equals(changed), "Repeat conflict silently replaced");
|
||||
fx(
|
||||
() -> {
|
||||
((CheckBox) pane.lookup("#repeat-replace")).setSelected(true);
|
||||
return null;
|
||||
});
|
||||
click(pane, "Apply Repeat binding");
|
||||
await(() -> !documentText(document).equals(changed));
|
||||
String replaced = documentText(document);
|
||||
check(
|
||||
!replaced.contains("component=\"fixture.components.VisitRow\""),
|
||||
"Repeat replacement retained old declaration");
|
||||
repeatHistory(pane, document, changed, replaced);
|
||||
selectRepeat(pane, "otherVisits");
|
||||
repeatField(pane, "collection", "invalid path");
|
||||
click(pane, "Apply Repeat binding");
|
||||
String invalid = replaced.replace("collection=\"visits\"/>", "collection=\"invalid path\"/>");
|
||||
await(() -> documentText(document).equals(invalid));
|
||||
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(invalid),
|
||||
"Invalid Repeat was 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("Repeat collection"))));
|
||||
repeatHistory(pane, document, replaced, invalid);
|
||||
set(document, full);
|
||||
await(() -> fx(() -> source(pane).getText().equals(full)));
|
||||
System.out.println(
|
||||
"PASS Repeat inspector: create, change, remove, conflict, exact undo/redo, save, target"
|
||||
+ " load and invalid draft");
|
||||
}
|
||||
|
||||
private static void viewModelBindings(
|
||||
DesignerPane pane, DocumentSession document, String original) throws Exception {
|
||||
bindingTab(pane, "Discover targets");
|
||||
@@ -1022,6 +1180,7 @@ public final class ProjectionHostChecks {
|
||||
converterEdits(pane, document, full);
|
||||
directBindingEdits(pane, document, full);
|
||||
computedBindingEdits(pane, document, full);
|
||||
repeatBindingEdits(pane, document, full);
|
||||
document
|
||||
.save(document.snapshot().toCompletableFuture().get().revision())
|
||||
.toCompletableFuture()
|
||||
|
||||
Reference in New Issue
Block a user