Show a Message only when a view binds it or asks for a popup.
The library stays quiet until a Direct binding reads latestMessage or the developer turns on a dialog. An in-place binding failure must not pop again. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -43,10 +43,12 @@ public final class LoadedView {
|
|||||||
/**
|
/**
|
||||||
* Returns the JavaFX property for an Attribute name.
|
* Returns the JavaFX property for an Attribute name.
|
||||||
* <p>
|
* <p>
|
||||||
* 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
|
* @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) {
|
public Property<?> property(String name) {
|
||||||
return viewModel.property(name);
|
return viewModel.property(name);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* 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<Void> dialog = new Dialog<>();
|
||||||
|
dialog.setHeaderText(null);
|
||||||
|
dialog.getDialogPane().setContent(new Label(message.getText()));
|
||||||
|
dialog.getDialogPane().getButtonTypes().add(ButtonType.OK);
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
* 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. 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 fxml the FXML document
|
||||||
* @param model the Model instance
|
* @param model the Model instance
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import javafx.beans.property.SimpleIntegerProperty;
|
|||||||
import javafx.beans.property.SimpleLongProperty;
|
import javafx.beans.property.SimpleLongProperty;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
import javafx.beans.property.SimpleStringProperty;
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.beans.property.StringProperty;
|
||||||
import javafx.beans.value.ChangeListener;
|
import javafx.beans.value.ChangeListener;
|
||||||
import javafx.collections.FXCollections;
|
import javafx.collections.FXCollections;
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
@@ -40,6 +41,7 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The object a View binds to, built at runtime for one Model instance.
|
* The object a View binds to, built at runtime for one Model instance.
|
||||||
@@ -64,7 +66,10 @@ 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. 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 {
|
public class ViewModel {
|
||||||
|
|
||||||
@@ -75,6 +80,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 StringProperty latestMessageText = new SimpleStringProperty();
|
||||||
|
private final List<Consumer<Message>> messageAdded = new ArrayList<>();
|
||||||
private Message latestMessage;
|
private Message latestMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,11 +120,19 @@ public class ViewModel {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the JavaFX property for an Attribute name.
|
* Returns the JavaFX property for an Attribute name.
|
||||||
|
* <p>
|
||||||
|
* 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
|
* @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) {
|
public Property<?> property(String name) {
|
||||||
|
if ("latestMessage".equals(name)) {
|
||||||
|
return latestMessageText;
|
||||||
|
}
|
||||||
return properties.get(name);
|
return properties.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +325,22 @@ public class ViewModel {
|
|||||||
public void addMessage(Message message) {
|
public void addMessage(Message message) {
|
||||||
messages.add(message);
|
messages.add(message);
|
||||||
latestMessage = message;
|
latestMessage = message;
|
||||||
|
publishLatestMessage();
|
||||||
logSevere(message);
|
logSevere(message);
|
||||||
|
for (Consumer<Message> listener : List.copyOf(messageAdded)) {
|
||||||
|
listener.accept(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls {@code listener} each time a Message is added.
|
||||||
|
* <p>
|
||||||
|
* An in-place update of a binding failure does not call the listener.
|
||||||
|
*
|
||||||
|
* @param listener the listener
|
||||||
|
*/
|
||||||
|
void whenMessageAdded(Consumer<Message> listener) {
|
||||||
|
messageAdded.add(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -323,6 +353,9 @@ public class ViewModel {
|
|||||||
Message existing = bindingFailures.get(path);
|
Message existing = bindingFailures.get(path);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
existing.setText(text);
|
existing.setText(text);
|
||||||
|
if (existing == latestMessage) {
|
||||||
|
publishLatestMessage();
|
||||||
|
}
|
||||||
logSevere(existing);
|
logSevere(existing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -344,9 +377,17 @@ public class ViewModel {
|
|||||||
messages.remove(existing);
|
messages.remove(existing);
|
||||||
if (latestMessage == existing) {
|
if (latestMessage == existing) {
|
||||||
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
|
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.
|
* Writes an error or fatal notice to the log. Info and warn are left out.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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<Node> nodesBefore = new HashSet<>(root.lookupAll("*"));
|
||||||
|
Set<Window> 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<Window> 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<Window> 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<Window> snapshotWindows() {
|
||||||
|
return new HashSet<>(Window.getWindows());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Window windowShowing(String text, Set<Window> 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<Window> windowsOpenedSince(Set<Window> alreadyOpen) {
|
||||||
|
List<Window> opened = new ArrayList<>();
|
||||||
|
for (Window window : Window.getWindows()) {
|
||||||
|
if (!alreadyOpen.contains(window)) {
|
||||||
|
opened.add(window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opened;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void closeWindowsOpenedSince(Set<Window> alreadyOpen) {
|
||||||
|
List<Window> 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<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,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.scene.control.Label?>
|
||||||
|
<?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="messageLabel" property="text" path="latestMessage" direction="view"/>
|
||||||
|
</BindingList>
|
||||||
|
</fx:define>
|
||||||
|
<Label fx:id="messageLabel" id="messageLabel"/>
|
||||||
|
</VBox>
|
||||||
Reference in New Issue
Block a user