Merge child Messages that travel up to the parent ViewModel.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.IdentityHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries one child ViewModel's current notices onto its parent.
|
||||||
|
* <p>
|
||||||
|
* Each copy is keyed by the identity of the child {@link Message}. A later text change on that
|
||||||
|
* child notice updates the same copy, and dropping the child notice drops the copy. The copy's
|
||||||
|
* source is the Attribute name, a dot, and the child source. A further parent carries that copy
|
||||||
|
* the same way, so the source grows into a Path. The copy is not logged again.
|
||||||
|
*/
|
||||||
|
final class ChildMessageMirror implements MessageListener {
|
||||||
|
|
||||||
|
private final ViewModel parent;
|
||||||
|
private final String attribute;
|
||||||
|
private final ViewModel child;
|
||||||
|
private final Map<Message, Message> copies = new IdentityHashMap<>();
|
||||||
|
|
||||||
|
private ChildMessageMirror(ViewModel parent, String attribute, ViewModel child) {
|
||||||
|
this.parent = parent;
|
||||||
|
this.attribute = attribute;
|
||||||
|
this.child = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts carrying {@code child} onto {@code parent} under {@code attribute}.
|
||||||
|
* <p>
|
||||||
|
* Notices already on the child are copied, and notices added later are copied as they arrive.
|
||||||
|
*
|
||||||
|
* @param parent the parent ViewModel
|
||||||
|
* @param attribute the Attribute that leads to the child
|
||||||
|
* @param child the child ViewModel
|
||||||
|
* @return the mirror, so it can be detached when the Attribute is cleared
|
||||||
|
*/
|
||||||
|
static ChildMessageMirror attach(ViewModel parent, String attribute, ViewModel child) {
|
||||||
|
ChildMessageMirror mirror = new ChildMessageMirror(parent, attribute, child);
|
||||||
|
child.addMessageListener(mirror);
|
||||||
|
for (Message message : List.copyOf(child.getMessages())) {
|
||||||
|
mirror.carry(message);
|
||||||
|
}
|
||||||
|
return mirror;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops carrying and drops this Attribute's copies from the parent.
|
||||||
|
*/
|
||||||
|
void detach() {
|
||||||
|
child.removeMessageListener(this);
|
||||||
|
List<Message> dropped = new ArrayList<>(copies.values());
|
||||||
|
copies.clear();
|
||||||
|
for (Message copy : dropped) {
|
||||||
|
parent.removeCarriedMessage(copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies {@code message} onto the parent unless this parent already holds it.
|
||||||
|
*
|
||||||
|
* @param message the child notice
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void added(Message message) {
|
||||||
|
carry(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the parent's copy of {@code message}.
|
||||||
|
*
|
||||||
|
* @param message the child notice
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void removed(Message message) {
|
||||||
|
Message copy = copies.remove(message);
|
||||||
|
if (copy != null) {
|
||||||
|
parent.removeCarriedMessage(copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the child's new text onto the same parent copy.
|
||||||
|
*
|
||||||
|
* @param message the child notice
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void textChanged(Message message) {
|
||||||
|
Message copy = copies.get(message);
|
||||||
|
if (copy == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copy.setText(message.getText());
|
||||||
|
parent.noteMessageText(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void carry(Message message) {
|
||||||
|
if (copies.containsKey(message) || repeats(message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Message copy = message.withExtendedSource(attribute + "." + message.getSource());
|
||||||
|
copies.put(message, copy);
|
||||||
|
parent.addCarriedMessage(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cycle would carry a notice back onto a ViewModel that already holds it, or that holds
|
||||||
|
* the notice it was copied from.
|
||||||
|
*
|
||||||
|
* @param message the child notice
|
||||||
|
* @return {@code true} when the parent already has this notice or its origin
|
||||||
|
*/
|
||||||
|
private boolean repeats(Message message) {
|
||||||
|
Message current = message;
|
||||||
|
Set<Message> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||||
|
while (current != null && seen.add(current)) {
|
||||||
|
if (parent.getMessages().contains(current)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current.origin();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,11 +57,11 @@ final class DirectBindingApplier {
|
|||||||
Converter converter = converter(context, converterName);
|
Converter converter = converter(context, converterName);
|
||||||
switch (binding.getDirection()) {
|
switch (binding.getDirection()) {
|
||||||
case "both" -> alignConverted(
|
case "both" -> alignConverted(
|
||||||
controlProperty, attribute, converter, viewModel, binding.getPath(), true, true);
|
controlProperty, attribute, converter, resolved.owner, resolved.name, true, true);
|
||||||
case "view" -> alignConverted(
|
case "view" -> alignConverted(
|
||||||
controlProperty, attribute, converter, viewModel, binding.getPath(), true, false);
|
controlProperty, attribute, converter, resolved.owner, resolved.name, true, false);
|
||||||
case "model" -> alignConverted(
|
case "model" -> alignConverted(
|
||||||
controlProperty, attribute, converter, viewModel, binding.getPath(), false, true);
|
controlProperty, attribute, converter, resolved.owner, resolved.name, false, true);
|
||||||
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
|
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,15 +76,22 @@ final class DirectBindingApplier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Attribute a Path names, and whether that Attribute is complex.
|
* The Attribute a Path names, the ViewModel that owns it, and whether that Attribute is complex.
|
||||||
|
* <p>
|
||||||
|
* {@code name} is the last segment. A conversion failure is recorded on {@code owner} with that
|
||||||
|
* name as the source, so a parent only carries the extended copy.
|
||||||
*/
|
*/
|
||||||
private static final class Resolved {
|
private static final class Resolved {
|
||||||
private final Property<?> attribute;
|
private final Property<?> attribute;
|
||||||
private final boolean complex;
|
private final boolean complex;
|
||||||
|
private final ViewModel owner;
|
||||||
|
private final String name;
|
||||||
|
|
||||||
private Resolved(Property<?> attribute, boolean complex) {
|
private Resolved(Property<?> attribute, boolean complex, ViewModel owner, String name) {
|
||||||
this.attribute = attribute;
|
this.attribute = attribute;
|
||||||
this.complex = complex;
|
this.complex = complex;
|
||||||
|
this.owner = owner;
|
||||||
|
this.name = name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +130,7 @@ final class DirectBindingApplier {
|
|||||||
if (attribute == null) {
|
if (attribute == null) {
|
||||||
throw missing(path);
|
throw missing(path);
|
||||||
}
|
}
|
||||||
return new Resolved(attribute, current.isComplex(last));
|
return new Resolved(attribute, current.isComplex(last), current, last);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IllegalArgumentException missing(String path) {
|
private static IllegalArgumentException missing(String path) {
|
||||||
@@ -143,31 +150,35 @@ final class DirectBindingApplier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aligns a converted Attribute. Failures are recorded on {@code owner} with {@code sourceName},
|
||||||
|
* which is the last name of the Path.
|
||||||
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static void alignConverted(Property<?> controlProperty, Property<?> attribute, Converter converter,
|
private static void alignConverted(Property<?> controlProperty, Property<?> attribute, Converter converter,
|
||||||
ViewModel viewModel, String path, boolean towardView, boolean towardModel) {
|
ViewModel owner, String sourceName, boolean towardView, boolean towardModel) {
|
||||||
Property<Object> control = (Property<Object>) controlProperty;
|
Property<Object> control = (Property<Object>) controlProperty;
|
||||||
Property<Object> source = (Property<Object>) attribute;
|
Property<Object> source = (Property<Object>) attribute;
|
||||||
Link link = new Link();
|
Link link = new Link();
|
||||||
pushControl(control, source, converter, viewModel, path, link);
|
pushControl(control, source, converter, owner, sourceName, link);
|
||||||
if (towardView) {
|
if (towardView) {
|
||||||
source.addListener((observable, oldValue, newValue) -> {
|
source.addListener((observable, oldValue, newValue) -> {
|
||||||
if (!link.applying) {
|
if (!link.applying) {
|
||||||
pushControl(control, source, converter, viewModel, path, link);
|
pushControl(control, source, converter, owner, sourceName, link);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (towardModel) {
|
if (towardModel) {
|
||||||
control.addListener((observable, oldValue, newValue) -> {
|
control.addListener((observable, oldValue, newValue) -> {
|
||||||
if (!link.applying) {
|
if (!link.applying) {
|
||||||
pushAttribute(control, source, converter, viewModel, path, link);
|
pushAttribute(control, source, converter, owner, sourceName, link);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void pushControl(Property<Object> control, Property<Object> attribute, Converter converter,
|
private static void pushControl(Property<Object> control, Property<Object> attribute, Converter converter,
|
||||||
ViewModel viewModel, String path, Link link) {
|
ViewModel owner, String source, Link link) {
|
||||||
try {
|
try {
|
||||||
Object shown = converter.toControlValue(attribute.getValue());
|
Object shown = converter.toControlValue(attribute.getValue());
|
||||||
link.applying = true;
|
link.applying = true;
|
||||||
@@ -177,14 +188,14 @@ final class DirectBindingApplier {
|
|||||||
} finally {
|
} finally {
|
||||||
link.applying = false;
|
link.applying = false;
|
||||||
}
|
}
|
||||||
viewModel.clearBindingFailure(path);
|
owner.clearBindingFailure(source);
|
||||||
} catch (RuntimeException failure) {
|
} catch (RuntimeException failure) {
|
||||||
viewModel.reportBindingFailure(path, failureText(failure));
|
owner.reportBindingFailure(source, failureText(failure));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void pushAttribute(Property<Object> control, Property<Object> attribute, Converter converter,
|
private static void pushAttribute(Property<Object> control, Property<Object> attribute, Converter converter,
|
||||||
ViewModel viewModel, String path, Link link) {
|
ViewModel owner, String source, Link link) {
|
||||||
try {
|
try {
|
||||||
Object converted = converter.toAttributeValue(control.getValue());
|
Object converted = converter.toAttributeValue(control.getValue());
|
||||||
link.applying = true;
|
link.applying = true;
|
||||||
@@ -194,7 +205,7 @@ final class DirectBindingApplier {
|
|||||||
} finally {
|
} finally {
|
||||||
link.applying = false;
|
link.applying = false;
|
||||||
}
|
}
|
||||||
viewModel.clearBindingFailure(path);
|
owner.clearBindingFailure(source);
|
||||||
} catch (RuntimeException failure) {
|
} catch (RuntimeException failure) {
|
||||||
link.applying = true;
|
link.applying = true;
|
||||||
try {
|
try {
|
||||||
@@ -202,7 +213,7 @@ final class DirectBindingApplier {
|
|||||||
} finally {
|
} finally {
|
||||||
link.applying = false;
|
link.applying = false;
|
||||||
}
|
}
|
||||||
viewModel.reportBindingFailure(path, failureText(failure));
|
owner.reportBindingFailure(source, failureText(failure));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public final class Message {
|
|||||||
private final Severity severity;
|
private final Severity severity;
|
||||||
private String text;
|
private String text;
|
||||||
private final String source;
|
private final String source;
|
||||||
|
private Message origin;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a notice.
|
* Creates a notice.
|
||||||
@@ -71,4 +72,28 @@ public final class Message {
|
|||||||
void setText(String text) {
|
void setText(String text) {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy whose source is extended for a parent ViewModel.
|
||||||
|
* <p>
|
||||||
|
* The copy remembers this notice, so a cycle does not carry it back onto the ViewModel
|
||||||
|
* that already holds it.
|
||||||
|
*
|
||||||
|
* @param source the extended source
|
||||||
|
* @return the copy
|
||||||
|
*/
|
||||||
|
Message withExtendedSource(String source) {
|
||||||
|
Message copy = new Message(severity, text, source);
|
||||||
|
copy.origin = this;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the notice this copy mirrors.
|
||||||
|
*
|
||||||
|
* @return that notice, or {@code null} when this notice was added on its own ViewModel
|
||||||
|
*/
|
||||||
|
Message origin() {
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hears a notice added, dropped, or edited on a ViewModel.
|
||||||
|
*/
|
||||||
|
interface MessageListener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A notice was added.
|
||||||
|
*
|
||||||
|
* @param message the notice
|
||||||
|
*/
|
||||||
|
void added(Message message);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A notice was dropped.
|
||||||
|
*
|
||||||
|
* @param message the notice
|
||||||
|
*/
|
||||||
|
void removed(Message message);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A notice kept its place and changed its text.
|
||||||
|
*
|
||||||
|
* @param message the notice
|
||||||
|
*/
|
||||||
|
void textChanged(Message message);
|
||||||
|
}
|
||||||
@@ -33,7 +33,9 @@ public final class ViewLoader {
|
|||||||
* {@link javafx.collections.ObservableList}, so an add or remove is visible on both sides.
|
* {@link javafx.collections.ObservableList}, so an add or remove is visible on both sides.
|
||||||
* A path that is not a read-write Attribute fails the load. The context creates a Controller,
|
* A path that is not a read-write Attribute fails the load. The context creates a Controller,
|
||||||
* and a Converter when a Direct binding names one. A converter that cannot produce a value
|
* and a Converter when a Direct binding names one. A converter that cannot produce a value
|
||||||
* keeps the previous value and records one error Message. It does not fail the load.
|
* keeps the previous value and records one error Message on the ViewModel that owns the last
|
||||||
|
* 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
|
* 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. 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
|
* is loaded with the Component as {@code fx:root} and as the Controller. That document's
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ import java.util.Set;
|
|||||||
* <p>
|
* <p>
|
||||||
* Beside those attributes, the ViewModel keeps a list of {@link Message}s and the latest one.
|
* Beside those attributes, the ViewModel keeps a list of {@link Message}s and the latest one.
|
||||||
* The developer can add a message of any severity. Error and fatal messages are written to the log.
|
* The developer can add a message of any severity. Error and fatal messages are written to the log.
|
||||||
* Info and warn are not.
|
* Info and warn are not. A parent carries each current child message, with the source extended by
|
||||||
|
* the Attribute that leads to that child. Each further parent prepends its own Attribute. The
|
||||||
|
* carried copy is not logged again. When the child drops the message, the parent drops that copy.
|
||||||
*/
|
*/
|
||||||
public class ViewModel {
|
public class ViewModel {
|
||||||
|
|
||||||
@@ -75,6 +77,8 @@ public class ViewModel {
|
|||||||
private final Set<String> complexAttributes = new HashSet<>();
|
private final Set<String> complexAttributes = new HashSet<>();
|
||||||
private final List<Message> messages = new ArrayList<>();
|
private final List<Message> messages = new ArrayList<>();
|
||||||
private final Map<String, Message> bindingFailures = new HashMap<>();
|
private final Map<String, Message> bindingFailures = new HashMap<>();
|
||||||
|
private final List<MessageListener> messageListeners = new ArrayList<>();
|
||||||
|
private final Map<String, ChildMessageMirror> childMirrors = new HashMap<>();
|
||||||
private Message latestMessage;
|
private Message latestMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,12 +272,43 @@ public class ViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void watchComplex(Property<?> property, Method setter, String name) {
|
private void watchComplex(Property<?> property, Method setter, String name) {
|
||||||
|
if (property.getValue() instanceof ViewModel child) {
|
||||||
|
attachChild(name, child);
|
||||||
|
}
|
||||||
property.addListener((observable, oldValue, newValue) -> {
|
property.addListener((observable, oldValue, newValue) -> {
|
||||||
|
if (oldValue != newValue) {
|
||||||
|
detachChild(name);
|
||||||
|
}
|
||||||
Object written = newValue instanceof ViewModel viewModel ? viewModel.model() : newValue;
|
Object written = newValue instanceof ViewModel viewModel ? viewModel.model() : newValue;
|
||||||
write(setter, name, written);
|
write(setter, name, written);
|
||||||
|
if (oldValue != newValue && newValue instanceof ViewModel child) {
|
||||||
|
attachChild(name, child);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries the child ViewModel's current notices, extending each source with this Attribute.
|
||||||
|
*
|
||||||
|
* @param name the Attribute that leads to the child
|
||||||
|
* @param child the child ViewModel
|
||||||
|
*/
|
||||||
|
private void attachChild(String name, ViewModel child) {
|
||||||
|
childMirrors.put(name, ChildMessageMirror.attach(this, name, child));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the copies carried for an Attribute and stops following that child.
|
||||||
|
*
|
||||||
|
* @param name the Attribute
|
||||||
|
*/
|
||||||
|
private void detachChild(String name) {
|
||||||
|
ChildMessageMirror mirror = childMirrors.remove(name);
|
||||||
|
if (mirror != null) {
|
||||||
|
mirror.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void write(Method setter, String name, Object value) {
|
private void write(Method setter, String name, Object value) {
|
||||||
try {
|
try {
|
||||||
setter.invoke(model, value);
|
setter.invoke(model, value);
|
||||||
@@ -311,12 +346,103 @@ public class ViewModel {
|
|||||||
messages.add(message);
|
messages.add(message);
|
||||||
latestMessage = message;
|
latestMessage = message;
|
||||||
logSevere(message);
|
logSevere(message);
|
||||||
|
notifyAdded(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records one error for a binding Path. A further failure of that Path replaces the text.
|
* Adds a copy of a child notice. The copy is not written to the log.
|
||||||
*
|
*
|
||||||
* @param path the binding Path
|
* @param copy the carried notice
|
||||||
|
*/
|
||||||
|
void addCarriedMessage(Message copy) {
|
||||||
|
messages.add(copy);
|
||||||
|
latestMessage = copy;
|
||||||
|
notifyAdded(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops a carried copy. When it was the latest notice, the previous one becomes latest.
|
||||||
|
*
|
||||||
|
* @param copy the carried notice
|
||||||
|
*/
|
||||||
|
void removeCarriedMessage(Message copy) {
|
||||||
|
if (!messages.remove(copy)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (latestMessage == copy) {
|
||||||
|
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
|
||||||
|
}
|
||||||
|
notifyRemoved(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hears notices added on this ViewModel, including carried copies, and hears when they are
|
||||||
|
* dropped or their text changes.
|
||||||
|
*
|
||||||
|
* @param listener the listener
|
||||||
|
*/
|
||||||
|
void addMessageListener(MessageListener listener) {
|
||||||
|
messageListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops hearing notices added on this ViewModel.
|
||||||
|
*
|
||||||
|
* @param listener the listener
|
||||||
|
*/
|
||||||
|
void removeMessageListener(MessageListener listener) {
|
||||||
|
messageListeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells listeners that a notice was added.
|
||||||
|
*
|
||||||
|
* @param message the notice
|
||||||
|
*/
|
||||||
|
private void notifyAdded(Message message) {
|
||||||
|
for (MessageListener listener : List.copyOf(messageListeners)) {
|
||||||
|
listener.added(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells listeners that a notice kept its place and changed its text.
|
||||||
|
* <p>
|
||||||
|
* An in-place change does not log again and does not become a new latest notice.
|
||||||
|
*
|
||||||
|
* @param message the notice
|
||||||
|
*/
|
||||||
|
void noteMessageText(Message message) {
|
||||||
|
notifyTextChanged(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells listeners that a notice was dropped.
|
||||||
|
*
|
||||||
|
* @param message the notice
|
||||||
|
*/
|
||||||
|
private void notifyRemoved(Message message) {
|
||||||
|
for (MessageListener listener : List.copyOf(messageListeners)) {
|
||||||
|
listener.removed(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells listeners that a notice kept its place and changed its text.
|
||||||
|
*
|
||||||
|
* @param message the notice
|
||||||
|
*/
|
||||||
|
private void notifyTextChanged(Message message) {
|
||||||
|
for (MessageListener listener : List.copyOf(messageListeners)) {
|
||||||
|
listener.textChanged(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records one error whose source is an Attribute name. A further failure of that Attribute
|
||||||
|
* replaces the text of the same notice. Parents that carry the notice hear the new text.
|
||||||
|
*
|
||||||
|
* @param path the Attribute name that owns the failure
|
||||||
* @param text the error text
|
* @param text the error text
|
||||||
*/
|
*/
|
||||||
void reportBindingFailure(String path, String text) {
|
void reportBindingFailure(String path, String text) {
|
||||||
@@ -324,6 +450,7 @@ public class ViewModel {
|
|||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
existing.setText(text);
|
existing.setText(text);
|
||||||
logSevere(existing);
|
logSevere(existing);
|
||||||
|
notifyTextChanged(existing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Message message = new Message(Message.Severity.ERROR, text, path);
|
Message message = new Message(Message.Severity.ERROR, text, path);
|
||||||
@@ -332,9 +459,9 @@ public class ViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drops the error recorded for a binding Path after a later conversion succeeds.
|
* Drops the error recorded for an Attribute after a later conversion succeeds.
|
||||||
*
|
*
|
||||||
* @param path the binding Path
|
* @param path the Attribute name that owned the failure
|
||||||
*/
|
*/
|
||||||
void clearBindingFailure(String path) {
|
void clearBindingFailure(String path) {
|
||||||
Message existing = bindingFailures.remove(path);
|
Message existing = bindingFailures.remove(path);
|
||||||
@@ -345,6 +472,7 @@ public class ViewModel {
|
|||||||
if (latestMessage == existing) {
|
if (latestMessage == existing) {
|
||||||
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
|
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
|
||||||
}
|
}
|
||||||
|
notifyRemoved(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
import de.neitzel.fx.view.fixture.Address;
|
||||||
|
import de.neitzel.fx.view.fixture.Household;
|
||||||
|
import de.neitzel.fx.view.fixture.Person;
|
||||||
|
import de.neitzel.injection.ApplicationContext;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.beans.property.Property;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
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.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 CarryChildMessageTest {
|
||||||
|
|
||||||
|
@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 aStreetNoticeOnTheAddressAppearsOnThePersonAsAddressStreet() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAddress(new Address("Main"));
|
||||||
|
URL fxml = CarryChildMessageTest.class.getResource("name-both-ways.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
ViewModel personModel = loaded.getViewModel();
|
||||||
|
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||||
|
addressModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
|
||||||
|
|
||||||
|
assertEquals(1, addressModel.getMessages().size());
|
||||||
|
assertEquals("street", addressModel.getMessages().get(0).getSource());
|
||||||
|
assertEquals(1, personModel.getMessages().size());
|
||||||
|
Message carried = personModel.getMessages().get(0);
|
||||||
|
assertEquals("address.street", carried.getSource());
|
||||||
|
assertEquals("lane is closed", carried.getText());
|
||||||
|
assertEquals(Message.Severity.INFO, carried.getSeverity());
|
||||||
|
assertNotSame(addressModel.getMessages().get(0), carried);
|
||||||
|
assertEquals(carried, personModel.getLatestMessage());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aNestedStreetNoticeReadsAsPersonAddressStreet() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAddress(new Address("Main"));
|
||||||
|
Household household = new Household();
|
||||||
|
household.setPerson(person);
|
||||||
|
URL fxml = CarryChildMessageTest.class.getResource("plain-view.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, household, context);
|
||||||
|
ViewModel householdModel = loaded.getViewModel();
|
||||||
|
ViewModel personModel = (ViewModel) householdModel.property("person").getValue();
|
||||||
|
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||||
|
addressModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
|
||||||
|
|
||||||
|
assertEquals("street", addressModel.getMessages().get(0).getSource());
|
||||||
|
assertEquals("address.street", personModel.getMessages().get(0).getSource());
|
||||||
|
assertEquals(1, householdModel.getMessages().size());
|
||||||
|
Message carried = householdModel.getMessages().get(0);
|
||||||
|
assertEquals("person.address.street", carried.getSource());
|
||||||
|
assertEquals("lane is closed", carried.getText());
|
||||||
|
assertEquals(Message.Severity.INFO, carried.getSeverity());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aBadStreetIsOwnedByTheAddressAndCarriedAsAddressStreet() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAddress(new Address("Main"));
|
||||||
|
URL fxml = CarryChildMessageTest.class.getResource("address-street-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||||
|
streetField.setText("nope");
|
||||||
|
|
||||||
|
assertEquals("Main", person.getAddress().getStreet());
|
||||||
|
assertEquals("Main", streetField.getText());
|
||||||
|
ViewModel personModel = loaded.getViewModel();
|
||||||
|
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||||
|
assertEquals(1, addressModel.getMessages().size());
|
||||||
|
Message owned = addressModel.getMessages().get(0);
|
||||||
|
assertEquals(Message.Severity.ERROR, owned.getSeverity());
|
||||||
|
assertEquals("street", owned.getSource());
|
||||||
|
assertEquals("not a street: nope", owned.getText());
|
||||||
|
assertEquals(1, personModel.getMessages().size());
|
||||||
|
Message carried = personModel.getMessages().get(0);
|
||||||
|
assertEquals("address.street", carried.getSource());
|
||||||
|
assertEquals("not a street: nope", carried.getText());
|
||||||
|
assertEquals(Message.Severity.ERROR, carried.getSeverity());
|
||||||
|
assertNotSame(owned, carried);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aFixedStreetDropsTheNoticeFromTheAddressAndThePerson() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAddress(new Address("Main"));
|
||||||
|
URL fxml = CarryChildMessageTest.class.getResource("address-street-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||||
|
streetField.setText("nope");
|
||||||
|
streetField.setText("ok Oak");
|
||||||
|
|
||||||
|
assertEquals("Oak", person.getAddress().getStreet());
|
||||||
|
ViewModel personModel = loaded.getViewModel();
|
||||||
|
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||||
|
assertEquals(0, addressModel.getMessages().size());
|
||||||
|
assertNull(addressModel.getLatestMessage());
|
||||||
|
assertEquals(0, personModel.getMessages().size());
|
||||||
|
assertNull(personModel.getLatestMessage());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anotherBadStreetUpdatesTheCarriedNoticeInPlace() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAddress(new Address("Main"));
|
||||||
|
Household household = new Household();
|
||||||
|
household.setPerson(person);
|
||||||
|
URL fxml = CarryChildMessageTest.class.getResource("household-street-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, household, context);
|
||||||
|
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||||
|
streetField.setText("nope");
|
||||||
|
|
||||||
|
ViewModel householdModel = loaded.getViewModel();
|
||||||
|
ViewModel personModel = (ViewModel) householdModel.property("person").getValue();
|
||||||
|
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||||
|
assertEquals(1, addressModel.getMessages().size());
|
||||||
|
assertEquals(1, personModel.getMessages().size());
|
||||||
|
assertEquals(1, householdModel.getMessages().size());
|
||||||
|
Message owned = addressModel.getMessages().get(0);
|
||||||
|
Message personCopy = personModel.getMessages().get(0);
|
||||||
|
Message householdCopy = householdModel.getMessages().get(0);
|
||||||
|
assertEquals("street", owned.getSource());
|
||||||
|
assertEquals("address.street", personCopy.getSource());
|
||||||
|
assertEquals("person.address.street", householdCopy.getSource());
|
||||||
|
|
||||||
|
streetField.setText("still-bad");
|
||||||
|
|
||||||
|
assertEquals("Main", person.getAddress().getStreet());
|
||||||
|
assertEquals(1, addressModel.getMessages().size());
|
||||||
|
assertEquals(1, personModel.getMessages().size());
|
||||||
|
assertEquals(1, householdModel.getMessages().size());
|
||||||
|
assertSame(owned, addressModel.getMessages().get(0));
|
||||||
|
assertSame(personCopy, personModel.getMessages().get(0));
|
||||||
|
assertSame(householdCopy, householdModel.getMessages().get(0));
|
||||||
|
assertEquals("not a street: still-bad", owned.getText());
|
||||||
|
assertEquals("not a street: still-bad", personCopy.getText());
|
||||||
|
assertEquals("not a street: still-bad", householdCopy.getText());
|
||||||
|
assertSame(householdCopy, householdModel.getLatestMessage());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearingTheAddressDropsTheCarriedNoticeAndANewAddressBringsItsOwn() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAddress(new Address("Main"));
|
||||||
|
URL fxml = CarryChildMessageTest.class.getResource("plain-view.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
ViewModel personModel = loaded.getViewModel();
|
||||||
|
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||||
|
addressModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
|
||||||
|
assertEquals("address.street", personModel.getMessages().get(0).getSource());
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Property<Object> address = (Property<Object>) personModel.property("address");
|
||||||
|
address.setValue(null);
|
||||||
|
|
||||||
|
assertNull(person.getAddress());
|
||||||
|
assertEquals(0, personModel.getMessages().size());
|
||||||
|
assertNull(personModel.getLatestMessage());
|
||||||
|
assertEquals(1, addressModel.getMessages().size());
|
||||||
|
assertEquals("lane is closed", addressModel.getMessages().get(0).getText());
|
||||||
|
|
||||||
|
Address moved = new Address("Oak");
|
||||||
|
LoadedView movedView = ViewLoader.load(fxml, moved, context);
|
||||||
|
movedView.getViewModel().addMessage(new Message(Message.Severity.WARN, "under repair", "street"));
|
||||||
|
address.setValue(movedView.getViewModel());
|
||||||
|
|
||||||
|
assertSame(moved, person.getAddress());
|
||||||
|
assertEquals(1, personModel.getMessages().size());
|
||||||
|
Message carried = personModel.getMessages().get(0);
|
||||||
|
assertEquals("address.street", carried.getSource());
|
||||||
|
assertEquals("under repair", carried.getText());
|
||||||
|
assertEquals(Message.Severity.WARN, carried.getSeverity());
|
||||||
|
assertSame(carried, personModel.getLatestMessage());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fixingTheStreetLeavesThePersonsOwnNotice() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAddress(new Address("Main"));
|
||||||
|
URL fxml = CarryChildMessageTest.class.getResource("address-street-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
ViewModel personModel = loaded.getViewModel();
|
||||||
|
personModel.addMessage(new Message(Message.Severity.INFO, "keep me", "form"));
|
||||||
|
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||||
|
streetField.setText("nope");
|
||||||
|
|
||||||
|
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||||
|
assertEquals(1, addressModel.getMessages().size());
|
||||||
|
assertEquals(2, personModel.getMessages().size());
|
||||||
|
Message carried = personModel.getMessages().get(1);
|
||||||
|
assertEquals("address.street", carried.getSource());
|
||||||
|
assertEquals("not a street: nope", carried.getText());
|
||||||
|
assertSame(carried, personModel.getLatestMessage());
|
||||||
|
|
||||||
|
streetField.setText("still-bad");
|
||||||
|
|
||||||
|
assertEquals(2, personModel.getMessages().size());
|
||||||
|
assertSame(carried, personModel.getMessages().get(1));
|
||||||
|
assertEquals("not a street: still-bad", carried.getText());
|
||||||
|
assertSame(carried, personModel.getLatestMessage());
|
||||||
|
assertEquals("keep me", personModel.getMessages().get(0).getText());
|
||||||
|
|
||||||
|
streetField.setText("ok Oak");
|
||||||
|
|
||||||
|
assertEquals("Oak", person.getAddress().getStreet());
|
||||||
|
assertEquals(0, addressModel.getMessages().size());
|
||||||
|
assertEquals(1, personModel.getMessages().size());
|
||||||
|
assertEquals("keep me", personModel.getLatestMessage().getText());
|
||||||
|
assertEquals("form", personModel.getLatestMessage().getSource());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aFixedNestedStreetDropsTheNoticeUpThePath() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAddress(new Address("Main"));
|
||||||
|
Household household = new Household();
|
||||||
|
household.setPerson(person);
|
||||||
|
URL fxml = CarryChildMessageTest.class.getResource("household-street-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, household, context);
|
||||||
|
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||||
|
streetField.setText("nope");
|
||||||
|
streetField.setText("ok Oak");
|
||||||
|
|
||||||
|
assertEquals("Oak", person.getAddress().getStreet());
|
||||||
|
ViewModel householdModel = loaded.getViewModel();
|
||||||
|
ViewModel personModel = (ViewModel) householdModel.property("person").getValue();
|
||||||
|
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||||
|
assertEquals(0, addressModel.getMessages().size());
|
||||||
|
assertEquals(0, personModel.getMessages().size());
|
||||||
|
assertEquals(0, householdModel.getMessages().size());
|
||||||
|
assertNull(addressModel.getLatestMessage());
|
||||||
|
assertNull(personModel.getLatestMessage());
|
||||||
|
assertNull(householdModel.getLatestMessage());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aSharedAddressCarriesTheStreetNoticeOnHomeAndOnWork() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Address shared = new Address("Main");
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setHome(shared);
|
||||||
|
person.setWork(shared);
|
||||||
|
URL fxml = CarryChildMessageTest.class.getResource("plain-view.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
ViewModel personModel = loaded.getViewModel();
|
||||||
|
ViewModel homeModel = (ViewModel) personModel.property("home").getValue();
|
||||||
|
homeModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
|
||||||
|
|
||||||
|
assertEquals(2, personModel.getMessages().size());
|
||||||
|
Set<String> sources = new HashSet<>();
|
||||||
|
for (Message message : personModel.getMessages()) {
|
||||||
|
sources.add(message.getSource());
|
||||||
|
assertEquals("lane is closed", message.getText());
|
||||||
|
}
|
||||||
|
assertEquals(Set.of("home.street", "work.street"), sources);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onFx(FxAction action) throws Exception {
|
||||||
|
if (Platform.isFxApplicationThread()) {
|
||||||
|
action.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||||
|
CountDownLatch done = new CountDownLatch(1);
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
thrown.set(failure);
|
||||||
|
} finally {
|
||||||
|
done.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||||
|
fail("JavaFX thread did not finish");
|
||||||
|
}
|
||||||
|
Throwable failure = thrown.get();
|
||||||
|
if (failure instanceof Exception exception) {
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
if (failure instanceof Error error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (failure != null) {
|
||||||
|
throw new AssertionError(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface FxAction {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.neitzel.fx.view.fixture;
|
||||||
|
|
||||||
|
import de.neitzel.fx.view.Converter;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a street written as {@code ok } plus the street, and rejects anything else.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
public class CheckedStreetConverter implements Converter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the street as it is stored.
|
||||||
|
*
|
||||||
|
* @param attributeValue the street
|
||||||
|
* @return the control text
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Object toControlValue(Object attributeValue) {
|
||||||
|
return attributeValue == null ? "" : attributeValue.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a street that starts with {@code ok }.
|
||||||
|
*
|
||||||
|
* @param controlValue the control text
|
||||||
|
* @return the street
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Object toAttributeValue(Object controlValue) {
|
||||||
|
String text = controlValue == null ? "" : controlValue.toString();
|
||||||
|
if (!text.startsWith("ok ")) {
|
||||||
|
throw new IllegalArgumentException("not a street: " + text);
|
||||||
|
}
|
||||||
|
return text.substring("ok ".length());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.neitzel.fx.view.fixture;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordinary model that holds a person, so a test can read a message two levels up.
|
||||||
|
*/
|
||||||
|
public class Household {
|
||||||
|
|
||||||
|
private Person person;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the person.
|
||||||
|
*
|
||||||
|
* @return the person, or {@code null} when the household has none
|
||||||
|
*/
|
||||||
|
public Person getPerson() {
|
||||||
|
return person;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the person.
|
||||||
|
*
|
||||||
|
* @param person the person
|
||||||
|
*/
|
||||||
|
public void setPerson(Person person) {
|
||||||
|
this.person = person;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.scene.control.TextField?>
|
||||||
|
<?import javafx.scene.layout.VBox?>
|
||||||
|
<?import de.neitzel.fx.view.BindingList?>
|
||||||
|
<?import de.neitzel.fx.view.DirectBinding?>
|
||||||
|
|
||||||
|
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||||
|
<fx:define>
|
||||||
|
<BindingList>
|
||||||
|
<DirectBinding control="streetField" property="text" path="address.street" direction="both"
|
||||||
|
converter="de.neitzel.fx.view.fixture.CheckedStreetConverter"/>
|
||||||
|
</BindingList>
|
||||||
|
</fx:define>
|
||||||
|
<TextField fx:id="streetField" id="streetField"/>
|
||||||
|
</VBox>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.scene.control.TextField?>
|
||||||
|
<?import javafx.scene.layout.VBox?>
|
||||||
|
<?import de.neitzel.fx.view.BindingList?>
|
||||||
|
<?import de.neitzel.fx.view.DirectBinding?>
|
||||||
|
|
||||||
|
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||||
|
<fx:define>
|
||||||
|
<BindingList>
|
||||||
|
<DirectBinding control="streetField" property="text" path="person.address.street" direction="both"
|
||||||
|
converter="de.neitzel.fx.view.fixture.CheckedStreetConverter"/>
|
||||||
|
</BindingList>
|
||||||
|
</fx:define>
|
||||||
|
<TextField fx:id="streetField" id="streetField"/>
|
||||||
|
</VBox>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.scene.layout.VBox?>
|
||||||
|
|
||||||
|
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"/>
|
||||||
Reference in New Issue
Block a user