Complete JavaFX MVVM examples, bindings and library documentation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user