Show one Address component per address so the pane stays in step with the person's list.
A second component of the same class is a new injected instance, leaving the cached singleton in place, and selection stays an attribute of the person. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,8 +11,8 @@ import java.util.Map;
|
||||
/**
|
||||
* The list of binding elements declared inside {@code fx:define}.
|
||||
* <p>
|
||||
* The list is not a scene-graph child. FXML adds each {@link DirectBinding} or
|
||||
* {@link ComputedBinding} child to {@link #getElements()}.
|
||||
* The list is not a scene-graph child. FXML adds each {@link DirectBinding},
|
||||
* {@link ComputedBinding}, or {@link Repeat} child to {@link #getElements()}.
|
||||
*/
|
||||
@DefaultProperty("elements")
|
||||
public class BindingList {
|
||||
@@ -29,8 +29,8 @@ public class BindingList {
|
||||
/**
|
||||
* Returns the binding elements in this list.
|
||||
* <p>
|
||||
* FXML appends child elements to the returned list. A child is a {@link DirectBinding} or a
|
||||
* {@link ComputedBinding}.
|
||||
* FXML appends child elements to the returned list. A child is a {@link DirectBinding}, a
|
||||
* {@link ComputedBinding}, or a {@link Repeat}.
|
||||
*
|
||||
* @return the mutable list of binding elements
|
||||
*/
|
||||
@@ -54,6 +54,8 @@ public class BindingList {
|
||||
DirectBindingApplier.apply(namespace, viewModel, directBinding, context);
|
||||
} else if (element instanceof ComputedBinding computedBinding) {
|
||||
ComputedBindingApplier.apply(namespace, viewModel, controller, root, computedBinding);
|
||||
} else if (element instanceof Repeat repeat) {
|
||||
RepeatApplier.apply(namespace, viewModel, repeat, context);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Not a binding element: " + element);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ final class Components {
|
||||
* <p>
|
||||
* An unknown type, such as a JavaFX control, makes {@link ApplicationContext#getComponent}
|
||||
* throw {@link IllegalArgumentException}. This method returns null so FXML constructs that
|
||||
* type itself. A failure while creating a known Component is not caught.
|
||||
* type itself. A failure while creating a known Component is not caught. The first Component
|
||||
* of a class in one load is the cached singleton. A second Component of that class is a new
|
||||
* injected instance, so the document can show two of them.
|
||||
*
|
||||
* @param type the type FXML is about to construct
|
||||
* @param context the container that creates Components
|
||||
@@ -44,7 +46,38 @@ final class Components {
|
||||
} catch (IllegalArgumentException notAComponent) {
|
||||
return null;
|
||||
}
|
||||
return () -> load(component, context);
|
||||
if (!LoadSession.claimFirst(type)) {
|
||||
component = context.createComponent(type);
|
||||
}
|
||||
Object created = component;
|
||||
return () -> load(created, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Component and loads its document.
|
||||
* <p>
|
||||
* The instance is not the cached singleton. Its dependencies still come from
|
||||
* {@link ApplicationContext#getComponent}. Bindings wait until {@link #setModel} runs.
|
||||
*
|
||||
* @param type the Component class
|
||||
* @param context the container that creates the instance and its dependencies
|
||||
* @return the loaded Component
|
||||
*/
|
||||
static Object fresh(Class<?> type, ApplicationContext context) {
|
||||
Object component = context.createComponent(type.asSubclass(Object.class));
|
||||
return load(component, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Component's model property.
|
||||
* <p>
|
||||
* A non-null value selects the ViewModel and then runs the Component's bindings.
|
||||
*
|
||||
* @param component the Component
|
||||
* @param model the Model instance
|
||||
*/
|
||||
static void setModel(Object component, Object model) {
|
||||
modelProperty(component).setValue(model);
|
||||
}
|
||||
|
||||
private static Object load(Object component, ApplicationContext context) {
|
||||
|
||||
@@ -3,7 +3,9 @@ package de.neitzel.fx.view;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Holds the binding lists instantiated while one View is loading.
|
||||
@@ -18,6 +20,7 @@ final class LoadSession {
|
||||
private static final ThreadLocal<Deque<LoadSession>> STACK = new ThreadLocal<>();
|
||||
|
||||
private final List<BindingList> bindingLists = new ArrayList<>();
|
||||
private final Set<Class<?>> built = new HashSet<>();
|
||||
|
||||
private LoadSession() {
|
||||
}
|
||||
@@ -39,20 +42,33 @@ final class LoadSession {
|
||||
* Opens a nested session for a Component document.
|
||||
* <p>
|
||||
* Binding lists created until {@link #unnest()} register here, not on the parent session.
|
||||
* A Component created after the parent load has finished, such as a repeated child added
|
||||
* later, still gets a session so its own binding list can be captured.
|
||||
*
|
||||
* @return the nested session
|
||||
*/
|
||||
static LoadSession nest() {
|
||||
Deque<LoadSession> stack = STACK.get();
|
||||
if (stack == null) {
|
||||
stack = new ArrayDeque<>();
|
||||
STACK.set(stack);
|
||||
}
|
||||
LoadSession session = new LoadSession();
|
||||
STACK.get().push(session);
|
||||
stack.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the session that was current before {@link #nest()}.
|
||||
* <p>
|
||||
* When the nested session was the only one, the thread-local stack is dropped.
|
||||
*/
|
||||
static void unnest() {
|
||||
STACK.get().pop();
|
||||
Deque<LoadSession> stack = STACK.get();
|
||||
stack.pop();
|
||||
if (stack.isEmpty()) {
|
||||
STACK.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +91,23 @@ final class LoadSession {
|
||||
stack.peek().bindingLists.add(bindingList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the first Component of {@code type} built during this load.
|
||||
* <p>
|
||||
* Nested documents share the outermost session, so a second Component of the same class
|
||||
* anywhere in the load is not the first.
|
||||
*
|
||||
* @param type the Component class
|
||||
* @return {@code true} when this is the first time that class is built during the load
|
||||
*/
|
||||
static boolean claimFirst(Class<?> type) {
|
||||
Deque<LoadSession> stack = STACK.get();
|
||||
if (stack == null || stack.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return stack.peekLast().built.add(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the lists registered during this session, in document order.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* A repeat element declared in a {@link BindingList}.
|
||||
* <p>
|
||||
* The element names the pane by {@code fx:id}, the Component class, and the collection
|
||||
* Attribute. The library keeps one Component child in that pane for each Model instance, in
|
||||
* list order.
|
||||
*/
|
||||
public class Repeat {
|
||||
|
||||
private String pane;
|
||||
private String component;
|
||||
private String collection;
|
||||
|
||||
/**
|
||||
* Sets the {@code fx:id} of the pane that holds the Component children.
|
||||
*
|
||||
* @param pane the pane id
|
||||
*/
|
||||
public void setPane(String pane) {
|
||||
this.pane = pane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Component class name. Each element gets a new instance of that class.
|
||||
*
|
||||
* @param component the class name
|
||||
*/
|
||||
public void setComponent(String component) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the collection Attribute name. The list holds the Model instances.
|
||||
*
|
||||
* @param collection the attribute name
|
||||
*/
|
||||
public void setCollection(String collection) {
|
||||
this.collection = collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@code fx:id} of the pane.
|
||||
*
|
||||
* @return the pane id
|
||||
*/
|
||||
public String getPane() {
|
||||
return pane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Component class name.
|
||||
*
|
||||
* @return the class name
|
||||
*/
|
||||
public String getComponent() {
|
||||
return component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collection Attribute name.
|
||||
*
|
||||
* @return the attribute name
|
||||
*/
|
||||
public String getCollection() {
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.layout.Pane;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Keeps one Component child in a pane for each Model instance in a collection.
|
||||
*/
|
||||
final class RepeatApplier {
|
||||
|
||||
private RepeatApplier() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the named pane with one new Component for each element, in list order.
|
||||
* <p>
|
||||
* Each Component is a new injected instance. The cached singleton of that class is left
|
||||
* in place. The child's model property is the element. Adding or removing an element
|
||||
* adds or removes the matching child. The list is the one the entity holds, so a change
|
||||
* on either side is visible in the pane.
|
||||
*
|
||||
* @param namespace objects addressed by {@code fx:id}
|
||||
* @param viewModel the ViewModel for this View
|
||||
* @param repeat the declaration
|
||||
* @param context the container that creates each Component
|
||||
*/
|
||||
static void apply(Map<String, Object> namespace, ViewModel viewModel, Repeat repeat,
|
||||
ApplicationContext context) {
|
||||
Object named = namespace.get(repeat.getPane());
|
||||
if (!(named instanceof Pane pane)) {
|
||||
throw new IllegalArgumentException("No pane \"" + repeat.getPane() + "\"");
|
||||
}
|
||||
Property<?> property = viewModel.property(repeat.getCollection());
|
||||
if (property == null || !(property.getValue() instanceof ObservableList<?> values)) {
|
||||
throw new IllegalArgumentException("No collection \"" + repeat.getCollection() + "\"");
|
||||
}
|
||||
Class<?> type = componentType(repeat.getComponent());
|
||||
for (Object element : values) {
|
||||
pane.getChildren().add(child(type, context, element));
|
||||
}
|
||||
listen(pane, type, context, values);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void listen(Pane pane, Class<?> type, ApplicationContext context, ObservableList<?> values) {
|
||||
ObservableList<Object> elements = (ObservableList<Object>) values;
|
||||
elements.addListener((ListChangeListener<Object>) change -> {
|
||||
while (change.next()) {
|
||||
if (change.wasPermutated() || change.wasUpdated()) {
|
||||
continue;
|
||||
}
|
||||
if (change.getRemovedSize() > 0) {
|
||||
pane.getChildren().remove(change.getFrom(), change.getFrom() + change.getRemovedSize());
|
||||
}
|
||||
for (int index = 0; index < change.getAddedSize(); index++) {
|
||||
pane.getChildren().add(
|
||||
change.getFrom() + index,
|
||||
child(type, context, change.getAddedSubList().get(index)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Node child(Class<?> type, ApplicationContext context, Object element) {
|
||||
Object component = Components.fresh(type, context);
|
||||
Components.setModel(component, element);
|
||||
return (Node) component;
|
||||
}
|
||||
|
||||
private static Class<?> componentType(String className) {
|
||||
try {
|
||||
return Class.forName(className);
|
||||
} catch (ClassNotFoundException failure) {
|
||||
throw new IllegalArgumentException("No component " + className, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,21 @@ public final class ViewLoader {
|
||||
* Attribute, with that Attribute's name as the source. A parent carries a copy with the source
|
||||
* extended. It does not fail the load.
|
||||
* A parent document names a Component class as an element. The context creates that Component
|
||||
* and injects its services. The Component document {@code ClassName.fxml}, in the same package,
|
||||
* is loaded with the Component as {@code fx:root} and as the Controller. That document's
|
||||
* bindings are not applied during this load. They run when the Component's model property
|
||||
* becomes a non-null Model. A Component that implements {@link HasViewModel} receives that
|
||||
* ViewModel after the property is set and before those bindings run.
|
||||
* and injects its services. A second element of the same class is another injected instance,
|
||||
* and the cached singleton stays the first one. The Component document {@code ClassName.fxml},
|
||||
* in the same package, is loaded with the Component as {@code fx:root} and as the Controller.
|
||||
* That document's bindings are not applied during this load. They run when the Component's
|
||||
* model property becomes a non-null Model. A Component that implements {@link HasViewModel}
|
||||
* receives that ViewModel after the property is set and before those bindings run.
|
||||
* A {@link Repeat} in the same list names a pane, a Component class, and a collection
|
||||
* Attribute. One new Component child is placed in that pane for each Model instance, in list
|
||||
* order. Adding or removing an element updates that pane. Those instances are created with
|
||||
* {@link de.neitzel.injection.ApplicationContext#createComponent} so the cached singleton of
|
||||
* that class stays in place. A collection of Model instances is an
|
||||
* {@link javafx.collections.ObservableList} of those instances, shared with the entity.
|
||||
* Selection is a separate Attribute of the parent Model. Null means nothing is selected.
|
||||
* A Direct binding can tie a control property to that Attribute. The repeated Component does
|
||||
* not store the selection.
|
||||
* Loading does not open a popup or fill a status bar. A Direct binding whose path is
|
||||
* {@code latestMessage} shows the latest Message text. {@link MessagePopup#showNewMessages}
|
||||
* shows each newly added Message in a dialog.
|
||||
|
||||
@@ -54,11 +54,12 @@ import java.util.function.Consumer;
|
||||
* 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 of Model instances is not a
|
||||
* Complex attribute. A list, set, or collection of simple values is an object property whose
|
||||
* value is an {@link ObservableList} of those values. When that attribute's current value is a
|
||||
* list, the observable list is backed by it and stored on the entity when the setter accepts it,
|
||||
* so both sides hold one list. A cycle, or another Attribute that reaches the same instance,
|
||||
* reuses that ViewModel.
|
||||
* Complex attribute. It is an object property whose value is an {@link ObservableList} of those
|
||||
* Model instances, not of ViewModels. A list, set, or collection of simple values is an object
|
||||
* property whose value is an {@link ObservableList} of those values. When either attribute's
|
||||
* current value is a list, the observable list is backed by it and stored on the entity when the
|
||||
* setter accepts it, so both sides hold one list. 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 Complex attribute writes the Model instance behind the ViewModel.
|
||||
@@ -113,7 +114,7 @@ public class ViewModel {
|
||||
properties.put(name, property);
|
||||
complexAttributes.add(name);
|
||||
} else {
|
||||
if (isSimpleValueCollection(getter)) {
|
||||
if (isSimpleValueCollection(getter) || isModelCollection(getter)) {
|
||||
current = shareSimpleList(setter, name, current);
|
||||
}
|
||||
Property<?> property = createProperty(type, current);
|
||||
@@ -545,6 +546,14 @@ public class ViewModel {
|
||||
return element != null && isSimpleValue(element);
|
||||
}
|
||||
|
||||
private static boolean isModelCollection(Method getter) {
|
||||
if (!Collection.class.isAssignableFrom(getter.getReturnType())) {
|
||||
return false;
|
||||
}
|
||||
Class<?> element = elementType(getter.getGenericReturnType());
|
||||
return element != null && isComplex(element);
|
||||
}
|
||||
|
||||
private static Class<?> elementType(Type type) {
|
||||
if (!(type instanceof ParameterizedType parameterized) || parameterized.getActualTypeArguments().length != 1) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.component.AddressForm;
|
||||
import de.neitzel.fx.view.fixture.Address;
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
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 RepeatAddressComponentsTest {
|
||||
|
||||
@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 addressesAreTheAddressInstancesOnTheSharedList() throws Exception {
|
||||
onFx(() -> {
|
||||
Address main = new Address("Main");
|
||||
Address oak = new Address("Oak");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddresses(new ArrayList<>(List.of(main, oak)));
|
||||
URL fxml = RepeatAddressComponentsTest.class.getResource("plain-view.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertInstanceOf(ObservableList.class, loaded.property("addresses").getValue());
|
||||
@SuppressWarnings("unchecked")
|
||||
ObservableList<Address> addresses = (ObservableList<Address>) loaded.property("addresses").getValue();
|
||||
assertSame(main, addresses.get(0));
|
||||
assertSame(oak, addresses.get(1));
|
||||
assertEquals("Main", addresses.get(0).getStreet());
|
||||
assertEquals("Oak", addresses.get(1).getStreet());
|
||||
assertSame(person.getAddresses(), addresses);
|
||||
addresses.add(new Address("Elm"));
|
||||
assertEquals(List.of("Main", "Oak", "Elm"), streets(person.getAddresses()));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void eachAddressAppearsAsItsOwnFormInListOrder() throws Exception {
|
||||
onFx(() -> {
|
||||
Address main = new Address("Main");
|
||||
Address oak = new Address("Oak");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddresses(new ArrayList<>(List.of(main, oak)));
|
||||
URL fxml = RepeatAddressComponentsTest.class.getResource("repeat-addresses.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
AddressForm cached = context.getComponent(AddressForm.class);
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
VBox pane = (VBox) loaded.getRoot().lookup("#addressPane");
|
||||
assertEquals(2, pane.getChildren().size());
|
||||
AddressForm first = (AddressForm) pane.getChildren().get(0);
|
||||
AddressForm second = (AddressForm) pane.getChildren().get(1);
|
||||
assertNotSame(first, second);
|
||||
assertNotSame(cached, first);
|
||||
assertNotSame(cached, second);
|
||||
assertSame(cached, context.getComponent(AddressForm.class));
|
||||
assertSame(main, first.getModel());
|
||||
assertSame(oak, second.getModel());
|
||||
assertEquals("Main", ((TextField) first.lookup("#streetField")).getText());
|
||||
assertEquals("Oak", ((TextField) second.lookup("#streetField")).getText());
|
||||
assertEquals("Lane", ((Label) first.lookup("#streetLabel")).getText());
|
||||
assertEquals("Lane", ((Label) second.lookup("#streetLabel")).getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void addingAndRemovingAnAddressUpdatesThePaneAndTheEntity() throws Exception {
|
||||
onFx(() -> {
|
||||
Address main = new Address("Main");
|
||||
Address oak = new Address("Oak");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddresses(new ArrayList<>(List.of(main, oak)));
|
||||
URL fxml = RepeatAddressComponentsTest.class.getResource("repeat-addresses.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
VBox pane = (VBox) loaded.getRoot().lookup("#addressPane");
|
||||
@SuppressWarnings("unchecked")
|
||||
ObservableList<Address> addresses = (ObservableList<Address>) loaded.property("addresses").getValue();
|
||||
Address elm = new Address("Elm");
|
||||
addresses.add(elm);
|
||||
assertEquals(3, pane.getChildren().size());
|
||||
AddressForm added = (AddressForm) pane.getChildren().get(2);
|
||||
assertSame(elm, added.getModel());
|
||||
assertEquals("Elm", ((TextField) added.lookup("#streetField")).getText());
|
||||
assertEquals(List.of("Main", "Oak", "Elm"), streets(person.getAddresses()));
|
||||
addresses.remove(oak);
|
||||
assertEquals(2, pane.getChildren().size());
|
||||
assertSame(main, ((AddressForm) pane.getChildren().get(0)).getModel());
|
||||
assertSame(elm, ((AddressForm) pane.getChildren().get(1)).getModel());
|
||||
assertEquals(List.of("Main", "Elm"), streets(person.getAddresses()));
|
||||
Address pine = new Address("Pine");
|
||||
person.getAddresses().add(pine);
|
||||
assertEquals(3, pane.getChildren().size());
|
||||
AddressForm pineForm = (AddressForm) pane.getChildren().get(2);
|
||||
assertSame(pine, pineForm.getModel());
|
||||
assertEquals("Pine", ((TextField) pineForm.lookup("#streetField")).getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectionIsASeparateAttributeAndTheFormsDoNotStoreIt() throws Exception {
|
||||
onFx(() -> {
|
||||
Address main = new Address("Main");
|
||||
Address oak = new Address("Oak");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddresses(new ArrayList<>(List.of(main, oak)));
|
||||
person.setSelectedAddress(oak);
|
||||
URL fxml = RepeatAddressComponentsTest.class.getResource("repeat-addresses.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
@SuppressWarnings("unchecked")
|
||||
ComboBox<Address> selection = (ComboBox<Address>) loaded.getRoot().lookup("#selection");
|
||||
assertSame(oak, selection.getValue());
|
||||
selection.setValue(null);
|
||||
assertNull(person.getSelectedAddress());
|
||||
selection.setValue(main);
|
||||
assertSame(main, person.getSelectedAddress());
|
||||
VBox pane = (VBox) loaded.getRoot().lookup("#addressPane");
|
||||
assertEquals(2, pane.getChildren().size());
|
||||
AddressForm first = (AddressForm) pane.getChildren().get(0);
|
||||
AddressForm second = (AddressForm) pane.getChildren().get(1);
|
||||
assertSame(main, first.getModel());
|
||||
assertSame(oak, second.getModel());
|
||||
assertEquals("Main", ((TextField) first.lookup("#streetField")).getText());
|
||||
assertEquals("Oak", ((TextField) second.lookup("#streetField")).getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSecondAddressFormInTheSameDocumentIsAnotherComponent() throws Exception {
|
||||
onFx(() -> {
|
||||
Address home = new Address("Main");
|
||||
Address work = new Address("Oak");
|
||||
Person person = new Person("Ada");
|
||||
person.setHome(home);
|
||||
person.setWork(work);
|
||||
URL fxml = RepeatAddressComponentsTest.class.getResource("two-address-forms.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
AddressForm cached = context.getComponent(AddressForm.class);
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
AddressForm homeForm = (AddressForm) loaded.getRoot().lookup("#homeForm");
|
||||
AddressForm workForm = (AddressForm) loaded.getRoot().lookup("#workForm");
|
||||
assertNotNull(homeForm);
|
||||
assertNotNull(workForm);
|
||||
assertNotSame(homeForm, workForm);
|
||||
assertSame(cached, context.getComponent(AddressForm.class));
|
||||
assertSame(home, homeForm.getModel());
|
||||
assertSame(work, workForm.getModel());
|
||||
assertEquals("Main", ((TextField) homeForm.lookup("#streetField")).getText());
|
||||
assertEquals("Oak", ((TextField) workForm.lookup("#streetField")).getText());
|
||||
assertEquals("Lane", ((Label) homeForm.lookup("#streetLabel")).getText());
|
||||
assertEquals("Lane", ((Label) workForm.lookup("#streetLabel")).getText());
|
||||
});
|
||||
}
|
||||
|
||||
private static List<String> streets(List<Address> addresses) {
|
||||
return addresses.stream().map(Address::getStreet).toList();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ public class Person {
|
||||
private Date updated;
|
||||
private Calendar hired;
|
||||
private List<String> tags;
|
||||
private List<Address> addresses;
|
||||
private Address selectedAddress;
|
||||
|
||||
/**
|
||||
* Creates a person with the given name.
|
||||
@@ -256,4 +258,40 @@ public class Person {
|
||||
public void setTags(List<String> tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the addresses. The value may be null.
|
||||
*
|
||||
* @return the addresses
|
||||
*/
|
||||
public List<Address> getAddresses() {
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the addresses.
|
||||
*
|
||||
* @param addresses the addresses
|
||||
*/
|
||||
public void setAddresses(List<Address> addresses) {
|
||||
this.addresses = addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the selected address. Null means nothing is selected.
|
||||
*
|
||||
* @return the selected address, or {@code null}
|
||||
*/
|
||||
public Address getSelectedAddress() {
|
||||
return selectedAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selected address.
|
||||
*
|
||||
* @param selectedAddress the selected address, or {@code null} when nothing is selected
|
||||
*/
|
||||
public void setSelectedAddress(Address selectedAddress) {
|
||||
this.selectedAddress = selectedAddress;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.ComboBox?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import de.neitzel.fx.view.BindingList?>
|
||||
<?import de.neitzel.fx.view.DirectBinding?>
|
||||
<?import de.neitzel.fx.view.Repeat?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<Repeat pane="addressPane" component="de.neitzel.fx.view.component.AddressForm" collection="addresses"/>
|
||||
<DirectBinding control="selection" property="value" path="selectedAddress" direction="both"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<VBox fx:id="addressPane" id="addressPane"/>
|
||||
<ComboBox fx:id="selection" id="selection"/>
|
||||
</VBox>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?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.component.AddressForm?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="homeForm" property="model" path="home" direction="both"/>
|
||||
<DirectBinding control="workForm" property="model" path="work" direction="both"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<AddressForm fx:id="homeForm"/>
|
||||
<AddressForm fx:id="workForm"/>
|
||||
</VBox>
|
||||
@@ -67,11 +67,7 @@ public class ApplicationContext {
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T getComponent(Class<? extends T> type) {
|
||||
ComponentData data = components.get(type);
|
||||
if (data == null) {
|
||||
throw new IllegalArgumentException("No component found for type: " + type.getName());
|
||||
}
|
||||
|
||||
ComponentData data = componentData(type);
|
||||
Scope scope = data.getScope();
|
||||
Object instance = data.getInstance();
|
||||
|
||||
@@ -79,10 +75,60 @@ public class ApplicationContext {
|
||||
return (T) instance;
|
||||
}
|
||||
|
||||
instance = createInjected(data);
|
||||
if (scope == Scope.SINGLETON) {
|
||||
data.setInstance(instance);
|
||||
}
|
||||
return (T) instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new injected instance without replacing a cached singleton.
|
||||
* <p>
|
||||
* Dependencies of the new instance are the objects {@link #getComponent} returns, so a
|
||||
* singleton dependency is still shared. The new instance itself is not stored as the
|
||||
* singleton for {@code type}.
|
||||
*
|
||||
* @param <T> the type of the component to create
|
||||
* @param type the {@code Class} object representing the type of the component
|
||||
* @return a new instance of the requested component type
|
||||
* @throws IllegalArgumentException if no component is found for the specified type
|
||||
* @throws IllegalStateException if no suitable constructor is found for the component
|
||||
* @throws RuntimeException if any error occurs during instantiation
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T createComponent(Class<? extends T> type) {
|
||||
return (T) createInjected(componentData(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scanned data for a component type.
|
||||
*
|
||||
* @param type the component type
|
||||
* @return the component data
|
||||
* @throws IllegalArgumentException if no component is found for the specified type
|
||||
*/
|
||||
private ComponentData componentData(Class<?> type) {
|
||||
ComponentData data = components.get(type);
|
||||
if (data == null) {
|
||||
throw new IllegalArgumentException("No component found for type: " + type.getName());
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a component and injects its dependencies.
|
||||
* <p>
|
||||
* The instance is not stored. {@link #getComponent} decides whether a singleton caches it.
|
||||
*
|
||||
* @param data the component to construct
|
||||
* @return the new instance
|
||||
*/
|
||||
private Object createInjected(ComponentData data) {
|
||||
try {
|
||||
Constructor<?> injectionConstructor = data.getInjectionConstructor();
|
||||
if (injectionConstructor == null) {
|
||||
throw new IllegalStateException("No injection constructor resolved for " + type.getName());
|
||||
throw new IllegalStateException("No injection constructor resolved for " + data.getType().getName());
|
||||
}
|
||||
|
||||
Class<?>[] paramTypes = injectionConstructor.getParameterTypes();
|
||||
@@ -91,7 +137,7 @@ public class ApplicationContext {
|
||||
parameters[i] = getComponent(paramTypes[i]);
|
||||
}
|
||||
|
||||
instance = injectionConstructor.newInstance(parameters);
|
||||
Object instance = injectionConstructor.newInstance(parameters);
|
||||
|
||||
for (Field field : data.getInjectableFields()) {
|
||||
field.setAccessible(true);
|
||||
@@ -104,12 +150,9 @@ public class ApplicationContext {
|
||||
setter.invoke(instance, getComponent(dependencyType));
|
||||
}
|
||||
|
||||
if (scope == Scope.SINGLETON) {
|
||||
data.setInstance(instance);
|
||||
}
|
||||
return (T) instance;
|
||||
return instance;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Fehler beim Erstellen der Instanz für " + type.getName(), e);
|
||||
throw new RuntimeException("Fehler beim Erstellen der Instanz für " + data.getType().getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
class ApplicationContextInjectionTest {
|
||||
@@ -46,5 +47,29 @@ class ApplicationContextInjectionTest {
|
||||
assertSame(rootDependency, service.getFieldInjectedService().getRootDependency());
|
||||
assertSame(rootDependency, service.getSetterInjectedService().getRootDependency());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createComponentInjectsANewInstanceAndKeepsTheSingleton() {
|
||||
MixedInjectionService singleton = context.getComponent(MixedInjectionService.class);
|
||||
MixedInjectionService created = context.createComponent(MixedInjectionService.class);
|
||||
RootDependency root = context.getComponent(RootDependency.class);
|
||||
assertNotSame(singleton, created);
|
||||
assertSame(singleton, context.getComponent(MixedInjectionService.class));
|
||||
assertSame(root, created.getConstructorInjectedService().getRootDependency());
|
||||
assertSame(root, created.getFieldInjectedService().getRootDependency());
|
||||
assertSame(root, created.getSetterInjectedService().getRootDependency());
|
||||
assertSame(singleton.getConstructorInjectedService(), created.getConstructorInjectedService());
|
||||
assertSame(singleton.getFieldInjectedService(), created.getFieldInjectedService());
|
||||
assertSame(singleton.getSetterInjectedService(), created.getSetterInjectedService());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createComponentDoesNotBecomeTheSingleton() {
|
||||
MixedInjectionService created = context.createComponent(MixedInjectionService.class);
|
||||
MixedInjectionService singleton = context.getComponent(MixedInjectionService.class);
|
||||
assertNotSame(created, singleton);
|
||||
assertSame(singleton, context.getComponent(MixedInjectionService.class));
|
||||
assertSame(created.getConstructorInjectedService(), singleton.getConstructorInjectedService());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user