Complete JavaFX MVVM examples, bindings and library documentation

This commit is contained in:
2026-11-07 18:36:53 +01:00
parent c76f5b2a28
commit 8062b61ed6
110 changed files with 3808 additions and 220 deletions
+5 -3
View File
@@ -1,11 +1,13 @@
# fx-example
A person desk that uses the [`fx`](../fx/README.md) library: a Direct binding, a Converter, a Computed binding, a nested Component, a Repeat, a list of simple values, and Messages.
A person desk that uses [`fx`](../fx/README.md) and [`injection`](../injection/README.md). The guides use this module as the worked example.
The desk shows a direct binding in each direction, a converter, a computed binding, a view model subclass, a component with a nested note, a repeat, a table with a starting width and an open column, a date column, a list of simple values, and messages. The container side shows `@Config`, `@Singleton`, `@Named`, and injection by constructor, field, and setter.
From the NeitzelLib root, with Java 21:
```bash
mvn -pl fx-example javafx:run
mvn -pl fx-example -am javafx:run
```
The launch class is `de.neitzel.fx.example.Main`. The Model is an ordinary `Person`. The View is `Desk.fxml`.
The launch class is `de.neitzel.fx.example.Main`. The model is an ordinary `Person`. The view is `Desk.fxml`. `DeskLoadsTest` loads that same desk.
+35
View File
@@ -52,6 +52,41 @@
<mainClass>${main.class}</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<classpathPrefix>libs/</classpathPrefix>
<mainClass>${main.class}</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>${maven.dependency.plugin}</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/libs</outputDirectory>
<includeScope>runtime</includeScope>
<useBaseVersion>true</useBaseVersion>
<overWriteReleases>false</overWriteReleases>
<overWriteSnapshots>true</overWriteSnapshots>
<overWriteIfNewer>true</overWriteIfNewer>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
@@ -1,22 +1,31 @@
package de.neitzel.fx.example;
import de.neitzel.fx.example.model.Address;
import de.neitzel.fx.example.model.Note;
import de.neitzel.fx.example.model.Person;
import de.neitzel.fx.example.model.Role;
import de.neitzel.fx.example.view.DeskController;
import de.neitzel.fx.view.LoadedView;
import de.neitzel.fx.view.MessagePopup;
import de.neitzel.fx.view.ViewLoader;
import de.neitzel.injection.ApplicationContext;
import de.neitzel.injection.annotation.Config;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Opens the person desk for one ordinary {@link Person}.
* <p>
* {@link Config} names the package the container scans. The same package would be used without
* the annotation, because this class lives there. The attribute is set so the annotation is what
* chooses the package.
*/
@Config(basePackage = "de.neitzel.fx.example")
public class DeskApp extends Application {
/**
@@ -28,16 +37,24 @@ public class DeskApp extends Application {
/**
* Builds the person the desk edits.
*
* @return a person with a home address, one visit, and two tags
* @return a person with a home address, three visits, and two tags
*/
public static Person sample() {
Address home = new Address("Sample Street 1", "Sample City");
home.setNote(new Note("Kept at home"));
Address visit = new Address("Work Road 2", "Office City");
visit.setVisitedOn(LocalDate.of(2020, 1, 2));
Address berlin = new Address("Sample Lane", "Berlin");
berlin.setVisitedOn(LocalDate.of(2020, 3, 4));
Address bonn = new Address("Oak Street", "Bonn");
bonn.setVisitedOn(LocalDate.of(2019, 12, 1));
Person person = new Person();
person.setName("Ada Lovelace");
person.setAge(36);
person.setActive(true);
person.setRole(Role.AUTHOR);
person.setHome(home);
person.setVisits(new ArrayList<>(List.of(visit)));
person.setVisits(new ArrayList<>(List.of(visit, berlin, bonn)));
person.setSelectedVisit(visit);
person.setTags(new ArrayList<>(List.of("dns", "math")));
return person;
@@ -9,6 +9,9 @@ import javafx.application.Application;
*/
public final class Main {
/**
* Prevents instantiation. The class is only a launch entry point.
*/
private Main() {
}
@@ -1,13 +1,32 @@
package de.neitzel.fx.example.model;
import java.time.LocalDate;
/**
* An ordinary address the desk edits.
*/
public class Address {
/**
* The street.
*/
private String street = "";
/**
* The city.
*/
private String city = "";
/**
* The day of the visit, or {@code null} when the address is not a dated visit.
*/
private LocalDate visitedOn;
/**
* The note attached to this address, or {@code null} when there is none.
*/
private Note note;
/**
* Creates an empty address.
*/
@@ -61,6 +80,42 @@ public class Address {
this.city = city;
}
/**
* Returns the day of the visit.
*
* @return the date, or {@code null} when none is set
*/
public LocalDate getVisitedOn() {
return visitedOn;
}
/**
* Sets the day of the visit.
*
* @param visitedOn the date, or {@code null} to clear it
*/
public void setVisitedOn(LocalDate visitedOn) {
this.visitedOn = visitedOn;
}
/**
* Returns the note attached to this address.
*
* @return the note, or {@code null} when none is set
*/
public Note getNote() {
return note;
}
/**
* Sets the note attached to this address.
*
* @param note the note, or {@code null} when there is none
*/
public void setNote(Note note) {
this.note = note;
}
/**
* Returns the street and city, for a combo box row.
*
@@ -0,0 +1,48 @@
package de.neitzel.fx.example.model;
/**
* A short note attached to an address.
* <p>
* The class is a complex attribute of {@link Address}: it has a read-write property of its own.
* The home address form nests a component that edits this note.
*/
public class Note {
/**
* The note text.
*/
private String text = "";
/**
* Creates an empty note.
*/
public Note() {
}
/**
* Creates a note with the given text.
*
* @param text the note text
*/
public Note(String text) {
this.text = text;
}
/**
* Returns the note text.
*
* @return the text
*/
public String getText() {
return text;
}
/**
* Sets the note text.
*
* @param text the text
*/
public void setText(String text) {
this.text = text;
}
}
@@ -10,13 +10,46 @@ import java.util.List;
*/
public class Person {
/**
* The person's name.
*/
private String name = "";
/**
* The person's age in years, or {@code null} when it has not been set.
*/
private Integer age;
/**
* The home address.
*/
private Address home;
/**
* The visit addresses. After the View loads, this is the list the visit table and Repeat watch.
*/
private List<Address> visits = new ArrayList<>();
/**
* The visit selected in the table and the combo box, or {@code null} when none is selected.
*/
private Address selectedVisit;
/**
* The tags. After the View loads, this is the list the list view watches.
*/
private List<String> tags = new ArrayList<>();
/**
* Whether the person is active. A primitive boolean cannot be null.
*/
private boolean active;
/**
* The person's role, or {@code null} when none is set.
*/
private Role role;
/**
* Creates an empty person.
*/
@@ -98,7 +131,7 @@ public class Person {
}
/**
* Returns the visit chosen in the combo box.
* Returns the visit chosen in the table and the combo box.
*
* @return the selected visit, or {@code null} when none is selected
*/
@@ -107,7 +140,7 @@ public class Person {
}
/**
* Sets the visit chosen in the combo box.
* Sets the visit chosen in the table and the combo box.
*
* @param selectedVisit the selected visit
*/
@@ -134,4 +167,40 @@ public class Person {
public void setTags(List<String> tags) {
this.tags = tags;
}
/**
* Returns whether the person is active.
*
* @return {@code true} when the person is active
*/
public boolean isActive() {
return active;
}
/**
* Sets whether the person is active.
*
* @param active {@code true} when the person is active
*/
public void setActive(boolean active) {
this.active = active;
}
/**
* Returns the role.
*
* @return the role, or {@code null} when none is set
*/
public Role getRole() {
return role;
}
/**
* Sets the role.
*
* @param role the role
*/
public void setRole(Role role) {
this.role = role;
}
}
@@ -0,0 +1,19 @@
package de.neitzel.fx.example.model;
/**
* The role a person has on the desk.
* <p>
* An enum is a simple attribute. The desk binds it to a choice box.
*/
public enum Role {
/**
* A person who reads.
*/
READER,
/**
* A person who writes.
*/
AUTHOR
}
@@ -1,21 +1,47 @@
package de.neitzel.fx.example.view;
import de.neitzel.fx.view.HasViewModel;
import de.neitzel.fx.view.ViewModel;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
/**
* Component that edits one address.
* Component that edits the home address, including its nested note.
* <p>
* The class is the root and the Controller of {@code AddressForm.fxml}. A parent View sets
* {@link #modelProperty()} from a Direct binding. A repeated row gets its own instance.
* The class is the root and the controller of {@code AddressForm.fxml}. The desk sets
* {@link #modelProperty()} once. {@link #setHeading} is setter injection. {@link #setViewModel}
* runs after that model is set and before this form's own bindings.
*/
@Singleton
public class AddressForm extends VBox {
public class AddressForm extends VBox implements HasViewModel {
/**
* The address this form edits. The desk sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* The heading shown above the fields. The container sets it through {@link #setHeading}.
*/
private FormHeading heading;
/**
* The heading label. {@link #initialize()} writes the injected heading onto it.
*/
@FXML
private Label headingLabel;
/**
* The label filled from the address before the field bindings run.
*/
@FXML
private Label openedAsLabel;
/**
* Creates an empty form. The container calls this constructor.
*/
@@ -23,7 +49,37 @@ public class AddressForm extends VBox {
}
/**
* Returns the model property a parent View sets.
* Receives the heading. The container calls this setter after construction.
*
* @param heading the heading
*/
@Inject
public void setHeading(FormHeading heading) {
this.heading = heading;
}
/**
* Writes the injected heading onto the label.
* <p>
* FXML calls this during load, before the model is set.
*/
@FXML
public void initialize() {
headingLabel.setText(heading.text());
}
/**
* Records the city as it stands when the model arrives, before the field bindings run.
*
* @param viewModel the address view model
*/
@Override
public void setViewModel(ViewModel viewModel) {
openedAsLabel.setText("Opened as " + viewModel.property("city").getValue());
}
/**
* Returns the model property the desk sets.
*
* @return the model property
*/
@@ -10,6 +10,9 @@ import jakarta.inject.Singleton;
@Singleton
public class AgeConverter implements Converter {
/**
* The text written in front of the age.
*/
private final AgeSuffix suffix;
/**
@@ -1,11 +1,14 @@
package de.neitzel.fx.example.view;
import jakarta.inject.Singleton;
import jakarta.inject.Named;
/**
* The suffix an {@link AgeConverter} writes in front of an age.
* <p>
* {@link Named} marks the class for scanning the same way {@link jakarta.inject.Singleton} does.
* The container does not read the annotation value, and the instance is still cached.
*/
@Singleton
@Named
public class AgeSuffix {
/**
@@ -4,28 +4,40 @@ import de.neitzel.fx.example.model.Address;
import de.neitzel.fx.view.HasViewModel;
import de.neitzel.fx.view.Message;
import de.neitzel.fx.view.ViewModel;
import de.neitzel.fx.view.ViewModelClass;
import jakarta.inject.Singleton;
import javafx.fxml.FXML;
import javafx.scene.control.TextField;
import java.time.LocalDate;
import java.util.List;
/**
* Controller for the person desk.
* <p>
* The container creates this class and injects nothing else. {@link #setViewModel} receives the
* ViewModel before the binding elements run, so {@link #greeting()} can read the name field on
* its first call.
* The container creates this class. {@link #setViewModel} receives the view model before the
* binding elements run, so {@link #greeting()} can read the name field and the age on its first
* call. {@link PersonViewModel} is the view model class for this controller.
*/
@Singleton
@ViewModelClass(PersonViewModel.class)
public class DeskController implements HasViewModel {
/**
* The name field. {@link #greeting()} reads it.
*/
@FXML
private TextField nameField;
/**
* The tag field cleared after {@link #addTag()}.
*/
@FXML
private TextField tagField;
/**
* The person view model, set before the bindings run.
*/
private ViewModel viewModel;
/**
@@ -35,9 +47,9 @@ public class DeskController implements HasViewModel {
}
/**
* Receives the ViewModel for the person on the desk.
* Receives the view model for the person on the desk.
*
* @param viewModel the ViewModel
* @param viewModel the view model
*/
@Override
public void setViewModel(ViewModel viewModel) {
@@ -47,16 +59,18 @@ public class DeskController implements HasViewModel {
/**
* Returns the greeting written to the label.
* <p>
* The method reads the name field. It does not write the Model.
* The method reads the name field and the age on the view model. It does not write the model.
* A change of either one calls it again.
*
* @return the greeting
*/
public String greeting() {
String name = nameField.getText();
Object age = viewModel.property("age").getValue();
if (name == null || name.isBlank()) {
return "Hello";
return "Hello (" + age + ")";
}
return "Hello, " + name.trim();
return "Hello, " + name.trim() + " (" + age + ")";
}
/**
@@ -64,7 +78,21 @@ public class DeskController implements HasViewModel {
*/
@FXML
public void addVisit() {
visits().add(new Address("New street", "New city"));
Address created = new Address("New street", "New city");
created.setVisitedOn(LocalDate.now());
visits().add(created);
}
/**
* Removes a visit from the list the table and Repeat are watching.
*
* @param visit the visit
*/
public void removeVisit(Address visit) {
if (visit == null) {
return;
}
visits().remove(visit);
}
/**
@@ -81,22 +109,63 @@ public class DeskController implements HasViewModel {
}
/**
* Adds an info Message that quotes the name already stored on the Model.
* Adds an info message that quotes the name already stored on the model.
*/
@FXML
public void leaveNote() {
Object name = viewModel.property("name").getValue();
viewModel.addMessage(new Message(
Message.Severity.INFO,
"The model already has the name " + name + ".",
"desk"));
leave(Message.Severity.INFO, "The model already has the name " + name() + ".");
}
/**
* Adds a warning. The notice label shows it, and it is not written to the log.
*/
@FXML
public void leaveWarning() {
leave(Message.Severity.WARN, "Check the age before leaving the desk.");
}
/**
* Adds a fatal notice. The notice label shows it, and it is written to the log.
*/
@FXML
public void leaveFatal() {
leave(Message.Severity.FATAL, "The desk cannot store this person.");
}
/**
* Adds a notice whose source is this desk.
*
* @param severity how serious the notice is
* @param text the notice text
*/
private void leave(Message.Severity severity, String text) {
viewModel.addMessage(new Message(severity, text, "desk"));
}
/**
* Returns the name currently held by the view model.
*
* @return the name
*/
private Object name() {
return viewModel.property("name").getValue();
}
/**
* Returns the visit list the view is watching.
*
* @return the visits
*/
@SuppressWarnings("unchecked")
private List<Address> visits() {
return (List<Address>) viewModel.property("visits").getValue();
}
/**
* Returns the tag list the view is watching.
*
* @return the tags
*/
@SuppressWarnings("unchecked")
private List<String> tags() {
return (List<String>) viewModel.property("tags").getValue();
@@ -0,0 +1,25 @@
package de.neitzel.fx.example.view;
import jakarta.inject.Singleton;
/**
* The heading an address form shows above its fields.
*/
@Singleton
public class FormHeading {
/**
* Creates the heading used by the desk.
*/
public FormHeading() {
}
/**
* Returns the heading text.
*
* @return the heading
*/
public String text() {
return "Address";
}
}
@@ -0,0 +1,54 @@
package de.neitzel.fx.example.view;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.VBox;
/**
* Component that edits one {@link de.neitzel.fx.example.model.Note}.
* <p>
* The class is the root and the controller of {@code NoteForm.fxml}. The home address form
* nests this component and sets {@link #modelProperty()} once.
*/
@Singleton
public class NoteForm extends VBox {
/**
* The note this form edits. The parent component sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Creates an empty form. The container calls this constructor.
*/
public NoteForm() {
}
/**
* Returns the model property the parent sets.
*
* @return the model property
*/
public ObjectProperty<Object> modelProperty() {
return model;
}
/**
* Returns the note this form edits.
*
* @return the note, or {@code null} when none is set
*/
public Object getModel() {
return model.get();
}
/**
* Sets the note this form edits.
*
* @param model the note
*/
public void setModel(Object model) {
this.model.set(model);
}
}
@@ -0,0 +1,40 @@
package de.neitzel.fx.example.view;
import de.neitzel.fx.example.model.Person;
import de.neitzel.fx.view.ViewModel;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
/**
* ViewModel for a {@link Person} that adds a title beside the person's attributes.
* <p>
* The library constructs this class. The container does not. {@link DeskController} names it
* with {@link de.neitzel.fx.view.ViewModelClass}.
*/
public class PersonViewModel extends ViewModel {
/**
* The title this view model adds. A binding addresses it as {@code title}.
*/
private final StringProperty title = new SimpleStringProperty(this, "title");
/**
* Builds the view model and keeps the title aligned with the person's name.
*
* @param model the person
*/
public PersonViewModel(Object model) {
super(model);
title.set("titled-" + ((Person) model).getName());
property("name").addListener((observable, oldValue, newValue) -> title.set("titled-" + newValue));
}
/**
* Returns the title property a binding element addresses by name.
*
* @return the title property
*/
public StringProperty titleProperty() {
return title;
}
}
@@ -0,0 +1,56 @@
package de.neitzel.fx.example.view;
import de.neitzel.fx.view.Converter;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
/**
* Keeps a street that is not blank, and reports an empty street through the injected policy.
* <p>
* The container fills {@link #policy} by field injection after the no-arg constructor runs.
*/
@Singleton
public class StreetConverter implements Converter {
/**
* The notice text used when the street is empty. The container sets this field.
*/
@Inject
private StreetPolicy policy;
/**
* Creates a converter. The container calls this constructor, then injects {@link #policy}.
*/
public StreetConverter() {
}
/**
* Returns the street unchanged.
*
* @param attributeValue the street
* @return the text shown in the field
*/
@Override
public Object toControlValue(Object attributeValue) {
if (attributeValue == null) {
return "";
}
return attributeValue.toString();
}
/**
* Returns the street with surrounding whitespace removed.
*
* @param controlValue the field text
* @return the street written to the address
* @throws IllegalArgumentException when the text is blank
*/
@Override
public Object toAttributeValue(Object controlValue) {
String text = controlValue == null ? "" : controlValue.toString().trim();
if (text.isEmpty()) {
throw new IllegalArgumentException(policy.message());
}
return text;
}
}
@@ -0,0 +1,25 @@
package de.neitzel.fx.example.view;
import jakarta.inject.Singleton;
/**
* The rule a {@link StreetConverter} applies when a street is empty.
*/
@Singleton
public class StreetPolicy {
/**
* Creates the policy used by the desk.
*/
public StreetPolicy() {
}
/**
* Returns the notice text for an empty street.
*
* @return the notice text
*/
public String message() {
return "Street is required";
}
}
@@ -0,0 +1,56 @@
package de.neitzel.fx.example.view;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.VBox;
/**
* Component that edits one visit address in the repeated list.
* <p>
* The class is the root and the controller of {@code VisitForm.fxml}. Repeat creates a new
* instance for each visit. The home address uses {@link AddressForm}, which nests a note.
* This form does not, so a visit added after the desk has loaded does not reuse the note
* component already showing on the home address.
*/
@Singleton
public class VisitForm extends VBox {
/**
* The visit this form edits. Repeat sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Creates an empty form. The container calls this constructor.
*/
public VisitForm() {
}
/**
* Returns the model property Repeat sets.
*
* @return the model property
*/
public ObjectProperty<Object> modelProperty() {
return model;
}
/**
* Returns the visit this form edits.
*
* @return the visit, or {@code null} when none is set
*/
public Object getModel() {
return model.get();
}
/**
* Sets the visit this form edits.
*
* @param model the visit
*/
public void setModel(Object model) {
this.model.set(model);
}
}
@@ -0,0 +1,77 @@
package de.neitzel.fx.example.view;
import de.neitzel.fx.example.model.Address;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.fxml.FXML;
import javafx.scene.layout.HBox;
/**
* Row that edits one visit in the visit table.
* <p>
* The class is the root and the Controller of {@code VisitRow.fxml}. The table sets
* {@link #modelProperty()} once, and this row binds its own fields. Those fields are in column
* order: street, city, then the visit date. Remove sits after them and is not a column.
*/
@Singleton
public class VisitRow extends HBox {
/**
* The desk controller that removes a visit from the person.
*/
private final DeskController desk;
/**
* The visit this row edits. The table sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Creates a row that can remove its visit.
*
* @param desk the desk controller
*/
@Inject
public VisitRow(DeskController desk) {
this.desk = desk;
}
/**
* Returns the model property the table sets.
*
* @return the model property
*/
public ObjectProperty<Object> modelProperty() {
return model;
}
/**
* Returns the visit this row edits.
*
* @return the visit, or {@code null} when none is set
*/
public Object getModel() {
return model.get();
}
/**
* Sets the visit this row edits.
*
* @param model the visit
*/
public void setModel(Object model) {
this.model.set(model);
}
/**
* Removes this visit from the person.
*/
@FXML
public void remove() {
if (getModel() instanceof Address address) {
desk.removeVisit(address);
}
}
}
@@ -0,0 +1,54 @@
package de.neitzel.fx.example.view;
import de.neitzel.fx.view.Converter;
import jakarta.inject.Singleton;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
/**
* Translates a visit date between a {@link LocalDate} and the ISO text in the table row.
*/
@Singleton
public class VisitedOnConverter implements Converter {
/**
* Creates the converter. The container calls this constructor.
*/
public VisitedOnConverter() {
}
/**
* Writes the date as an ISO local date, or an empty string when the date is missing.
*
* @param attributeValue the visit date
* @return the text shown in the field
*/
@Override
public Object toControlValue(Object attributeValue) {
if (attributeValue == null) {
return "";
}
return attributeValue.toString();
}
/**
* Reads an ISO local date. An empty field clears the date.
*
* @param controlValue the field text
* @return the date, or {@code null} when the field is empty
* @throws IllegalArgumentException when the text is not an ISO local date
*/
@Override
public Object toAttributeValue(Object controlValue) {
String text = controlValue == null ? "" : controlValue.toString().trim();
if (text.isEmpty()) {
return null;
}
try {
return LocalDate.parse(text);
} catch (DateTimeParseException failure) {
throw new IllegalArgumentException("Visited on must be a date: " + text);
}
}
}
@@ -4,6 +4,7 @@
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.example.view.NoteForm?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
@@ -14,12 +15,17 @@
</padding>
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"/>
<DirectBinding control="streetField" property="text" path="street" direction="both"
converter="de.neitzel.fx.example.view.StreetConverter"/>
<DirectBinding control="cityField" property="text" path="city" direction="both"/>
<DirectBinding control="noteForm" property="model" path="note" direction="both"/>
</BindingList>
</fx:define>
<Label fx:id="headingLabel" id="headingLabel"/>
<Label fx:id="openedAsLabel" id="openedAsLabel"/>
<Label text="Street"/>
<TextField fx:id="streetField" id="streetField"/>
<Label text="City"/>
<TextField fx:id="cityField" id="cityField"/>
<NoteForm fx:id="noteForm"/>
</fx:root>
@@ -1,7 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.collections.FXCollections?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.CheckBox?>
<?import javafx.scene.control.ChoiceBox?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ListView?>
@@ -10,11 +13,14 @@
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.example.model.Role?>
<?import de.neitzel.fx.example.view.AddressForm?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.ComputedBinding?>
<?import de.neitzel.fx.view.DirectBinding?>
<?import de.neitzel.fx.view.Repeat?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="de.neitzel.fx.example.view.DeskController"
@@ -25,14 +31,20 @@
<fx:define>
<BindingList>
<DirectBinding control="nameField" property="text" path="name" direction="both"/>
<DirectBinding control="nameEcho" property="text" path="name" direction="view"/>
<DirectBinding control="nameDraft" property="text" path="name" direction="model"/>
<DirectBinding control="ageField" property="text" path="age" direction="both"
converter="de.neitzel.fx.example.view.AgeConverter"/>
<ComputedBinding control="greetingLabel" property="text" method="#greeting"/>
<DirectBinding control="titleLabel" property="text" path="title" direction="view"/>
<DirectBinding control="activeBox" property="selected" path="active" direction="both"/>
<DirectBinding control="roleChoice" property="value" path="role" direction="both"/>
<DirectBinding control="homeForm" property="model" path="home" direction="both"/>
<DirectBinding control="homeStreetLabel" property="text" path="home.street" direction="view"/>
<Repeat pane="visitPane" component="de.neitzel.fx.example.view.AddressForm" collection="visits"/>
<Repeat pane="visitPane" component="de.neitzel.fx.example.view.VisitForm" collection="visits"/>
<DirectBinding control="visitChoice" property="items" path="visits" direction="view"/>
<DirectBinding control="visitChoice" property="value" path="selectedVisit" direction="both"/>
<DirectBinding control="visitTable" property="selection" path="selectedVisit" direction="both"/>
<DirectBinding control="tagList" property="items" path="tags" direction="view"/>
<DirectBinding control="noticeLabel" property="text" path="latestMessage" direction="view"/>
</BindingList>
@@ -46,14 +58,30 @@
</columnConstraints>
<Label text="Name" GridPane.rowIndex="0" GridPane.columnIndex="0"/>
<TextField fx:id="nameField" id="nameField" GridPane.rowIndex="0" GridPane.columnIndex="1"/>
<Label text="Age" GridPane.rowIndex="1" GridPane.columnIndex="0"/>
<Label text="Shown name" GridPane.rowIndex="1" GridPane.columnIndex="0"/>
<TextField fx:id="nameEcho" id="nameEcho" GridPane.rowIndex="1" GridPane.columnIndex="1"/>
<Label text="Name draft" GridPane.rowIndex="2" GridPane.columnIndex="0"/>
<TextField fx:id="nameDraft" id="nameDraft" GridPane.rowIndex="2" GridPane.columnIndex="1"/>
<Label text="Age" GridPane.rowIndex="3" GridPane.columnIndex="0"/>
<TextField fx:id="ageField" id="ageField" promptText="years 36"
GridPane.rowIndex="1" GridPane.columnIndex="1"/>
<Label text="Greeting" GridPane.rowIndex="2" GridPane.columnIndex="0"/>
<Label fx:id="greetingLabel" id="greetingLabel" GridPane.rowIndex="2" GridPane.columnIndex="1"/>
<Label text="Home street" GridPane.rowIndex="3" GridPane.columnIndex="0"/>
<Label fx:id="homeStreetLabel" id="homeStreetLabel" GridPane.rowIndex="3" GridPane.columnIndex="1"/>
GridPane.rowIndex="3" GridPane.columnIndex="1"/>
<Label text="Greeting" GridPane.rowIndex="4" GridPane.columnIndex="0"/>
<Label fx:id="greetingLabel" id="greetingLabel" GridPane.rowIndex="4" GridPane.columnIndex="1"/>
<Label text="Title" GridPane.rowIndex="5" GridPane.columnIndex="0"/>
<Label fx:id="titleLabel" id="titleLabel" GridPane.rowIndex="5" GridPane.columnIndex="1"/>
<Label text="Role" GridPane.rowIndex="6" GridPane.columnIndex="0"/>
<ChoiceBox fx:id="roleChoice" id="roleChoice" GridPane.rowIndex="6" GridPane.columnIndex="1">
<items>
<FXCollections fx:factory="observableArrayList">
<Role fx:constant="READER"/>
<Role fx:constant="AUTHOR"/>
</FXCollections>
</items>
</ChoiceBox>
<Label text="Home street" GridPane.rowIndex="7" GridPane.columnIndex="0"/>
<Label fx:id="homeStreetLabel" id="homeStreetLabel" GridPane.rowIndex="7" GridPane.columnIndex="1"/>
</GridPane>
<CheckBox fx:id="activeBox" id="activeBox" text="Active"/>
<Label text="Home"/>
<AddressForm fx:id="homeForm"/>
@@ -63,6 +91,12 @@
<ComboBox fx:id="visitChoice" id="visitChoice" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
<Button text="Add visit" onAction="#addVisit"/>
</HBox>
<Table fx:id="visitTable" id="visitTable" collection="visits"
row="de.neitzel.fx.example.view.VisitRow" prefWidth="496" prefHeight="160">
<Column header="Street" attribute="street" width="300"/>
<Column header="City" attribute="city"/>
<Column header="Visited" attribute="visitedOn" width="120"/>
</Table>
<VBox fx:id="visitPane" id="visitPane" spacing="4"/>
<Label text="Tags"/>
@@ -74,5 +108,9 @@
<Label text="Notice"/>
<Label fx:id="noticeLabel" id="noticeLabel" wrapText="true"/>
<Button text="Leave a note" onAction="#leaveNote"/>
<HBox spacing="8">
<Button text="Leave a note" onAction="#leaveNote"/>
<Button text="Leave a warning" onAction="#leaveWarning"/>
<Button text="Leave a fatal notice" onAction="#leaveFatal"/>
</HBox>
</VBox>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<fx:root type="javafx.scene.layout.VBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" spacing="6">
<fx:define>
<BindingList>
<DirectBinding control="noteField" property="text" path="text" direction="both"/>
</BindingList>
</fx:define>
<Label text="Note"/>
<TextField fx:id="noteField" id="noteField"/>
</fx:root>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<fx:root type="javafx.scene.layout.VBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" spacing="6">
<padding>
<Insets top="4" right="4" bottom="4" left="4"/>
</padding>
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"
converter="de.neitzel.fx.example.view.StreetConverter"/>
<DirectBinding control="cityField" property="text" path="city" direction="both"/>
</BindingList>
</fx:define>
<Label text="Street"/>
<TextField fx:id="streetField" id="streetField"/>
<Label text="City"/>
<TextField fx:id="cityField" id="cityField"/>
</fx:root>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.HBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<fx:root type="javafx.scene.layout.HBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" spacing="8">
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"/>
<DirectBinding control="cityField" property="text" path="city" direction="both"/>
<DirectBinding control="visitedField" property="text" path="visitedOn" direction="both"
converter="de.neitzel.fx.example.view.VisitedOnConverter"/>
</BindingList>
</fx:define>
<TextField fx:id="streetField" id="streetField"/>
<TextField fx:id="cityField" id="cityField"/>
<TextField fx:id="visitedField" id="visitedField"/>
<Button text="Remove" onAction="#remove"/>
</fx:root>
@@ -1,17 +1,27 @@
package de.neitzel.fx.example;
import de.neitzel.fx.example.model.Person;
import de.neitzel.fx.example.model.Role;
import de.neitzel.fx.example.view.AddressForm;
import de.neitzel.fx.example.view.DeskController;
import de.neitzel.fx.example.view.PersonViewModel;
import de.neitzel.fx.example.view.VisitForm;
import de.neitzel.fx.example.view.VisitRow;
import de.neitzel.fx.view.LoadedView;
import de.neitzel.fx.view.ViewLoader;
import de.neitzel.injection.ApplicationContext;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -21,7 +31,12 @@ 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
@@ -29,6 +44,9 @@ import static org.junit.jupiter.api.Assertions.fail;
*/
class DeskLoadsTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
@@ -42,50 +60,342 @@ class DeskLoadsTest {
}
}
/**
* The desk shows the sample person, including directions, the title, and the home note.
*/
@Test
void deskEditsThePersonAndKeepsTheGreetingCurrent() throws Exception {
void deskShowsThePerson() throws Exception {
onFx(() -> {
Person person = DeskApp.sample();
ApplicationContext context = new ApplicationContext(DeskApp.class);
LoadedView loaded = ViewLoader.load(DeskController.class.getResource("Desk.fxml"), person, context);
Parent root = loaded.getRoot();
Loaded loaded = load();
Parent root = loaded.root();
AddressForm home = homeForm(root);
TextField nameField = (TextField) root.lookup("#nameField");
TextField ageField = (TextField) root.lookup("#ageField");
Label greeting = (Label) root.lookup("#greetingLabel");
Label homeStreet = (Label) root.lookup("#homeStreetLabel");
TextField street = (TextField) homeForm(root).lookup("#streetField");
VBox visits = (VBox) root.lookup("#visitPane");
ListView<?> tags = (ListView<?>) root.lookup("#tagList");
assertEquals("Ada Lovelace", nameField.getText());
assertEquals("Hello, Ada Lovelace", greeting.getText());
assertEquals("years 36", ageField.getText());
assertEquals("Sample Street 1", street.getText());
assertEquals("Sample Street 1", homeStreet.getText());
assertEquals(1, visits.getChildren().size());
assertEquals(2, tags.getItems().size());
nameField.setText("Grace");
assertEquals("Grace", person.getName());
assertEquals("Hello, Grace", greeting.getText());
ageField.setText("years 41");
assertEquals(Integer.valueOf(41), person.getAge());
ageField.setText("nope");
assertEquals(Integer.valueOf(41), person.getAge());
assertNotNull(loaded.getViewModel().getLatestMessage());
assertEquals("age", loaded.getViewModel().getLatestMessage().getSource());
street.setText("Other Street");
assertEquals("Other Street", person.getHome().getStreet());
assertEquals("Other Street", homeStreet.getText());
person.getTags().add("fx");
assertEquals(3, tags.getItems().size());
assertEquals("Ada Lovelace", text(root, "nameField"));
assertEquals("Ada Lovelace", text(root, "nameEcho"));
assertEquals("Ada Lovelace", text(root, "nameDraft"));
assertEquals("Hello, Ada Lovelace (36)", text(root, "greetingLabel"));
assertEquals("years 36", text(root, "ageField"));
assertEquals("titled-Ada Lovelace", text(root, "titleLabel"));
assertTrue(((CheckBox) root.lookup("#activeBox")).isSelected());
assertEquals(Role.AUTHOR, ((ChoiceBox<?>) root.lookup("#roleChoice")).getValue());
assertEquals("Sample Street 1", text(home, "streetField"));
assertEquals("Sample City", text(home, "cityField"));
assertEquals("Sample Street 1", text(root, "homeStreetLabel"));
assertEquals("Address", text(home, "headingLabel"));
assertEquals("Opened as Sample City", text(home, "openedAsLabel"));
assertEquals("Kept at home", text(home, "noteField"));
assertEquals(3, ((VBox) root.lookup("#visitPane")).getChildren().size());
assertEquals(3, tableBody(root).getChildren().size());
assertEquals("Street", headerText(root, 0));
assertEquals("City", headerText(root, 1));
assertEquals("Visited", headerText(root, 2));
HBox header = tableHeader(root);
assertEquals(300, ((Region) header.getChildren().get(0)).getPrefWidth());
assertEquals(76, ((Region) header.getChildren().get(1)).getPrefWidth());
assertEquals(120, ((Region) header.getChildren().get(2)).getPrefWidth());
assertEquals(8, header.getSpacing());
VisitRow first = (VisitRow) tableBody(root).getChildren().get(0);
assertSame(loaded.person().getVisits().get(0), first.getModel());
assertEquals("Work Road 2", text(first, "streetField"));
assertEquals("Office City", text(first, "cityField"));
assertEquals("2020-01-02", text(first, "visitedField"));
assertTrue(first.getStyleClass().contains("selected"));
assertEquals(2, ((ListView<?>) root.lookup("#tagList")).getItems().size());
assertInstanceOf(PersonViewModel.class, loaded.view().getViewModel());
});
}
/**
* A later call to the entity setter leaves the controls on the view-model value.
*/
@Test
void entitySetterLeavesTheControls() throws Exception {
onFx(() -> {
Loaded loaded = load();
loaded.person().setName("Ignored");
assertEquals("Ignored", loaded.person().getName());
assertEquals("Ada Lovelace", text(loaded.root(), "nameField"));
assertEquals("Ada Lovelace", text(loaded.root(), "nameEcho"));
assertEquals("Ada Lovelace", text(loaded.root(), "nameDraft"));
});
}
/**
* A second view of the same person reuses the view model, so a name edit shows on both.
* <p>
* The second document is only a name field. Loading the desk again would rebuild the home
* address form, and that form is the cached singleton already showing.
*/
@Test
void secondViewReusesTheViewModel() throws Exception {
onFx(() -> {
Loaded first = load();
LoadedView second = ViewLoader.load(
DeskLoadsTest.class.getResource("NameLine.fxml"), first.person(), first.context());
assertSame(first.view().getViewModel(), second.getViewModel());
textField(first.root(), "nameField").setText("Grace");
assertEquals("Grace", first.person().getName());
assertEquals("Grace", text(second.getRoot(), "nameField"));
assertEquals("titled-Grace", text(first.root(), "titleLabel"));
});
}
/**
* Both, view, and model directions on the name, and the title follows the view model.
*/
@Test
void nameDirectionsUpdateTheTitleAndTheGreeting() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
textField(root, "nameField").setText("Grace");
assertEquals("Grace", loaded.person().getName());
assertEquals("Grace", text(root, "nameEcho"));
assertEquals("Ada Lovelace", text(root, "nameDraft"));
assertEquals("Hello, Grace (36)", text(root, "greetingLabel"));
assertEquals("titled-Grace", text(root, "titleLabel"));
textField(root, "nameEcho").setText("Hopper");
assertEquals("Grace", loaded.person().getName());
assertEquals("Grace", text(root, "nameField"));
textField(root, "nameDraft").setText("Byron");
assertEquals("Byron", loaded.person().getName());
assertEquals("Byron", text(root, "nameField"));
assertEquals("Byron", text(root, "nameEcho"));
assertEquals("titled-Byron", text(root, "titleLabel"));
});
}
/**
* A bad age keeps the previous value, records one error, and a later success clears it.
* Changing the age also refreshes the greeting.
*/
@Test
void ageConverterRecordsAndClearsAFailure() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
TextField age = textField(root, "ageField");
age.setText("years 41");
assertEquals(Integer.valueOf(41), loaded.person().getAge());
assertEquals("Hello, Ada Lovelace (41)", text(root, "greetingLabel"));
age.setText("nope");
assertEquals(Integer.valueOf(41), loaded.person().getAge());
assertEquals("nope", age.getText());
assertEquals("age", loaded.view().getViewModel().getLatestMessage().getSource());
assertEquals(de.neitzel.fx.view.Message.Severity.ERROR,
loaded.view().getViewModel().getLatestMessage().getSeverity());
assertEquals(loaded.view().getViewModel().getLatestMessage().getText(), text(root, "noticeLabel"));
age.setText("years 41");
assertNull(loaded.view().getViewModel().getLatestMessage());
assertNull(text(root, "noticeLabel"));
});
}
/**
* An empty home street is carried onto the person as {@code home.street}, then cleared.
*/
@Test
void emptyStreetIsCarriedFromTheHomeAddress() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
AddressForm home = homeForm(root);
TextField street = textField(home, "streetField");
street.setText(" ");
assertEquals("Sample Street 1", loaded.person().getHome().getStreet());
assertEquals(" ", street.getText());
assertEquals("Sample Street 1", text(root, "homeStreetLabel"));
assertEquals("home.street", loaded.view().getViewModel().getLatestMessage().getSource());
assertEquals("Street is required", text(root, "noticeLabel"));
street.setText("Other Street");
assertEquals("Other Street", loaded.person().getHome().getStreet());
assertEquals("Other Street", text(root, "homeStreetLabel"));
assertNull(loaded.view().getViewModel().getLatestMessage());
textField(home, "cityField").setText("Other City");
assertEquals("Opened as Sample City", text(home, "openedAsLabel"));
textField(home, "noteField").setText("Moved");
assertEquals("Moved", loaded.person().getHome().getNote().getText());
});
}
/**
* Editing a table row updates the visit and the repeated form. Removing the row drops it.
*/
@Test
void tableRowEditsTheVisitAndRemovesIt() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
VBox visits = (VBox) root.lookup("#visitPane");
VisitRow first = (VisitRow) tableBody(root).getChildren().get(0);
textField(first, "streetField").setText("Changed Road");
assertEquals("Changed Road", loaded.person().getVisits().get(0).getStreet());
VisitForm repeated = (VisitForm) visits.getChildren().get(0);
assertEquals("Changed Road", text(repeated, "streetField"));
ListView<?> tags = (ListView<?>) root.lookup("#tagList");
loaded.person().getTags().add("fx");
assertEquals(3, tags.getItems().size());
Button remove = (Button) first.lookup(".button");
assertEquals(Region.USE_COMPUTED_SIZE, remove.getPrefWidth());
remove.fire();
assertEquals(2, loaded.person().getVisits().size());
assertNull(loaded.person().getSelectedVisit());
assertEquals(2, visits.getChildren().size());
assertEquals(2, tableBody(root).getChildren().size());
});
}
/**
* Info, warning, and fatal notices show on the notice label.
*/
@Test
void noticesShowInfoWarningAndFatal() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
DeskController desk = loaded.context().getComponent(DeskController.class);
desk.leaveNote();
assertEquals(de.neitzel.fx.view.Message.Severity.INFO,
loaded.view().getViewModel().getLatestMessage().getSeverity());
assertEquals("The model already has the name Ada Lovelace.", text(root, "noticeLabel"));
desk.leaveWarning();
assertEquals(de.neitzel.fx.view.Message.Severity.WARN,
loaded.view().getViewModel().getLatestMessage().getSeverity());
assertEquals("Check the age before leaving the desk.", text(root, "noticeLabel"));
desk.leaveFatal();
assertEquals(de.neitzel.fx.view.Message.Severity.FATAL,
loaded.view().getViewModel().getLatestMessage().getSeverity());
assertEquals("The desk cannot store this person.", text(root, "noticeLabel"));
});
}
/**
* The active flag and the role write back to the person.
*/
@Test
void activeAndRoleWriteBack() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
CheckBox active = (CheckBox) root.lookup("#activeBox");
active.setSelected(false);
assertFalse(loaded.person().isActive());
@SuppressWarnings("unchecked")
ChoiceBox<Role> role = (ChoiceBox<Role>) root.lookup("#roleChoice");
role.setValue(Role.READER);
assertEquals(Role.READER, loaded.person().getRole());
});
}
/**
* One loaded desk: the person, the view, and the container that built it.
*
* @param person the model
* @param view the loaded view
* @param context the container
*/
private record Loaded(Person person, LoadedView view, ApplicationContext context) {
/**
* Returns the scene-graph root.
*
* @return the root
*/
private Parent root() {
return view.getRoot();
}
}
/**
* Loads the desk for a fresh sample person.
*
* @return the loaded desk
*/
private static Loaded load() {
Person person = DeskApp.sample();
ApplicationContext context = new ApplicationContext(DeskApp.class);
LoadedView view = ViewLoader.load(DeskController.class.getResource("Desk.fxml"), person, context);
return new Loaded(person, view, context);
}
/**
* Returns the text of a labeled control.
*
* @param parent the parent to search
* @param id the control id
* @return the text
*/
private static String text(Parent parent, String id) {
Node node = parent.lookup("#" + id);
if (node instanceof TextField field) {
return field.getText();
}
if (node instanceof Label label) {
return label.getText();
}
fail("no text on " + id);
return null;
}
/**
* Returns a text field.
*
* @param parent the parent to search
* @param id the field id
* @return the field
*/
private static TextField textField(Parent parent, String id) {
return (TextField) parent.lookup("#" + id);
}
/**
* Returns the visit table body.
*
* @param root the loaded scene
* @return the rows currently showing
*/
private static VBox tableBody(Parent root) {
ScrollPane scroll = (ScrollPane) root.lookup("#visitTable").lookup(".table-scroll");
return (VBox) scroll.getContent();
}
/**
* Returns the header text of one visit-table column.
*
* @param root the loaded scene
* @param index the column index
* @return the header text
*/
private static String headerText(Parent root, int index) {
Label label = (Label) tableHeader(root).getChildren().get(index).lookup(".column-header");
return label.getText();
}
/**
* Returns the visit-table header.
*
* @param root the loaded scene
* @return the header
*/
private static HBox tableHeader(Parent root) {
ScrollPane headerScroll = (ScrollPane) root.lookup("#visitTable").lookup(".table-header-scroll");
return (HBox) headerScroll.getContent();
}
/**
* Returns the home-address component in the loaded desk.
*
* @param root the loaded scene
* @return the home-address component
*/
private static AddressForm homeForm(Parent root) {
for (Node child : root.getChildrenUnmodifiable()) {
if (child instanceof AddressForm form) {
@@ -96,6 +406,11 @@ class DeskLoadsTest {
return null;
}
/**
* Runs one check on the JavaFX application thread.
*
* @param check the check
*/
private static void onFx(Runnable check) throws InterruptedException {
CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> failure = new AtomicReference<>();
@@ -0,0 +1,15 @@
<?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="nameField" property="text" path="name" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="nameField" id="nameField"/>
</VBox>