From 0321e0de8d678a1d188f446ceab81ea8db07bb29 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Fri, 30 Oct 2026 18:17:47 +0100 Subject: [PATCH] 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 --- .../java/de/neitzel/fx/view/BindingList.java | 10 +- .../java/de/neitzel/fx/view/Components.java | 37 ++- .../java/de/neitzel/fx/view/LoadSession.java | 37 ++- .../main/java/de/neitzel/fx/view/Repeat.java | 69 ++++++ .../de/neitzel/fx/view/RepeatApplier.java | 83 +++++++ .../java/de/neitzel/fx/view/ViewLoader.java | 20 +- .../java/de/neitzel/fx/view/ViewModel.java | 21 +- .../fx/view/RepeatAddressComponentsTest.java | 231 ++++++++++++++++++ .../de/neitzel/fx/view/fixture/Person.java | 38 +++ .../de/neitzel/fx/view/repeat-addresses.fxml | 18 ++ .../de/neitzel/fx/view/two-address-forms.fxml | 17 ++ .../neitzel/injection/ApplicationContext.java | 67 ++++- .../ApplicationContextInjectionTest.java | 25 ++ 13 files changed, 642 insertions(+), 31 deletions(-) create mode 100644 fx/src/main/java/de/neitzel/fx/view/Repeat.java create mode 100644 fx/src/main/java/de/neitzel/fx/view/RepeatApplier.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/RepeatAddressComponentsTest.java create mode 100644 fx/src/test/resources/de/neitzel/fx/view/repeat-addresses.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/two-address-forms.fxml diff --git a/fx/src/main/java/de/neitzel/fx/view/BindingList.java b/fx/src/main/java/de/neitzel/fx/view/BindingList.java index 97fc280..a3f7bb1 100644 --- a/fx/src/main/java/de/neitzel/fx/view/BindingList.java +++ b/fx/src/main/java/de/neitzel/fx/view/BindingList.java @@ -11,8 +11,8 @@ import java.util.Map; /** * The list of binding elements declared inside {@code fx:define}. *

- * 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. *

- * 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); } diff --git a/fx/src/main/java/de/neitzel/fx/view/Components.java b/fx/src/main/java/de/neitzel/fx/view/Components.java index 780ecc0..be7c6de 100644 --- a/fx/src/main/java/de/neitzel/fx/view/Components.java +++ b/fx/src/main/java/de/neitzel/fx/view/Components.java @@ -31,7 +31,9 @@ final class Components { *

* 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. + *

+ * 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. + *

+ * 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) { diff --git a/fx/src/main/java/de/neitzel/fx/view/LoadSession.java b/fx/src/main/java/de/neitzel/fx/view/LoadSession.java index 3e14e81..e2f91cb 100644 --- a/fx/src/main/java/de/neitzel/fx/view/LoadSession.java +++ b/fx/src/main/java/de/neitzel/fx/view/LoadSession.java @@ -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> STACK = new ThreadLocal<>(); private final List bindingLists = new ArrayList<>(); + private final Set> built = new HashSet<>(); private LoadSession() { } @@ -39,20 +42,33 @@ final class LoadSession { * Opens a nested session for a Component document. *

* 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 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()}. + *

+ * When the nested session was the only one, the thread-local stack is dropped. */ static void unnest() { - STACK.get().pop(); + Deque 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. + *

+ * 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 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. * diff --git a/fx/src/main/java/de/neitzel/fx/view/Repeat.java b/fx/src/main/java/de/neitzel/fx/view/Repeat.java new file mode 100644 index 0000000..2ca04e1 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/Repeat.java @@ -0,0 +1,69 @@ +package de.neitzel.fx.view; + +/** + * A repeat element declared in a {@link BindingList}. + *

+ * 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; + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/RepeatApplier.java b/fx/src/main/java/de/neitzel/fx/view/RepeatApplier.java new file mode 100644 index 0000000..529b5dc --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/RepeatApplier.java @@ -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. + *

+ * 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 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 elements = (ObservableList) values; + elements.addListener((ListChangeListener) 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); + } + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java index 73541de..4b9a68b 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java @@ -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. diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java index 765a269..698fab2 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java @@ -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; diff --git a/fx/src/test/java/de/neitzel/fx/view/RepeatAddressComponentsTest.java b/fx/src/test/java/de/neitzel/fx/view/RepeatAddressComponentsTest.java new file mode 100644 index 0000000..21dd8b9 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/RepeatAddressComponentsTest.java @@ -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
addresses = (ObservableList
) 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
addresses = (ObservableList
) 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
selection = (ComboBox
) 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 streets(List
addresses) { + return addresses.stream().map(Address::getStreet).toList(); + } + + private static void onFx(FxAction action) throws Exception { + if (Platform.isFxApplicationThread()) { + action.run(); + return; + } + AtomicReference 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; + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java b/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java index 36a416e..1acc05e 100644 --- a/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java +++ b/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java @@ -22,6 +22,8 @@ public class Person { private Date updated; private Calendar hired; private List tags; + private List
addresses; + private Address selectedAddress; /** * Creates a person with the given name. @@ -256,4 +258,40 @@ public class Person { public void setTags(List tags) { this.tags = tags; } + + /** + * Returns the addresses. The value may be null. + * + * @return the addresses + */ + public List
getAddresses() { + return addresses; + } + + /** + * Sets the addresses. + * + * @param addresses the addresses + */ + public void setAddresses(List
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; + } } diff --git a/fx/src/test/resources/de/neitzel/fx/view/repeat-addresses.fxml b/fx/src/test/resources/de/neitzel/fx/view/repeat-addresses.fxml new file mode 100644 index 0000000..deba185 --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/repeat-addresses.fxml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/two-address-forms.fxml b/fx/src/test/resources/de/neitzel/fx/view/two-address-forms.fxml new file mode 100644 index 0000000..b6253ab --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/two-address-forms.fxml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/injection/src/main/java/de/neitzel/injection/ApplicationContext.java b/injection/src/main/java/de/neitzel/injection/ApplicationContext.java index 7276394..149e2af 100644 --- a/injection/src/main/java/de/neitzel/injection/ApplicationContext.java +++ b/injection/src/main/java/de/neitzel/injection/ApplicationContext.java @@ -67,11 +67,7 @@ public class ApplicationContext { */ @SuppressWarnings("unchecked") public T getComponent(Class 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. + *

+ * 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 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 createComponent(Class 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. + *

+ * 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); } } diff --git a/injection/src/test/java/de/neitzel/injection/ApplicationContextInjectionTest.java b/injection/src/test/java/de/neitzel/injection/ApplicationContextInjectionTest.java index d8f2b93..9e03227 100644 --- a/injection/src/test/java/de/neitzel/injection/ApplicationContextInjectionTest.java +++ b/injection/src/test/java/de/neitzel/injection/ApplicationContextInjectionTest.java @@ -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()); + } }