Let a Direct binding follow a dotted Path into a nested Model.
One ViewModel per Model instance keeps two Views and a cycle aligned, and a missing nested Model leaves both sides unchanged.
This commit is contained in:
@@ -4,8 +4,10 @@ package de.neitzel.fx.view;
|
||||
* A Direct binding declared in FXML.
|
||||
* <p>
|
||||
* The element names the control by {@code fx:id}, the control property, the Path, and the
|
||||
* direction. A Path of one name is that Attribute of this View's ViewModel. Direction values
|
||||
* are {@code view}, {@code model}, and {@code both}.
|
||||
* direction. A Path of one name is that Attribute of this View's ViewModel. Further names step
|
||||
* into the ViewModel of a Complex attribute, and the direction applies to the last name.
|
||||
* When that last name is complex, the control receives the Model instance behind the ViewModel.
|
||||
* Direction values are {@code view}, {@code model}, and {@code both}.
|
||||
*/
|
||||
public class DirectBinding {
|
||||
|
||||
|
||||
@@ -26,11 +26,20 @@ final class DirectBindingApplier {
|
||||
if (control == null) {
|
||||
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
|
||||
}
|
||||
Resolved resolved = resolve(viewModel, binding.getPath());
|
||||
if (resolved == null) {
|
||||
return;
|
||||
}
|
||||
Property<?> controlProperty = controlProperty(control, binding.getProperty());
|
||||
Property<?> attribute = viewModel.property(binding.getPath());
|
||||
if (attribute == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"No read-write attribute for path \"" + binding.getPath() + "\"");
|
||||
Property<?> attribute = resolved.attribute;
|
||||
if (resolved.complex) {
|
||||
switch (binding.getDirection()) {
|
||||
case "both" -> alignBothComplex(controlProperty, attribute);
|
||||
case "view" -> alignTowardViewComplex(controlProperty, attribute);
|
||||
case "model" -> alignTowardModelComplex(controlProperty, attribute);
|
||||
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (binding.getDirection()) {
|
||||
case "both" -> alignBoth(controlProperty, attribute);
|
||||
@@ -40,6 +49,61 @@ final class DirectBindingApplier {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Attribute a Path names, and whether that Attribute is complex.
|
||||
*/
|
||||
private static final class Resolved {
|
||||
private final Property<?> attribute;
|
||||
private final boolean complex;
|
||||
|
||||
private Resolved(Property<?> attribute, boolean complex) {
|
||||
this.attribute = attribute;
|
||||
this.complex = complex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a Path to the Attribute it names.
|
||||
* <p>
|
||||
* One name is an Attribute of {@code viewModel}. Each earlier name steps into the ViewModel
|
||||
* held by that Complex attribute. A null Complex attribute along the Path leaves the binding
|
||||
* unapplied.
|
||||
*
|
||||
* @param viewModel the ViewModel for this View
|
||||
* @param path the Path
|
||||
* @return the Attribute for the last name, or {@code null} when a Complex attribute
|
||||
* along the Path is null
|
||||
*/
|
||||
private static Resolved resolve(ViewModel viewModel, String path) {
|
||||
String[] names = path.split("\\.", -1);
|
||||
ViewModel current = viewModel;
|
||||
for (int index = 0; index < names.length - 1; index++) {
|
||||
String name = names[index];
|
||||
Property<?> step = current.property(name);
|
||||
if (step == null || !current.isComplex(name)) {
|
||||
throw missing(path);
|
||||
}
|
||||
Object nested = step.getValue();
|
||||
if (nested == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(nested instanceof ViewModel child)) {
|
||||
throw missing(path);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
String last = names[names.length - 1];
|
||||
Property<?> attribute = current.property(last);
|
||||
if (attribute == null) {
|
||||
throw missing(path);
|
||||
}
|
||||
return new Resolved(attribute, current.isComplex(last));
|
||||
}
|
||||
|
||||
private static IllegalArgumentException missing(String path) {
|
||||
return new IllegalArgumentException("No read-write attribute for path \"" + path + "\"");
|
||||
}
|
||||
|
||||
private static Property<?> controlProperty(Object control, String propertyName) {
|
||||
String methodName = propertyName + "Property";
|
||||
try {
|
||||
@@ -57,6 +121,53 @@ final class DirectBindingApplier {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignBothComplex(Property<?> controlProperty, Property<?> attribute) {
|
||||
Property<Object> control = (Property<Object>) controlProperty;
|
||||
Property<Object> source = (Property<Object>) attribute;
|
||||
control.setValue(modelBehind(source.getValue()));
|
||||
source.addListener((observable, oldValue, newValue) -> {
|
||||
Object model = modelBehind(newValue);
|
||||
if (control.getValue() != model) {
|
||||
control.setValue(model);
|
||||
}
|
||||
});
|
||||
control.addListener((observable, oldValue, newValue) -> {
|
||||
if (modelBehind(source.getValue()) == newValue) {
|
||||
return;
|
||||
}
|
||||
source.setValue(newValue == null ? null : ViewModels.of(newValue));
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignTowardViewComplex(Property<?> controlProperty, Property<?> attribute) {
|
||||
Property<Object> control = (Property<Object>) controlProperty;
|
||||
Property<Object> source = (Property<Object>) attribute;
|
||||
control.setValue(modelBehind(source.getValue()));
|
||||
source.addListener((observable, oldValue, newValue) -> control.setValue(modelBehind(newValue)));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignTowardModelComplex(Property<?> controlProperty, Property<?> attribute) {
|
||||
Property<Object> control = (Property<Object>) controlProperty;
|
||||
Property<Object> source = (Property<Object>) attribute;
|
||||
control.setValue(modelBehind(source.getValue()));
|
||||
control.addListener((observable, oldValue, newValue) -> {
|
||||
if (modelBehind(source.getValue()) == newValue) {
|
||||
return;
|
||||
}
|
||||
source.setValue(newValue == null ? null : ViewModels.of(newValue));
|
||||
});
|
||||
}
|
||||
|
||||
private static Object modelBehind(Object value) {
|
||||
if (value instanceof ViewModel viewModel) {
|
||||
return viewModel.model();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
|
||||
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute);
|
||||
|
||||
@@ -18,13 +18,17 @@ public final class ViewLoader {
|
||||
/**
|
||||
* Loads a View for one Model instance.
|
||||
* <p>
|
||||
* Builds a ViewModel for {@code model}, loads {@code fxml}, and applies Direct bindings
|
||||
* declared in {@code fx:define}. Direction {@code both} keeps the control and the Attribute
|
||||
* Reuses the ViewModel already built for {@code model}, or builds one, then loads {@code fxml}
|
||||
* and applies Direct bindings declared in {@code fx:define}. Direction {@code both} keeps the
|
||||
* control and the Attribute
|
||||
* aligned, so editing the control writes the Attribute immediately. Direction {@code view}
|
||||
* shows the Attribute and follows later property changes without writing the Attribute back.
|
||||
* Direction {@code model} shows the current value, writes the Attribute when the control
|
||||
* changes, and does not push later property changes to the control. A path that is not a
|
||||
* read-write Attribute fails the load. The context is used when the document names a Controller.
|
||||
* changes, and does not push later property changes to the control. A Path of one name is an
|
||||
* Attribute of this View's ViewModel. Further names step into a nested ViewModel, and the
|
||||
* direction applies to the last name. When a Complex attribute along the Path is null, the
|
||||
* control and the Model keep their current values. A path that is not a read-write Attribute
|
||||
* fails the load. The context is used when the document names a Controller.
|
||||
*
|
||||
* @param fxml the FXML document
|
||||
* @param model the Model instance
|
||||
@@ -33,7 +37,7 @@ public final class ViewLoader {
|
||||
* @throws IllegalArgumentException when a Direct binding names a path that is not a read-write Attribute
|
||||
*/
|
||||
public static LoadedView load(URL fxml, Object model, ApplicationContext context) {
|
||||
ViewModel viewModel = new ViewModel(model);
|
||||
ViewModel viewModel = ViewModels.of(model);
|
||||
LoadSession session = LoadSession.open();
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(fxml);
|
||||
|
||||
@@ -20,9 +20,14 @@ import java.beans.Introspector;
|
||||
import java.beans.PropertyDescriptor;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The object a View binds to, built at runtime for one Model instance.
|
||||
@@ -31,14 +36,21 @@ import java.util.Map;
|
||||
* property under the Attribute name. The property class follows the Attribute's declared type,
|
||||
* including when the current value is null. String, integer, long, float, double, and boolean
|
||||
* attributes use the matching JavaFX property. Any other declared type is an object property.
|
||||
* A Complex attribute, a read-write Attribute whose declared type itself has a read-write
|
||||
* Attribute, stores the ViewModel for the nested Model instance, or null when the Attribute
|
||||
* is null. Strings, numbers, booleans, enums, {@link Date}, {@link Calendar}, and {@code java.time}
|
||||
* values stay simple even when that type has setters. A collection is not a Complex attribute.
|
||||
* A cycle, or another Attribute that reaches the same instance, reuses that ViewModel.
|
||||
* The property is set from the Attribute when the ViewModel is created. A null number or
|
||||
* boolean leaves the primitive property at its default. After that, a change of the property
|
||||
* writes the Attribute. A call to the entity's setter does not update the property.
|
||||
* writes the Attribute. A Complex attribute writes the Model instance behind the ViewModel.
|
||||
* A call to the entity's setter does not update the property.
|
||||
*/
|
||||
public class ViewModel {
|
||||
|
||||
private final Object model;
|
||||
private final Map<String, Property<?>> properties = new HashMap<>();
|
||||
private final Set<String> complexAttributes = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Builds a ViewModel for one Model instance.
|
||||
@@ -47,6 +59,7 @@ public class ViewModel {
|
||||
*/
|
||||
ViewModel(Object model) {
|
||||
this.model = model;
|
||||
ViewModels.register(model, this);
|
||||
for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
|
||||
Method getter = descriptor.getReadMethod();
|
||||
Method setter = descriptor.getWriteMethod();
|
||||
@@ -54,11 +67,21 @@ public class ViewModel {
|
||||
continue;
|
||||
}
|
||||
String name = descriptor.getName();
|
||||
Property<?> property = createProperty(descriptor.getPropertyType(), read(getter, name));
|
||||
Class<?> type = descriptor.getPropertyType();
|
||||
Object current = read(getter, name);
|
||||
if (isComplex(type)) {
|
||||
Property<?> property = new SimpleObjectProperty<>(
|
||||
current == null ? null : ViewModels.of(current));
|
||||
watchComplex(property, setter, name);
|
||||
properties.put(name, property);
|
||||
complexAttributes.add(name);
|
||||
} else {
|
||||
Property<?> property = createProperty(type, current);
|
||||
watch(property, setter, name);
|
||||
properties.put(name, property);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JavaFX property for an Attribute name.
|
||||
@@ -70,6 +93,25 @@ public class ViewModel {
|
||||
return properties.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Model instance this ViewModel was built for.
|
||||
*
|
||||
* @return the Model instance
|
||||
*/
|
||||
Object model() {
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether an Attribute is complex.
|
||||
*
|
||||
* @param name the Attribute name
|
||||
* @return {@code true} when that Attribute holds a nested ViewModel
|
||||
*/
|
||||
boolean isComplex(String name) {
|
||||
return complexAttributes.contains(name);
|
||||
}
|
||||
|
||||
private static List<PropertyDescriptor> descriptors(Class<?> type) {
|
||||
try {
|
||||
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
|
||||
@@ -79,6 +121,38 @@ public class ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isComplex(Class<?> type) {
|
||||
if (simpleValue(type)) {
|
||||
return false;
|
||||
}
|
||||
for (PropertyDescriptor descriptor : descriptors(type)) {
|
||||
if (descriptor.getReadMethod() != null
|
||||
&& descriptor.getWriteMethod() != null
|
||||
&& descriptor.getPropertyType() != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean simpleValue(Class<?> type) {
|
||||
if (type.isPrimitive() || type.isEnum() || type.isArray()
|
||||
|| type == String.class || type == Boolean.class) {
|
||||
return true;
|
||||
}
|
||||
if (Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type)) {
|
||||
return true;
|
||||
}
|
||||
if (Number.class.isAssignableFrom(type)) {
|
||||
return true;
|
||||
}
|
||||
if (Date.class.isAssignableFrom(type) || Calendar.class.isAssignableFrom(type)) {
|
||||
return true;
|
||||
}
|
||||
Package packaged = type.getPackage();
|
||||
return packaged != null && packaged.getName().startsWith("java.time");
|
||||
}
|
||||
|
||||
private static Property<?> createProperty(Class<?> type, Object value) {
|
||||
if (type == String.class) {
|
||||
return new SimpleStringProperty((String) value);
|
||||
@@ -153,6 +227,13 @@ public class ViewModel {
|
||||
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
|
||||
}
|
||||
|
||||
private void watchComplex(Property<?> property, Method setter, String name) {
|
||||
property.addListener((observable, oldValue, newValue) -> {
|
||||
Object written = newValue instanceof ViewModel viewModel ? viewModel.model() : newValue;
|
||||
write(setter, name, written);
|
||||
});
|
||||
}
|
||||
|
||||
private void write(Method setter, String name, Object value) {
|
||||
try {
|
||||
setter.invoke(model, value);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The ViewModels already built, one for each Model instance.
|
||||
* <p>
|
||||
* Instances are compared by identity, so two Models that are equal still get two ViewModels.
|
||||
*/
|
||||
final class ViewModels {
|
||||
|
||||
private static final Map<Object, ViewModel> BY_INSTANCE = new IdentityHashMap<>();
|
||||
|
||||
private ViewModels() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel for a Model instance, building it when none exists yet.
|
||||
*
|
||||
* @param model the Model instance
|
||||
* @return the ViewModel for that instance
|
||||
*/
|
||||
static ViewModel of(Object model) {
|
||||
ViewModel existing = BY_INSTANCE.get(model);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
return new ViewModel(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a ViewModel before its Attributes are walked, so a cycle reuses it.
|
||||
*
|
||||
* @param model the Model instance
|
||||
* @param viewModel the ViewModel under construction
|
||||
*/
|
||||
static void register(Object model, ViewModel viewModel) {
|
||||
BY_INSTANCE.put(model, viewModel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Address;
|
||||
import de.neitzel.fx.view.fixture.ObjectHolder;
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.fx.view.fixture.Role;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URL;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
class ReachNestedAttributeTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void complexAttributeHoldsTheViewModelForTheNestedAddress() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Object addressValue = loaded.property("address").getValue();
|
||||
assertInstanceOf(ViewModel.class, addressValue);
|
||||
assertEquals("Main", ((ViewModel) addressValue).property("street").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothWaysTextFieldReadsAndWritesTheNestedStreet() throws Exception {
|
||||
onFx(() -> {
|
||||
Address address = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(address);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("address-street-both.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
assertEquals("Main", streetField.getText());
|
||||
streetField.setText("Oak");
|
||||
assertEquals("Oak", address.getStreet());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void towardViewLabelFollowsTheNestedStreetAndDoesNotWrite() throws Exception {
|
||||
onFx(() -> {
|
||||
Address address = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(address);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("address-street-both-and-view.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
Label streetLabel = (Label) loaded.getRoot().lookup("#streetLabel");
|
||||
assertEquals("Main", streetLabel.getText());
|
||||
streetField.setText("Oak");
|
||||
assertEquals("Oak", streetLabel.getText());
|
||||
assertEquals("Oak", address.getStreet());
|
||||
streetLabel.setText("Side");
|
||||
assertEquals("Oak", address.getStreet());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothWaysBindingOnAComplexPathYieldsTheModelInstance() throws Exception {
|
||||
onFx(() -> {
|
||||
Address address = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(address);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("address-object.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
ObjectHolder addressHolder = (ObjectHolder) loaded.getRoot().lookup("#addressHolder");
|
||||
assertSame(address, addressHolder.getValue());
|
||||
Address moved = new Address("Oak");
|
||||
addressHolder.setValue(moved);
|
||||
assertSame(moved, person.getAddress());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void fillingAMissingAddressStoresTheViewModelAndWritesTheModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("address-object.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
ObjectHolder addressHolder = (ObjectHolder) loaded.getRoot().lookup("#addressHolder");
|
||||
assertNull(addressHolder.getValue());
|
||||
assertNull(person.getAddress());
|
||||
Address address = new Address("Main");
|
||||
addressHolder.setValue(address);
|
||||
assertSame(address, person.getAddress());
|
||||
Object addressValue = loaded.property("address").getValue();
|
||||
assertInstanceOf(ViewModel.class, addressValue);
|
||||
assertEquals("Main", ((ViewModel) addressValue).property("street").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAddressLeavesTheControlAndTheModelUnchanged() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("address-street-kept.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
assertEquals("kept", streetField.getText());
|
||||
assertNull(person.getAddress());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullComplexAttributeHoldsNull() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertNull(loaded.property("address").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void attributesThatReachTheSameAddressShareItsViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Address shared = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setHome(shared);
|
||||
person.setWork(shared);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("home-work-street.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertSame(loaded.property("home").getValue(), loaded.property("work").getValue());
|
||||
TextField homeField = (TextField) loaded.getRoot().lookup("#homeField");
|
||||
TextField workField = (TextField) loaded.getRoot().lookup("#workField");
|
||||
homeField.setText("Oak");
|
||||
assertEquals("Oak", workField.getText());
|
||||
assertEquals("Oak", shared.getStreet());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void equalAddressesKeepSeparateViewModels() throws Exception {
|
||||
onFx(() -> {
|
||||
Address home = new Address("Main");
|
||||
Address work = new Address("Main");
|
||||
assertEquals(home, work);
|
||||
Person person = new Person("Ada");
|
||||
person.setHome(home);
|
||||
person.setWork(work);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("home-work-street.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertNotSame(loaded.property("home").getValue(), loaded.property("work").getValue());
|
||||
TextField homeField = (TextField) loaded.getRoot().lookup("#homeField");
|
||||
TextField workField = (TextField) loaded.getRoot().lookup("#workField");
|
||||
homeField.setText("Oak");
|
||||
assertEquals("Oak", home.getStreet());
|
||||
assertEquals("Main", work.getStreet());
|
||||
assertEquals("Main", workField.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPersonWhoIsTheirOwnFriendReusesTheSameViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setFriend(person);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertSame(loaded.getViewModel(), loaded.property("friend").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoPeopleWhoPointAtEachOtherReuseTheViewModelsAlreadyBuilt() throws Exception {
|
||||
onFx(() -> {
|
||||
Person ada = new Person("Ada");
|
||||
Person grace = new Person("Grace");
|
||||
ada.setFriend(grace);
|
||||
grace.setFriend(ada);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, ada, context);
|
||||
ViewModel graceModel = (ViewModel) loaded.property("friend").getValue();
|
||||
assertEquals("Grace", graceModel.property("name").getValue());
|
||||
assertSame(loaded.getViewModel(), graceModel.property("friend").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void enumsBooleansAndDateValuesStaySimpleValues() throws Exception {
|
||||
onFx(() -> {
|
||||
Date updated = new Date(0L);
|
||||
Calendar hired = new GregorianCalendar(2020, Calendar.JANUARY, 2);
|
||||
LocalDate born = LocalDate.of(1815, 12, 10);
|
||||
Person person = new Person("Ada");
|
||||
person.setActive(true);
|
||||
person.setRole(Role.ADMIN);
|
||||
person.setBorn(born);
|
||||
person.setUpdated(updated);
|
||||
person.setHired(hired);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertEquals(true, loaded.property("active").getValue());
|
||||
assertSame(Role.ADMIN, loaded.property("role").getValue());
|
||||
assertEquals(born, loaded.property("born").getValue());
|
||||
assertSame(updated, loaded.property("updated").getValue());
|
||||
assertSame(hired, loaded.property("hired").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCollectionStaysTheCollectionRatherThanAViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
List<String> tags = new ArrayList<>();
|
||||
tags.add("friend");
|
||||
Person person = new Person("Ada");
|
||||
person.setTags(tags);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertSame(tags, loaded.property("tags").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoLoadsOfTheSamePersonShareTheViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView first = ViewLoader.load(fxml, person, context);
|
||||
LoadedView second = ViewLoader.load(fxml, person, context);
|
||||
assertSame(first.getViewModel(), second.getViewModel());
|
||||
TextField firstName = (TextField) first.getRoot().lookup("#nameField");
|
||||
TextField secondName = (TextField) second.getRoot().lookup("#nameField");
|
||||
firstName.setText("Grace");
|
||||
assertEquals("Grace", secondName.getText());
|
||||
assertEquals("Grace", person.getName());
|
||||
});
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ordinary nested model used by view-loading tests.
|
||||
* <p>
|
||||
* Equality follows the street, so two addresses with the same street are equal while remaining
|
||||
* different instances.
|
||||
*/
|
||||
public class Address {
|
||||
|
||||
private String street;
|
||||
|
||||
/**
|
||||
* Creates an address with the given street.
|
||||
*
|
||||
* @param street the current street
|
||||
*/
|
||||
public Address(String street) {
|
||||
this.street = street;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the street.
|
||||
*
|
||||
* @return the street
|
||||
*/
|
||||
public String getStreet() {
|
||||
return street;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the street.
|
||||
*
|
||||
* @param street the street
|
||||
*/
|
||||
public void setStreet(String street) {
|
||||
this.street = street;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other instanceof Address address) {
|
||||
return Objects.equals(street, address.street);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(street);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.layout.Pane;
|
||||
|
||||
/**
|
||||
* A small control whose value is an object, used to bind a Complex attribute.
|
||||
*/
|
||||
public class ObjectHolder extends Pane {
|
||||
|
||||
private final ObjectProperty<Object> value = new SimpleObjectProperty<>(this, "value");
|
||||
|
||||
/**
|
||||
* Returns the value property.
|
||||
*
|
||||
* @return the value property
|
||||
*/
|
||||
public ObjectProperty<Object> valueProperty() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current value.
|
||||
*
|
||||
* @return the value
|
||||
*/
|
||||
public Object getValue() {
|
||||
return value.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value.
|
||||
*
|
||||
* @param value the value
|
||||
*/
|
||||
public void setValue(Object value) {
|
||||
this.value.set(value);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ordinary model used by view-loading tests.
|
||||
*/
|
||||
@@ -7,6 +12,16 @@ public class Person {
|
||||
|
||||
private String name;
|
||||
private Integer age;
|
||||
private boolean active;
|
||||
private Address address;
|
||||
private Address home;
|
||||
private Address work;
|
||||
private Person friend;
|
||||
private Role role;
|
||||
private LocalDate born;
|
||||
private Date updated;
|
||||
private Calendar hired;
|
||||
private List<String> tags;
|
||||
|
||||
/**
|
||||
* Creates a person with the given name.
|
||||
@@ -61,4 +76,184 @@ public class Person {
|
||||
public void setAge(Integer age) {
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the address.
|
||||
*
|
||||
* @return the address, or {@code null} when the person has none
|
||||
*/
|
||||
public Address getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the address.
|
||||
*
|
||||
* @param address the address
|
||||
*/
|
||||
public void setAddress(Address address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the home address.
|
||||
*
|
||||
* @return the home address
|
||||
*/
|
||||
public Address getHome() {
|
||||
return home;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the home address.
|
||||
*
|
||||
* @param home the home address
|
||||
*/
|
||||
public void setHome(Address home) {
|
||||
this.home = home;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the work address.
|
||||
*
|
||||
* @return the work address
|
||||
*/
|
||||
public Address getWork() {
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the work address.
|
||||
*
|
||||
* @param work the work address
|
||||
*/
|
||||
public void setWork(Address work) {
|
||||
this.work = work;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the friend.
|
||||
*
|
||||
* @return the friend
|
||||
*/
|
||||
public Person getFriend() {
|
||||
return friend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the friend.
|
||||
*
|
||||
* @param friend the friend
|
||||
*/
|
||||
public void setFriend(Person friend) {
|
||||
this.friend = friend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the person is active.
|
||||
*
|
||||
* @return {@code true} when the person is active
|
||||
*/
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the person is active.
|
||||
*
|
||||
* @param active {@code true} when the person is active
|
||||
*/
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the role.
|
||||
*
|
||||
* @return the role
|
||||
*/
|
||||
public Role getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the role.
|
||||
*
|
||||
* @param role the role
|
||||
*/
|
||||
public void setRole(Role role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the birth date.
|
||||
*
|
||||
* @return the birth date
|
||||
*/
|
||||
public LocalDate getBorn() {
|
||||
return born;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the birth date.
|
||||
*
|
||||
* @param born the birth date
|
||||
*/
|
||||
public void setBorn(LocalDate born) {
|
||||
this.born = born;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last update.
|
||||
*
|
||||
* @return the last update
|
||||
*/
|
||||
public Date getUpdated() {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last update.
|
||||
*
|
||||
* @param updated the last update
|
||||
*/
|
||||
public void setUpdated(Date updated) {
|
||||
this.updated = updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hire date.
|
||||
*
|
||||
* @return the hire date
|
||||
*/
|
||||
public Calendar getHired() {
|
||||
return hired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the hire date.
|
||||
*
|
||||
* @param hired the hire date
|
||||
*/
|
||||
public void setHired(Calendar hired) {
|
||||
this.hired = hired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tags.
|
||||
*
|
||||
* @return the tags
|
||||
*/
|
||||
public List<String> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the tags.
|
||||
*
|
||||
* @param tags the tags
|
||||
*/
|
||||
public void setTags(List<String> tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
/**
|
||||
* A simple enum Attribute used by view-loading tests.
|
||||
*/
|
||||
public enum Role {
|
||||
MEMBER,
|
||||
ADMIN
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import de.neitzel.fx.view.BindingList?>
|
||||
<?import de.neitzel.fx.view.DirectBinding?>
|
||||
<?import de.neitzel.fx.view.fixture.ObjectHolder?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="addressHolder" property="value" path="address" direction="both"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<ObjectHolder fx:id="addressHolder" id="addressHolder"/>
|
||||
</VBox>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import de.neitzel.fx.view.BindingList?>
|
||||
<?import de.neitzel.fx.view.DirectBinding?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="streetField" property="text" path="address.street" direction="both"/>
|
||||
<DirectBinding control="streetLabel" property="text" path="address.street" direction="view"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<TextField fx:id="streetField" id="streetField"/>
|
||||
<Label fx:id="streetLabel" id="streetLabel"/>
|
||||
</VBox>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import de.neitzel.fx.view.BindingList?>
|
||||
<?import de.neitzel.fx.view.DirectBinding?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="streetField" property="text" path="address.street" direction="both"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<TextField fx:id="streetField" id="streetField"/>
|
||||
</VBox>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import de.neitzel.fx.view.BindingList?>
|
||||
<?import de.neitzel.fx.view.DirectBinding?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="streetField" property="text" path="address.street" direction="both"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<TextField fx:id="streetField" id="streetField" text="kept"/>
|
||||
</VBox>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import de.neitzel.fx.view.BindingList?>
|
||||
<?import de.neitzel.fx.view.DirectBinding?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="homeField" property="text" path="home.street" direction="both"/>
|
||||
<DirectBinding control="workField" property="text" path="work.street" direction="both"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<TextField fx:id="homeField" id="homeField"/>
|
||||
<TextField fx:id="workField" id="workField"/>
|
||||
</VBox>
|
||||
Reference in New Issue
Block a user