diff --git a/fx/src/main/java/de/neitzel/fx/view/LoadedView.java b/fx/src/main/java/de/neitzel/fx/view/LoadedView.java
index 25e394d..e5050ea 100644
--- a/fx/src/main/java/de/neitzel/fx/view/LoadedView.java
+++ b/fx/src/main/java/de/neitzel/fx/view/LoadedView.java
@@ -43,10 +43,12 @@ public final class LoadedView {
/**
* Returns the JavaFX property for an Attribute name.
*
- * The property's class follows the Attribute's declared type.
+ * The property's class follows the Attribute's declared type. The name {@code latestMessage}
+ * is a string property holding the text of the latest Message.
*
- * @param name the Attribute name
+ * @param name the Attribute name, or {@code latestMessage}
* @return the property, or {@code null} when that name is not a read-write Attribute
+ * and is not {@code latestMessage}
*/
public Property> property(String name) {
return viewModel.property(name);
diff --git a/fx/src/main/java/de/neitzel/fx/view/MessagePopup.java b/fx/src/main/java/de/neitzel/fx/view/MessagePopup.java
new file mode 100644
index 0000000..e42163d
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/MessagePopup.java
@@ -0,0 +1,43 @@
+package de.neitzel.fx.view;
+
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.Dialog;
+import javafx.scene.control.Label;
+
+/**
+ * Shows each newly added {@link Message} in a dialog when the developer asks.
+ *
+ * The library shows nothing until {@link #showNewMessages(ViewModel)} is called. An in-place
+ * update of an existing binding failure is not a new Message, so it does not open another dialog.
+ */
+public final class MessagePopup {
+
+ private MessagePopup() {
+ }
+
+ /**
+ * Shows each Message added to {@code viewModel} after this call in a dialog.
+ *
+ * The dialog is shown with {@link Dialog#show()}, so the caller is not blocked. Messages
+ * already on the ViewModel stay where they are. A later in-place update of a binding failure
+ * does not open another dialog.
+ *
+ * @param viewModel the ViewModel whose new Messages should be shown
+ */
+ public static void showNewMessages(ViewModel viewModel) {
+ viewModel.whenMessageAdded(MessagePopup::show);
+ }
+
+ /**
+ * Opens a dialog whose content is the Message text.
+ *
+ * @param message the notice to show
+ */
+ private static void show(Message message) {
+ Dialog dialog = new Dialog<>();
+ dialog.setHeaderText(null);
+ dialog.getDialogPane().setContent(new Label(message.getText()));
+ dialog.getDialogPane().getButtonTypes().add(ButtonType.OK);
+ dialog.show();
+ }
+}
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 7a1d168..e707765 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
@@ -34,6 +34,9 @@ public final class ViewLoader {
* 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
* keeps the previous value and records one error Message. It does not fail the load.
+ * 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.
*
* @param fxml the FXML document
* @param model the Model instance
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 fbcbd94..01e6c9c 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
@@ -13,6 +13,7 @@ import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleLongProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -40,6 +41,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.function.Consumer;
/**
* The object a View binds to, built at runtime for one Model instance.
@@ -64,7 +66,10 @@ import java.util.Set;
*
* 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.
- * Info and warn are not.
+ * Info and warn are not. The name {@code latestMessage} is a string property holding the text of
+ * the latest Message, so a Direct binding can show that text on a control. The library does not
+ * open a popup or fill a status bar unless the developer asks. {@link MessagePopup#showNewMessages}
+ * shows each newly added Message in a dialog. An in-place update does not.
*/
public class ViewModel {
@@ -75,6 +80,8 @@ public class ViewModel {
private final Set complexAttributes = new HashSet<>();
private final List messages = new ArrayList<>();
private final Map bindingFailures = new HashMap<>();
+ private final StringProperty latestMessageText = new SimpleStringProperty();
+ private final List> messageAdded = new ArrayList<>();
private Message latestMessage;
/**
@@ -113,11 +120,19 @@ public class ViewModel {
/**
* Returns the JavaFX property for an Attribute name.
+ *
+ * The name {@code latestMessage} is the text of the latest {@link Message}, beside the
+ * Model attributes. It is a string property. A Direct binding can read it. An in-place
+ * update of that Message refreshes the text and does not replace the Message.
*
- * @param name the Attribute name
+ * @param name the Attribute name, or {@code latestMessage}
* @return the property, or {@code null} when that name is not a read-write Attribute
+ * and is not {@code latestMessage}
*/
public Property> property(String name) {
+ if ("latestMessage".equals(name)) {
+ return latestMessageText;
+ }
return properties.get(name);
}
@@ -310,7 +325,22 @@ public class ViewModel {
public void addMessage(Message message) {
messages.add(message);
latestMessage = message;
+ publishLatestMessage();
logSevere(message);
+ for (Consumer listener : List.copyOf(messageAdded)) {
+ listener.accept(message);
+ }
+ }
+
+ /**
+ * Calls {@code listener} each time a Message is added.
+ *
+ * An in-place update of a binding failure does not call the listener.
+ *
+ * @param listener the listener
+ */
+ void whenMessageAdded(Consumer listener) {
+ messageAdded.add(listener);
}
/**
@@ -323,6 +353,9 @@ public class ViewModel {
Message existing = bindingFailures.get(path);
if (existing != null) {
existing.setText(text);
+ if (existing == latestMessage) {
+ publishLatestMessage();
+ }
logSevere(existing);
return;
}
@@ -344,9 +377,17 @@ public class ViewModel {
messages.remove(existing);
if (latestMessage == existing) {
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
+ publishLatestMessage();
}
}
+ /**
+ * Makes the string property for {@code latestMessage} match {@link #getLatestMessage()}.
+ */
+ private void publishLatestMessage() {
+ latestMessageText.set(latestMessage == null ? null : latestMessage.getText());
+ }
+
/**
* Writes an error or fatal notice to the log. Info and warn are left out.
*
diff --git a/fx/src/test/java/de/neitzel/fx/view/ShowMessagesWhenAskedTest.java b/fx/src/test/java/de/neitzel/fx/view/ShowMessagesWhenAskedTest.java
new file mode 100644
index 0000000..1b46744
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/ShowMessagesWhenAskedTest.java
@@ -0,0 +1,240 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.fixture.Person;
+import de.neitzel.injection.ApplicationContext;
+import javafx.application.Platform;
+import javafx.scene.Node;
+import javafx.scene.Parent;
+import javafx.scene.control.DialogPane;
+import javafx.scene.control.Label;
+import javafx.scene.control.Labeled;
+import javafx.scene.control.TextField;
+import javafx.scene.control.TextInputControl;
+import javafx.stage.Window;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+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.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.fail;
+
+class ShowMessagesWhenAskedTest {
+
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ Platform.setImplicitExit(false);
+ 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 addingAMessageDoesNotOpenAPopupOrFillAStatusBar() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ShowMessagesWhenAskedTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ Parent root = loaded.getRoot();
+ Set nodesBefore = new HashSet<>(root.lookupAll("*"));
+ Set windowsBefore = snapshotWindows();
+ loaded.getViewModel().addMessage(
+ new Message(Message.Severity.INFO, "saved the person", "form"));
+ assertEquals(windowsBefore, snapshotWindows());
+ assertEquals(nodesBefore, new HashSet<>(root.lookupAll("*")));
+ assertMessageIsNotShown(root, "saved the person");
+ });
+ }
+
+ @Test
+ void directBindingShowsTheLatestMessageText() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ShowMessagesWhenAskedTest.class.getResource("latest-message.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ Label messageLabel = (Label) loaded.getRoot().lookup("#messageLabel");
+ loaded.getViewModel().addMessage(
+ new Message(Message.Severity.INFO, "saved the person", "form"));
+ assertEquals("saved the person", messageLabel.getText());
+ loaded.getViewModel().addMessage(
+ new Message(Message.Severity.WARN, "name is required", "name"));
+ assertEquals("name is required", messageLabel.getText());
+ });
+ }
+
+ @Test
+ void optedInHelperShowsANewMessageInAPopup() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = ShowMessagesWhenAskedTest.class.getResource("name-both-ways.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ Set windowsBefore = snapshotWindows();
+ MessagePopup.showNewMessages(loaded.getViewModel());
+ assertEquals(windowsBefore, snapshotWindows());
+ try {
+ loaded.getViewModel().addMessage(
+ new Message(Message.Severity.INFO, "saved the person", "form"));
+ assertNotNull(windowShowing("saved the person", windowsBefore));
+ assertMessageIsNotShown(loaded.getRoot(), "saved the person");
+ } finally {
+ closeWindowsOpenedSince(windowsBefore);
+ }
+ });
+ }
+
+ @Test
+ void updatingABindingFailureDoesNotOpenAnotherPopup() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAge(42);
+ URL fxml = ShowMessagesWhenAskedTest.class.getResource("age-both-converted.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
+ Set windowsBefore = snapshotWindows();
+ MessagePopup.showNewMessages(loaded.getViewModel());
+ try {
+ ageField.setText("nope");
+ ViewModel viewModel = loaded.getViewModel();
+ Message failure = viewModel.getLatestMessage();
+ assertEquals("not a whole number: nope", failure.getText());
+ assertEquals(1, windowsOpenedSince(windowsBefore).size());
+ assertNotNull(windowShowing("not a whole number: nope", windowsBefore));
+
+ ageField.setText("still-bad");
+ assertEquals(1, viewModel.getMessages().size());
+ assertSame(failure, viewModel.getLatestMessage());
+ assertEquals("not a whole number: still-bad", failure.getText());
+ assertEquals(1, windowsOpenedSince(windowsBefore).size());
+
+ viewModel.addMessage(new Message(Message.Severity.INFO, "saved the person", "form"));
+ assertEquals(2, windowsOpenedSince(windowsBefore).size());
+ assertNotNull(windowShowing("saved the person", windowsBefore));
+ assertEquals("saved the person", viewModel.getLatestMessage().getText());
+ } finally {
+ closeWindowsOpenedSince(windowsBefore);
+ }
+ });
+ }
+
+ private static Set snapshotWindows() {
+ return new HashSet<>(Window.getWindows());
+ }
+
+ private static Window windowShowing(String text, Set alreadyOpen) {
+ for (Window window : Window.getWindows()) {
+ if (!alreadyOpen.contains(window) && windowShows(window, text)) {
+ return window;
+ }
+ }
+ return null;
+ }
+
+ private static boolean windowShows(Window window, String text) {
+ if (window.getScene() == null || !(window.getScene().getRoot() instanceof Parent root)) {
+ return false;
+ }
+ if (root instanceof DialogPane pane && nodeShows(pane.getContent(), text)) {
+ return true;
+ }
+ for (Node node : root.lookupAll("*")) {
+ if (nodeShows(node, text)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean nodeShows(Node node, String text) {
+ return node instanceof Labeled labeled && text.equals(labeled.getText());
+ }
+
+ private static List windowsOpenedSince(Set alreadyOpen) {
+ List opened = new ArrayList<>();
+ for (Window window : Window.getWindows()) {
+ if (!alreadyOpen.contains(window)) {
+ opened.add(window);
+ }
+ }
+ return opened;
+ }
+
+ private static void closeWindowsOpenedSince(Set alreadyOpen) {
+ List open = new ArrayList<>(Window.getWindows());
+ for (Window window : open) {
+ if (!alreadyOpen.contains(window)) {
+ window.hide();
+ }
+ }
+ }
+
+ private static void assertMessageIsNotShown(Parent root, String text) {
+ for (Node node : root.lookupAll("*")) {
+ if (node instanceof Labeled labeled) {
+ assertNotEquals(text, labeled.getText());
+ }
+ if (node instanceof TextInputControl input) {
+ assertNotEquals(text, input.getText());
+ }
+ }
+ }
+
+ 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/resources/de/neitzel/fx/view/latest-message.fxml b/fx/src/test/resources/de/neitzel/fx/view/latest-message.fxml
new file mode 100644
index 0000000..a0013bc
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/latest-message.fxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+