diff --git a/fx/src/main/java/de/neitzel/fx/view/Attributes.java b/fx/src/main/java/de/neitzel/fx/view/Attributes.java
new file mode 100644
index 0000000..f7e1729
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/Attributes.java
@@ -0,0 +1,136 @@
+package de.neitzel.fx.view;
+
+import java.beans.BeanInfo;
+import java.beans.IntrospectionException;
+import java.beans.Introspector;
+import java.beans.PropertyDescriptor;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.lang.reflect.WildcardType;
+import java.time.temporal.TemporalAccessor;
+import java.time.temporal.TemporalAmount;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * Reads an Attribute's declared type from a Model.
+ */
+final class Attributes {
+
+ /**
+ * Prevents instantiation.
+ */
+ private Attributes() {
+ }
+
+ /**
+ * Returns the element type of a collection Attribute.
+ *
+ * @param model the Model that holds the collection
+ * @param name the collection Attribute
+ * @return the element class
+ */
+ static Class> elementType(Object model, String name) {
+ Method reader = reader(model.getClass(), name);
+ Class> element = classArgument(reader.getGenericReturnType());
+ if (element == null) {
+ throw new IllegalArgumentException("No element type for \"" + name + "\"");
+ }
+ return element;
+ }
+
+ /**
+ * Returns the declared type of one Attribute.
+ *
+ * @param type the Model class
+ * @param name the Attribute
+ * @return the declared type
+ */
+ static Class> type(Class> type, String name) {
+ return reader(type, name).getReturnType();
+ }
+
+ /**
+ * Returns whether a Column can use this declared type.
+ *
+ * A string, number, boolean, enum, or date or time is simple. A type with its own
+ * Attributes is not.
+ *
+ * @param type the declared type
+ * @return {@code true} when the type is a simple value
+ */
+ static boolean simple(Class> type) {
+ if (type == String.class || type == Boolean.class || type == boolean.class || type.isEnum()) {
+ return true;
+ }
+ if (type.isPrimitive()) {
+ return type != char.class && type != void.class;
+ }
+ if (Number.class.isAssignableFrom(type)) {
+ return true;
+ }
+ if (Date.class.isAssignableFrom(type) || Calendar.class.isAssignableFrom(type)) {
+ return true;
+ }
+ return TemporalAccessor.class.isAssignableFrom(type) || TemporalAmount.class.isAssignableFrom(type);
+ }
+
+ /**
+ * Returns the read method of a read-write Attribute.
+ *
+ * @param type the Model class
+ * @param name the Attribute
+ * @return the getter
+ */
+ private static Method reader(Class> type, String name) {
+ for (PropertyDescriptor descriptor : descriptors(type)) {
+ if (descriptor.getName().equals(name)
+ && descriptor.getReadMethod() != null
+ && descriptor.getWriteMethod() != null) {
+ return descriptor.getReadMethod();
+ }
+ }
+ throw new IllegalArgumentException("No attribute \"" + name + "\"");
+ }
+
+ /**
+ * Returns the bean attributes declared on {@code type}.
+ *
+ * @param type the Model class
+ * @return the attributes
+ */
+ private static List descriptors(Class> type) {
+ try {
+ BeanInfo info = Introspector.getBeanInfo(type, Object.class);
+ return List.of(info.getPropertyDescriptors());
+ } catch (IntrospectionException failure) {
+ throw new IllegalStateException("Could not introspect " + type.getName(), failure);
+ }
+ }
+
+ /**
+ * Returns the single type argument of a collection type.
+ *
+ * @param type the generic return type
+ * @return the element class, or {@code null} when it cannot be read
+ */
+ private static Class> classArgument(Type type) {
+ if (!(type instanceof ParameterizedType parameterized)
+ || parameterized.getActualTypeArguments().length != 1) {
+ return null;
+ }
+ Type argument = parameterized.getActualTypeArguments()[0];
+ if (argument instanceof Class> raw) {
+ return raw;
+ }
+ if (argument instanceof WildcardType wildcard && wildcard.getUpperBounds().length == 1) {
+ Type bound = wildcard.getUpperBounds()[0];
+ if (bound instanceof Class> raw) {
+ return raw;
+ }
+ }
+ return null;
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/Column.java b/fx/src/main/java/de/neitzel/fx/view/Column.java
new file mode 100644
index 0000000..2420631
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/Column.java
@@ -0,0 +1,56 @@
+package de.neitzel.fx.view;
+
+/**
+ * One Column of a {@link Table}.
+ *
+ * The Column names the header the developer sees and the Attribute of the row element
+ * that the header, the Filter, and the Sort share.
+ */
+public class Column {
+
+ /**
+ * The header text.
+ */
+ private String header;
+
+ /**
+ * The Attribute of the row element.
+ */
+ private String attribute;
+
+ /**
+ * Sets the header text.
+ *
+ * @param header the header text
+ */
+ public void setHeader(String header) {
+ this.header = header;
+ }
+
+ /**
+ * Returns the header text.
+ *
+ * @return the header text
+ */
+ public String getHeader() {
+ return header;
+ }
+
+ /**
+ * Sets the Attribute name.
+ *
+ * @param attribute the Attribute of the row element
+ */
+ public void setAttribute(String attribute) {
+ this.attribute = attribute;
+ }
+
+ /**
+ * Returns the Attribute name.
+ *
+ * @return the Attribute of the row element
+ */
+ public String getAttribute() {
+ return attribute;
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/Table.java b/fx/src/main/java/de/neitzel/fx/view/Table.java
new file mode 100644
index 0000000..3321f50
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/Table.java
@@ -0,0 +1,1578 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.injection.ApplicationContext;
+import javafx.beans.DefaultProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.Property;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.scene.Node;
+import javafx.scene.Parent;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.TextField;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.VBox;
+import javafx.scene.text.Text;
+
+import java.beans.PropertyDescriptor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.TemporalAccessor;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A collection shown with one Component for each element.
+ *
+ * The developer places the Table in the View and names the collection Attribute, the Row
+ * class, and each Column. Filter and Sort change which Rows are showing and in what order.
+ * The collection keeps its own order. The body scrolls vertically and the header stays put.
+ */
+@DefaultProperty("columns")
+public class Table extends VBox {
+
+ /**
+ * Style class of the header row.
+ */
+ static final String HEADER_STYLE = "column-header-row";
+
+ /**
+ * Style class of the vertical scroll.
+ */
+ static final String SCROLL_STYLE = "table-scroll";
+
+ /**
+ * Style class of the header text.
+ */
+ static final String HEADER_TEXT_STYLE = "column-header";
+
+ /**
+ * Style class of the drag boundary.
+ */
+ static final String RESIZE_STYLE = "column-resize";
+
+ /**
+ * Style class of the popup opener.
+ */
+ static final String OPENER_STYLE = "column-opener";
+
+ /**
+ * Style class of the sort mark.
+ */
+ static final String SORT_MARK_STYLE = "sort-mark";
+
+ /**
+ * Style class of a selected Row.
+ */
+ static final String SELECTED_STYLE = "selected";
+
+ /**
+ * Style class of the column popup.
+ */
+ static final String POPUP_STYLE = "column-popup";
+
+ /**
+ * The collection Attribute.
+ */
+ private String collection;
+
+ /**
+ * The Row class name.
+ */
+ private String rowClass;
+
+ /**
+ * The Columns, in declared order.
+ */
+ private final ObservableList columns = FXCollections.observableArrayList();
+
+ /**
+ * The element the user selected, or {@code null}.
+ */
+ private final ObjectProperty selection = new SimpleObjectProperty<>(this, "selection");
+
+ /**
+ * The header cells, one per Column.
+ */
+ private final HBox header = new HBox();
+
+ /**
+ * Scrolls the header horizontally with the body. It does not scroll vertically.
+ */
+ private final ScrollPane headerScroll = new ScrollPane();
+
+ /**
+ * The vertical scroll. The header is not inside it.
+ */
+ private final ScrollPane scroll = new ScrollPane();
+
+ /**
+ * The Rows currently showing.
+ */
+ private final VBox body = new VBox();
+
+ /**
+ * The column popup.
+ */
+ private final VBox popup = new VBox();
+
+ /**
+ * One Row per element that has been shown. A hidden element keeps its Row.
+ */
+ private final Map rows = new IdentityHashMap<>();
+
+ /**
+ * The collection this Table shows.
+ */
+ private ObservableList elements = FXCollections.observableArrayList();
+
+ /**
+ * The width of each Column after the Table has opened. A drag changes one entry.
+ */
+ private double[] columnWidths;
+
+ /**
+ * The Column being sorted, or {@code -1} when the Table shows collection order.
+ */
+ private int sortIndex = -1;
+
+ /**
+ * Whether the Sort is ascending.
+ */
+ private boolean sortAscending = true;
+
+ /**
+ * The checklist in the popup, rebuilt each time the popup opens.
+ */
+ private final VBox checklist = new VBox();
+
+ /**
+ * Narrows which checklist entries are visible. It does not change their checks.
+ */
+ private final TextField search = new TextField();
+
+ /**
+ * Checks or clears every checklist value, including values the search box is hiding.
+ */
+ private final CheckBox selectAll = new CheckBox("Select all");
+
+ /**
+ * The comparison operator for the open Column.
+ */
+ private final ComboBox comparisonOperator = new ComboBox<>();
+
+ /**
+ * The comparison value, written in the checklist's text.
+ */
+ private final TextField comparisonValue = new TextField();
+
+ /**
+ * Stops Select All and the value boxes from updating each other in a loop.
+ */
+ private boolean adjustingSelectAll;
+
+ /**
+ * The element class of the collection.
+ */
+ private Class> elementType;
+
+ /**
+ * One Filter per Column. A null entry means that Column has no Filter yet.
+ */
+ private final List valueFilters = new ArrayList<>();
+ private int popupIndex = -1;
+
+ /**
+ * Whether {@link #open} has already run.
+ */
+ private boolean opened;
+
+ /**
+ * The container that creates Rows.
+ */
+ private ApplicationContext context;
+
+ /**
+ * The Row class.
+ */
+ private Class> rowType;
+
+ /**
+ * Creates a Table and prepares its header, scrolling body, and popup.
+ */
+ public Table() {
+ getStyleClass().add("table");
+ header.getStyleClass().add(HEADER_STYLE);
+ headerScroll.setContent(header);
+ headerScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
+ headerScroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
+ headerScroll.setFitToHeight(true);
+ headerScroll.getStyleClass().add("table-header-scroll");
+ scroll.getStyleClass().add(SCROLL_STYLE);
+ scroll.setContent(body);
+ scroll.setFitToWidth(false);
+ scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
+ scroll.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
+ scroll.hvalueProperty().bindBidirectional(headerScroll.hvalueProperty());
+ popup.getStyleClass().add(POPUP_STYLE);
+ popup.setVisible(false);
+ popup.setManaged(false);
+ getChildren().addAll(headerScroll, scroll, popup);
+ body.setOnMouseClicked(this::clearSelectionFromBlank);
+ selection.addListener((observable, previous, current) -> markSelection());
+ addEventFilter(MouseEvent.MOUSE_CLICKED, this::dismissPopup);
+ search.getStyleClass().add("column-search");
+ search.textProperty().addListener((observable, previous, current) -> applySearch());
+ selectAll.getStyleClass().add("column-select-all");
+ selectAll.setAllowIndeterminate(true);
+ selectAll.setOnMouseClicked(event -> applySelectAll());
+ comparisonOperator.getStyleClass().add("column-comparison-operator");
+ comparisonValue.getStyleClass().add("column-comparison-value");
+ buildSortCommands();
+ }
+
+ /**
+ * Sets the collection Attribute.
+ *
+ * @param collection the collection Attribute
+ */
+ public void setCollection(String collection) {
+ this.collection = collection;
+ }
+
+ /**
+ * Returns the collection Attribute.
+ *
+ * @return the collection Attribute
+ */
+ public String getCollection() {
+ return collection;
+ }
+
+ /**
+ * Sets the Row class name.
+ *
+ * @param row the Row class name
+ */
+ public void setRow(String row) {
+ this.rowClass = row;
+ }
+
+ /**
+ * Returns the Row class name.
+ *
+ * @return the Row class name
+ */
+ public String getRow() {
+ return rowClass;
+ }
+
+ /**
+ * Returns the Columns in declared order.
+ *
+ * @return the Columns
+ */
+ public ObservableList getColumns() {
+ return columns;
+ }
+
+ /**
+ * Returns the selection property.
+ *
+ * A Direct binding can tie this property to the parent Model's Selection.
+ *
+ * @return the selection property
+ */
+ public ObjectProperty selectionProperty() {
+ return selection;
+ }
+
+ /**
+ * Returns the selected element.
+ *
+ * @return the selected element, or {@code null}
+ */
+ public Object getSelection() {
+ return selection.get();
+ }
+
+ /**
+ * Sets the selected element.
+ *
+ * @param selection the selected element, or {@code null}
+ */
+ public void setSelection(Object selection) {
+ this.selection.set(selection);
+ }
+
+ /**
+ * Shows every Table under {@code root}.
+ *
+ * @param root the View root
+ * @param viewModel the ViewModel for this View
+ * @param context the container that creates each Row
+ */
+ static void openAll(Parent root, Object viewModel, ApplicationContext context) {
+ walk(root, viewModel, context);
+ }
+
+ /**
+ * Opens this Table for the ViewModel.
+ *
+ * @param viewModel the ViewModel for this View
+ * @param context the container that creates each Row
+ */
+ void open(Object viewModel, ApplicationContext context) {
+ if (opened) {
+ return;
+ }
+ opened = true;
+ this.context = context;
+ if (columns.isEmpty()) {
+ throw new IllegalArgumentException("A table needs a column");
+ }
+ requireDistinctAttributes();
+ elements = elements(viewModel);
+ elementType = Attributes.elementType(modelOf(viewModel), collection);
+ requireSimpleColumns(elementType);
+ rowType = rowType();
+ valueFilters.clear();
+ for (int index = 0; index < columns.size(); index++) {
+ valueFilters.add(new ValueFilter());
+ }
+ buildHeader();
+ ensureWidths();
+ elements.addListener((ListChangeListener) this::onCollectionChange);
+ refresh();
+ }
+
+ /**
+ * Builds the sort commands at the top of the popup.
+ */
+ private void buildSortCommands() {
+ popup.getChildren().add(command("Ascending", () -> chooseSort(true)));
+ popup.getChildren().add(command("Descending", () -> chooseSort(false)));
+ popup.getChildren().add(command("Clear sort", this::clearSort));
+ popup.getChildren().add(command("Clear filter", this::clearFilter));
+ popup.getChildren().add(search);
+ popup.getChildren().add(selectAll);
+ popup.getChildren().add(checklist);
+ popup.getChildren().add(comparisonOperator);
+ popup.getChildren().add(comparisonValue);
+ popup.getChildren().add(command("Confirm", this::confirmChecklist));
+ }
+
+ /**
+ * Creates a popup command.
+ *
+ * @param text the label
+ * @param action the action
+ * @return the button
+ */
+ private static Button command(String text, Runnable action) {
+ Button button = new Button(text);
+ button.setMaxWidth(Double.MAX_VALUE);
+ button.setOnAction(event -> action.run());
+ return button;
+ }
+
+ /**
+ * Rejects a Table that names one Attribute twice.
+ */
+ private void requireDistinctAttributes() {
+ Set seen = new HashSet<>();
+ for (Column column : columns) {
+ if (column.getAttribute() == null || column.getHeader() == null || column.getHeader().isBlank()) {
+ throw new IllegalArgumentException("A column needs a header and an attribute");
+ }
+ if (!seen.add(column.getAttribute())) {
+ throw new IllegalArgumentException("Attribute \"" + column.getAttribute() + "\" is used twice");
+ }
+ }
+ }
+
+ /**
+ * Rejects a Column whose Attribute is not a simple value.
+ *
+ * @param elementType the element class
+ */
+ private void requireSimpleColumns(Class> elementType) {
+ for (Column column : columns) {
+ Class> type = Attributes.type(elementType, column.getAttribute());
+ if (!Attributes.simple(type)) {
+ throw new IllegalArgumentException(
+ "Attribute \"" + column.getAttribute() + "\" is not a simple value");
+ }
+ }
+ }
+
+ /**
+ * Builds one header cell per Column.
+ */
+ private void buildHeader() {
+ header.getChildren().clear();
+ for (int index = 0; index < columns.size(); index++) {
+ header.getChildren().add(headerCell(index));
+ }
+ }
+
+ /**
+ * Creates the header cell for one Column.
+ *
+ * @param index the Column index
+ * @return the cell
+ */
+ private HBox headerCell(int index) {
+ Column column = columns.get(index);
+ Label title = new Label(column.getHeader());
+ title.getStyleClass().add(HEADER_TEXT_STYLE);
+ title.setMaxWidth(Double.MAX_VALUE);
+ HBox.setHgrow(title, Priority.ALWAYS);
+ Label mark = new Label();
+ mark.getStyleClass().add(SORT_MARK_STYLE);
+ mark.setVisible(false);
+ mark.setManaged(false);
+ Button opener = new Button();
+ opener.getStyleClass().add(OPENER_STYLE);
+ opener.setOnAction(event -> showPopup(index));
+ Region handle = new Region();
+ handle.getStyleClass().add(RESIZE_STYLE);
+ handle.setMinWidth(6);
+ handle.setPrefWidth(6);
+ handle.setOnMousePressed(event -> beginResize(index, event));
+ handle.setOnMouseDragged(event -> dragResize(index, event));
+ HBox cell = new HBox(title, mark, opener, handle);
+ cell.getStyleClass().add("column-cell");
+ cell.setOnMouseClicked(event -> {
+ if (isInside(opener, event.getTarget()) || isInside(handle, event.getTarget())) {
+ return;
+ }
+ toggleSort(index);
+ });
+ cell.setUserData(new double[] {0, 0});
+ return cell;
+ }
+
+ /**
+ * Remembers where a resize drag started.
+ *
+ * @param index the Column index
+ * @param event the press
+ */
+ private void beginResize(int index, MouseEvent event) {
+ HBox cell = (HBox) header.getChildren().get(index);
+ cell.setUserData(new double[] {event.getScreenX(), columnWidths[index]});
+ event.consume();
+ }
+
+ /**
+ * Changes one Column's width. The other Columns stay as they are.
+ *
+ * @param index the Column index
+ * @param event the drag
+ */
+ private void dragResize(int index, MouseEvent event) {
+ HBox cell = (HBox) header.getChildren().get(index);
+ double[] start = (double[]) cell.getUserData();
+ double next = start[1] + (event.getScreenX() - start[0]);
+ columnWidths[index] = Math.max(minimumWidth(index), next);
+ applyWidths();
+ event.consume();
+ }
+
+ /**
+ * Returns the narrowest width that still shows the header text.
+ *
+ * @param index the Column index
+ * @return the minimum width
+ */
+ private double minimumWidth(int index) {
+ Text text = new Text(columns.get(index).getHeader());
+ return Math.ceil(text.getLayoutBounds().getWidth()) + 8;
+ }
+
+ /**
+ * Shows the popup for one Column.
+ *
+ * @param index the Column index
+ */
+ private void showPopup(int index) {
+ popupIndex = index;
+ search.setText("");
+ fillChecklist(index);
+ applySearch();
+ syncSelectAll();
+ showComparison(index);
+ popup.setVisible(true);
+ popup.setManaged(true);
+ }
+
+ /**
+ * Hides checklist entries whose text does not contain the search, ignoring case.
+ *
+ * A {@code *} is an ordinary character. Checks are left as they are.
+ */
+ private void applySearch() {
+ String query = search.getText() == null ? "" : search.getText().toLowerCase(Locale.ROOT);
+ for (Node node : checklist.getChildren()) {
+ if (!(node instanceof CheckBox box)) {
+ continue;
+ }
+ String text = box.getText() == null ? "" : box.getText().toLowerCase(Locale.ROOT);
+ boolean shown = query.isEmpty() || text.contains(query);
+ box.setVisible(shown);
+ box.setManaged(shown);
+ }
+ }
+
+ /**
+ * Checks or clears every value, including values the search box is hiding.
+ */
+ private void applySelectAll() {
+ if (adjustingSelectAll) {
+ return;
+ }
+ adjustingSelectAll = true;
+ boolean selected = !everyBoxSelected();
+ selectAll.setIndeterminate(false);
+ for (Node node : checklist.getChildren()) {
+ if (node instanceof CheckBox box) {
+ box.setSelected(selected);
+ }
+ }
+ adjustingSelectAll = false;
+ syncSelectAll();
+ }
+
+ /**
+ * Reports whether every checklist value is checked.
+ *
+ * @return {@code true} when every value is checked
+ */
+ private boolean everyBoxSelected() {
+ boolean any = false;
+ for (Node node : checklist.getChildren()) {
+ if (!(node instanceof CheckBox box)) {
+ continue;
+ }
+ any = true;
+ if (!box.isSelected()) {
+ return false;
+ }
+ }
+ return any;
+ }
+
+ /**
+ * Shows Select All as checked, unchecked, or mixed from every value.
+ */
+ private void syncSelectAll() {
+ if (adjustingSelectAll) {
+ return;
+ }
+ int total = 0;
+ int selected = 0;
+ for (Node node : checklist.getChildren()) {
+ if (!(node instanceof CheckBox box)) {
+ continue;
+ }
+ total++;
+ if (box.isSelected()) {
+ selected++;
+ }
+ }
+ adjustingSelectAll = true;
+ if (total == 0 || selected == total) {
+ selectAll.setIndeterminate(false);
+ selectAll.setSelected(true);
+ } else if (selected == 0) {
+ selectAll.setIndeterminate(false);
+ selectAll.setSelected(false);
+ } else {
+ selectAll.setSelected(false);
+ selectAll.setIndeterminate(true);
+ }
+ adjustingSelectAll = false;
+ }
+
+ /**
+ * Fills the checklist with the values this Column can filter, blank first.
+ *
+ * @param index the Column index
+ */
+ private void fillChecklist(int index) {
+ checklist.getChildren().clear();
+ ValueFilter filter = valueFilters.get(index);
+ List values = checklistValues(index);
+ for (Object value : values) {
+ CheckBox box = new CheckBox(checklistText(value));
+ box.setUserData(value);
+ box.setSelected(!filter.active || filter.comparison != Comparison.NONE || filter.matches(value));
+ box.selectedProperty().addListener((observable, previous, current) -> syncSelectAll());
+ checklist.getChildren().add(box);
+ }
+ }
+
+ /**
+ * Returns the distinct values that pass every other Column's Filter.
+ *
+ * @param index the Column whose own Filter is ignored
+ * @return the values, blank first, then ascending
+ */
+ private List checklistValues(int index) {
+ List values = new ArrayList<>();
+ for (Object element : elements) {
+ if (!passes(element, index)) {
+ continue;
+ }
+ Object value = read(reader(element.getClass(), columns.get(index).getAttribute()), element);
+ if (!values.contains(value)) {
+ values.add(value);
+ }
+ }
+ values.sort(this::checklistOrder);
+ return values;
+ }
+
+ /**
+ * Orders checklist values with a missing value first.
+ *
+ * @param left one value
+ * @param right the other value
+ * @return the order
+ */
+ private int checklistOrder(Object left, Object right) {
+ if (left == null && right == null) {
+ return 0;
+ }
+ if (left == null) {
+ return -1;
+ }
+ if (right == null) {
+ return 1;
+ }
+ return compareValues(left, right);
+ }
+
+ /**
+ * Returns the text shown for a checklist value.
+ *
+ * @param value the Attribute value, or {@code null}
+ * @return the text
+ */
+ private static String checklistText(Object value) {
+ if (value == null) {
+ return "";
+ }
+ if (value instanceof Enum> enumValue) {
+ return enumValue.name();
+ }
+ if (value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte) {
+ return value.toString();
+ }
+ return String.valueOf(value);
+ }
+
+ /**
+ * Shows the comparison for a string, number, or date Column, and hides it otherwise.
+ *
+ * @param index the Column index
+ */
+ private void showComparison(int index) {
+ Class> type = Attributes.type(elementType, columns.get(index).getAttribute());
+ boolean offered = comparisonOffered(index);
+ comparisonOperator.setVisible(offered);
+ comparisonOperator.setManaged(offered);
+ comparisonValue.setVisible(offered);
+ comparisonValue.setManaged(offered);
+ if (!offered) {
+ comparisonOperator.setValue(null);
+ comparisonValue.setText("");
+ return;
+ }
+ comparisonOperator.getItems().setAll(operatorNames(type));
+ ValueFilter filter = valueFilters.get(index);
+ if (filter.comparison == Comparison.NONE) {
+ comparisonOperator.setValue(null);
+ comparisonValue.setText("");
+ } else {
+ comparisonOperator.setValue(comparisonName(filter.comparison));
+ comparisonValue.setText(filter.comparisonText);
+ }
+ }
+
+ /**
+ * Reports whether this Column offers a comparison.
+ *
+ * @param index the Column index
+ * @return {@code true} for a string, number, or date or time
+ */
+ private boolean comparisonOffered(int index) {
+ Class> type = Attributes.type(elementType, columns.get(index).getAttribute());
+ if (type == Boolean.class || type == boolean.class || type.isEnum()) {
+ return false;
+ }
+ return type == String.class || number(type) || time(type);
+ }
+
+ /**
+ * Returns the operator names for a Column type.
+ *
+ * @param type the Attribute type
+ * @return the operator names
+ */
+ private static List operatorNames(Class> type) {
+ if (type == String.class) {
+ return List.of("Contains", "Starts with", "Ends with");
+ }
+ if (number(type)) {
+ return List.of("Greater than", "Less than");
+ }
+ return List.of("Before", "After");
+ }
+
+ /**
+ * Reports whether the user has started a comparison.
+ *
+ * @return {@code true} when an operator or a value is present
+ */
+ private boolean comparisonAttempted() {
+ boolean operatorChosen = comparisonOperator.getValue() != null && !comparisonOperator.getValue().isBlank();
+ boolean textEntered = comparisonValue.getText() != null && !comparisonValue.getText().isBlank();
+ return operatorChosen || textEntered;
+ }
+
+ /**
+ * Reports whether the comparison can be applied.
+ *
+ * @param index the Column index
+ * @return {@code true} when the operator and the value both fit the Attribute
+ */
+ private boolean comparisonValid(int index) {
+ String operator = comparisonOperator.getValue();
+ String text = comparisonValue.getText() == null ? "" : comparisonValue.getText().trim();
+ if (operator == null || operator.isBlank() || text.isEmpty()) {
+ return false;
+ }
+ if (comparisonKind(operator) == Comparison.NONE) {
+ return false;
+ }
+ return parses(Attributes.type(elementType, columns.get(index).getAttribute()), text);
+ }
+
+ /**
+ * Returns the comparison named by the popup.
+ *
+ * @param name the operator name
+ * @return the comparison, or {@link Comparison#NONE}
+ */
+ private static Comparison comparisonKind(String name) {
+ if (name == null) {
+ return Comparison.NONE;
+ }
+ return switch (name) {
+ case "Contains" -> Comparison.CONTAINS;
+ case "Starts with" -> Comparison.STARTS;
+ case "Ends with" -> Comparison.ENDS;
+ case "Greater than" -> Comparison.GREATER;
+ case "Less than" -> Comparison.LESS;
+ case "Before" -> Comparison.BEFORE;
+ case "After" -> Comparison.AFTER;
+ default -> Comparison.NONE;
+ };
+ }
+
+ /**
+ * Returns the popup name of a comparison.
+ *
+ * @param comparison the comparison
+ * @return the operator name
+ */
+ private static String comparisonName(Comparison comparison) {
+ return switch (comparison) {
+ case CONTAINS -> "Contains";
+ case STARTS -> "Starts with";
+ case ENDS -> "Ends with";
+ case GREATER -> "Greater than";
+ case LESS -> "Less than";
+ case BEFORE -> "Before";
+ case AFTER -> "After";
+ case NONE -> "";
+ };
+ }
+
+ /**
+ * Reports whether {@code text} is the checklist form of {@code type}.
+ *
+ * @param type the Attribute type
+ * @param text the comparison value
+ * @return {@code true} when the text parses
+ */
+ private static boolean parses(Class> type, String text) {
+ try {
+ if (type == String.class) {
+ return true;
+ }
+ if (integral(type)) {
+ Long.parseLong(text);
+ return true;
+ }
+ if (number(type)) {
+ Double.valueOf(text);
+ return true;
+ }
+ return parsedTime(type, text) != null;
+ } catch (NumberFormatException failure) {
+ return false;
+ }
+ }
+
+ /**
+ * Reports whether the type is a whole number.
+ *
+ * @param type the Attribute type
+ * @return {@code true} for an integer type
+ */
+ private static boolean integral(Class> type) {
+ return type == Integer.class || type == int.class
+ || type == Long.class || type == long.class
+ || type == Short.class || type == short.class
+ || type == Byte.class || type == byte.class;
+ }
+
+ /**
+ * Reports whether the type is a number.
+ *
+ * @param type the Attribute type
+ * @return {@code true} for a number
+ */
+ private static boolean number(Class> type) {
+ return integral(type) || type == Double.class || type == double.class
+ || type == Float.class || type == float.class || Number.class.isAssignableFrom(type);
+ }
+
+ /**
+ * Reports whether the type is a date or time.
+ *
+ * @param type the Attribute type
+ * @return {@code true} for a date or time
+ */
+ private static boolean time(Class> type) {
+ return TemporalAccessor.class.isAssignableFrom(type)
+ || Date.class.isAssignableFrom(type)
+ || Calendar.class.isAssignableFrom(type);
+ }
+
+ /**
+ * Parses a date or time in the checklist's text.
+ *
+ * @param type the Attribute type
+ * @param text the comparison value
+ * @return the parsed value, or {@code null}
+ */
+ private static Object parsedTime(Class> type, String text) {
+ try {
+ if (type == LocalDate.class) {
+ return LocalDate.parse(text);
+ }
+ if (type == LocalDateTime.class) {
+ return LocalDateTime.parse(text);
+ }
+ if (type == Instant.class) {
+ return Instant.parse(text);
+ }
+ if (Date.class.isAssignableFrom(type)) {
+ return Date.from(Instant.parse(text));
+ }
+ if (Calendar.class.isAssignableFrom(type)) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(Date.from(Instant.parse(text)));
+ return calendar;
+ }
+ return null;
+ } catch (DateTimeParseException failure) {
+ return null;
+ }
+ }
+
+ /**
+ * Applies the checklist and closes the popup.
+ */
+ private void confirmChecklist() {
+ ValueFilter filter = valueFilters.get(popupIndex);
+ if (comparisonOffered(popupIndex) && comparisonAttempted()) {
+ if (!comparisonValid(popupIndex)) {
+ return;
+ }
+ filter.comparison = comparisonKind(comparisonOperator.getValue());
+ filter.comparisonText = comparisonValue.getText().trim();
+ filter.active = true;
+ filter.values.clear();
+ filter.includeMissing = false;
+ hidePopup();
+ refresh();
+ return;
+ }
+ filter.comparison = Comparison.NONE;
+ filter.comparisonText = "";
+ int boxes = checklist.getChildren().size();
+ int checked = 0;
+ filter.values.clear();
+ filter.includeMissing = false;
+ for (Node node : checklist.getChildren()) {
+ CheckBox box = (CheckBox) node;
+ if (!box.isSelected()) {
+ continue;
+ }
+ checked++;
+ if (box.getUserData() == null) {
+ filter.includeMissing = true;
+ } else {
+ filter.values.add(box.getUserData());
+ }
+ }
+ filter.active = checked != boxes;
+ hidePopup();
+ refresh();
+ }
+
+ /**
+ * Removes the open Column's Filter and closes the popup.
+ */
+ private void clearFilter() {
+ if (popupIndex >= 0 && popupIndex < valueFilters.size()) {
+ ValueFilter filter = valueFilters.get(popupIndex);
+ filter.active = false;
+ filter.comparison = Comparison.NONE;
+ filter.comparisonText = "";
+ }
+ hidePopup();
+ refresh();
+ }
+
+ /**
+ * Reports whether an element passes the Filters, optionally ignoring one Column.
+ *
+ * @param element the element
+ * @param except the Column index to ignore, or {@code -1}
+ * @return {@code true} when the element stays visible
+ */
+ private boolean passes(Object element, int except) {
+ for (int index = 0; index < valueFilters.size(); index++) {
+ if (index == except || !valueFilters.get(index).active) {
+ continue;
+ }
+ Object value = read(reader(element.getClass(), columns.get(index).getAttribute()), element);
+ if (!valueFilters.get(index).matches(value)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Hides the popup when the press is outside it.
+ *
+ * @param event the press
+ */
+ private void dismissPopup(MouseEvent event) {
+ if (!popup.isVisible() || isInside(popup, event.getTarget())) {
+ return;
+ }
+ if (isInside(opener(popupIndex), event.getTarget())) {
+ return;
+ }
+ hidePopup();
+ }
+
+ /**
+ * Hides the popup.
+ */
+ private void hidePopup() {
+ popup.setVisible(false);
+ popup.setManaged(false);
+ }
+
+ /**
+ * Returns the opener of a Column, or {@code null}.
+ *
+ * @param index the Column index
+ * @return the opener
+ */
+ private Node opener(int index) {
+ if (index < 0 || index >= header.getChildren().size()) {
+ return null;
+ }
+ return header.getChildren().get(index).lookup("." + OPENER_STYLE);
+ }
+
+ /**
+ * Reports whether {@code target} is {@code node} or inside it.
+ *
+ * @param node the ancestor
+ * @param target the event target
+ * @return {@code true} when the target is inside the node
+ */
+ private static boolean isInside(Node node, Object target) {
+ if (!(target instanceof Node current) || node == null) {
+ return false;
+ }
+ while (current != null) {
+ if (current == node) {
+ return true;
+ }
+ current = current.getParent();
+ }
+ return false;
+ }
+
+ /**
+ * Switches the Sort between ascending and descending, or starts at ascending.
+ *
+ * @param index the Column index
+ */
+ private void toggleSort(int index) {
+ if (sortIndex == index) {
+ sortAscending = !sortAscending;
+ } else {
+ sortIndex = index;
+ sortAscending = true;
+ }
+ refresh();
+ }
+
+ /**
+ * Sorts by the popup's Column and closes the popup.
+ *
+ * @param ascending whether the Sort is ascending
+ */
+ private void chooseSort(boolean ascending) {
+ sortIndex = popupIndex;
+ sortAscending = ascending;
+ hidePopup();
+ refresh();
+ }
+
+ /**
+ * Clears the Sort and closes the popup.
+ */
+ private void clearSort() {
+ sortIndex = -1;
+ hidePopup();
+ refresh();
+ }
+
+ /**
+ * Clears Selection when the click lands on the blank body.
+ *
+ * @param event the click
+ */
+ private void clearSelectionFromBlank(MouseEvent event) {
+ if (event.getTarget() == body && event.getButton() == MouseButton.PRIMARY) {
+ selection.set(null);
+ }
+ }
+
+ /**
+ * Returns the collection the Table shows.
+ *
+ * @param viewModel the ViewModel for this View
+ * @return the collection
+ */
+ @SuppressWarnings("unchecked")
+ private ObservableList elements(Object viewModel) {
+ Property> property = viewModel instanceof ViewModel model
+ ? model.property(collection)
+ : NamedProperties.get(viewModel, collection);
+ if (property == null || !(property.getValue() instanceof ObservableList> values)) {
+ throw new IllegalArgumentException("No collection \"" + collection + "\"");
+ }
+ return (ObservableList) values;
+ }
+
+ /**
+ * Returns the Model behind the ViewModel.
+ *
+ * @param viewModel the ViewModel for this View
+ * @return the Model
+ */
+ private static Object modelOf(Object viewModel) {
+ if (viewModel instanceof ViewModel model) {
+ return model.model();
+ }
+ return viewModel;
+ }
+
+ /**
+ * Returns the Row class named on this Table.
+ *
+ * @return the Row class
+ */
+ private Class> rowType() {
+ try {
+ return Class.forName(rowClass);
+ } catch (ClassNotFoundException failure) {
+ throw new IllegalArgumentException("No row " + rowClass, failure);
+ }
+ }
+
+ /**
+ * Follows adds and removes. A permutation of the collection is left as it is.
+ *
+ * @param change the collection change
+ */
+ private void onCollectionChange(ListChangeListener.Change> change) {
+ while (change.next()) {
+ if (change.wasPermutated() || change.wasUpdated()) {
+ continue;
+ }
+ for (Object removed : change.getRemoved()) {
+ rows.remove(removed);
+ if (selection.get() == removed) {
+ selection.set(null);
+ }
+ }
+ }
+ refresh();
+ }
+
+ /**
+ * Shows the Rows that pass the Filter, in Sort order, reusing each element's Row.
+ */
+ private void refresh() {
+ List shown = new ArrayList<>();
+ for (Object element : elements) {
+ if (passes(element, -1)) {
+ shown.add(element);
+ }
+ }
+ if (sortIndex >= 0 && !shown.isEmpty()) {
+ shown.sort(comparator());
+ }
+ body.getChildren().clear();
+ for (Object element : shown) {
+ body.getChildren().add(rows.computeIfAbsent(element, this::createRow));
+ }
+ applyWidths();
+ markSelection();
+ updateSortMarks();
+ }
+
+ /**
+ * Returns a comparator that keeps equal values in collection order and missing values last.
+ *
+ * @return the comparator
+ */
+ private Comparator comparator() {
+ Method reader = reader(elements.get(0).getClass(), columns.get(sortIndex).getAttribute());
+ return (left, right) -> compare(reader, left, right);
+ }
+
+ /**
+ * Compares two elements by the sorted Column.
+ *
+ * @param reader the Attribute getter
+ * @param left one element
+ * @param right the other element
+ * @return the comparison, with missing values last
+ */
+ private int compare(Method reader, Object left, Object right) {
+ Object leftValue = read(reader, left);
+ Object rightValue = read(reader, right);
+ if (leftValue == null && rightValue == null) {
+ return 0;
+ }
+ if (leftValue == null) {
+ return 1;
+ }
+ if (rightValue == null) {
+ return -1;
+ }
+ int compared = compareValues(leftValue, rightValue);
+ return sortAscending ? compared : -compared;
+ }
+
+ /**
+ * Compares two present values.
+ *
+ * @param left one value
+ * @param right the other value
+ * @return the comparison
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static int compareValues(Object left, Object right) {
+ if (left instanceof String leftText && right instanceof String rightText) {
+ return leftText.compareToIgnoreCase(rightText);
+ }
+ if (left instanceof Number leftNumber && right instanceof Number rightNumber) {
+ return Double.compare(leftNumber.doubleValue(), rightNumber.doubleValue());
+ }
+ if (left instanceof Comparable comparable && left.getClass() == right.getClass()) {
+ return comparable.compareTo(right);
+ }
+ return String.valueOf(left).compareToIgnoreCase(String.valueOf(right));
+ }
+
+ /**
+ * Reads an Attribute.
+ *
+ * @param reader the getter
+ * @param element the element
+ * @return the value, or {@code null}
+ */
+ private static Object read(Method reader, Object element) {
+ try {
+ return reader.invoke(element);
+ } catch (IllegalAccessException | InvocationTargetException failure) {
+ throw new IllegalStateException("Could not read " + reader.getName(), failure);
+ }
+ }
+
+ /**
+ * Returns the getter for an Attribute.
+ *
+ * @param type the element class
+ * @param name the Attribute
+ * @return the getter
+ */
+ private static Method reader(Class> type, String name) {
+ try {
+ java.beans.BeanInfo info = java.beans.Introspector.getBeanInfo(type, Object.class);
+ for (PropertyDescriptor descriptor : info.getPropertyDescriptors()) {
+ if (descriptor.getName().equals(name) && descriptor.getReadMethod() != null) {
+ return descriptor.getReadMethod();
+ }
+ }
+ } catch (java.beans.IntrospectionException failure) {
+ throw new IllegalStateException("Could not introspect " + type.getName(), failure);
+ }
+ throw new IllegalArgumentException("No attribute \"" + name + "\"");
+ }
+
+ /**
+ * Creates one Row and sets its model to {@code element}.
+ *
+ * @param element the element
+ * @return the Row
+ */
+ private Node createRow(Object element) {
+ Object component = Components.fresh(rowType, context);
+ Components.setModel(component, element);
+ if (!(component instanceof Node node)) {
+ throw new IllegalArgumentException("Row is not a node: " + rowType.getName());
+ }
+ node.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> {
+ if (event.getButton() == MouseButton.PRIMARY) {
+ selection.set(element);
+ }
+ });
+ watchEdits(node);
+ return node;
+ }
+
+ /**
+ * Refreshes the Table when a field in the Row is edited.
+ *
+ * @param node the Row or one of its fields
+ */
+ private void watchEdits(Node node) {
+ if (node instanceof javafx.scene.control.TextInputControl input) {
+ input.textProperty().addListener((observable, previous, current) -> refresh());
+ }
+ if (node instanceof Parent parent) {
+ for (Node child : parent.getChildrenUnmodifiable()) {
+ watchEdits(child);
+ }
+ }
+ }
+
+ /**
+ * Marks the visible Row for the current Selection.
+ */
+ private void markSelection() {
+ for (Node node : rows.values()) {
+ node.getStyleClass().remove(SELECTED_STYLE);
+ }
+ Node selected = rows.get(selection.get());
+ if (selected != null && body.getChildren().contains(selected)
+ && !selected.getStyleClass().contains(SELECTED_STYLE)) {
+ selected.getStyleClass().add(SELECTED_STYLE);
+ }
+ }
+
+ /**
+ * Shows the sort mark only on the sorted Column.
+ */
+ private void updateSortMarks() {
+ for (int index = 0; index < header.getChildren().size(); index++) {
+ Label mark = (Label) header.getChildren().get(index).lookup("." + SORT_MARK_STYLE);
+ boolean sorting = index == sortIndex;
+ mark.setVisible(sorting);
+ mark.setManaged(sorting);
+ mark.setText(sortAscending ? "ascending" : "descending");
+ Node opener = header.getChildren().get(index).lookup("." + OPENER_STYLE);
+ boolean filtered = index < valueFilters.size() && valueFilters.get(index).active;
+ if (opener != null && filtered && !opener.getStyleClass().contains("filtered")) {
+ opener.getStyleClass().add("filtered");
+ }
+ if (opener != null && !filtered) {
+ opener.getStyleClass().remove("filtered");
+ }
+ }
+ }
+
+ /**
+ * Gives each Column its width, and the same width to each field in that order.
+ */
+ private void ensureWidths() {
+ if (columnWidths != null && columnWidths.length == columns.size()) {
+ return;
+ }
+ double width = getWidth() > 0 ? getWidth() : getPrefWidth();
+ if (width <= 0) {
+ width = columns.size() * 80.0;
+ }
+ columnWidths = new double[columns.size()];
+ java.util.Arrays.fill(columnWidths, width / columns.size());
+ }
+
+ /**
+ * Applies the stored Column widths to the header and the fields.
+ */
+ private void applyWidths() {
+ if (columns.isEmpty() || columnWidths == null) {
+ return;
+ }
+ double sum = 0;
+ for (double width : columnWidths) {
+ sum += width;
+ }
+ header.setPrefWidth(sum);
+ header.setMinWidth(sum);
+ for (int index = 0; index < header.getChildren().size(); index++) {
+ fixWidth((Region) header.getChildren().get(index), columnWidths[index]);
+ }
+ for (Node rowNode : body.getChildren()) {
+ if (rowNode instanceof Region region) {
+ region.setPrefWidth(sum);
+ region.setMinWidth(sum);
+ }
+ if (rowNode instanceof Parent parent) {
+ int index = 0;
+ for (Node field : parent.getChildrenUnmodifiable()) {
+ if (index >= columnWidths.length) {
+ break;
+ }
+ if (field instanceof Region region) {
+ fixWidth(region, columnWidths[index]);
+ }
+ index++;
+ }
+ }
+ }
+ }
+
+ /**
+ * Fixes one node's width.
+ *
+ * @param region the header cell or field
+ * @param width the Column width
+ */
+ private static void fixWidth(Region region, double width) {
+ region.setMinWidth(width);
+ region.setPrefWidth(width);
+ region.setMaxWidth(width);
+ }
+
+ /**
+ * Opens every Table in {@code node} and below it.
+ *
+ * @param node a node in the View
+ * @param viewModel the ViewModel for this View
+ * @param context the container that creates each Row
+ */
+ private static void walk(Node node, Object viewModel, ApplicationContext context) {
+ if (node instanceof Table table) {
+ table.open(viewModel, context);
+ }
+ if (!(node instanceof Parent parent)) {
+ return;
+ }
+ for (Node child : parent.getChildrenUnmodifiable()) {
+ walk(child, viewModel, context);
+ }
+ if (parent instanceof ScrollPane pane && pane.getContent() != null) {
+ walk(pane.getContent(), viewModel, context);
+ }
+ }
+
+ /**
+ * The set of Attribute values a Column is showing.
+ */
+ private static final class ValueFilter {
+
+ /**
+ * Whether this Filter is applied. All values checked leaves it inactive.
+ */
+ private boolean active;
+
+ /**
+ * Whether a missing value is included.
+ */
+ private boolean includeMissing;
+
+ /**
+ * The confirmed values, not including a missing value.
+ */
+ private final Set values = new HashSet<>();
+
+ /**
+ * The comparison that replaced the value set, or {@link Comparison#NONE}.
+ */
+ private Comparison comparison = Comparison.NONE;
+
+ /**
+ * The comparison value in checklist text.
+ */
+ private String comparisonText = "";
+
+ /**
+ * Reports whether a value passes this Filter.
+ *
+ * @param value the Attribute value, or {@code null}
+ * @return {@code true} when the value is included
+ */
+ private boolean matches(Object value) {
+ if (comparison != Comparison.NONE) {
+ return matchesComparison(value);
+ }
+ if (value == null) {
+ return includeMissing;
+ }
+ return values.contains(value);
+ }
+
+ /**
+ * Reports whether a value passes the comparison. A missing value does not.
+ *
+ * @param value the Attribute value, or {@code null}
+ * @return {@code true} when the comparison matches
+ */
+ private boolean matchesComparison(Object value) {
+ if (value == null) {
+ return false;
+ }
+ String shown = checklistText(value).toLowerCase(Locale.ROOT);
+ String query = comparisonText.toLowerCase(Locale.ROOT);
+ return switch (comparison) {
+ case CONTAINS -> shown.contains(query);
+ case STARTS -> shown.startsWith(query);
+ case ENDS -> shown.endsWith(query);
+ case GREATER -> compareNumber(value, comparisonText) > 0;
+ case LESS -> compareNumber(value, comparisonText) < 0;
+ case BEFORE -> compareTime(value, comparisonText) < 0;
+ case AFTER -> compareTime(value, comparisonText) > 0;
+ case NONE -> false;
+ };
+ }
+
+ /**
+ * Compares a number with the comparison text. The boundary is not a match.
+ *
+ * @param value the Attribute value
+ * @param text the comparison text
+ * @return the comparison
+ */
+ private static int compareNumber(Object value, String text) {
+ double left = ((Number) value).doubleValue();
+ double right = Double.parseDouble(text);
+ return Double.compare(left, right);
+ }
+
+ /**
+ * Compares a date or time with the comparison text. The boundary is not a match.
+ *
+ * @param value the Attribute value
+ * @param text the comparison text
+ * @return the comparison
+ */
+ private static int compareTime(Object value, String text) {
+ if (value instanceof LocalDate date) {
+ return date.compareTo(LocalDate.parse(text));
+ }
+ if (value instanceof LocalDateTime date) {
+ return date.compareTo(LocalDateTime.parse(text));
+ }
+ if (value instanceof Instant instant) {
+ return instant.compareTo(Instant.parse(text));
+ }
+ if (value instanceof Date date) {
+ return date.toInstant().compareTo(Instant.parse(text));
+ }
+ if (value instanceof Calendar calendar) {
+ return calendar.toInstant().compareTo(Instant.parse(text));
+ }
+ return 0;
+ }
+ }
+
+ /**
+ * A typed comparison that can replace a Column's value set.
+ */
+ private enum Comparison {
+ /**
+ * No comparison. The value set decides.
+ */
+ NONE,
+ /**
+ * The text contains the value, ignoring case.
+ */
+ CONTAINS,
+ /**
+ * The text starts with the value, ignoring case.
+ */
+ STARTS,
+ /**
+ * The text ends with the value, ignoring case.
+ */
+ ENDS,
+ /**
+ * The number is greater than the value.
+ */
+ GREATER,
+ /**
+ * The number is less than the value.
+ */
+ LESS,
+ /**
+ * The date or time is before the value.
+ */
+ BEFORE,
+ /**
+ * The date or time is after the value.
+ */
+ AFTER
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
index 02d7536..c6cff7e 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
@@ -12,6 +12,9 @@ import java.net.URL;
*/
public final class ViewLoader {
+ /**
+ * Prevents instantiation.
+ */
private ViewLoader() {
}
@@ -93,6 +96,7 @@ public final class ViewLoader {
for (BindingList bindingList : session.bindingLists()) {
bindingList.apply(loader.getNamespace(), viewModel, controller, root, context);
}
+ Table.openAll(root, viewModel, context);
return new LoadedView(root, viewModel);
} catch (IOException failure) {
throw new IllegalStateException("Could not load view " + fxml, failure);
@@ -101,6 +105,13 @@ public final class ViewLoader {
}
}
+ /**
+ * Asks the container for the Controller class named by the document.
+ *
+ * @param context the container
+ * @param type the Controller class
+ * @return the Controller
+ */
private static Object controller(ApplicationContext context, Class> type) {
return context.getComponent(type.asSubclass(Object.class));
}
diff --git a/fx/src/test/java/de/neitzel/fx/view/FilterByChecklistTest.java b/fx/src/test/java/de/neitzel/fx/view/FilterByChecklistTest.java
new file mode 100644
index 0000000..0953dfa
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/FilterByChecklistTest.java
@@ -0,0 +1,295 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.component.VisitRow;
+import de.neitzel.fx.view.fixture.Host;
+import de.neitzel.fx.view.fixture.Visit;
+import de.neitzel.injection.ApplicationContext;
+import javafx.collections.ObservableList;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.TextField;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.VBox;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A Column checklist shows only the confirmed values.
+ */
+class FilterByChecklistTest {
+
+ /**
+ * Starts the JavaFX toolkit once for this class.
+ */
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ FxThread.start();
+ }
+
+ /**
+ * Confirming a subset hides the rest, and the same Row comes back when the Filter is cleared.
+ */
+ @Test
+ void confirmingASubsetHidesTheOtherRows() throws Exception {
+ FxThread.onFx(() -> {
+ Visit main = new Visit("Main", "Berlin");
+ Visit oak = new Visit("Oak", "Bonn");
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(main, oak)));
+ host.setSelectedVisit(oak);
+ LoadedView loaded = load(host);
+ Table table = table(loaded);
+ VisitRow oakRow = (VisitRow) body(table).getChildren().get(1);
+ openCity(table);
+ uncheck(table, "Bonn");
+ command(table, "Confirm").fire();
+ assertEquals(List.of("Main"), streets(table));
+ assertTrue(opener(table, 1).getStyleClass().contains("filtered"));
+ assertEquals(List.of("Main", "Oak"), streets(host.getVisits()));
+ assertSame(oak, host.getSelectedVisit());
+ assertFalse(body(table).getChildren().stream()
+ .anyMatch(node -> node.getStyleClass().contains(Table.SELECTED_STYLE)));
+ openCity(table);
+ command(table, "Clear filter").fire();
+ assertSame(oakRow, body(table).getChildren().get(1));
+ assertEquals(List.of("Main", "Oak"), streets(table));
+ assertFalse(opener(table, 1).getStyleClass().contains("filtered"));
+ });
+ }
+
+ /**
+ * All checked is no Filter. A later value is shown. None checked hides every Row.
+ */
+ @Test
+ void allCheckedLeavesNoFilterAndNoneCheckedHidesEveryRow() throws Exception {
+ FxThread.onFx(() -> {
+ Visit main = new Visit("Main", "Berlin");
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(main, new Visit("Oak", null))));
+ LoadedView loaded = load(host);
+ Table table = table(loaded);
+ openCity(table);
+ List labels = checks(table).stream().map(CheckBox::getText).toList();
+ assertEquals(List.of("", "Berlin"), labels);
+ command(table, "Confirm").fire();
+ assertFalse(opener(table, 1).getStyleClass().contains("filtered"));
+ @SuppressWarnings("unchecked")
+ ObservableList visits = (ObservableList) loaded.property("visits").getValue();
+ visits.add(new Visit("Elm", "Hamburg"));
+ assertEquals(List.of("Main", "Oak", "Elm"), streets(table));
+ openCity(table);
+ for (CheckBox box : checks(table)) {
+ box.setSelected(false);
+ }
+ command(table, "Confirm").fire();
+ assertEquals(List.of(), streets(table));
+ assertTrue(opener(table, 1).getStyleClass().contains("filtered"));
+ visits.add(new Visit("Pine", "Kiel"));
+ assertEquals(List.of(), streets(table));
+ });
+ }
+
+ /**
+ * An edit that fails the Filter hides that Row and keeps the new value on the entity.
+ */
+ @Test
+ void anEditThatFailsTheFilterHidesTheRow() throws Exception {
+ FxThread.onFx(() -> {
+ Visit main = new Visit("Main", "Berlin");
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(main, new Visit("Oak", "Bonn"))));
+ Table table = table(load(host));
+ openCity(table);
+ uncheck(table, "Bonn");
+ command(table, "Confirm").fire();
+ TextField city = (TextField) body(table).getChildren().get(0).lookup("#cityField");
+ city.setText("Bonn");
+ assertEquals(List.of(), streets(table));
+ assertEquals("Bonn", main.getCity());
+ openCity(table);
+ assertTrue(checks(table).stream().anyMatch(box -> "Bonn".equals(box.getText())));
+ });
+ }
+
+ /**
+ * A click outside the popup keeps the previous Filter.
+ */
+ @Test
+ void aClickOutsideKeepsThePreviousFilter() throws Exception {
+ FxThread.onFx(() -> {
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(new Visit("Main", "Berlin"), new Visit("Oak", "Bonn"))));
+ Table table = table(load(host));
+ openCity(table);
+ uncheck(table, "Bonn");
+ table.fireEvent(click());
+ assertEquals(List.of("Main", "Oak"), streets(table));
+ assertFalse(popup(table).isVisible());
+ });
+ }
+
+ /**
+ * Loads the selection view, which names the same Columns.
+ *
+ * @param host the model
+ * @return the loaded view
+ */
+ private static LoadedView load(Host host) {
+ URL fxml = FilterByChecklistTest.class.getResource("show-visits-selection.fxml");
+ assertNotNull(fxml);
+ return ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
+ }
+
+ /**
+ * Returns the table.
+ *
+ * @param loaded the loaded view
+ * @return the table
+ */
+ private static Table table(LoadedView loaded) {
+ return (Table) loaded.getRoot().lookup("#visitsTable");
+ }
+
+ /**
+ * Returns the body.
+ *
+ * @param table the table
+ * @return the body
+ */
+ private static VBox body(Table table) {
+ ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
+ return (VBox) scroll.getContent();
+ }
+
+ /**
+ * Returns the opener of one Column.
+ *
+ * @param table the table
+ * @param index the Column index
+ * @return the opener
+ */
+ private static javafx.scene.control.Button opener(Table table, int index) {
+ ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
+ HBox header = (HBox) headerScroll.getContent();
+ return (javafx.scene.control.Button) header.getChildren().get(index).lookup("." + Table.OPENER_STYLE);
+ }
+
+ /**
+ * Opens the City popup.
+ *
+ * @param table the table
+ */
+ private static void openCity(Table table) {
+ ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
+ HBox header = (HBox) headerScroll.getContent();
+ ((javafx.scene.control.Button) header.getChildren().get(1).lookup("." + Table.OPENER_STYLE)).fire();
+ }
+
+ /**
+ * Returns the popup.
+ *
+ * @param table the table
+ * @return the popup
+ */
+ private static VBox popup(Table table) {
+ return (VBox) table.lookup("." + Table.POPUP_STYLE);
+ }
+
+ /**
+ * Returns the checklist boxes.
+ *
+ * @param table the table
+ * @return the boxes
+ */
+ private static List checks(Table table) {
+ List boxes = new ArrayList<>();
+ for (Node node : popup(table).getChildren()) {
+ if (node instanceof VBox list) {
+ for (Node child : list.getChildren()) {
+ if (child instanceof CheckBox box) {
+ boxes.add(box);
+ }
+ }
+ }
+ }
+ return boxes;
+ }
+
+ /**
+ * Unchecks the box with the given text.
+ *
+ * @param table the table
+ * @param text the checklist text
+ */
+ private static void uncheck(Table table, String text) {
+ for (CheckBox box : checks(table)) {
+ if (text.equals(box.getText())) {
+ box.setSelected(false);
+ }
+ }
+ }
+
+ /**
+ * Returns a popup command.
+ *
+ * @param table the table
+ * @param text the command text
+ * @return the button
+ */
+ private static Button command(Table table, String text) {
+ return popup(table).getChildren().stream()
+ .filter(Button.class::isInstance)
+ .map(Button.class::cast)
+ .filter(button -> text.equals(button.getText()))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ /**
+ * Returns the streets of the visible rows.
+ *
+ * @param table the table
+ * @return the streets
+ */
+ private static List streets(Table table) {
+ List streets = new ArrayList<>();
+ for (Node node : body(table).getChildren()) {
+ streets.add(((TextField) node.lookup("#streetField")).getText());
+ }
+ return streets;
+ }
+
+ /**
+ * Returns the streets of the collection.
+ *
+ * @param visits the visits
+ * @return the streets
+ */
+ private static List streets(List visits) {
+ return visits.stream().map(Visit::getStreet).toList();
+ }
+
+ /**
+ * Returns a primary click.
+ *
+ * @return the event
+ */
+ private static MouseEvent click() {
+ return new MouseEvent(MouseEvent.MOUSE_CLICKED, 1, 1, 1, 1, MouseButton.PRIMARY, 1,
+ false, false, false, false, true, false, false, false, false, false, null);
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/FilterByComparisonTest.java b/fx/src/test/java/de/neitzel/fx/view/FilterByComparisonTest.java
new file mode 100644
index 0000000..afa6cec
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/FilterByComparisonTest.java
@@ -0,0 +1,325 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.fixture.Host;
+import de.neitzel.fx.view.fixture.Role;
+import de.neitzel.fx.view.fixture.Visit;
+import de.neitzel.injection.ApplicationContext;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.TextField;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.VBox;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URL;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A typed comparison replaces the checklist on that Column.
+ */
+class FilterByComparisonTest {
+
+ /**
+ * Starts the JavaFX toolkit once for this class.
+ */
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ FxThread.start();
+ }
+
+ /**
+ * A string comparison ignores case, and confirming the checklist replaces it.
+ */
+ @Test
+ void aStringComparisonReplacesTheChecklist() throws Exception {
+ FxThread.onFx(() -> {
+ Visit berlin = new Visit("Main", "Berlin");
+ Visit bonn = new Visit("Oak", "Bonn");
+ Visit lower = new Visit("Elm", "berlin");
+ Host host = host(berlin, bonn, lower);
+ Table table = first(load(host));
+ open(table, 1);
+ operator(table).setValue("Contains");
+ value(table).setText("ER");
+ command(table, "Confirm").fire();
+ assertFalse(popup(table).isVisible());
+ assertEquals(List.of("Main", "Elm"), streets(table));
+ assertEquals(List.of("Main", "Oak", "Elm"), streets(host.getVisits()));
+ open(table, 1);
+ assertEquals("Contains", operator(table).getValue());
+ assertEquals("ER", value(table).getText());
+ operator(table).setValue(null);
+ value(table).setText("");
+ uncheck(table, "Berlin");
+ uncheck(table, "berlin");
+ command(table, "Confirm").fire();
+ assertEquals(List.of("Oak"), streets(table));
+ });
+ }
+
+ /**
+ * Starts with and ends with match the checklist text, ignoring case.
+ */
+ @Test
+ void startsWithAndEndsWithMatchTheChecklistText() throws Exception {
+ FxThread.onFx(() -> {
+ Host host = host(new Visit("Main", "Berlin"), new Visit("Oak", "Bonn"));
+ Table table = first(load(host));
+ open(table, 1);
+ operator(table).setValue("Starts with");
+ value(table).setText("bo");
+ command(table, "Confirm").fire();
+ assertEquals(List.of("Oak"), streets(table));
+ open(table, 1);
+ operator(table).setValue("Ends with");
+ value(table).setText("LIN");
+ command(table, "Confirm").fire();
+ assertEquals(List.of("Main"), streets(table));
+ });
+ }
+
+ /**
+ * A number comparison excludes the boundary, and a missing value does not pass.
+ */
+ @Test
+ void aNumberComparisonExcludesTheBoundary() throws Exception {
+ FxThread.onFx(() -> {
+ Visit five = visit("Five", 5, false, Role.MEMBER, LocalDate.of(2020, 1, 2));
+ Visit six = visit("Six", 6, true, Role.ADMIN, LocalDate.of(2020, 1, 1));
+ Visit missing = visit("Missing", null, false, null, null);
+ Host host = host(five, six, missing);
+ Table table = first(load(host));
+ open(table, 2);
+ operator(table).setValue("Greater than");
+ value(table).setText("5");
+ command(table, "Confirm").fire();
+ assertEquals(List.of("Six"), streets(table));
+ open(table, 2);
+ value(table).setText("abc");
+ command(table, "Confirm").fire();
+ assertTrue(popup(table).isVisible());
+ assertEquals(List.of("Six"), streets(table));
+ value(table).setText("");
+ command(table, "Confirm").fire();
+ assertTrue(popup(table).isVisible());
+ operator(table).setValue(null);
+ value(table).setText("5");
+ command(table, "Confirm").fire();
+ assertTrue(popup(table).isVisible());
+ assertEquals(List.of("Six"), streets(table));
+ });
+ }
+
+ /**
+ * Before and after exclude the named day.
+ */
+ @Test
+ void beforeAndAfterExcludeTheNamedDay() throws Exception {
+ FxThread.onFx(() -> {
+ Visit earlier = visit("Early", 1, false, Role.MEMBER, LocalDate.of(2020, 1, 1));
+ Visit day = visit("Day", 1, false, Role.MEMBER, LocalDate.of(2020, 1, 2));
+ Host host = host(earlier, day);
+ Table table = first(load(host));
+ open(table, 5);
+ operator(table).setValue("Before");
+ value(table).setText("2020-01-02");
+ command(table, "Confirm").fire();
+ assertEquals(List.of("Early"), streets(table));
+ open(table, 5);
+ operator(table).setValue("After");
+ value(table).setText("2020-01-01");
+ command(table, "Confirm").fire();
+ assertEquals(List.of("Day"), streets(table));
+ open(table, 5);
+ value(table).setText("02.01.2020");
+ command(table, "Confirm").fire();
+ assertTrue(popup(table).isVisible());
+ assertEquals(List.of("Day"), streets(table));
+ });
+ }
+
+ /**
+ * A boolean and an enum have no comparison.
+ */
+ @Test
+ void aBooleanAndAnEnumHaveNoComparison() throws Exception {
+ FxThread.onFx(() -> {
+ Host host = host(visit("Main", 1, true, Role.MEMBER, LocalDate.of(2020, 1, 1)));
+ Table table = first(load(host));
+ open(table, 3);
+ assertFalse(operator(table).isVisible());
+ assertFalse(value(table).isVisible());
+ open(table, 4);
+ assertFalse(operator(table).isVisible());
+ assertFalse(value(table).isVisible());
+ });
+ }
+
+ /**
+ * Returns a visit with the extra Attributes filled in.
+ *
+ * @param street the street
+ * @param rank the rank
+ * @param open whether the visit is open
+ * @param role the role
+ * @param day the day
+ * @return the visit
+ */
+ private static Visit visit(String street, Integer rank, boolean open, Role role, LocalDate day) {
+ Visit visit = new Visit(street, "Berlin");
+ visit.setRank(rank);
+ visit.setOpen(open);
+ visit.setRole(role);
+ visit.setDay(day);
+ return visit;
+ }
+
+ /**
+ * Returns a host with those visits.
+ *
+ * @param visits the visits
+ * @return the host
+ */
+ private static Host host(Visit... visits) {
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(visits)));
+ return host;
+ }
+
+ /**
+ * Loads the two-table view.
+ *
+ * @param host the model
+ * @return the loaded view
+ */
+ private static LoadedView load(Host host) {
+ URL fxml = FilterByComparisonTest.class.getResource("show-two-tables.fxml");
+ assertNotNull(fxml);
+ return ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
+ }
+
+ /**
+ * Returns the first table.
+ *
+ * @param loaded the loaded view
+ * @return the table
+ */
+ private static Table first(LoadedView loaded) {
+ return (Table) loaded.getRoot().lookup("#firstTable");
+ }
+
+ /**
+ * Opens one Column's popup.
+ *
+ * @param table the table
+ * @param index the Column index
+ */
+ private static void open(Table table, int index) {
+ ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
+ HBox header = (HBox) headerScroll.getContent();
+ ((Button) header.getChildren().get(index).lookup("." + Table.OPENER_STYLE)).fire();
+ }
+
+ /**
+ * Returns the popup.
+ *
+ * @param table the table
+ * @return the popup
+ */
+ private static VBox popup(Table table) {
+ return (VBox) table.lookup("." + Table.POPUP_STYLE);
+ }
+
+ /**
+ * Returns the comparison operator.
+ *
+ * @param table the table
+ * @return the operator
+ */
+ @SuppressWarnings("unchecked")
+ private static ComboBox operator(Table table) {
+ return (ComboBox) popup(table).lookup(".column-comparison-operator");
+ }
+
+ /**
+ * Returns the comparison value.
+ *
+ * @param table the table
+ * @return the value
+ */
+ private static TextField value(Table table) {
+ return (TextField) popup(table).lookup(".column-comparison-value");
+ }
+
+ /**
+ * Unchecks the checklist entry with the given text.
+ *
+ * @param table the table
+ * @param text the checklist text
+ */
+ private static void uncheck(Table table, String text) {
+ for (Node node : popup(table).getChildren()) {
+ if (!(node instanceof VBox list)) {
+ continue;
+ }
+ for (Node child : list.getChildren()) {
+ if (child instanceof CheckBox box && text.equals(box.getText())) {
+ box.setSelected(false);
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns a popup command.
+ *
+ * @param table the table
+ * @param text the command text
+ * @return the button
+ */
+ private static Button command(Table table, String text) {
+ return popup(table).getChildren().stream()
+ .filter(Button.class::isInstance)
+ .map(Button.class::cast)
+ .filter(button -> text.equals(button.getText()))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ /**
+ * Returns the streets of the visible rows.
+ *
+ * @param table the table
+ * @return the streets
+ */
+ private static List streets(Table table) {
+ ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
+ VBox body = (VBox) scroll.getContent();
+ List streets = new ArrayList<>();
+ for (Node node : body.getChildren()) {
+ streets.add(((TextField) node.lookup("#streetField")).getText());
+ }
+ return streets;
+ }
+
+ /**
+ * Returns the streets of the collection.
+ *
+ * @param visits the visits
+ * @return the streets
+ */
+ private static List streets(List visits) {
+ return visits.stream().map(Visit::getStreet).toList();
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/FxThread.java b/fx/src/test/java/de/neitzel/fx/view/FxThread.java
new file mode 100644
index 0000000..477f667
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/FxThread.java
@@ -0,0 +1,88 @@
+package de.neitzel.fx.view;
+
+import javafx.application.Platform;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Runs a check on the JavaFX application thread.
+ */
+final class FxThread {
+
+ /**
+ * Prevents instantiation.
+ */
+ private FxThread() {
+ }
+
+ /**
+ * Starts the JavaFX toolkit once.
+ *
+ * @throws InterruptedException when the toolkit does not start
+ */
+ static void start() throws InterruptedException {
+ CountDownLatch started = new CountDownLatch(1);
+ try {
+ Platform.startup(started::countDown);
+ } catch (IllegalStateException alreadyStarted) {
+ started.countDown();
+ }
+ if (!started.await(10, TimeUnit.SECONDS)) {
+ fail("JavaFX toolkit did not start");
+ }
+ }
+
+ /**
+ * Runs one check on the JavaFX application thread.
+ *
+ * @param action the check
+ * @throws Exception when the check fails
+ */
+ static void onFx(FxAction action) throws Exception {
+ if (Platform.isFxApplicationThread()) {
+ action.run();
+ return;
+ }
+ AtomicReference thrown = new AtomicReference<>();
+ CountDownLatch done = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ try {
+ action.run();
+ } catch (Throwable failure) {
+ thrown.set(failure);
+ } finally {
+ done.countDown();
+ }
+ });
+ if (!done.await(10, TimeUnit.SECONDS)) {
+ fail("JavaFX thread did not finish");
+ }
+ Throwable failure = thrown.get();
+ if (failure instanceof Exception exception) {
+ throw exception;
+ }
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ if (failure != null) {
+ throw new AssertionError(failure);
+ }
+ }
+
+ /**
+ * One check that runs on the JavaFX application thread.
+ */
+ @FunctionalInterface
+ interface FxAction {
+ /**
+ * Runs the check.
+ *
+ * @throws Exception when the check fails
+ */
+ void run() throws Exception;
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/ResizeAColumnTest.java b/fx/src/test/java/de/neitzel/fx/view/ResizeAColumnTest.java
new file mode 100644
index 0000000..4cf8a04
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/ResizeAColumnTest.java
@@ -0,0 +1,141 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.fixture.Host;
+import de.neitzel.fx.view.fixture.Visit;
+import de.neitzel.injection.ApplicationContext;
+import javafx.scene.control.Label;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Region;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Dragging a Column boundary changes that Column's width only.
+ */
+class ResizeAColumnTest {
+
+ /**
+ * Starts the JavaFX toolkit once for this class.
+ */
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ FxThread.start();
+ }
+
+ /**
+ * A drag widens one Column and leaves the other, the Sort, and the order alone.
+ */
+ @Test
+ void aDragChangesOnlyThatColumn() throws Exception {
+ FxThread.onFx(() -> {
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(new Visit("Main", "Berlin"))));
+ Table table = table(host);
+ HBox header = header(table);
+ Region street = (Region) header.getChildren().get(0);
+ Region city = (Region) header.getChildren().get(1);
+ assertEquals(150, street.getPrefWidth());
+ assertEquals(150, city.getPrefWidth());
+ drag(street.lookup("." + Table.RESIZE_STYLE), 100, 140);
+ assertEquals(190, street.getPrefWidth());
+ assertEquals(150, city.getPrefWidth());
+ double widened = street.getPrefWidth();
+ street.lookup("." + Table.RESIZE_STYLE).fireEvent(click(2));
+ assertEquals(widened, street.getPrefWidth());
+ table.setPrefWidth(800);
+ assertEquals(widened, street.getPrefWidth());
+ assertEquals(150, city.getPrefWidth());
+ assertEquals("Street", ((Label) street.lookup("." + Table.HEADER_TEXT_STYLE)).getText());
+ assertEquals("City", ((Label) city.lookup("." + Table.HEADER_TEXT_STYLE)).getText());
+ Label mark = (Label) street.lookup("." + Table.SORT_MARK_STYLE);
+ assertFalse(mark.isVisible());
+ });
+ }
+
+ /**
+ * Columns wider than the Table make the header wider than the viewport.
+ */
+ @Test
+ void wideColumnsScrollHorizontally() throws Exception {
+ FxThread.onFx(() -> {
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(new Visit("Main", "Berlin"))));
+ Table table = table(host);
+ HBox header = header(table);
+ drag(((Region) header.getChildren().get(0)).lookup("." + Table.RESIZE_STYLE), 0, 400);
+ assertEquals(700, header.getPrefWidth());
+ ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
+ assertEquals(javafx.scene.control.ScrollPane.ScrollBarPolicy.AS_NEEDED, scroll.getHbarPolicy());
+ });
+ }
+
+ /**
+ * Loads the visits table.
+ *
+ * @param host the model
+ * @return the table
+ */
+ private static Table table(Host host) {
+ URL fxml = ResizeAColumnTest.class.getResource("show-visits.fxml");
+ assertNotNull(fxml);
+ LoadedView loaded = ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
+ return (Table) loaded.getRoot().lookup("#visitsTable");
+ }
+
+ /**
+ * Returns the header row.
+ *
+ * @param table the table
+ * @return the header
+ */
+ private static HBox header(Table table) {
+ ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
+ return (HBox) headerScroll.getContent();
+ }
+
+ /**
+ * Drags a resize handle from one screen x to another.
+ *
+ * @param handle the handle
+ * @param from the start screen x
+ * @param to the end screen x
+ */
+ private static void drag(javafx.scene.Node handle, double from, double to) {
+ handle.fireEvent(mouse(MouseEvent.MOUSE_PRESSED, from, 1));
+ handle.fireEvent(mouse(MouseEvent.MOUSE_DRAGGED, to, 1));
+ }
+
+ /**
+ * Returns a primary click.
+ *
+ * @param clicks the click count
+ * @return the event
+ */
+ private static MouseEvent click(int clicks) {
+ return mouse(MouseEvent.MOUSE_CLICKED, 0, clicks);
+ }
+
+ /**
+ * Returns a primary mouse event.
+ *
+ * @param type the event type
+ * @param screen the screen x
+ * @param clicks the click count
+ * @return the event
+ */
+ private static MouseEvent mouse(javafx.event.EventType type, double screen, int clicks) {
+ return new MouseEvent(type, screen, 0, screen, 0, MouseButton.PRIMARY, clicks,
+ false, false, false, false, true, false, false, false, false, false, null);
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/SearchTheChecklistTest.java b/fx/src/test/java/de/neitzel/fx/view/SearchTheChecklistTest.java
new file mode 100644
index 0000000..b449d01
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/SearchTheChecklistTest.java
@@ -0,0 +1,241 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.fixture.Host;
+import de.neitzel.fx.view.fixture.Visit;
+import de.neitzel.injection.ApplicationContext;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.control.TextField;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.VBox;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The checklist search narrows entries and Select All still reaches the hidden ones.
+ */
+class SearchTheChecklistTest {
+
+ /**
+ * Starts the JavaFX toolkit once for this class.
+ */
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ FxThread.start();
+ }
+
+ /**
+ * Typing narrows the list and leaves the checks and the Rows alone.
+ */
+ @Test
+ void typingNarrowsTheChecklistWithoutChangingTheRows() throws Exception {
+ FxThread.onFx(() -> {
+ Host host = host(new Visit("Main", "Berlin"), new Visit("Oak", "Bonn"), new Visit("Elm", "Bern"));
+ Table table = table(load(host));
+ openCity(table);
+ search(table).setText("ber");
+ assertTrue(box(table, "Berlin").isVisible());
+ assertTrue(box(table, "Bern").isVisible());
+ assertFalse(box(table, "Bonn").isVisible());
+ assertTrue(box(table, "Bonn").isSelected());
+ assertEquals(List.of("Main", "Oak", "Elm"), streets(table));
+ search(table).setText("*");
+ assertFalse(box(table, "Berlin").isVisible());
+ assertEquals(List.of("Main", "Oak", "Elm"), streets(table));
+ });
+ }
+
+ /**
+ * Each opening starts with an empty search, so every entry is visible.
+ */
+ @Test
+ void eachOpeningStartsWithAnEmptySearch() throws Exception {
+ FxThread.onFx(() -> {
+ Host host = host(new Visit("Main", "Berlin"), new Visit("Oak", "Bonn"));
+ Table table = table(load(host));
+ openCity(table);
+ search(table).setText("ber");
+ command(table, "Confirm").fire();
+ openCity(table);
+ assertEquals("", search(table).getText());
+ assertTrue(box(table, "Berlin").isVisible());
+ assertTrue(box(table, "Bonn").isVisible());
+ });
+ }
+
+ /**
+ * Select All reaches values the search box is hiding, and shows a mixed mark from the whole Column.
+ */
+ @Test
+ void selectAllReachesValuesTheSearchIsHiding() throws Exception {
+ FxThread.onFx(() -> {
+ Host host = host(new Visit("Main", "Berlin"), new Visit("Oak", "Bonn"), new Visit("Elm", "Bern"));
+ Table table = table(load(host));
+ openCity(table);
+ box(table, "Bonn").setSelected(false);
+ search(table).setText("ber");
+ assertFalse(box(table, "Bonn").isVisible());
+ assertTrue(selectAll(table).isIndeterminate());
+ assertFalse(selectAll(table).isSelected());
+ click(selectAll(table));
+ assertTrue(box(table, "Bonn").isSelected());
+ search(table).setText("");
+ assertTrue(box(table, "Bonn").isSelected());
+ search(table).setText("ber");
+ click(selectAll(table));
+ assertFalse(box(table, "Bonn").isSelected());
+ assertFalse(box(table, "Berlin").isSelected());
+ });
+ }
+
+ /**
+ * Loads the visits table.
+ *
+ * @param host the model
+ * @return the loaded view
+ */
+ private static LoadedView load(Host host) {
+ URL fxml = SearchTheChecklistTest.class.getResource("show-visits.fxml");
+ assertNotNull(fxml);
+ return ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
+ }
+
+ /**
+ * Returns a host with those visits.
+ *
+ * @param visits the visits
+ * @return the host
+ */
+ private static Host host(Visit... visits) {
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(visits)));
+ return host;
+ }
+
+ /**
+ * Returns the table.
+ *
+ * @param loaded the loaded view
+ * @return the table
+ */
+ private static Table table(LoadedView loaded) {
+ return (Table) loaded.getRoot().lookup("#visitsTable");
+ }
+
+ /**
+ * Opens the City popup.
+ *
+ * @param table the table
+ */
+ private static void openCity(Table table) {
+ ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
+ HBox header = (HBox) headerScroll.getContent();
+ ((Button) header.getChildren().get(1).lookup("." + Table.OPENER_STYLE)).fire();
+ }
+
+ /**
+ * Returns the popup.
+ *
+ * @param table the table
+ * @return the popup
+ */
+ private static VBox popup(Table table) {
+ return (VBox) table.lookup("." + Table.POPUP_STYLE);
+ }
+
+ /**
+ * Returns the search box.
+ *
+ * @param table the table
+ * @return the search box
+ */
+ private static TextField search(Table table) {
+ return (TextField) popup(table).lookup(".column-search");
+ }
+
+ /**
+ * Returns Select All.
+ *
+ * @param table the table
+ * @return Select All
+ */
+ private static CheckBox selectAll(Table table) {
+ return (CheckBox) popup(table).lookup(".column-select-all");
+ }
+
+ /**
+ * Returns the checklist box with the given text.
+ *
+ * @param table the table
+ * @param text the checklist text
+ * @return the box
+ */
+ private static CheckBox box(Table table, String text) {
+ for (Node node : popup(table).getChildren()) {
+ if (!(node instanceof VBox list)) {
+ continue;
+ }
+ for (Node child : list.getChildren()) {
+ if (child instanceof CheckBox check && text.equals(check.getText())) {
+ return check;
+ }
+ }
+ }
+ throw new AssertionError("No checklist entry " + text);
+ }
+
+ /**
+ * Returns a popup command.
+ *
+ * @param table the table
+ * @param text the command text
+ * @return the button
+ */
+ private static Button command(Table table, String text) {
+ return popup(table).getChildren().stream()
+ .filter(Button.class::isInstance)
+ .map(Button.class::cast)
+ .filter(button -> text.equals(button.getText()))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ /**
+ * Returns the streets of the visible rows.
+ *
+ * @param table the table
+ * @return the streets
+ */
+ private static List streets(Table table) {
+ ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
+ VBox body = (VBox) scroll.getContent();
+ List streets = new ArrayList<>();
+ for (Node node : body.getChildren()) {
+ streets.add(((TextField) node.lookup("#streetField")).getText());
+ }
+ return streets;
+ }
+
+ /**
+ * Clicks a node with the primary button.
+ *
+ * @param node the node
+ */
+ private static void click(Node node) {
+ node.fireEvent(new MouseEvent(MouseEvent.MOUSE_CLICKED, 1, 1, 1, 1, MouseButton.PRIMARY, 1,
+ false, false, false, false, true, false, false, false, false, false, null));
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/SelectARowTest.java b/fx/src/test/java/de/neitzel/fx/view/SelectARowTest.java
new file mode 100644
index 0000000..49a5098
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/SelectARowTest.java
@@ -0,0 +1,134 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.component.VisitRow;
+import de.neitzel.fx.view.fixture.Host;
+import de.neitzel.fx.view.fixture.Visit;
+import de.neitzel.injection.ApplicationContext;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.VBox;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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;
+
+/**
+ * Clicking a Row sets the parent Model's Selection.
+ */
+class SelectARowTest {
+
+ /**
+ * Starts the JavaFX toolkit once for this class.
+ */
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ FxThread.start();
+ }
+
+ /**
+ * A click selects the visit, including a click in a field, and a second click leaves it.
+ */
+ @Test
+ void aClickSelectsTheVisit() throws Exception {
+ FxThread.onFx(() -> {
+ Visit main = new Visit("Main", "Berlin");
+ Visit oak = new Visit("Oak", "Bonn");
+ Host host = host(oak, main, oak);
+ LoadedView loaded = load(host);
+ VBox body = body(loaded);
+ VisitRow mainRow = (VisitRow) body.getChildren().get(0);
+ VisitRow oakRow = (VisitRow) body.getChildren().get(1);
+ assertTrue(oakRow.getStyleClass().contains(Table.SELECTED_STYLE));
+ assertFalse(mainRow.getStyleClass().contains(Table.SELECTED_STYLE));
+ click(mainRow.lookup("#streetField"));
+ assertSame(main, host.getSelectedVisit());
+ assertTrue(mainRow.getStyleClass().contains(Table.SELECTED_STYLE));
+ assertFalse(oakRow.getStyleClass().contains(Table.SELECTED_STYLE));
+ click(mainRow);
+ assertSame(main, host.getSelectedVisit());
+ });
+ }
+
+ /**
+ * A click on the blank body clears Selection. Removing the element clears it too.
+ */
+ @Test
+ void theBlankBodyAndRemovalClearSelection() throws Exception {
+ FxThread.onFx(() -> {
+ Visit main = new Visit("Main", "Berlin");
+ Visit oak = new Visit("Oak", "Bonn");
+ Host host = host(main, main, oak);
+ LoadedView loaded = load(host);
+ VBox rows = body(loaded);
+ click(rows);
+ assertNull(host.getSelectedVisit());
+ assertFalse(((VisitRow) rows.getChildren().get(0)).getStyleClass().contains(Table.SELECTED_STYLE));
+ click(rows.getChildren().get(1));
+ assertSame(oak, host.getSelectedVisit());
+ @SuppressWarnings("unchecked")
+ javafx.collections.ObservableList visits =
+ (javafx.collections.ObservableList) loaded.property("visits").getValue();
+ visits.remove(oak);
+ assertNull(host.getSelectedVisit());
+ assertEquals(1, rows.getChildren().size());
+ });
+ }
+
+ /**
+ * Returns a host with the selection already set.
+ *
+ * @param selected the selected visit
+ * @param visits the visits
+ * @return the host
+ */
+ private static Host host(Visit selected, Visit... visits) {
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(visits)));
+ host.setSelectedVisit(selected);
+ return host;
+ }
+
+ /**
+ * Loads the selection view.
+ *
+ * @param host the model
+ * @return the loaded view
+ */
+ private static LoadedView load(Host host) {
+ URL fxml = SelectARowTest.class.getResource("show-visits-selection.fxml");
+ assertNotNull(fxml);
+ return ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
+ }
+
+ /**
+ * Returns the row body.
+ *
+ * @param loaded the loaded view
+ * @return the body
+ */
+ private static VBox body(LoadedView loaded) {
+ Table table = (Table) loaded.getRoot().lookup("#visitsTable");
+ ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
+ return (VBox) scroll.getContent();
+ }
+
+ /**
+ * Clicks a node with the primary button.
+ *
+ * @param node the node
+ */
+ private static void click(javafx.scene.Node node) {
+ node.fireEvent(new MouseEvent(MouseEvent.MOUSE_CLICKED, 1, 1, 1, 1, MouseButton.PRIMARY, 1,
+ false, false, false, false, true, false, false, false, false, false, null));
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/ShowATableOfRowsTest.java b/fx/src/test/java/de/neitzel/fx/view/ShowATableOfRowsTest.java
new file mode 100644
index 0000000..4845807
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/ShowATableOfRowsTest.java
@@ -0,0 +1,360 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.component.CityFirstRow;
+import de.neitzel.fx.view.component.VisitRow;
+import de.neitzel.fx.view.fixture.Host;
+import de.neitzel.fx.view.fixture.Visit;
+import de.neitzel.injection.ApplicationContext;
+import javafx.application.Platform;
+import javafx.collections.ObservableList;
+import javafx.scene.Parent;
+import javafx.scene.control.Label;
+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;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.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.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * A Table shows one Row for each element, in collection order.
+ */
+class ShowATableOfRowsTest {
+
+ /**
+ * Starts the JavaFX toolkit once for this class.
+ */
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ CountDownLatch started = new CountDownLatch(1);
+ try {
+ Platform.startup(started::countDown);
+ } catch (IllegalStateException alreadyStarted) {
+ started.countDown();
+ }
+ if (!started.await(10, TimeUnit.SECONDS)) {
+ fail("JavaFX toolkit did not start");
+ }
+ }
+
+ /**
+ * Each visit appears in collection order, with its fields under the headers.
+ */
+ @Test
+ void eachVisitAppearsInCollectionOrderUnderTheHeaders() throws Exception {
+ onFx(() -> {
+ Visit main = new Visit("Main", "Berlin");
+ Visit oak = new Visit("Oak", "Bonn");
+ Host host = host(main, oak);
+ LoadedView loaded = load("show-visits.fxml", host);
+ Table table = table(loaded);
+ HBox header = header(table);
+ assertEquals("Street", headerText(header, 0));
+ assertEquals("City", headerText(header, 1));
+ VBox body = body(table);
+ assertEquals(2, body.getChildren().size());
+ VisitRow first = (VisitRow) body.getChildren().get(0);
+ VisitRow second = (VisitRow) body.getChildren().get(1);
+ assertSame(main, first.getModel());
+ assertSame(oak, second.getModel());
+ assertEquals("Main", field(first, "streetField").getText());
+ assertEquals("Berlin", field(first, "cityField").getText());
+ assertEquals("Oak", field(second, "streetField").getText());
+ assertEquals("Bonn", field(second, "cityField").getText());
+ assertEquals(150, field(first, "streetField").getPrefWidth());
+ assertEquals(150, field(first, "cityField").getPrefWidth());
+ assertEquals(150, ((javafx.scene.layout.Region) header.getChildren().get(0)).getPrefWidth());
+ assertEquals(150, ((javafx.scene.layout.Region) header.getChildren().get(1)).getPrefWidth());
+ first.setModel(new Visit("Other", "Hamburg"));
+ assertEquals("Main", field(first, "streetField").getText());
+ });
+ }
+
+ /**
+ * A row keeps the height of its own fields.
+ */
+ @Test
+ void aRowKeepsTheHeightOfItsOwnFields() throws Exception {
+ onFx(() -> {
+ Host host = host(new Visit("Tall", "Berlin"), new Visit("Main", "Bonn"));
+ LoadedView loaded = load("show-visits.fxml", host);
+ VBox body = body(table(loaded));
+ assertEquals(80, ((Region) body.getChildren().get(0)).getPrefHeight());
+ assertEquals(24, ((Region) body.getChildren().get(1)).getPrefHeight());
+ });
+ }
+
+ /**
+ * A row whose fields are swapped still loads.
+ */
+ @Test
+ void aRowWhoseFieldsAreSwappedStillLoads() throws Exception {
+ onFx(() -> {
+ Visit main = new Visit("Main", "Berlin");
+ Host host = host(main);
+ LoadedView loaded = load("show-visits-swapped.fxml", host);
+ VBox body = body(table(loaded));
+ assertEquals(1, body.getChildren().size());
+ CityFirstRow row = (CityFirstRow) body.getChildren().get(0);
+ assertSame(main, row.getModel());
+ assertEquals("Berlin", field(row, "cityField").getText());
+ assertEquals("Main", field(row, "streetField").getText());
+ });
+ }
+
+ /**
+ * Adding and removing a visit updates the table and the entity.
+ */
+ @Test
+ void addingAndRemovingAVisitUpdatesTheTableAndTheEntity() throws Exception {
+ onFx(() -> {
+ Visit main = new Visit("Main", "Berlin");
+ Visit oak = new Visit("Oak", "Bonn");
+ Host host = host(main, oak);
+ LoadedView loaded = load("show-visits.fxml", host);
+ @SuppressWarnings("unchecked")
+ ObservableList visits = (ObservableList) loaded.property("visits").getValue();
+ Visit elm = new Visit("Elm", "Hamburg");
+ visits.add(elm);
+ VBox body = body(table(loaded));
+ assertEquals(3, body.getChildren().size());
+ assertSame(elm, ((VisitRow) body.getChildren().get(2)).getModel());
+ assertEquals(List.of("Main", "Oak", "Elm"), streets(host.getVisits()));
+ visits.remove(oak);
+ assertEquals(2, body.getChildren().size());
+ assertSame(main, ((VisitRow) body.getChildren().get(0)).getModel());
+ assertSame(elm, ((VisitRow) body.getChildren().get(1)).getModel());
+ assertEquals(List.of("Main", "Elm"), streets(host.getVisits()));
+ });
+ }
+
+ /**
+ * A row scrolled out of sight still exists, and the header stays put.
+ */
+ @Test
+ void aRowScrolledOutOfSightStillExists() throws Exception {
+ onFx(() -> {
+ List visits = new ArrayList<>();
+ for (int index = 0; index < 12; index++) {
+ visits.add(new Visit("Street " + index, "Berlin"));
+ }
+ Host host = new Host();
+ host.setVisits(visits);
+ LoadedView loaded = load("show-visits.fxml", host);
+ Table table = table(loaded);
+ VBox body = body(table);
+ assertEquals(12, body.getChildren().size());
+ ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
+ assertSame(body, scroll.getContent());
+ HBox header = header(table);
+ assertFalse(scroll.getContent() instanceof Parent parent && parent.getChildrenUnmodifiable().contains(header));
+ assertSame(body, scroll.getContent());
+ });
+ }
+
+ /**
+ * An empty collection shows the headers and a blank body.
+ */
+ @Test
+ void anEmptyCollectionShowsTheHeadersAndABlankBody() throws Exception {
+ onFx(() -> {
+ Host host = new Host();
+ LoadedView loaded = load("show-visits.fxml", host);
+ Table table = table(loaded);
+ assertEquals(2, header(table).getChildren().size());
+ assertEquals(0, body(table).getChildren().size());
+ assertNull(body(table).lookup(".label"));
+ });
+ }
+
+ /**
+ * A table with no column fails to load.
+ */
+ @Test
+ void aTableWithNoColumnFailsToLoad() throws Exception {
+ onFx(() -> {
+ Host host = host(new Visit("Main", "Berlin"));
+ assertThrows(IllegalArgumentException.class, () -> load("show-visits-no-column.fxml", host));
+ });
+ }
+
+ /**
+ * A table that names one attribute twice fails to load.
+ */
+ @Test
+ void aRepeatedAttributeFailsToLoad() throws Exception {
+ onFx(() -> {
+ Host host = host(new Visit("Main", "Berlin"));
+ assertThrows(IllegalArgumentException.class, () -> load("show-visits-duplicate.fxml", host));
+ });
+ }
+
+ /**
+ * A column that is not a simple value fails to load.
+ */
+ @Test
+ void aComplexAttributeFailsToLoad() throws Exception {
+ onFx(() -> {
+ Host host = host(new Visit("Main", "Berlin"));
+ assertThrows(IllegalArgumentException.class, () -> load("show-visits-complex.fxml", host));
+ });
+ }
+
+ /**
+ * Loads a view of visits.
+ *
+ * @param name the document name
+ * @param host the model
+ * @return the loaded view
+ */
+ private static LoadedView load(String name, Host host) {
+ URL fxml = ShowATableOfRowsTest.class.getResource(name);
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
+ return ViewLoader.load(fxml, host, context);
+ }
+
+ /**
+ * Returns a host whose visits are {@code visits}.
+ *
+ * @param visits the visits
+ * @return the host
+ */
+ private static Host host(Visit... visits) {
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(visits)));
+ return host;
+ }
+
+ /**
+ * Returns the table in a loaded view.
+ *
+ * @param loaded the loaded view
+ * @return the table
+ */
+ private static Table table(LoadedView loaded) {
+ return (Table) loaded.getRoot().lookup("#visitsTable");
+ }
+
+ /**
+ * Returns the header row.
+ *
+ * @param table the table
+ * @return the header
+ */
+ private static HBox header(Table table) {
+ ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
+ return (HBox) headerScroll.getContent();
+ }
+
+ /**
+ * Returns the header text of one Column.
+ *
+ * @param header the header row
+ * @param index the Column index
+ * @return the header text
+ */
+ private static String headerText(HBox header, int index) {
+ Label label = (Label) header.getChildren().get(index).lookup("." + Table.HEADER_TEXT_STYLE);
+ return label.getText();
+ }
+
+ /**
+ * Returns the body that holds the rows.
+ *
+ * @param table the table
+ * @return the body
+ */
+ private static VBox body(Table table) {
+ ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
+ assertInstanceOf(VBox.class, scroll.getContent());
+ return (VBox) scroll.getContent();
+ }
+
+ /**
+ * Returns a named field in a row.
+ *
+ * @param row the row
+ * @param id the field id
+ * @return the field
+ */
+ private static TextField field(Parent row, String id) {
+ return (TextField) row.lookup("#" + id);
+ }
+
+ /**
+ * Returns the streets of the visits, in list order.
+ *
+ * @param visits the visits
+ * @return the streets
+ */
+ private static List streets(List visits) {
+ return visits.stream().map(Visit::getStreet).toList();
+ }
+
+ /**
+ * Runs one check on the JavaFX application thread.
+ *
+ * @param action the check
+ */
+ private static void onFx(FxAction action) throws Exception {
+ if (Platform.isFxApplicationThread()) {
+ action.run();
+ return;
+ }
+ AtomicReference thrown = new AtomicReference<>();
+ CountDownLatch done = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ try {
+ action.run();
+ } catch (Throwable failure) {
+ thrown.set(failure);
+ } finally {
+ done.countDown();
+ }
+ });
+ if (!done.await(10, TimeUnit.SECONDS)) {
+ fail("JavaFX thread did not finish");
+ }
+ Throwable failure = thrown.get();
+ if (failure instanceof Exception exception) {
+ throw exception;
+ }
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ if (failure != null) {
+ throw new AssertionError(failure);
+ }
+ }
+
+ /**
+ * One check that runs on the JavaFX application thread.
+ */
+ @FunctionalInterface
+ private interface FxAction {
+ /**
+ * Runs the check.
+ *
+ * @throws Exception when the check fails
+ */
+ void run() throws Exception;
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/SortOneColumnTest.java b/fx/src/test/java/de/neitzel/fx/view/SortOneColumnTest.java
new file mode 100644
index 0000000..0061d1c
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/SortOneColumnTest.java
@@ -0,0 +1,279 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.component.VisitRow;
+import de.neitzel.fx.view.fixture.Host;
+import de.neitzel.fx.view.fixture.Role;
+import de.neitzel.fx.view.fixture.Visit;
+import de.neitzel.injection.ApplicationContext;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.VBox;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URL;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A header click sorts one Column without rearranging the collection.
+ */
+class SortOneColumnTest {
+
+ /**
+ * Starts the JavaFX toolkit once for this class.
+ */
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ FxThread.start();
+ }
+
+ /**
+ * A header click switches direction and drops the other Column's Sort.
+ */
+ @Test
+ void aHeaderClickSortsOneColumn() throws Exception {
+ FxThread.onFx(() -> {
+ Visit main = new Visit("Main", "Bonn");
+ Visit oak = new Visit("Oak", "Berlin");
+ Visit elm = new Visit("Elm", null);
+ Host host = host(main, oak, elm);
+ LoadedView loaded = load("show-two-tables.fxml", host);
+ Table first = (Table) loaded.getRoot().lookup("#firstTable");
+ clickHeader(first, 1);
+ assertTrue(mark(first, 1).isVisible());
+ assertEquals(List.of("Oak", "Main", "Elm"), streets(first));
+ assertEquals("ascending", mark(first, 1).getText());
+ assertFalse(mark(first, 0).isVisible());
+ clickHeader(first, 1);
+ assertEquals(List.of("Main", "Oak", "Elm"), streets(first));
+ assertEquals("descending", mark(first, 1).getText());
+ clickHeader(first, 0);
+ assertEquals(List.of("Elm", "Main", "Oak"), streets(first));
+ assertTrue(mark(first, 0).isVisible());
+ assertFalse(mark(first, 1).isVisible());
+ assertEquals(List.of("Main", "Oak", "Elm"), streets(host.getVisits()));
+ Table second = (Table) loaded.getRoot().lookup("#secondTable");
+ assertEquals(List.of("Main", "Oak", "Elm"), streets(second));
+ });
+ }
+
+ /**
+ * The popup sorts and clears, and a click outside leaves the Sort.
+ */
+ @Test
+ void thePopupSortsAndClears() throws Exception {
+ FxThread.onFx(() -> {
+ Visit berlin = new Visit("Main", "berlin");
+ Visit capital = new Visit("Oak", "Berlin");
+ Visit aachen = new Visit("Elm", "Aachen");
+ Host host = host(berlin, capital, aachen);
+ Table table = (Table) load("show-two-tables.fxml", host).getRoot().lookup("#firstTable");
+ opener(table, 1).fire();
+ VBox popup = popup(table);
+ assertTrue(popup.isVisible());
+ command(popup, "Ascending").fire();
+ assertFalse(popup.isVisible());
+ assertEquals(List.of("Elm", "Main", "Oak"), streets(table));
+ opener(table, 1).fire();
+ table.fireEvent(click());
+ assertFalse(popup(table).isVisible());
+ assertEquals(List.of("Elm", "Main", "Oak"), streets(table));
+ opener(table, 1).fire();
+ command(popup(table), "Clear sort").fire();
+ assertEquals(List.of("Main", "Oak", "Elm"), streets(table));
+ assertFalse(mark(table, 1).isVisible());
+ });
+ }
+
+ /**
+ * Numbers, booleans, enums, and dates sort in their own order. Missing values stay last.
+ */
+ @Test
+ void eachTypeHasItsOwnOrder() throws Exception {
+ FxThread.onFx(() -> {
+ Visit low = visit("Low", 1, false, Role.ADMIN, LocalDate.of(2020, 1, 2));
+ Visit high = visit("High", 10, true, Role.MEMBER, LocalDate.of(2019, 5, 1));
+ Visit missing = visit("Missing", null, false, null, null);
+ Host host = host(high, missing, low);
+ Table table = (Table) load("show-two-tables.fxml", host).getRoot().lookup("#firstTable");
+ clickHeader(table, 2);
+ assertEquals(List.of("Low", "High", "Missing"), streets(table));
+ clickHeader(table, 3);
+ assertEquals(List.of("Missing", "Low", "High"), streets(table));
+ clickHeader(table, 4);
+ assertEquals(List.of("High", "Low", "Missing"), streets(table));
+ clickHeader(table, 5);
+ assertEquals(List.of("High", "Low", "Missing"), streets(table));
+ });
+ }
+
+ /**
+ * Returns a visit with the extra Attributes filled in.
+ *
+ * @param street the street
+ * @param rank the rank
+ * @param open whether the visit is open
+ * @param role the role
+ * @param day the day
+ * @return the visit
+ */
+ private static Visit visit(String street, Integer rank, boolean open, Role role, LocalDate day) {
+ Visit visit = new Visit(street, "Berlin");
+ visit.setRank(rank);
+ visit.setOpen(open);
+ visit.setRole(role);
+ visit.setDay(day);
+ return visit;
+ }
+
+ /**
+ * Loads a document.
+ *
+ * @param name the document name
+ * @param host the model
+ * @return the loaded view
+ */
+ private static LoadedView load(String name, Host host) {
+ URL fxml = SortOneColumnTest.class.getResource(name);
+ assertNotNull(fxml);
+ return ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
+ }
+
+ /**
+ * Returns a host with those visits.
+ *
+ * @param visits the visits
+ * @return the host
+ */
+ private static Host host(Visit... visits) {
+ Host host = new Host();
+ host.setVisits(new ArrayList<>(List.of(visits)));
+ return host;
+ }
+
+ /**
+ * Clicks a Column header.
+ *
+ * @param table the table
+ * @param index the Column index
+ */
+ private static void clickHeader(Table table, int index) {
+ HBox header = header(table);
+ header.getChildren().get(index).fireEvent(click());
+ }
+
+ /**
+ * Returns the header row.
+ *
+ * @param table the table
+ * @return the header
+ */
+ private static HBox header(Table table) {
+ ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
+ return (HBox) headerScroll.getContent();
+ }
+
+ /**
+ * Returns the sort mark of one Column.
+ *
+ * @param table the table
+ * @param index the Column index
+ * @return the mark
+ */
+ private static Label mark(Table table, int index) {
+ return (Label) header(table).getChildren().get(index).lookup("." + Table.SORT_MARK_STYLE);
+ }
+
+ /**
+ * Returns the opener of one Column.
+ *
+ * @param table the table
+ * @param index the Column index
+ * @return the opener
+ */
+ private static Button opener(Table table, int index) {
+ return (Button) header(table).getChildren().get(index).lookup("." + Table.OPENER_STYLE);
+ }
+
+ /**
+ * Returns the popup.
+ *
+ * @param table the table
+ * @return the popup
+ */
+ private static VBox popup(Table table) {
+ return (VBox) table.lookup("." + Table.POPUP_STYLE);
+ }
+
+ /**
+ * Returns a popup command.
+ *
+ * @param popup the popup
+ * @param text the command text
+ * @return the button
+ */
+ private static Button command(VBox popup, String text) {
+ return popup.getChildren().stream()
+ .filter(Button.class::isInstance)
+ .map(Button.class::cast)
+ .filter(button -> text.equals(button.getText()))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ /**
+ * Returns the streets of the visible rows.
+ *
+ * @param table the table
+ * @return the streets
+ */
+ private static List streets(Table table) {
+ return body(table).getChildren().stream()
+ .map(node -> ((javafx.scene.control.TextField) ((javafx.scene.Parent) node).lookup("#streetField"))
+ .getText())
+ .toList();
+ }
+
+ /**
+ * Returns the streets of the collection.
+ *
+ * @param visits the visits
+ * @return the streets
+ */
+ private static List streets(List visits) {
+ return visits.stream().map(Visit::getStreet).toList();
+ }
+
+ /**
+ * Returns the body.
+ *
+ * @param table the table
+ * @return the body
+ */
+ private static VBox body(Table table) {
+ ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
+ return (VBox) scroll.getContent();
+ }
+
+ /**
+ * Returns a primary click.
+ *
+ * @return the event
+ */
+ private static MouseEvent click() {
+ return new MouseEvent(MouseEvent.MOUSE_CLICKED, 1, 1, 1, 1, MouseButton.PRIMARY, 1,
+ false, false, false, false, true, false, false, false, false, false, null);
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/component/CityFirstRow.java b/fx/src/test/java/de/neitzel/fx/view/component/CityFirstRow.java
new file mode 100644
index 0000000..1686efc
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/component/CityFirstRow.java
@@ -0,0 +1,44 @@
+package de.neitzel.fx.view.component;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.scene.layout.HBox;
+
+/**
+ * A Row that lays city out before street.
+ */
+@Singleton
+public class CityFirstRow extends HBox {
+
+ /**
+ * The visit this row shows.
+ */
+ private final ObjectProperty model = new SimpleObjectProperty<>(this, "model");
+
+ /**
+ * Creates a row.
+ */
+ @Inject
+ public CityFirstRow() {
+ }
+
+ /**
+ * Returns the model property.
+ *
+ * @return the model property
+ */
+ public ObjectProperty modelProperty() {
+ return model;
+ }
+
+ /**
+ * Returns the visit this row shows.
+ *
+ * @return the visit, or {@code null}
+ */
+ public Object getModel() {
+ return model.get();
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/component/VisitRow.java b/fx/src/test/java/de/neitzel/fx/view/component/VisitRow.java
new file mode 100644
index 0000000..fa769d5
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/component/VisitRow.java
@@ -0,0 +1,76 @@
+package de.neitzel.fx.view.component;
+
+import de.neitzel.fx.view.fixture.Visit;
+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.TextField;
+import javafx.scene.layout.HBox;
+
+/**
+ * A Row that shows a visit's street and city, street first.
+ */
+@Singleton
+public class VisitRow extends HBox {
+
+ /**
+ * The visit this row shows.
+ */
+ private final ObjectProperty model = new SimpleObjectProperty<>(this, "model");
+
+ /**
+ * The street field, bound by this row.
+ */
+ @FXML
+ private TextField streetField;
+
+ /**
+ * Creates a row.
+ */
+ @Inject
+ public VisitRow() {
+ model.addListener((observable, previous, current) -> applyHeight(current));
+ }
+
+ /**
+ * Gives a visit named Tall a taller row. Every other visit stays one line.
+ *
+ * @param current the model, or {@code null}
+ */
+ private void applyHeight(Object current) {
+ if (current instanceof Visit visit && "Tall".equals(visit.getStreet())) {
+ setPrefHeight(80);
+ } else {
+ setPrefHeight(24);
+ }
+ }
+
+ /**
+ * Returns the model property.
+ *
+ * @return the model property
+ */
+ public ObjectProperty modelProperty() {
+ return model;
+ }
+
+ /**
+ * Returns the visit this row shows.
+ *
+ * @return the visit, or {@code null}
+ */
+ public Object getModel() {
+ return model.get();
+ }
+
+ /**
+ * Sets the visit this row shows.
+ *
+ * @param model the visit
+ */
+ public void setModel(Object model) {
+ this.model.set(model);
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/Host.java b/fx/src/test/java/de/neitzel/fx/view/fixture/Host.java
new file mode 100644
index 0000000..41af850
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/fixture/Host.java
@@ -0,0 +1,56 @@
+package de.neitzel.fx.view.fixture;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A model whose visits a Table shows.
+ */
+public class Host {
+
+ /**
+ * The visits. After a view loads, this is the list the Table watches.
+ */
+ private List visits = new ArrayList<>();
+
+ /**
+ * The selected visit, or {@code null} when none is selected.
+ */
+ private Visit selectedVisit;
+
+ /**
+ * Returns the visits.
+ *
+ * @return the visits
+ */
+ public List getVisits() {
+ return visits;
+ }
+
+ /**
+ * Sets the visits.
+ *
+ * @param visits the visits
+ */
+ public void setVisits(List visits) {
+ this.visits = visits;
+ }
+
+ /**
+ * Returns the selected visit.
+ *
+ * @return the selected visit, or {@code null}
+ */
+ public Visit getSelectedVisit() {
+ return selectedVisit;
+ }
+
+ /**
+ * Sets the selected visit.
+ *
+ * @param selectedVisit the selected visit
+ */
+ public void setSelectedVisit(Visit selectedVisit) {
+ this.selectedVisit = selectedVisit;
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/Visit.java b/fx/src/test/java/de/neitzel/fx/view/fixture/Visit.java
new file mode 100644
index 0000000..3deabd4
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/fixture/Visit.java
@@ -0,0 +1,179 @@
+package de.neitzel.fx.view.fixture;
+
+/**
+ * One visit a table can show.
+ */
+public class Visit {
+
+ /**
+ * The street.
+ */
+ private String street;
+
+ /**
+ * The city.
+ */
+ private String city;
+
+ /**
+ * A whole number, used when a Column sorts or filters by magnitude.
+ */
+ private Integer rank;
+
+ /**
+ * Whether the visit is open.
+ */
+ private boolean open;
+
+ /**
+ * The visitor's role.
+ */
+ private Role role;
+
+ /**
+ * The day of the visit.
+ */
+ private java.time.LocalDate day;
+
+ /**
+ * A nested address. A Column cannot use this Attribute.
+ */
+ private Address place;
+
+ /**
+ * Creates a visit.
+ *
+ * @param street the street
+ * @param city the city
+ */
+ public Visit(String street, String city) {
+ this.street = street;
+ this.city = city;
+ }
+
+ /**
+ * Returns the street.
+ *
+ * @return the street
+ */
+ public String getStreet() {
+ return street;
+ }
+
+ /**
+ * Sets the street.
+ *
+ * @param street the street
+ */
+ public void setStreet(String street) {
+ this.street = street;
+ }
+
+ /**
+ * Returns the city.
+ *
+ * @return the city
+ */
+ public String getCity() {
+ return city;
+ }
+
+ /**
+ * Sets the city.
+ *
+ * @param city the city
+ */
+ public void setCity(String city) {
+ this.city = city;
+ }
+
+ /**
+ * Returns the rank.
+ *
+ * @return the rank, or {@code null}
+ */
+ public Integer getRank() {
+ return rank;
+ }
+
+ /**
+ * Sets the rank.
+ *
+ * @param rank the rank
+ */
+ public void setRank(Integer rank) {
+ this.rank = rank;
+ }
+
+ /**
+ * Returns whether the visit is open.
+ *
+ * @return {@code true} when the visit is open
+ */
+ public boolean isOpen() {
+ return open;
+ }
+
+ /**
+ * Sets whether the visit is open.
+ *
+ * @param open whether the visit is open
+ */
+ public void setOpen(boolean open) {
+ this.open = open;
+ }
+
+ /**
+ * Returns the role.
+ *
+ * @return the role, or {@code null}
+ */
+ public Role getRole() {
+ return role;
+ }
+
+ /**
+ * Sets the role.
+ *
+ * @param role the role
+ */
+ public void setRole(Role role) {
+ this.role = role;
+ }
+
+ /**
+ * Returns the day.
+ *
+ * @return the day, or {@code null}
+ */
+ public java.time.LocalDate getDay() {
+ return day;
+ }
+
+ /**
+ * Sets the day.
+ *
+ * @param day the day
+ */
+ public void setDay(java.time.LocalDate day) {
+ this.day = day;
+ }
+
+ /**
+ * Returns the nested address.
+ *
+ * @return the nested address, or {@code null}
+ */
+ public Address getPlace() {
+ return place;
+ }
+
+ /**
+ * Sets the nested address.
+ *
+ * @param place the nested address
+ */
+ public void setPlace(Address place) {
+ this.place = place;
+ }
+}
diff --git a/fx/src/test/resources/de/neitzel/fx/view/component/CityFirstRow.fxml b/fx/src/test/resources/de/neitzel/fx/view/component/CityFirstRow.fxml
new file mode 100644
index 0000000..1d21cdc
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/component/CityFirstRow.fxml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/component/VisitRow.fxml b/fx/src/test/resources/de/neitzel/fx/view/component/VisitRow.fxml
new file mode 100644
index 0000000..259fd6f
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/component/VisitRow.fxml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/show-two-tables.fxml b/fx/src/test/resources/de/neitzel/fx/view/show-two-tables.fxml
new file mode 100644
index 0000000..30e4d95
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/show-two-tables.fxml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/show-visits-complex.fxml b/fx/src/test/resources/de/neitzel/fx/view/show-visits-complex.fxml
new file mode 100644
index 0000000..33007bb
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/show-visits-complex.fxml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/show-visits-duplicate.fxml b/fx/src/test/resources/de/neitzel/fx/view/show-visits-duplicate.fxml
new file mode 100644
index 0000000..81f9268
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/show-visits-duplicate.fxml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/show-visits-no-column.fxml b/fx/src/test/resources/de/neitzel/fx/view/show-visits-no-column.fxml
new file mode 100644
index 0000000..eaecb74
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/show-visits-no-column.fxml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/show-visits-selection.fxml b/fx/src/test/resources/de/neitzel/fx/view/show-visits-selection.fxml
new file mode 100644
index 0000000..f4d6544
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/show-visits-selection.fxml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/show-visits-swapped.fxml b/fx/src/test/resources/de/neitzel/fx/view/show-visits-swapped.fxml
new file mode 100644
index 0000000..52c21b7
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/show-visits-swapped.fxml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/show-visits.fxml b/fx/src/test/resources/de/neitzel/fx/view/show-visits.fxml
new file mode 100644
index 0000000..bb5333a
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/show-visits.fxml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+