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}.
|
* The list of binding elements declared inside {@code fx:define}.
|
||||||
* <p>
|
* <p>
|
||||||
* The list is not a scene-graph child. FXML adds each {@link DirectBinding} or
|
* The list is not a scene-graph child. FXML adds each {@link DirectBinding},
|
||||||
* {@link ComputedBinding} child to {@link #getElements()}.
|
* {@link ComputedBinding}, or {@link Repeat} child to {@link #getElements()}.
|
||||||
*/
|
*/
|
||||||
@DefaultProperty("elements")
|
@DefaultProperty("elements")
|
||||||
public class BindingList {
|
public class BindingList {
|
||||||
@@ -29,8 +29,8 @@ public class BindingList {
|
|||||||
/**
|
/**
|
||||||
* Returns the binding elements in this list.
|
* Returns the binding elements in this list.
|
||||||
* <p>
|
* <p>
|
||||||
* FXML appends child elements to the returned list. A child is a {@link DirectBinding} or a
|
* FXML appends child elements to the returned list. A child is a {@link DirectBinding}, a
|
||||||
* {@link ComputedBinding}.
|
* {@link ComputedBinding}, or a {@link Repeat}.
|
||||||
*
|
*
|
||||||
* @return the mutable list of binding elements
|
* @return the mutable list of binding elements
|
||||||
*/
|
*/
|
||||||
@@ -54,6 +54,8 @@ public class BindingList {
|
|||||||
DirectBindingApplier.apply(namespace, viewModel, directBinding, context);
|
DirectBindingApplier.apply(namespace, viewModel, directBinding, context);
|
||||||
} else if (element instanceof ComputedBinding computedBinding) {
|
} else if (element instanceof ComputedBinding computedBinding) {
|
||||||
ComputedBindingApplier.apply(namespace, viewModel, controller, root, computedBinding);
|
ComputedBindingApplier.apply(namespace, viewModel, controller, root, computedBinding);
|
||||||
|
} else if (element instanceof Repeat repeat) {
|
||||||
|
RepeatApplier.apply(namespace, viewModel, repeat, context);
|
||||||
} else {
|
} else {
|
||||||
throw new IllegalArgumentException("Not a binding element: " + element);
|
throw new IllegalArgumentException("Not a binding element: " + element);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ final class Components {
|
|||||||
* <p>
|
* <p>
|
||||||
* An unknown type, such as a JavaFX control, makes {@link ApplicationContext#getComponent}
|
* An unknown type, such as a JavaFX control, makes {@link ApplicationContext#getComponent}
|
||||||
* throw {@link IllegalArgumentException}. This method returns null so FXML constructs that
|
* 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 type the type FXML is about to construct
|
||||||
* @param context the container that creates Components
|
* @param context the container that creates Components
|
||||||
@@ -44,7 +46,38 @@ final class Components {
|
|||||||
} catch (IllegalArgumentException notAComponent) {
|
} catch (IllegalArgumentException notAComponent) {
|
||||||
return null;
|
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) {
|
private static Object load(Object component, ApplicationContext context) {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package de.neitzel.fx.view;
|
|||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the binding lists instantiated while one View is loading.
|
* 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 static final ThreadLocal<Deque<LoadSession>> STACK = new ThreadLocal<>();
|
||||||
|
|
||||||
private final List<BindingList> bindingLists = new ArrayList<>();
|
private final List<BindingList> bindingLists = new ArrayList<>();
|
||||||
|
private final Set<Class<?>> built = new HashSet<>();
|
||||||
|
|
||||||
private LoadSession() {
|
private LoadSession() {
|
||||||
}
|
}
|
||||||
@@ -39,20 +42,33 @@ final class LoadSession {
|
|||||||
* Opens a nested session for a Component document.
|
* Opens a nested session for a Component document.
|
||||||
* <p>
|
* <p>
|
||||||
* Binding lists created until {@link #unnest()} register here, not on the parent session.
|
* 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
|
* @return the nested session
|
||||||
*/
|
*/
|
||||||
static LoadSession nest() {
|
static LoadSession nest() {
|
||||||
|
Deque<LoadSession> stack = STACK.get();
|
||||||
|
if (stack == null) {
|
||||||
|
stack = new ArrayDeque<>();
|
||||||
|
STACK.set(stack);
|
||||||
|
}
|
||||||
LoadSession session = new LoadSession();
|
LoadSession session = new LoadSession();
|
||||||
STACK.get().push(session);
|
stack.push(session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores the session that was current before {@link #nest()}.
|
* 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() {
|
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);
|
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.
|
* 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
|
* Attribute, with that Attribute's name as the source. A parent carries a copy with the source
|
||||||
* extended. It does not fail the load.
|
* extended. It does not fail the load.
|
||||||
* A parent document names a Component class as an element. The context creates that Component
|
* 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,
|
* and injects its services. A second element of the same class is another injected instance,
|
||||||
* is loaded with the Component as {@code fx:root} and as the Controller. That document's
|
* and the cached singleton stays the first one. The Component document {@code ClassName.fxml},
|
||||||
* bindings are not applied during this load. They run when the Component's model property
|
* in the same package, is loaded with the Component as {@code fx:root} and as the Controller.
|
||||||
* becomes a non-null Model. A Component that implements {@link HasViewModel} receives that
|
* That document's bindings are not applied during this load. They run when the Component's
|
||||||
* ViewModel after the property is set and before those bindings run.
|
* 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
|
* 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}
|
* {@code latestMessage} shows the latest Message text. {@link MessagePopup#showNewMessages}
|
||||||
* shows each newly added Message in a dialog.
|
* 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
|
* 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}
|
* 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
|
* 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
|
* Complex attribute. It is an object property whose value is an {@link ObservableList} of those
|
||||||
* value is an {@link ObservableList} of those values. When that attribute's current value is a
|
* Model instances, not of ViewModels. A list, set, or collection of simple values is an object
|
||||||
* list, the observable list is backed by it and stored on the entity when the setter accepts it,
|
* property whose value is an {@link ObservableList} of those values. When either attribute's
|
||||||
* so both sides hold one list. A cycle, or another Attribute that reaches the same instance,
|
* current value is a list, the observable list is backed by it and stored on the entity when the
|
||||||
* reuses that ViewModel.
|
* 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
|
* 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
|
* 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.
|
* writes the Attribute. A Complex attribute writes the Model instance behind the ViewModel.
|
||||||
@@ -113,7 +114,7 @@ public class ViewModel {
|
|||||||
properties.put(name, property);
|
properties.put(name, property);
|
||||||
complexAttributes.add(name);
|
complexAttributes.add(name);
|
||||||
} else {
|
} else {
|
||||||
if (isSimpleValueCollection(getter)) {
|
if (isSimpleValueCollection(getter) || isModelCollection(getter)) {
|
||||||
current = shareSimpleList(setter, name, current);
|
current = shareSimpleList(setter, name, current);
|
||||||
}
|
}
|
||||||
Property<?> property = createProperty(type, current);
|
Property<?> property = createProperty(type, current);
|
||||||
@@ -545,6 +546,14 @@ public class ViewModel {
|
|||||||
return element != null && isSimpleValue(element);
|
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) {
|
private static Class<?> elementType(Type type) {
|
||||||
if (!(type instanceof ParameterizedType parameterized) || parameterized.getActualTypeArguments().length != 1) {
|
if (!(type instanceof ParameterizedType parameterized) || parameterized.getActualTypeArguments().length != 1) {
|
||||||
return null;
|
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 Date updated;
|
||||||
private Calendar hired;
|
private Calendar hired;
|
||||||
private List<String> tags;
|
private List<String> tags;
|
||||||
|
private List<Address> addresses;
|
||||||
|
private Address selectedAddress;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a person with the given name.
|
* Creates a person with the given name.
|
||||||
@@ -256,4 +258,40 @@ public class Person {
|
|||||||
public void setTags(List<String> tags) {
|
public void setTags(List<String> tags) {
|
||||||
this.tags = 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")
|
@SuppressWarnings("unchecked")
|
||||||
public <T> T getComponent(Class<? extends T> type) {
|
public <T> T getComponent(Class<? extends T> type) {
|
||||||
ComponentData data = components.get(type);
|
ComponentData data = componentData(type);
|
||||||
if (data == null) {
|
|
||||||
throw new IllegalArgumentException("No component found for type: " + type.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
Scope scope = data.getScope();
|
Scope scope = data.getScope();
|
||||||
Object instance = data.getInstance();
|
Object instance = data.getInstance();
|
||||||
|
|
||||||
@@ -79,10 +75,60 @@ public class ApplicationContext {
|
|||||||
return (T) instance;
|
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 {
|
try {
|
||||||
Constructor<?> injectionConstructor = data.getInjectionConstructor();
|
Constructor<?> injectionConstructor = data.getInjectionConstructor();
|
||||||
if (injectionConstructor == null) {
|
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();
|
Class<?>[] paramTypes = injectionConstructor.getParameterTypes();
|
||||||
@@ -91,7 +137,7 @@ public class ApplicationContext {
|
|||||||
parameters[i] = getComponent(paramTypes[i]);
|
parameters[i] = getComponent(paramTypes[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
instance = injectionConstructor.newInstance(parameters);
|
Object instance = injectionConstructor.newInstance(parameters);
|
||||||
|
|
||||||
for (Field field : data.getInjectableFields()) {
|
for (Field field : data.getInjectableFields()) {
|
||||||
field.setAccessible(true);
|
field.setAccessible(true);
|
||||||
@@ -104,12 +150,9 @@ public class ApplicationContext {
|
|||||||
setter.invoke(instance, getComponent(dependencyType));
|
setter.invoke(instance, getComponent(dependencyType));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope == Scope.SINGLETON) {
|
return instance;
|
||||||
data.setInstance(instance);
|
|
||||||
}
|
|
||||||
return (T) instance;
|
|
||||||
} catch (Exception e) {
|
} 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 org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
|
||||||
class ApplicationContextInjectionTest {
|
class ApplicationContextInjectionTest {
|
||||||
@@ -46,5 +47,29 @@ class ApplicationContextInjectionTest {
|
|||||||
assertSame(rootDependency, service.getFieldInjectedService().getRootDependency());
|
assertSame(rootDependency, service.getFieldInjectedService().getRootDependency());
|
||||||
assertSame(rootDependency, service.getSetterInjectedService().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