From 188a6f38a5db77291962c0dee2a4fc86e00e501e Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Sat, 31 Oct 2026 16:22:42 +0100 Subject: [PATCH] Add a Table that shows one Row per element, with sort and filter. The collection keeps its own order. Header clicks and the popup sort one Column, and the checklist or a typed comparison decides which Rows stay visible. Co-authored-by: Cursor --- .../java/de/neitzel/fx/view/Attributes.java | 136 ++ .../main/java/de/neitzel/fx/view/Column.java | 56 + .../main/java/de/neitzel/fx/view/Table.java | 1578 +++++++++++++++++ .../java/de/neitzel/fx/view/ViewLoader.java | 11 + .../fx/view/FilterByChecklistTest.java | 295 +++ .../fx/view/FilterByComparisonTest.java | 325 ++++ .../java/de/neitzel/fx/view/FxThread.java | 88 + .../de/neitzel/fx/view/ResizeAColumnTest.java | 141 ++ .../fx/view/SearchTheChecklistTest.java | 241 +++ .../de/neitzel/fx/view/SelectARowTest.java | 134 ++ .../neitzel/fx/view/ShowATableOfRowsTest.java | 360 ++++ .../de/neitzel/fx/view/SortOneColumnTest.java | 279 +++ .../fx/view/component/CityFirstRow.java | 44 + .../neitzel/fx/view/component/VisitRow.java | 76 + .../java/de/neitzel/fx/view/fixture/Host.java | 56 + .../de/neitzel/fx/view/fixture/Visit.java | 179 ++ .../fx/view/component/CityFirstRow.fxml | 18 + .../neitzel/fx/view/component/VisitRow.fxml | 18 + .../de/neitzel/fx/view/show-two-tables.fxml | 21 + .../neitzel/fx/view/show-visits-complex.fxml | 12 + .../fx/view/show-visits-duplicate.fxml | 13 + .../fx/view/show-visits-no-column.fxml | 9 + .../fx/view/show-visits-selection.fxml | 20 + .../neitzel/fx/view/show-visits-swapped.fxml | 13 + .../de/neitzel/fx/view/show-visits.fxml | 13 + 25 files changed, 4136 insertions(+) create mode 100644 fx/src/main/java/de/neitzel/fx/view/Attributes.java create mode 100644 fx/src/main/java/de/neitzel/fx/view/Column.java create mode 100644 fx/src/main/java/de/neitzel/fx/view/Table.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/FilterByChecklistTest.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/FilterByComparisonTest.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/FxThread.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/ResizeAColumnTest.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/SearchTheChecklistTest.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/SelectARowTest.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/ShowATableOfRowsTest.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/SortOneColumnTest.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/component/CityFirstRow.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/component/VisitRow.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/fixture/Host.java create mode 100644 fx/src/test/java/de/neitzel/fx/view/fixture/Visit.java create mode 100644 fx/src/test/resources/de/neitzel/fx/view/component/CityFirstRow.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/component/VisitRow.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/show-two-tables.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/show-visits-complex.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/show-visits-duplicate.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/show-visits-no-column.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/show-visits-selection.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/show-visits-swapped.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/show-visits.fxml 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 @@ + + + + + + + + + + +
+