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 @@ + + + + + + + + + + + + + +