This is an automated email from the ASF dual-hosted git repository.
bamaer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new fea61fe001 Issue #8941 : Filter TableView rows when browsing data
(#8983)
fea61fe001 is described below
commit fea61fe001e9bfadbfe104d0afed6705d9afde0a
Author: Matt Casters <[email protected]>
AuthorDate: Sat Oct 17 08:41:46 2026 +0200
Issue #8941 : Filter TableView rows when browsing data (#8983)
Show rows can hide rows that do not match selected column values.
Configuration grids leave filtering off, so a saved view still keeps every
row.
---
.../hop-gui/pipeline/column-filter-applied.png | Bin 0 -> 231375 bytes
.../hop-gui/pipeline/column-filter-values.png | Bin 0 -> 332827 bytes
.../hop-gui/pipeline/column-header-filter-menu.png | Bin 0 -> 43540 bytes
.../images/hop-gui/pipeline/table-views-dialog.png | Bin 420612 -> 194352 bytes
.../pages/pipeline/run-preview-debug-pipeline.adoc | 35 +-
.../core/widget/TableViewColumnViewFilterTest.java | 591 +++++++++++++
.../hop/ui/core/dialog/EnterSelectionDialog.java | 11 +
.../apache/hop/ui/core/dialog/ShowRowsDialog.java | 2 +
.../ui/core/dialog/TableViewColumnViewDialog.java | 348 ++++++--
.../org/apache/hop/ui/core/widget/TableView.java | 915 ++++++++++++++++++++-
...wColumnView.java => TableViewColumnFilter.java} | 18 +-
.../hop/ui/core/widget/TableViewColumnView.java | 11 +-
.../ui/core/widget/TableViewColumnViewManager.java | 52 ++
.../hop/ui/core/widget/TableViewColumnViews.java | 239 +++++-
.../core/dialog/messages/messages_en_US.properties | 11 +
.../core/widget/messages/messages_en_US.properties | 8 +
.../widget/TableViewColumnViewManagerTest.java | 42 +
.../ui/core/widget/TableViewColumnViewsTest.java | 214 +++++
18 files changed, 2385 insertions(+), 112 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/column-filter-applied.png
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/column-filter-applied.png
new file mode 100644
index 0000000000..c67c23c3a3
Binary files /dev/null and
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/column-filter-applied.png
differ
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/column-filter-values.png
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/column-filter-values.png
new file mode 100644
index 0000000000..b72c315c6a
Binary files /dev/null and
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/column-filter-values.png
differ
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/column-header-filter-menu.png
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/column-header-filter-menu.png
new file mode 100644
index 0000000000..4137165226
Binary files /dev/null and
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/column-header-filter-menu.png
differ
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/table-views-dialog.png
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/table-views-dialog.png
index bc33b81d71..0f49231f8f 100644
Binary files
a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/table-views-dialog.png
and
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/table-views-dialog.png
differ
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
index 3b358c0a7b..217c39f429 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
@@ -112,23 +112,44 @@ image::hop-gui/pipeline/preview-pipeline.png[Preview
pipeline,75%,align="left"]
Preview windows (and other table grids in Hop Gui) can contain a large number
of columns.
A **table view** is a named subset of those columns, in the order you want to
see them.
-The underlying rows are not changed: hidden columns stay in the data and are
still exported, copied or saved.
+Hidden columns stay in the data and are still exported, copied or saved.
+A view can also carry filters on its columns: rows that do not match are
hidden while the view is applied, and come back with **Show all columns**.
+Row filters are offered when browsing data, such as **Show rows**.
+A grid that edits configuration leaves **Add filter** and **Remove filter**
disabled and does not hide rows for a saved filter, so confirming the dialog
still keeps every row.
Open the **Table views** dialog from the table toolbar
image:getting-started/icons/view.svg[Table views,25px,align="bottom"] icon,
from the row context menu, or from the column header context menu.
-image::hop-gui/pipeline/table-views-dialog.png[Table views dialog on a wide
preview,75%,align="left"]
+image::hop-gui/pipeline/table-views-dialog.png[Table views dialog with an
applied column filter,75%,align="left"]
In the dialog:
-* Create, rename or delete a view. **Use current layout** fills the view from
the columns that are visible now.
-* Move fields between **Available columns** and **Columns in view**, and use
**Move up** / **Move down** to set the display order.
-* **Apply** shows only the selected columns, in that order. **Show all
columns** restores the full table.
-* **Save** stores the view so you can reuse it later.
+* The dialog opens with every column in **Columns in view** (columns on screen
first, then the rest) and with any filters already applied from the column
header. **Use current layout** copies the columns that are visible now.
+* Move fields between **Available columns** and **Columns in view**, and use
the **arrow up** and **arrow down** buttons in the middle to set the display
order.
+* **Add filter** picks a column and then shows the unique sorted values of
that column, where you select the values to keep. An `<null>` entry covers
empty values. The filter shows up as `Column-Name : value1,value2,value3` in
the **Applied filters** list, and **Remove filter** takes the selected filters
out again.
+* A column with more than 500 unique values asks for confirmation before the
values are listed.
+* **Apply** shows only the selected columns, in that order, and hides the rows
that do not match the applied filters. Filtered columns get a filter icon in
their header.
+* **Show all columns** restores the full table: every column and every row
comes back.
+* **Save** stores the view, including its filters, so you can reuse it later.
Views are stored per project in the Hop audit folder and are matched by column
name.
A view such as `customer-bk-fields` can be applied to any table that has those
field names, not only the preview where you created it.
-You can also hide a single column from the column header context menu, or drag
headers to reorder them (desktop Hop Gui).
+The column header context menu can hide that column, add a filter on it, or
remove the filter it already has.
+**Remove filter** stays disabled until that column has a filter.
+
+image::hop-gui/pipeline/column-header-filter-menu.png[Column header menu with
Add filter,40%,align="left"]
+
+**Add filter** lists the unique sorted values of that column, the same way the
dialog does.
+Select the values to keep and press **OK**.
+
+image::hop-gui/pipeline/column-filter-values.png[Filter values for the
table-name column,75%,align="left"]
+
+The filter is applied straight away.
+The column header shows a filter icon, and only the matching rows stay visible.
+
+image::hop-gui/pipeline/column-filter-applied.png[Rows filtered to one table
name,75%,align="left"]
+
+You can also drag headers to reorder them (desktop Hop Gui).
Those one-off changes can be captured with **Use current layout** and then
saved as a named view.
The table toolbar itself can be turned on or off with **Show a toolbar above
tables** in the xref:hop-gui/perspective-configuration.adoc[Configuration]
perspective.
diff --git
a/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewFilterTest.java
b/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewFilterTest.java
new file mode 100644
index 0000000000..5019732b1b
--- /dev/null
+++
b/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewFilterTest.java
@@ -0,0 +1,591 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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.assertTrue;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.row.value.ValueMetaNumber;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.TableViewColumnViewDialog;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Menu;
+import org.eclipse.swt.widgets.MenuItem;
+import org.eclipse.swt.widgets.ScrollBar;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swt.widgets.TableColumn;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Drives the column-view filters on a real {@link TableView}: the rows that
do not pass the filters
+ * of a table view leave the table, "Show all columns" puts them back exactly
as they were, and the
+ * filtered column headers carry the filter icon while the view is applied.
+ *
+ * <p>The matching and sorting logic itself is covered SWT-free by {@link
TableViewColumnViewsTest};
+ * this test is about what only works against a live table: taking rows out
and putting them back,
+ * with their values, row numbers and header images intact.
+ */
+@Tag("uitest")
+class TableViewColumnViewFilterTest extends SwtBotTestBase {
+
+ /** name, country, amount - the country of row 2 is empty, which is the null
value. */
+ private static final String[][] ROWS = {
+ {"aaa", "BE", "1.5"},
+ {"bbb", "NL", "12"},
+ {"ccc", "", "2.5"},
+ {"ddd", "FR", "2"},
+ {"eee", "NL", "10"},
+ };
+
+ @Test
+ void filterHidesNonMatchingRowsAndResetBringsThemBack() {
+ withScene(
+ shell -> {
+ TableView tableView = newTableView(shell);
+
+ TableViewColumnFilter filter =
+ new TableViewColumnFilter("Country", List.of("BE", "FR",
"<null>"));
+ assertTrue(
+ tableView.applyColumnView(List.of("Name", "Country", "Amount"),
List.of(filter)),
+ "the view applies to the table");
+
+ // aaa (BE), ccc (empty), ddd (FR) stay; bbb and eee (NL) are taken
out.
+ assertEquals(3, tableView.table.getItemCount());
+ assertEquals("aaa", tableView.table.getItem(0).getText(1));
+ assertEquals("ccc", tableView.table.getItem(1).getText(1));
+ assertEquals("ddd", tableView.table.getItem(2).getText(1));
+
+ tableView.resetColumnView();
+
+ assertEquals(5, tableView.table.getItemCount());
+ for (int i = 0; i < ROWS.length; i++) {
+ assertArrayEquals(ROWS[i], rowValues(tableView.table.getItem(i)));
+ }
+ assertTrue(tableView.getAppliedColumnFilters().isEmpty());
+ },
+ bot -> {
+ // The whole scenario is synchronous: nothing waits for the event
loop.
+ });
+ }
+
+ @Test
+ void applyingAnotherViewWithoutFiltersRestoresTheRows() {
+ withScene(
+ shell -> {
+ TableView tableView = newTableView(shell);
+
+ tableView.applyColumnView(
+ List.of("Name", "Country"),
+ List.of(new TableViewColumnFilter("Country", List.of("NL"))));
+ assertEquals(2, tableView.table.getItemCount());
+
+ tableView.applyColumnView(List.of("Name"));
+ assertEquals(5, tableView.table.getItemCount());
+ assertEquals(List.of("Name"),
tableView.getVisibleColumnNamesInOrder());
+ assertTrue(tableView.getAppliedColumnFilters().isEmpty());
+ },
+ bot -> {
+ // Nothing asynchronous here either.
+ });
+ }
+
+ @Test
+ void uniqueValuesStayCompleteWhileAFilterIsApplied() {
+ withScene(
+ shell -> {
+ TableView tableView = newTableView(shell);
+
+ tableView.applyColumnView(
+ List.of("Name", "Country", "Amount"),
+ List.of(new TableViewColumnFilter("Country", List.of("BE"))));
+ assertEquals(1, tableView.table.getItemCount());
+
+ // The hidden rows are still part of the original distinct list.
+ assertEquals(List.of("<null>", "BE", "FR", "NL"),
tableView.getUniqueColumnValues(1));
+ assertEquals(List.of("1.5", "2", "2.5", "10", "12"),
tableView.getUniqueColumnValues(2));
+ },
+ bot -> {
+ // No event-loop interaction.
+ });
+ }
+
+ @Test
+ void uniqueValuesSortByColumnType() {
+ withScene(
+ shell -> {
+ TableView tableView = newTableView(shell);
+
+ // Numeric column: 10 and 12 sort after 2.5, not before it.
+ assertEquals(List.of("1.5", "2", "2.5", "10", "12"),
tableView.getUniqueColumnValues(2));
+
+ // Text column: natural order, the empty country first as the null
value.
+ assertEquals(List.of("<null>", "BE", "FR", "NL"),
tableView.getUniqueColumnValues(1));
+ },
+ bot -> {
+ // No event-loop interaction.
+ });
+ }
+
+ @Test
+ void filteredColumnsGetAFilterIcon() {
+ withScene(
+ shell -> {
+ TableView tableView = newTableView(shell);
+
+ tableView.applyColumnView(
+ List.of("Name", "Country", "Amount"),
+ List.of(new TableViewColumnFilter("Country", List.of("NL"))));
+
+ TableColumn countryColumn = tableView.table.getColumn(2);
+ assertNotNull(countryColumn.getImage(), "the filtered column carries
the filter icon");
+ assertNull(tableView.table.getColumn(1).getImage());
+ assertFalse(countryColumn.getToolTipText().isEmpty());
+
+ tableView.resetColumnView();
+
+ assertNull(tableView.table.getColumn(2).getImage(), "the icon goes
away again");
+ },
+ bot -> {
+ // No event-loop interaction.
+ });
+ }
+
+ @Test
+ void filtersStayOffUnlessTheGridOptsIn() {
+ AtomicReference<TableView> ref = new AtomicReference<>();
+ withScene(
+ shell -> ref.set(newTableView(shell, false)),
+ bot ->
+ display.syncExec(
+ () -> {
+ TableView tableView = ref.get();
+ tableView.getShell().layout(true, true);
+
+ Menu headerMenu = openHeaderMenu(tableView, 2);
+ assertFalse(menuHasItem(headerMenu,
"TableView.menu.AddFilter"));
+ assertFalse(menuHasItem(headerMenu,
"TableView.menu.RemoveFilter"));
+
+ tableView.applyColumnView(
+ List.of("Name", "Country"),
+ List.of(new TableViewColumnFilter("Country",
List.of("NL"))));
+ assertEquals(ROWS.length, tableView.table.getItemCount());
+ assertTrue(tableView.getAppliedColumnFilters().isEmpty());
+ }));
+ }
+
+ @Test
+ void headerMenuAddsAndRemovesAFilterForThatColumn() {
+ AtomicReference<TableView> ref = new AtomicReference<>();
+ withScene(
+ shell -> ref.set(newTableView(shell)),
+ bot ->
+ display.syncExec(
+ () -> {
+ TableView tableView = ref.get();
+ tableView.getShell().layout(true, true);
+
+ Menu countryMenu = openHeaderMenu(tableView, 2);
+ assertTrue(menuItem(countryMenu,
"TableView.menu.AddFilter").isEnabled());
+ assertFalse(menuItem(countryMenu,
"TableView.menu.RemoveFilter").isEnabled());
+
+ Menu numberMenu = openHeaderMenu(tableView, 0);
+ assertFalse(menuItem(numberMenu,
"TableView.menu.AddFilter").isEnabled());
+ assertFalse(menuItem(numberMenu,
"TableView.menu.RemoveFilter").isEnabled());
+ assertFalse(rowMenuHasAddFilter(tableView));
+
+ // Data columns are 0 Name, 1 Country, 2 Amount. The menu
column index counts the
+ // # column.
+ tableView.replaceColumnFilter(1, List.of("NL"));
+ assertEquals(2, tableView.table.getItemCount());
+ assertEquals(
+ List.of("Name", "Country", "Amount"),
+ tableView.getVisibleColumnNamesInOrder());
+
+ tableView.replaceColumnFilter(2, List.of("12"));
+ assertEquals(1, tableView.table.getItemCount());
+ assertEquals("bbb", tableView.table.getItem(0).getText(1));
+
+ Menu amountMenu = openHeaderMenu(tableView, 3);
+ assertTrue(menuItem(amountMenu,
"TableView.menu.RemoveFilter").isEnabled());
+ Menu nameMenu = openHeaderMenu(tableView, 1);
+ assertTrue(menuItem(nameMenu,
"TableView.menu.AddFilter").isEnabled());
+ assertFalse(menuItem(nameMenu,
"TableView.menu.RemoveFilter").isEnabled());
+
+ countryMenu = openHeaderMenu(tableView, 2);
+ assertTrue(menuItem(countryMenu,
"TableView.menu.RemoveFilter").isEnabled());
+ menuItem(countryMenu, "TableView.menu.RemoveFilter")
+ .notifyListeners(SWT.Selection, new Event());
+
+ // Amount 12 stays filtered; the country filter and its
header icon are gone.
+ assertEquals(1, tableView.table.getItemCount());
+ assertEquals("bbb", tableView.table.getItem(0).getText(1));
+ assertEquals(
+ "Amount",
tableView.getAppliedColumnFilters().get(0).getColumnName());
+ assertNull(tableView.table.getColumn(2).getImage());
+ assertNotNull(tableView.table.getColumn(3).getImage());
+ }));
+ }
+
+ @Test
+ void matchingRowCountCountsTheVisibleRowsBeforeApplying() {
+ withScene(
+ shell -> {
+ TableView tableView = newTableView(shell);
+
+ List<TableViewColumnFilter> filters =
+ List.of(new TableViewColumnFilter("Country", List.of("NL")));
+ assertEquals(2, tableView.getMatchingRowCount(filters));
+ assertEquals(5, tableView.getMatchingRowCount(null));
+
+ tableView.applyColumnView(List.of("Name", "Country", "Amount"),
filters);
+ assertEquals(2, tableView.table.getItemCount());
+ // Hidden rows still count: BE matches one hidden row, and the total
is all five.
+ assertEquals(2, tableView.getMatchingRowCount(filters));
+ assertEquals(
+ 1,
+ tableView.getMatchingRowCount(
+ List.of(new TableViewColumnFilter("Country",
List.of("BE")))));
+ assertEquals(5, tableView.getMatchingRowCount(null));
+ assertEquals(5, tableView.getUnfilteredRowCount());
+ },
+ bot -> {
+ // No event-loop interaction.
+ });
+ }
+
+ @Test
+ void sortThenShowAllKeepsEveryRowInSortOrder() {
+ withScene(
+ shell -> {
+ TableView tableView = newTableView(shell);
+ tableView.applyColumnView(
+ List.of("Name", "Country", "Amount"),
+ List.of(new TableViewColumnFilter("Country", List.of("NL"))));
+
+ // Column 3 is Amount. The sort covers the hidden rows, then the
filter goes back on.
+ tableView.sortTable(3);
+ assertEquals(2, tableView.table.getItemCount());
+ assertEquals("eee", tableView.table.getItem(0).getText(1));
+ assertEquals("bbb", tableView.table.getItem(1).getText(1));
+
+ tableView.resetColumnView();
+
+ assertEquals(
+ List.of("aaa", "ddd", "ccc", "eee", "bbb"),
+ List.of(
+ tableView.table.getItem(0).getText(1),
+ tableView.table.getItem(1).getText(1),
+ tableView.table.getItem(2).getText(1),
+ tableView.table.getItem(3).getText(1),
+ tableView.table.getItem(4).getText(1)));
+ // The preview line number stays with its row.
+ assertEquals("1", tableView.table.getItem(0).getText(0));
+ assertEquals("4", tableView.table.getItem(1).getText(0));
+ assertEquals("2", tableView.table.getItem(4).getText(0));
+ },
+ bot -> {
+ // No event-loop interaction.
+ });
+ }
+
+ @Test
+ void deletingAVisibleRowThenShowAllDoesNotUseAStaleIndex() {
+ withScene(
+ shell -> {
+ TableView tableView = newTableView(shell);
+ tableView.applyColumnView(
+ List.of("Name", "Country", "Amount"),
+ List.of(new TableViewColumnFilter("Country", List.of("NL"))));
+
+ // Visible row 0 is bbb, the first NL row. The hidden rows are aaa,
ccc and ddd.
+ tableView.table.remove(0);
+ assertEquals(1, tableView.table.getItemCount());
+ assertEquals("eee", tableView.table.getItem(0).getText(1));
+
+ tableView.resetColumnView();
+
+ assertEquals(4, tableView.table.getItemCount());
+ assertEquals("aaa", tableView.table.getItem(0).getText(1));
+ assertEquals("ccc", tableView.table.getItem(1).getText(1));
+ assertEquals("ddd", tableView.table.getItem(2).getText(1));
+ assertEquals("eee", tableView.table.getItem(3).getText(1));
+ },
+ bot -> {
+ // No event-loop interaction.
+ });
+ }
+
+ @Test
+ void replacingTheTableDropsHiddenRows() {
+ withScene(
+ shell -> {
+ TableView tableView = newTableView(shell);
+ tableView.applyColumnView(
+ List.of("Name", "Country", "Amount"),
+ List.of(new TableViewColumnFilter("Country", List.of("NL"))));
+
+ tableView.table.removeAll();
+ org.eclipse.swt.widgets.TableItem item =
+ new org.eclipse.swt.widgets.TableItem(tableView.table, SWT.NONE);
+ item.setText(1, "new");
+ tableView.resetColumnView();
+
+ assertEquals(1, tableView.table.getItemCount());
+ assertEquals("new", tableView.table.getItem(0).getText(1));
+ assertTrue(tableView.getAppliedColumnFilters().isEmpty());
+ },
+ bot -> {
+ // No event-loop interaction.
+ });
+ }
+
+ @Test
+ void aViewThatMatchesNoColumnLeavesTheFilterInPlace() {
+ withScene(
+ shell -> {
+ TableView tableView = newTableView(shell);
+ tableView.applyColumnView(
+ List.of("Name", "Country", "Amount"),
+ List.of(new TableViewColumnFilter("Country", List.of("NL"))));
+
+ assertFalse(
+ tableView.applyColumnView(
+ List.of("DoesNotExist"),
+ List.of(new TableViewColumnFilter("Name", List.of("aaa")))));
+
+ assertEquals(2, tableView.table.getItemCount());
+ assertEquals("Country",
tableView.getAppliedColumnFilters().get(0).getColumnName());
+ assertNotNull(tableView.table.getColumn(2).getImage());
+ },
+ bot -> {
+ // No event-loop interaction.
+ });
+ }
+
+ @Test
+ void dialogStartsFromTheHeaderFilterAndCountsHiddenRows() {
+ withDialog(
+ parent -> {
+ TableView tableView = newTableView(parent);
+ tableView.applyColumnView(
+ List.of("Name", "Country", "Amount"),
+ List.of(new TableViewColumnFilter("Country", List.of("NL"))));
+ new TableViewColumnViewDialog(parent, tableView).open();
+ },
+ bot -> {
+ bot.shell("Table views").activate();
+ display.syncExec(
+ () -> {
+ Shell dialog = findShell("Table views");
+ assertTrue(widgetListContains(dialog, "Country : NL"));
+ String status = statusText(dialog);
+ assertTrue(status.contains("2 of 5"), status);
+ });
+ bot.button("Use current layout").click();
+ display.syncExec(
+ () -> {
+ Shell dialog = findShell("Table views");
+ assertTrue(widgetListContains(dialog, "Country : NL"));
+ String status = statusText(dialog);
+ assertTrue(status.contains("3 of 3"), status);
+ assertTrue(status.contains("2 of 5"), status);
+ });
+ bot.button(buttonLabel("System.Button.Close")).click();
+ });
+ }
+
+ // --- scene
-----------------------------------------------------------------------------------
+
+ private TableView newTableView(Shell shell) {
+ return newTableView(shell, true);
+ }
+
+ private TableView newTableView(Shell shell, boolean filtersSupported) {
+ shell.setLayout(new FillLayout());
+ shell.setSize(760, 320);
+ ColumnInfo name = new ColumnInfo("Name", ColumnInfo.COLUMN_TYPE_TEXT,
false, false);
+ name.setValueMeta(new ValueMetaString("Name"));
+ ColumnInfo country = new ColumnInfo("Country",
ColumnInfo.COLUMN_TYPE_TEXT, false, false);
+ country.setValueMeta(new ValueMetaString("Country"));
+ ColumnInfo amount = new ColumnInfo("Amount", ColumnInfo.COLUMN_TYPE_TEXT,
true, false);
+ ValueMetaNumber amountMeta = new ValueMetaNumber("Amount");
+ amountMeta.setDecimalSymbol(".");
+ amountMeta.setGroupingSymbol(",");
+ amount.setValueMeta(amountMeta);
+
+ TableView tableView =
+ new TableView(
+ new Variables(),
+ shell,
+ SWT.BORDER | SWT.FULL_SELECTION,
+ new ColumnInfo[] {name, country, amount},
+ ROWS.length,
+ null,
+ PropsUi.getInstance());
+ if (filtersSupported) {
+ tableView.setFiltersSupported(true);
+ }
+ for (int i = 0; i < ROWS.length; i++) {
+ tableView.table.getItem(i).setText(1, ROWS[i][0]);
+ tableView.table.getItem(i).setText(2, ROWS[i][1]);
+ tableView.table.getItem(i).setText(3, ROWS[i][2]);
+ }
+ return tableView;
+ }
+
+ private static String[] rowValues(org.eclipse.swt.widgets.TableItem item) {
+ return new String[] {item.getText(1), item.getText(2), item.getText(3)};
+ }
+
+ /** Open the column-header menu on one table column (0 is the row-number
column). */
+ private static Menu openHeaderMenu(TableView view, int tableColumnIndex) {
+ Table table = view.table;
+ int headerHeight = table.getHeaderHeight();
+ if (headerHeight <= 0) {
+ throw new AssertionError("header has no height");
+ }
+ Rectangle client = table.getClientArea();
+ int y = client.y + headerHeight / 2;
+ table.notifyListeners(
+ SWT.MenuDetect, menuDetect(table, columnCenterX(table,
tableColumnIndex), y));
+ return table.getMenu();
+ }
+
+ /** The menu opened below the header is the row menu, which does not filter
a column. */
+ private static boolean rowMenuHasAddFilter(TableView view) {
+ Table table = view.table;
+ Rectangle client = table.getClientArea();
+ int y = client.y + table.getHeaderHeight() + 4;
+ table.notifyListeners(SWT.MenuDetect, menuDetect(table, 10, y));
+ String label = BaseMessages.getString(TableView.class,
"TableView.menu.AddFilter");
+ for (MenuItem item : table.getMenu().getItems()) {
+ if (label.equals(item.getText())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Event menuDetect(Table table, int x, int y) {
+ Point displayPoint = table.toDisplay(x, y);
+ Event detect = new Event();
+ detect.x = displayPoint.x;
+ detect.y = displayPoint.y;
+ detect.detail = SWT.MENU_MOUSE;
+ return detect;
+ }
+
+ private static int columnCenterX(Table table, int tableColumnIndex) {
+ int x = 0;
+ ScrollBar bar = table.getHorizontalBar();
+ if (bar != null && !bar.isDisposed()) {
+ x -= bar.getSelection();
+ }
+ for (int index : table.getColumnOrder()) {
+ int width = table.getColumn(index).getWidth();
+ if (index == tableColumnIndex) {
+ if (width <= 0) {
+ throw new AssertionError("column " + tableColumnIndex + " has no
width");
+ }
+ return x + width / 2;
+ }
+ x += width;
+ }
+ throw new AssertionError("missing column " + tableColumnIndex);
+ }
+
+ private Shell findShell(String title) {
+ for (Shell open : display.getShells()) {
+ if (!open.isDisposed() && title.equals(open.getText())) {
+ return open;
+ }
+ }
+ throw new AssertionError("no shell named " + title);
+ }
+
+ private static boolean widgetListContains(Control control, String item) {
+ if (control instanceof org.eclipse.swt.widgets.List list) {
+ for (String text : list.getItems()) {
+ if (item.equals(text)) {
+ return true;
+ }
+ }
+ }
+ if (control instanceof org.eclipse.swt.widgets.Composite composite) {
+ for (Control child : composite.getChildren()) {
+ if (widgetListContains(child, item)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static String statusText(Control control) {
+ if (control instanceof Label label && label.getText().contains("rows
match")) {
+ return label.getText();
+ }
+ if (control instanceof org.eclipse.swt.widgets.Composite composite) {
+ for (Control child : composite.getChildren()) {
+ String text = statusText(child);
+ if (text != null) {
+ return text;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static boolean menuHasItem(Menu menu, String key) {
+ String label = BaseMessages.getString(TableView.class, key);
+ for (MenuItem item : menu.getItems()) {
+ if (label.equals(item.getText())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static MenuItem menuItem(Menu menu, String key) {
+ String label = BaseMessages.getString(TableView.class, key);
+ for (MenuItem item : menu.getItems()) {
+ if (label.equals(item.getText())) {
+ return item;
+ }
+ }
+ throw new AssertionError("no '" + label + "' item");
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/dialog/EnterSelectionDialog.java
b/ui/src/main/java/org/apache/hop/ui/core/dialog/EnterSelectionDialog.java
index 40e70b2802..c3514ff233 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/EnterSelectionDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/EnterSelectionDialog.java
@@ -477,6 +477,17 @@ public class EnterSelectionDialog extends Dialog {
this.selectedNrs = selectedNrs;
}
+ /**
+ * Allow several values to be selected at once: the selection list is then
multi-select. The
+ * indexes of all selected choices come back with {@link
#getSelectionIndeces()} after {@link
+ * #open()}, which still returns the first selected value.
+ *
+ * @param multi true to allow multiple selections
+ */
+ public void setMulti(boolean multi) {
+ this.multi = multi;
+ }
+
/**
* @param selectedNrs the selectedNrs to set
*/
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java
index c3661f0e4d..67d1c3aa48 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java
@@ -151,6 +151,8 @@ public final class ShowRowsDialog {
view.setShowingBlueNullValues(true);
// Data rows, not configuration: draw long / multi-line values shortened.
view.setShortenDisplayedValues(true);
+ // Browsing data, not editing it: a filter must not be the set of rows a
dialog saves.
+ view.setFiltersSupported(true);
// Column sorting is enabled: items carry their full values, so a sort
reorders complete rows.
view.setSortable(true);
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/dialog/TableViewColumnViewDialog.java
b/ui/src/main/java/org/apache/hop/ui/core/dialog/TableViewColumnViewDialog.java
index 7dc035385f..ef12a70206 100644
---
a/ui/src/main/java/org/apache/hop/ui/core/dialog/TableViewColumnViewDialog.java
+++
b/ui/src/main/java/org/apache/hop/ui/core/dialog/TableViewColumnViewDialog.java
@@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
@@ -31,8 +32,10 @@ import org.apache.hop.ui.core.gui.HopNamespace;
import org.apache.hop.ui.core.gui.WindowProperty;
import org.apache.hop.ui.core.widget.ColumnInfo;
import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.core.widget.TableViewColumnFilter;
import org.apache.hop.ui.core.widget.TableViewColumnView;
import org.apache.hop.ui.core.widget.TableViewColumnViewManager;
+import org.apache.hop.ui.core.widget.TableViewColumnViews;
import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.SashForm;
@@ -48,7 +51,7 @@ import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Shell;
-/** Create, edit, apply and persist named {@link TableView} column views. */
+/** Create, edit, apply and persist named {@link TableView} column views and
column filters. */
public class TableViewColumnViewDialog extends Dialog {
private static final Class<?> PKG = TableViewColumnViewDialog.class;
@@ -60,10 +63,17 @@ public class TableViewColumnViewDialog extends Dialog {
private final Map<String, TableViewColumnView> views = new LinkedHashMap<>();
+ /** The filters the user is working on: those of the selected view, or
ad-hoc ones. */
+ private List<TableViewColumnFilter> currentFilters = new ArrayList<>();
+
private Shell shell;
private org.eclipse.swt.widgets.List wViews;
private org.eclipse.swt.widgets.List wAvailable;
private org.eclipse.swt.widgets.List wShown;
+ private org.eclipse.swt.widgets.List wFilters;
+ private Button wMoveUp;
+ private Button wMoveDown;
+ private Button wRemoveFilter;
private Label wlStatus;
public TableViewColumnViewDialog(Shell parent, TableView tableView) {
@@ -188,6 +198,9 @@ public class TableViewColumnViewDialog extends Dialog {
Composite gButtons = new Composite(middle, SWT.NONE);
GridLayout grid = new GridLayout(1, true);
+ // The attachment below is the gap to the available-columns list. Do not
add another inset.
+ grid.marginWidth = 0;
+ grid.marginHeight = 0;
gButtons.setLayout(grid);
PropsUi.setLook(gButtons);
@@ -202,47 +215,98 @@ public class TableViewColumnViewDialog extends Dialog {
wRemoveAll.setToolTipText(
BaseMessages.getString(PKG,
"TableViewColumnViewDialog.RemoveAll.ToolTip"));
+ wMoveUp = new Button(gButtons, SWT.ARROW | SWT.UP);
+ wMoveUp.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+ wMoveUp.setToolTipText(BaseMessages.getString(PKG,
"TableViewColumnViewDialog.MoveUp.ToolTip"));
+ wMoveUp.addListener(SWT.Selection, e -> moveShown(-1));
+
+ wMoveDown = new Button(gButtons, SWT.ARROW | SWT.DOWN);
+ wMoveDown.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+ wMoveDown.setToolTipText(
+ BaseMessages.getString(PKG,
"TableViewColumnViewDialog.MoveDown.ToolTip"));
+ wMoveDown.addListener(SWT.Selection, e -> moveShown(1));
+
+ Button wAddFilter = new Button(gButtons, SWT.PUSH);
+ wAddFilter.setText(BaseMessages.getString(PKG,
"TableViewColumnViewDialog.AddFilter.Label"));
+ wAddFilter.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+ wAddFilter.setToolTipText(
+ BaseMessages.getString(PKG,
"TableViewColumnViewDialog.AddFilter.ToolTip"));
+ wAddFilter.addListener(SWT.Selection, e -> addFilter());
+ wAddFilter.setEnabled(tableView.isFiltersSupported());
+
+ wRemoveFilter = new Button(gButtons, SWT.PUSH);
+ wRemoveFilter.setText(
+ BaseMessages.getString(PKG,
"TableViewColumnViewDialog.RemoveFilter.Label"));
+ wRemoveFilter.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+ wRemoveFilter.setToolTipText(
+ BaseMessages.getString(PKG,
"TableViewColumnViewDialog.RemoveFilter.ToolTip"));
+ wRemoveFilter.addListener(SWT.Selection, e -> removeFilter());
+
FormData fdButtons = new FormData();
- wAddAll.pack();
- fdButtons.left = new FormAttachment(50, -(wAddAll.getSize().x / 2) - 5);
- fdButtons.top = new FormAttachment(20, 0);
+ // The middle sash pane starts at the right side of the available-columns
list.
+ fdButtons.left = new FormAttachment(0, 2 * margin);
+ fdButtons.top = new FormAttachment(10, 0);
gButtons.setLayoutData(fdButtons);
- Composite right =
- createListPane(sash, BaseMessages.getString(PKG,
"TableViewColumnViewDialog.Shown.Label"));
- wShown = (org.eclipse.swt.widgets.List) right.getData("list");
+ Composite right = new Composite(sash, SWT.NONE);
+ PropsUi.setLook(right);
+ right.setLayout(new FormLayout());
+
+ Label wlShown = new Label(right, SWT.LEFT);
+ wlShown.setText(BaseMessages.getString(PKG,
"TableViewColumnViewDialog.Shown.Label"));
+ PropsUi.setLook(wlShown);
+ FormData fdlShown = new FormData();
+ fdlShown.left = new FormAttachment(0, 0);
+ fdlShown.top = new FormAttachment(0, 0);
+ wlShown.setLayoutData(fdlShown);
+
+ wShown =
+ new org.eclipse.swt.widgets.List(
+ right, SWT.BORDER | SWT.MULTI | SWT.V_SCROLL | SWT.H_SCROLL);
+ PropsUi.setLook(wShown);
+ FormData fdShown = new FormData();
+ fdShown.left = new FormAttachment(0, 0);
+ fdShown.top = new FormAttachment(wlShown, 0);
+ fdShown.right = new FormAttachment(100, 0);
+ // Only reach until half the height of the available columns list, the
filters live below.
+ fdShown.bottom = new FormAttachment(50, 0);
+ wShown.setLayoutData(fdShown);
wShown.addListener(SWT.DefaultSelection, e -> removeSelected());
+ wShown.addListener(SWT.Selection, e -> updateFilterButtons());
- Composite orderButtons = new Composite(right, SWT.NONE);
- PropsUi.setLook(orderButtons);
- GridLayout orderLayout = new GridLayout(2, true);
- orderLayout.marginWidth = 0;
- orderLayout.marginHeight = 0;
- orderButtons.setLayout(orderLayout);
- FormData fdOrder = new FormData();
- fdOrder.left = new FormAttachment(0, 0);
- fdOrder.right = new FormAttachment(100, 0);
- fdOrder.bottom = new FormAttachment(100, 0);
- orderButtons.setLayoutData(fdOrder);
-
- Button wUp = new Button(orderButtons, SWT.PUSH);
- wUp.setText(BaseMessages.getString(PKG,
"TableViewColumnViewDialog.MoveUp.Label"));
- wUp.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
- wUp.addListener(SWT.Selection, e -> moveShown(-1));
-
- Button wDown = new Button(orderButtons, SWT.PUSH);
- wDown.setText(BaseMessages.getString(PKG,
"TableViewColumnViewDialog.MoveDown.Label"));
- wDown.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
- wDown.addListener(SWT.Selection, e -> moveShown(1));
-
- ((FormData) wShown.getLayoutData()).bottom = new
FormAttachment(orderButtons, -margin);
+ Label wlFilters = new Label(right, SWT.LEFT);
+ wlFilters.setText(BaseMessages.getString(PKG,
"TableViewColumnViewDialog.Filters.Label"));
+ PropsUi.setLook(wlFilters);
+ FormData fdlFilters = new FormData();
+ fdlFilters.left = new FormAttachment(0, 0);
+ fdlFilters.top = new FormAttachment(wShown, margin);
+ wlFilters.setLayoutData(fdlFilters);
+
+ wFilters =
+ new org.eclipse.swt.widgets.List(
+ right, SWT.BORDER | SWT.MULTI | SWT.V_SCROLL | SWT.H_SCROLL);
+ PropsUi.setLook(wFilters);
+ FormData fdFilters = new FormData();
+ fdFilters.left = new FormAttachment(0, 0);
+ fdFilters.top = new FormAttachment(wlFilters, 0);
+ fdFilters.right = new FormAttachment(100, 0);
+ fdFilters.bottom = new FormAttachment(100, 0);
+ wFilters.setLayoutData(fdFilters);
+ wFilters.addListener(SWT.Selection, e -> updateFilterButtons());
sash.setWeights(new int[] {42, 16, 42});
shell.addListener(SWT.Close, e -> close());
loadViews();
- refreshColumnLists(java.util.List.of());
+ // A filter added from the column header is already applied. Start from
it, and from every
+ // column, so Save and Apply keep the grid instead of clearing the columns.
+ currentFilters = copyFilters(tableView.getAppliedColumnFilters());
+ refreshColumnLists(
+ TableViewColumnViews.columnsInViewOnOpen(
+ tableView.getVisibleColumnNamesInOrder(), tableColumnNames));
+ refreshFiltersList();
+ updateFilterButtons();
updateStatus();
BaseTransformDialog.setSize(shell);
@@ -317,11 +381,17 @@ public class TableViewColumnViewDialog extends Dialog {
return;
}
TableViewColumnView view = views.get(name);
- java.util.List<String> shown =
+ List<String> shown =
view != null && view.getColumnNames() != null
? new ArrayList<>(view.getColumnNames())
: new ArrayList<>();
+ currentFilters =
+ view != null && view.getFilters() != null
+ ? new ArrayList<>(view.getFilters())
+ : new ArrayList<>();
refreshColumnLists(shown);
+ refreshFiltersList();
+ updateFilterButtons();
updateStatus();
}
@@ -335,8 +405,11 @@ public class TableViewColumnViewDialog extends Dialog {
}
TableViewColumnView view = new TableViewColumnView(name, new
ArrayList<>());
views.put(name, view);
+ currentFilters = new ArrayList<>();
refreshViewList(name);
- refreshColumnLists(java.util.List.of());
+ refreshColumnLists(List.of());
+ refreshFiltersList();
+ updateFilterButtons();
updateStatus();
}
@@ -358,6 +431,7 @@ public class TableViewColumnViewDialog extends Dialog {
}
view.setName(newName);
view.setColumnNames(currentShownColumns());
+ view.setFilters(new ArrayList<>(currentFilters));
views.remove(newName);
views.put(newName, view);
TableViewColumnViewManager.delete(group, oldName);
@@ -379,12 +453,18 @@ public class TableViewColumnViewDialog extends Dialog {
views.remove(name);
TableViewColumnViewManager.delete(group, name);
refreshViewList(null);
- refreshColumnLists(java.util.List.of());
+ currentFilters = new ArrayList<>();
+ refreshColumnLists(List.of());
+ refreshFiltersList();
+ updateFilterButtons();
updateStatus();
}
private void useCurrentLayout() {
refreshColumnLists(tableView.getVisibleColumnNamesInOrder());
+ currentFilters = copyFilters(tableView.getAppliedColumnFilters());
+ refreshFiltersList();
+ updateFilterButtons();
updateStatus();
}
@@ -416,29 +496,63 @@ public class TableViewColumnViewDialog extends Dialog {
to.add(item);
}
}
+ updateFilterButtons();
updateStatus();
}
+ /**
+ * Move the selected columns of the "Columns in view" list one position up
or down as a block,
+ * keeping their relative order.
+ *
+ * @param delta -1 to move up, 1 to move down
+ */
private void moveShown(int delta) {
int[] selection = wShown.getSelectionIndices();
- if (selection.length != 1) {
- return;
- }
- int from = selection[0];
- int to = from + delta;
- if (to < 0 || to >= wShown.getItemCount()) {
+ if (selection.length == 0) {
return;
}
String[] items = wShown.getItems();
- String moving = items[from];
- items[from] = items[to];
- items[to] = moving;
+ boolean[] selected = new boolean[items.length];
+ for (int index : selection) {
+ selected[index] = true;
+ }
+ if (delta < 0) {
+ for (int i = 0; i < items.length; i++) {
+ if (selected[i] && i > 0 && !selected[i - 1]) {
+ swap(items, i, i - 1);
+ selected[i] = false;
+ selected[i - 1] = true;
+ }
+ }
+ } else {
+ for (int i = items.length - 1; i >= 0; i--) {
+ if (selected[i] && i < items.length - 1 && !selected[i + 1]) {
+ swap(items, i, i + 1);
+ selected[i] = false;
+ selected[i + 1] = true;
+ }
+ }
+ }
wShown.setItems(items);
- wShown.setSelection(to);
+ int[] newSelection = new int[selection.length];
+ int n = 0;
+ for (int i = 0; i < selected.length; i++) {
+ if (selected[i]) {
+ newSelection[n++] = i;
+ }
+ }
+ wShown.setSelection(newSelection);
+ updateFilterButtons();
+ }
+
+ private void swap(String[] items, int i, int j) {
+ String tmp = items[i];
+ items[i] = items[j];
+ items[j] = tmp;
}
private void apply() {
- java.util.List<String> shown = currentShownColumns();
+ List<String> shown = currentShownColumns();
if (shown.isEmpty()) {
MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
box.setText(BaseMessages.getString(PKG,
"TableViewColumnViewDialog.Title"));
@@ -446,7 +560,7 @@ public class TableViewColumnViewDialog extends Dialog {
box.open();
return;
}
- if (!tableView.applyColumnView(shown)) {
+ if (!tableView.applyColumnView(shown, currentFilters)) {
MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
box.setText(BaseMessages.getString(PKG,
"TableViewColumnViewDialog.Title"));
box.setMessage(BaseMessages.getString(PKG,
"TableViewColumnViewDialog.NoMatch.Message"));
@@ -472,6 +586,7 @@ public class TableViewColumnViewDialog extends Dialog {
TableViewColumnView view = views.computeIfAbsent(name, key -> new
TableViewColumnView());
view.setName(name);
view.setColumnNames(currentShownColumns());
+ view.setFilters(new ArrayList<>(currentFilters));
TableViewColumnViewManager.save(group, view);
views.put(name, view);
refreshViewList(name);
@@ -482,8 +597,8 @@ public class TableViewColumnViewDialog extends Dialog {
shell.dispose();
}
- private void refreshColumnLists(java.util.List<String> shownNames) {
- java.util.List<String> shown = new ArrayList<>();
+ private void refreshColumnLists(List<String> shownNames) {
+ List<String> shown = new ArrayList<>();
Set<String> shownSet = new HashSet<>();
for (String name : shownNames) {
if (StringUtils.isEmpty(name) || shownSet.contains(name)) {
@@ -494,7 +609,7 @@ public class TableViewColumnViewDialog extends Dialog {
}
wShown.setItems(shown.toArray(new String[0]));
- java.util.List<String> available = new ArrayList<>();
+ List<String> available = new ArrayList<>();
for (String name : tableColumnNames) {
if (!shownSet.contains(name)) {
available.add(name);
@@ -503,8 +618,110 @@ public class TableViewColumnViewDialog extends Dialog {
wAvailable.setItems(available.toArray(new String[0]));
}
+ /** Show the filters the user is working on as "Column : value1,value2". */
+ private void refreshFiltersList() {
+ String[] items = new String[currentFilters.size()];
+ for (int i = 0; i < currentFilters.size(); i++) {
+ items[i] = TableViewColumnViews.formatFilter(currentFilters.get(i));
+ }
+ wFilters.setItems(items);
+ }
+
+ private void updateFilterButtons() {
+ boolean shownSelected = wShown.getSelectionCount() > 0;
+ wMoveUp.setEnabled(shownSelected);
+ wMoveDown.setEnabled(shownSelected);
+ wRemoveFilter.setEnabled(tableView.isFiltersSupported() &&
wFilters.getSelectionCount() > 0);
+ }
+
+ /**
+ * Add a filter on a column: pick the column (from the selection when a
single column is selected,
+ * with a dialog otherwise), then pick the values to show from the unique
sorted values of that
+ * column.
+ */
+ private void addFilter() {
+ if (!tableView.isFiltersSupported()) {
+ return;
+ }
+ String columnName = pickFilterColumn();
+ if (columnName == null) {
+ return;
+ }
+ int dataIndex = Const.indexOfString(columnName, tableColumnNames);
+ if (dataIndex < 0) {
+ return;
+ }
+ TableViewColumnFilter existing = filterFor(columnName);
+ // Null means cancelled, or the column has nothing to filter on. An empty
list means the user
+ // wants no filter on this column at all.
+ List<String> selected =
+ tableView.promptColumnFilterValues(
+ shell, dataIndex, existing == null ? List.of() :
existing.getValues());
+ if (selected == null) {
+ return;
+ }
+ currentFilters.removeIf(filter -> filter != null &&
columnName.equals(filter.getColumnName()));
+ if (!selected.isEmpty()) {
+ currentFilters.add(new TableViewColumnFilter(columnName, selected));
+ }
+ refreshFiltersList();
+ updateFilterButtons();
+ updateStatus();
+ }
+
+ /** Remove the filters that are selected in the applied-filters list. */
+ private void removeFilter() {
+ int[] selection = wFilters.getSelectionIndices();
+ if (selection.length == 0) {
+ return;
+ }
+ boolean[] removed = new boolean[currentFilters.size()];
+ for (int index : selection) {
+ if (index >= 0 && index < removed.length) {
+ removed[index] = true;
+ }
+ }
+ List<TableViewColumnFilter> kept = new ArrayList<>();
+ for (int i = 0; i < currentFilters.size(); i++) {
+ if (!removed[i]) {
+ kept.add(currentFilters.get(i));
+ }
+ }
+ currentFilters = kept;
+ refreshFiltersList();
+ updateFilterButtons();
+ updateStatus();
+ }
+
+ private TableViewColumnFilter filterFor(String columnName) {
+ for (TableViewColumnFilter filter : currentFilters) {
+ if (filter != null && columnName.equals(filter.getColumnName())) {
+ return filter;
+ }
+ }
+ return null;
+ }
+
+ private String pickFilterColumn() {
+ String[] shownSelection = wShown.getSelection();
+ if (shownSelection.length == 1 &&
tableColumnNameSet.contains(shownSelection[0])) {
+ return shownSelection[0];
+ }
+ String[] availableSelection = wAvailable.getSelection();
+ if (availableSelection.length == 1) {
+ return availableSelection[0];
+ }
+ EnterSelectionDialog dialog =
+ new EnterSelectionDialog(
+ shell,
+ tableColumnNames,
+ BaseMessages.getString(PKG,
"TableViewColumnViewDialog.Filter.Column.Title"),
+ BaseMessages.getString(PKG,
"TableViewColumnViewDialog.Filter.Column.Message"));
+ return dialog.open();
+ }
+
private void updateStatus() {
- java.util.List<String> shown = currentShownColumns();
+ List<String> shown = currentShownColumns();
int matching = 0;
int missing = 0;
for (String name : shown) {
@@ -526,10 +743,39 @@ public class TableViewColumnViewDialog extends Dialog {
+ BaseMessages.getString(
PKG, "TableViewColumnViewDialog.Status.Missing",
Integer.toString(missing));
}
+ if (!currentFilters.isEmpty()) {
+ status +=
+ " "
+ + BaseMessages.getString(
+ PKG,
+ "TableViewColumnViewDialog.Status.Filters",
+ Integer.toString(currentFilters.size()));
+ status +=
+ " "
+ + BaseMessages.getString(
+ PKG,
+ "TableViewColumnViewDialog.Status.Rows",
+
Integer.toString(tableView.getMatchingRowCount(currentFilters)),
+ Integer.toString(tableView.getUnfilteredRowCount()));
+ }
wlStatus.setText(status);
}
- private java.util.List<String> currentShownColumns() {
+ /** A private copy, so editing the dialog does not change the filter the
table is showing. */
+ private static List<TableViewColumnFilter>
copyFilters(List<TableViewColumnFilter> filters) {
+ List<TableViewColumnFilter> copy = new ArrayList<>();
+ if (filters == null) {
+ return copy;
+ }
+ for (TableViewColumnFilter filter : filters) {
+ if (filter != null) {
+ copy.add(new TableViewColumnFilter(filter.getColumnName(),
filter.getValues()));
+ }
+ }
+ return copy;
+ }
+
+ private List<String> currentShownColumns() {
return new ArrayList<>(Arrays.asList(wShown.getItems()));
}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
index 7470574d21..6bf4296bf8 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
@@ -19,8 +19,10 @@ package org.apache.hop.ui.core.widget;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
import java.util.HashSet;
import java.util.Hashtable;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -84,6 +86,7 @@ import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.events.SelectionListener;
import org.eclipse.swt.events.TraverseListener;
import org.eclipse.swt.graphics.Color;
+import org.eclipse.swt.graphics.Image;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.layout.FillLayout;
@@ -178,6 +181,10 @@ public class TableView extends Composite {
// define CANCEL_KEYS here so that RWT needs not to be imported.
//
private static final String CANCEL_KEYS = "org.eclipse.rap.rwt.cancelKeys";
+
+ /** Ask before listing this many distinct values for one column. */
+ private static final int FILTER_VALUE_LIST_WARN_LIMIT = 500;
+
public static final String CONST_SHIFT_TAB = "SHIFT+TAB";
private final Composite composite;
private final ColumnInfo[] columns;
@@ -274,6 +281,69 @@ public class TableView extends Composite {
private final Set<Integer> hiddenDataColumns = new HashSet<>();
private int[] rememberedWidths;
+ /**
+ * Off by default. A configuration dialog saves the rows that are still
visible, so a filter would
+ * drop the hidden ones. Turn it on for grids that only display data.
+ */
+ @Getter private boolean filtersSupported;
+
+ /** Header-menu entries for {@link #filtersSupported}. Null while the option
is off. */
+ private Menu headerContextMenu;
+
+ private MenuItem miHeaderAddFilter;
+
+ private MenuItem miHeaderRemoveFilter;
+
+ /** Data column under the header context menu, or -1 for the row-number
column. */
+ private final int[] headerDataColumn = {-1};
+
+ /**
+ * The filters of the column view that is applied right now. Empty when no
filters are active,
+ * even when a column view is.
+ */
+ private List<TableViewColumnFilter> columnViewFilters = new ArrayList<>();
+
+ /**
+ * The rows the column-view filters took out of the table, kept aside so
{@link
+ * #resetColumnView()} can put them back. Null while no rows are hidden.
Each index is the row's
+ * place in the unfiltered table.
+ */
+ private List<HiddenRow> hiddenRows;
+
+ /**
+ * True while a sort, delete or paste is changing the unfiltered rows and
the filter will be put
+ * back at the end. Nested table removes must not start another round.
+ */
+ private boolean filterMutationBatch;
+
+ /** A row a column-view filter took out of the table, with everything needed
to put it back. */
+ private static final class HiddenRow {
+ final int index;
+ final String rowNumber;
+ final String[] displayed;
+ final String[][] cellValues;
+ final Object data;
+ final Color background;
+ final boolean[] blueForeground;
+
+ HiddenRow(
+ int index,
+ String rowNumber,
+ String[] displayed,
+ String[][] cellValues,
+ Object data,
+ Color background,
+ boolean[] blueForeground) {
+ this.index = index;
+ this.rowNumber = rowNumber;
+ this.displayed = displayed;
+ this.cellValues = cellValues;
+ this.data = data;
+ this.background = background;
+ this.blueForeground = blueForeground;
+ }
+ }
+
/**
* Last width we asked each native column to take during {@link #optWidth}.
Win32/DPI often
* returns a slightly different {@code getWidth()} than the value passed to
{@code setWidth}, so
@@ -562,7 +632,68 @@ public class TableView extends Composite {
addToolbar();
// Create table, add columns & rows...
- table = new Table(this, style | SWT.MULTI);
+ // Removes go through this subclass so a filter's hidden rows are not
restored by a stale index
+ // after the table is rebuilt or a row is deleted.
+ table =
+ new Table(this, style | SWT.MULTI) {
+ @Override
+ protected void checkSubclass() {
+ // Remove is intercepted below so a column filter is not restored
by a stale index.
+ }
+
+ @Override
+ public void removeAll() {
+ if (!filterMutationBatch) {
+ // The content is being replaced: the rows kept aside belong to
the old content.
+ clearColumnViewFilters();
+ }
+ super.removeAll();
+ }
+
+ @Override
+ public void remove(int index) {
+ if (filterMutationBatch || hiddenRows == null ||
hiddenRows.isEmpty()) {
+ super.remove(index);
+ return;
+ }
+ int fullIndex = fullIndexOfVisible(index);
+ List<TableViewColumnFilter> filters = beginFilterMutation();
+ super.remove(filters == null ? index : fullIndex);
+ endFilterMutation(filters);
+ }
+
+ @Override
+ public void remove(int[] indices) {
+ if (filterMutationBatch
+ || indices == null
+ || hiddenRows == null
+ || hiddenRows.isEmpty()) {
+ super.remove(indices);
+ return;
+ }
+ int[] fullIndexes = new int[indices.length];
+ for (int i = 0; i < indices.length; i++) {
+ fullIndexes[i] = fullIndexOfVisible(indices[i]);
+ }
+ List<TableViewColumnFilter> filters = beginFilterMutation();
+ super.remove(filters == null ? indices : fullIndexes);
+ endFilterMutation(filters);
+ }
+
+ @Override
+ public void remove(int start, int end) {
+ if (filterMutationBatch || hiddenRows == null ||
hiddenRows.isEmpty()) {
+ super.remove(start, end);
+ return;
+ }
+ int count = end - start + 1;
+ int[] indices = new int[Math.max(count, 0)];
+ for (int i = 0; i < indices.length; i++) {
+ indices[i] = start + i;
+ }
+ remove(indices);
+ }
+ };
PropsUi.setLook(table);
table.setLinesVisible(true);
@@ -1859,12 +1990,24 @@ public class TableView extends Composite {
shouldRefresh = true;
}
- if (!shouldRefresh && table.getItemCount() == lastRowCount) {
+ // A filter keeps rows out of the table. Sort the whole set, then put the
filter back, so Show
+ // all columns returns every row in this sort order.
+ if (!shouldRefresh && table.getItemCount() == lastRowCount &&
columnViewFilters.isEmpty()) {
return;
}
- removeEmptyRows();
+ List<TableViewColumnFilter> filtersToReapply = beginFilterMutation();
+ try {
+ removeEmptyRows();
+ sortTableItems(sortField, sortingDescending);
+ } finally {
+ endFilterMutation(filtersToReapply);
+ }
+ }
+
+ /** Rebuild the table items in sort order. The column-view filter is not
applied here. */
+ private void sortTableItems(int sortField, boolean sortingDescending) {
try {
// First, get all info and put it in a Vector of Rows...
TableItem[] items = table.getItems();
@@ -2708,16 +2851,26 @@ public class TableView extends Composite {
}
private void insertRow(int rowNr) {
- TableItem tableItem = new TableItem(table, SWT.NONE, rowNr);
- tableItem.setText(1, "");
+ int index = rowNr;
+ List<TableViewColumnFilter> filters = null;
+ if (hasHiddenRows()) {
+ index = fullIndexOfVisible(rowNr);
+ filters = beginFilterMutation();
+ }
+ try {
+ TableItem tableItem = new TableItem(table, SWT.NONE, index);
+ tableItem.setText(1, "");
- // Add undo information
- ChangeAction ta = new ChangeAction();
- String[] str = getItemText(tableItem);
- ta.setNew(new String[][] {str}, new int[] {rowNr});
- addUndo(ta);
+ // Add undo information
+ ChangeAction ta = new ChangeAction();
+ String[] str = getItemText(tableItem);
+ ta.setNew(new String[][] {str}, new int[] {index});
+ addUndo(ta);
- setRowNums();
+ setRowNums();
+ } finally {
+ endFilterMutation(filters);
+ }
edit(rowNr, 1);
tableViewModifyListener.insertRow(rowNr);
@@ -2748,6 +2901,9 @@ public class TableView extends Composite {
}
if (id == SWT.YES) {
+ // The table content is replaced wholesale: forget the filtered-out rows
instead of
+ // bringing them back as ghosts with Show all columns.
+ clearColumnViewFilters();
table.removeAll();
new TableItem(table, SWT.NONE);
// Only start editing when the user cleared an already-visible grid.
Programmatic refill
@@ -3098,30 +3254,59 @@ public class TableView extends Composite {
if (text != null) {
String[] lines = text.split(Const.CR);
+ String[][] grid = null;
+ int[] idx = null;
if (lines.length > 1) {
// ALlocate complete paste grid!
- String[][] grid = new String[lines.length - 1][];
- int[] idx = new int[lines.length - 1];
-
+ grid = new String[lines.length - 1][];
+ idx = new int[lines.length - 1];
for (int i = 1; i < lines.length; i++) {
grid[i - 1] = lines[i].split("\t");
idx[i - 1] = rowNr + i - 1;
- addItem(idx[i - 1], grid[i - 1]);
}
-
- ChangeAction ta = new ChangeAction();
- ta.setNew(grid, idx);
- addUndo(ta);
}
- if (rowNr == 0 && table.getItemCount() > rowNr + 1 && isEmpty(rowNr,
-1)) {
- // Empty row at rowNr?
- // Remove it!
- table.remove(rowNr);
+
+ // Paste into the unfiltered table, then put the filter back. Otherwise
a row inserted
+ // between two visible rows lands in the wrong place once the hidden
rows return.
+ List<TableViewColumnFilter> filters = null;
+ if (idx != null && hasHiddenRows()) {
+ for (int i = 0; i < idx.length; i++) {
+ idx[i] = fullIndexOfVisible(idx[i]);
+ }
+ filters = beginFilterMutation();
}
- setRowNums();
- unEdit();
+ try {
+ if (idx != null) {
+ for (int i = 0; i < idx.length; i++) {
+ addItem(idx[i], grid[i]);
+ if (filters != null) {
+ for (int j = i + 1; j < idx.length; j++) {
+ if (idx[j] >= idx[i]) {
+ idx[j]++;
+ }
+ }
+ }
+ }
+
+ ChangeAction ta = new ChangeAction();
+ ta.setNew(grid, idx);
+ addUndo(ta);
+ }
+ if (filters == null
+ && rowNr == 0
+ && table.getItemCount() > rowNr + 1
+ && isEmpty(rowNr, -1)) {
+ // Empty row at rowNr?
+ // Remove it!
+ table.remove(rowNr);
+ }
+ setRowNums();
+ unEdit();
- setModified();
+ setModified();
+ } finally {
+ endFilterMutation(filters);
+ }
}
}
@@ -4154,16 +4339,22 @@ public class TableView extends Composite {
}
public void removeEmptyRows(int column) {
- // Remove "empty" table items, where item.getText(1) is empty, length==0
-
- for (int i = table.getItemCount() - 1; i >= 0; i--) {
- if (isEmpty(i, column)) {
- table.remove(i);
+ // Remove "empty" table items, where item.getText(1) is empty, length==0.
+ // Put filtered rows back first so an empty row that was hidden is removed
too, and the indexes
+ // in this loop are positions in the complete table.
+ List<TableViewColumnFilter> filters = beginFilterMutation();
+ try {
+ for (int i = table.getItemCount() - 1; i >= 0; i--) {
+ if (isEmpty(i, column)) {
+ table.remove(i);
+ }
}
- }
- if (table.getItemCount() == 0) { // At least one empty row!
+ if (table.getItemCount() == 0) { // At least one empty row!
- new TableItem(table, SWT.NONE);
+ new TableItem(table, SWT.NONE);
+ }
+ } finally {
+ endFilterMutation(filters);
}
}
@@ -4859,14 +5050,30 @@ public class TableView extends Composite {
/**
* Hide and reorder data columns so that only {@code columnNames} stay
visible, in that order.
- * Matching is by column name. Cell data is left in place.
+ * Matching is by column name. Cell data is left in place. Any row filters
of a previously applied
+ * view are removed first.
*
* @return {@code true} if at least one column matched
*/
public boolean applyColumnView(List<String> columnNames) {
+ return applyColumnView(columnNames, null);
+ }
+
+ /**
+ * Hide and reorder data columns so that only {@code columnNames} stay
visible, in that order, and
+ * hide every row that does not pass all {@code filters}. Matching is by
column name, rows are
+ * matched on their full cell values. Rows taken out of the table are kept
aside and come back
+ * with {@link #resetColumnView()} or when another view without filters is
applied.
+ *
+ * @param columnNames the columns to show, in display order
+ * @param filters the filters to apply, or null or empty for no row filtering
+ * @return {@code true} if at least one column matched
+ */
+ public boolean applyColumnView(List<String> columnNames,
List<TableViewColumnFilter> filters) {
if (table == null || table.isDisposed() || columns.length == 0) {
return false;
}
+ // Resolve first. A view that matches no column must leave the current
filter and layout alone.
String[] available = new String[columns.length];
for (int i = 0; i < columns.length; i++) {
available[i] = Const.NVL(columns[i].getName(), "");
@@ -4875,6 +5082,10 @@ public class TableView extends Composite {
if (visible.isEmpty()) {
return false;
}
+
+ restoreFilteredRows();
+ columnViewFilters = new ArrayList<>();
+ updateFilterIndications();
ensureRememberedWidths();
hiddenDataColumns.clear();
Set<Integer> visibleSet = new HashSet<>(visible);
@@ -4884,14 +5095,20 @@ public class TableView extends Composite {
}
}
applyHiddenAndOrder(visible);
+
+ if (filtersSupported && filters != null && !filters.isEmpty()) {
+ applyRowFilters(filters);
+ }
return true;
}
- /** Restore every data column to its original order and remembered width. */
+ /** Restore every data column to its original order and remembered width,
and show all rows. */
public void resetColumnView() {
if (table == null || table.isDisposed()) {
return;
}
+ restoreFilteredRows();
+ columnViewFilters = new ArrayList<>();
hiddenDataColumns.clear();
for (int i = 1; i < tableColumn.length; i++) {
TableColumn tc = tableColumn[i];
@@ -4907,6 +5124,404 @@ public class TableView extends Composite {
}
}
setColumnOrderSafe(naturalColumnOrder());
+ updateFilterIndications();
+ }
+
+ /** The filters of the column view that is applied right now; empty when
none are active. */
+ public List<TableViewColumnFilter> getAppliedColumnFilters() {
+ return new ArrayList<>(columnViewFilters);
+ }
+
+ /**
+ * The number of rows that would stay visible with these filters. Rows the
current filter has
+ * taken out of the table are included, so the count stays right after a
filter is already
+ * applied.
+ *
+ * @param filters the filters to count against, null or empty counts every
row
+ * @return the number of matching rows
+ */
+ public int getMatchingRowCount(List<TableViewColumnFilter> filters) {
+ if (table == null || table.isDisposed()) {
+ return 0;
+ }
+ if (filters == null || filters.isEmpty()) {
+ return getUnfilteredRowCount();
+ }
+ String[] names = new String[columns.length];
+ for (int i = 0; i < columns.length; i++) {
+ names[i] = Const.NVL(columns[i].getName(), "");
+ }
+ int count = 0;
+ for (TableItem item : table.getItems()) {
+ if (TableViewColumnViews.matchesFilters(filters, names,
getRowFilterValues(item))) {
+ count++;
+ }
+ }
+ if (hiddenRows != null) {
+ for (HiddenRow row : hiddenRows) {
+ if (TableViewColumnViews.matchesFilters(filters, names,
getHiddenRowFilterValues(row))) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ /** Visible rows plus the rows the current filter has taken out. */
+ public int getUnfilteredRowCount() {
+ if (table == null || table.isDisposed()) {
+ return 0;
+ }
+ return table.getItemCount() + (hiddenRows == null ? 0 : hiddenRows.size());
+ }
+
+ /**
+ * The unique values of one data column, sorted the way that column's data
type sorts: numbers
+ * numerically, dates by their conversion mask, booleans by truth, text as
text. Empty and null
+ * cells come back as {@link TableViewColumnViews#NULL_VALUE_LABEL}, which
sorts first.
+ *
+ * <p>Rows hidden by the column-view filter that is applied right now are
included. The list is
+ * the original distinct values, including after a filter is removed in the
dialog and before
+ * Apply puts those rows back on screen.
+ *
+ * @param dataIndex the index of the data column
+ * @return the unique values, sorted
+ */
+ public List<String> getUniqueColumnValues(int dataIndex) {
+ List<String> values = new ArrayList<>();
+ if (table == null || table.isDisposed() || dataIndex < 0 || dataIndex >=
columns.length) {
+ return values;
+ }
+ Set<String> unique = new LinkedHashSet<>();
+ for (TableItem item : table.getItems()) {
+ unique.add(getFilterValue(item, dataIndex));
+ }
+ if (hiddenRows != null) {
+ for (HiddenRow row : hiddenRows) {
+ unique.add(getHiddenFilterValue(row, dataIndex));
+ }
+ }
+ values.addAll(unique);
+
values.sort(TableViewColumnViews.valueComparator(columns[dataIndex].getValueMeta()));
+ return values;
+ }
+
+ private void applyRowFilters(List<TableViewColumnFilter> filters) {
+ String[] names = new String[columns.length];
+ for (int i = 0; i < columns.length; i++) {
+ names[i] = Const.NVL(columns[i].getName(), "");
+ }
+ List<TableViewColumnFilter> effective = new ArrayList<>();
+ for (TableViewColumnFilter filter : filters) {
+ if (filter == null
+ || StringUtils.isEmpty(filter.getColumnName())
+ || filter.getValues() == null
+ || filter.getValues().isEmpty()
+ || !columnExists(filter.getColumnName(), names)) {
+ continue;
+ }
+ effective.add(filter);
+ }
+ columnViewFilters = new ArrayList<>(effective);
+ updateFilterIndications();
+ if (effective.isEmpty()) {
+ return;
+ }
+
+ closeActiveEditors();
+ List<HiddenRow> hidden = new ArrayList<>();
+ // These removes are the filter itself. They must not be treated as the
user deleting a row.
+ filterMutationBatch = true;
+ try {
+ for (int i = table.getItemCount() - 1; i >= 0; i--) {
+ TableItem item = table.getItem(i);
+ if (!TableViewColumnViews.matchesFilters(effective, names,
getRowFilterValues(item))) {
+ hidden.add(captureRow(i, item));
+ table.remove(i);
+ }
+ }
+ } finally {
+ filterMutationBatch = false;
+ }
+ if (hidden.isEmpty()) {
+ return;
+ }
+ Collections.reverse(hidden);
+ hiddenRows = hidden;
+ // The row indices in the undo history no longer point at the same rows.
+ clearUndo();
+ if (table.getItemCount() > 0) {
+ table.setSelection(0);
+ activeTableItem = table.getItem(0);
+ } else {
+ activeTableItem = null;
+ }
+ activeTableRow = 0;
+ }
+
+ /** Put the rows taken out by the filters back where they came from. */
+ private void restoreFilteredRows() {
+ if (hiddenRows == null) {
+ return;
+ }
+ List<HiddenRow> hidden = hiddenRows;
+ hiddenRows = null;
+ if (hidden.isEmpty()) {
+ return;
+ }
+ closeActiveEditors();
+ // Ascending insert at the original index: at that moment the table holds
exactly the rows that
+ // originally sat above it - the survivors plus the hidden rows already
put back.
+ filterMutationBatch = true;
+ try {
+ for (HiddenRow row : hidden) {
+ insertHiddenRow(row, row.index);
+ }
+ } finally {
+ filterMutationBatch = false;
+ }
+ // The row indices in the undo history no longer point at the same rows.
+ clearUndo();
+ if (table.getItemCount() > 0) {
+ table.setSelection(0);
+ activeTableItem = table.getItem(0);
+ } else {
+ activeTableItem = null;
+ }
+ activeTableRow = 0;
+ }
+
+ private void insertHiddenRow(HiddenRow row, int position) {
+ TableItem item =
+ position >= 0 && position <= table.getItemCount()
+ ? new TableItem(table, SWT.NONE, position)
+ : new TableItem(table, SWT.NONE);
+ if (row.rowNumber != null) {
+ item.setText(0, row.rowNumber);
+ }
+ for (int i = 0; i < row.displayed.length; i++) {
+ if (row.displayed[i] != null) {
+ item.setText(i + 1, row.displayed[i]);
+ }
+ }
+ if (row.cellValues != null) {
+ item.setData(CELL_VALUES_KEY, row.cellValues);
+ }
+ if (row.data != null) {
+ item.setData(row.data);
+ }
+ if (row.background != null &&
!row.background.equals(defaultBackgroundColor)) {
+ item.setBackground(row.background);
+ }
+ for (int i = 0; i < row.blueForeground.length; i++) {
+ if (row.blueForeground[i]) {
+ item.setForeground(i + 1, nullTextColor);
+ }
+ }
+ }
+
+ private HiddenRow captureRow(int index, TableItem item) {
+ String[] displayed = new String[columns.length];
+ for (int i = 0; i < columns.length; i++) {
+ displayed[i] = item.getText(i + 1);
+ }
+ boolean[] blue = new boolean[columns.length];
+ Color blueColor = GuiResource.getInstance().getColorBlue();
+ for (int i = 0; i < columns.length; i++) {
+ blue[i] = blueColor.equals(item.getForeground(i + 1));
+ }
+ return new HiddenRow(
+ index,
+ item.getText(0),
+ displayed,
+ (String[][]) item.getData(CELL_VALUES_KEY),
+ item.getData(),
+ item.getBackground(),
+ blue);
+ }
+
+ /**
+ * The value of a cell the way the filters see it: null, empty and blue
(null-indicating) cells
+ * all come back as {@link TableViewColumnViews#NULL_VALUE_LABEL},
everything else as the full
+ * cell value.
+ */
+ private String getFilterValue(TableItem item, int dataColumn) {
+ if (item == null || item.isDisposed()) {
+ return TableViewColumnViews.NULL_VALUE_LABEL;
+ }
+ String value = getCellValue(item, dataColumn + 1);
+ if (value == null || value.isEmpty()) {
+ return TableViewColumnViews.NULL_VALUE_LABEL;
+ }
+ if
(GuiResource.getInstance().getColorBlue().equals(item.getForeground(dataColumn
+ 1))) {
+ return TableViewColumnViews.NULL_VALUE_LABEL;
+ }
+ return value;
+ }
+
+ /** The same reading as {@link #getFilterValue(TableItem, int)} for a row
kept aside. */
+ private String getHiddenFilterValue(HiddenRow row, int dataColumn) {
+ if (row.blueForeground != null
+ && dataColumn < row.blueForeground.length
+ && row.blueForeground[dataColumn]) {
+ return TableViewColumnViews.NULL_VALUE_LABEL;
+ }
+ String displayed =
+ row.displayed != null && dataColumn < row.displayed.length
+ ? row.displayed[dataColumn]
+ : null;
+ String value = displayed;
+ int colNr = dataColumn + 1;
+ if (row.cellValues != null
+ && row.cellValues.length > DISPLAYED
+ && colNr < row.cellValues[FULL].length
+ && row.cellValues[FULL][colNr] != null
+ && row.cellValues[DISPLAYED][colNr] != null
+ && row.cellValues[DISPLAYED][colNr].equals(displayed)) {
+ value = row.cellValues[FULL][colNr];
+ }
+ if (value == null || value.isEmpty()) {
+ return TableViewColumnViews.NULL_VALUE_LABEL;
+ }
+ return value;
+ }
+
+ private String[] getRowFilterValues(TableItem item) {
+ String[] values = new String[columns.length];
+ for (int i = 0; i < columns.length; i++) {
+ values[i] = getFilterValue(item, i);
+ }
+ return values;
+ }
+
+ /** True when the column name exists, matched the same way the row values
are matched. */
+ private static boolean columnExists(String columnName, String[] names) {
+ for (String name : names) {
+ if (columnName.equals(name)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Show a filter icon on the header of every filtered column and put the
header image and tooltip
+ * of the columns without a filter back.
+ */
+ private void updateFilterIndications() {
+ if (table == null || table.isDisposed()) {
+ return;
+ }
+ Set<Integer> filtered = new HashSet<>();
+ for (TableViewColumnFilter filter : columnViewFilters) {
+ if (filter == null || StringUtils.isEmpty(filter.getColumnName())) {
+ continue;
+ }
+ for (int i = 0; i < columns.length; i++) {
+ if (filter.getColumnName().equals(Const.NVL(columns[i].getName(),
""))) {
+ filtered.add(i);
+ }
+ }
+ }
+ Image filterImage =
+ filtered.isEmpty() ? null :
GuiResource.getInstance().getImage("ui/images/filter.svg");
+ for (int i = 0; i < columns.length; i++) {
+ TableColumn tc = tableColumn[i + 1];
+ if (tc == null || tc.isDisposed()) {
+ continue;
+ }
+ if (filtered.contains(i)) {
+ tc.setImage(filterImage);
+ List<String> values = new ArrayList<>();
+ for (TableViewColumnFilter filter : columnViewFilters) {
+ if (filter != null
+ && filter.getColumnName().equals(Const.NVL(columns[i].getName(),
""))) {
+ values.addAll(filter.getValues());
+ }
+ }
+ tc.setToolTipText(
+ BaseMessages.getString(PKG, "TableView.Filter.Tooltip",
String.join(",", values)));
+ } else {
+ tc.setImage(columns[i].getImage());
+ tc.setToolTipText(columns[i].getToolTip());
+ }
+ }
+ }
+
+ /**
+ * Forget the active row filters without putting the hidden rows back: for
when the table content
+ * is replaced wholesale and the kept-aside rows would only come back as
ghosts.
+ */
+ private void clearColumnViewFilters() {
+ hiddenRows = null;
+ columnViewFilters = new ArrayList<>();
+ updateFilterIndications();
+ }
+
+ private boolean hasHiddenRows() {
+ return hiddenRows != null && !hiddenRows.isEmpty();
+ }
+
+ /**
+ * Where visible row {@code visibleIndex} sits once the filtered-out rows
are back. {@code
+ * visibleIndex == item count} is the position after the last row.
+ */
+ private int fullIndexOfVisible(int visibleIndex) {
+ if (!hasHiddenRows() || visibleIndex < 0) {
+ return Math.max(visibleIndex, 0);
+ }
+ int visibleSeen = 0;
+ int hiddenPtr = 0;
+ int full = 0;
+ int limit = table.getItemCount() + hiddenRows.size();
+ while (full <= limit) {
+ boolean hiddenSlot = hiddenPtr < hiddenRows.size() &&
hiddenRows.get(hiddenPtr).index == full;
+ if (hiddenSlot) {
+ hiddenPtr++;
+ } else if (visibleSeen == visibleIndex) {
+ return full;
+ } else {
+ visibleSeen++;
+ }
+ full++;
+ }
+ return limit;
+ }
+
+ /**
+ * Put the filtered-out rows back and remember the filter so {@link
#endFilterMutation} can apply
+ * it again. Returns null when no filter is active or a mutation is already
in progress: the
+ * caller then edits the table as it stands.
+ */
+ private List<TableViewColumnFilter> beginFilterMutation() {
+ if (filterMutationBatch || columnViewFilters.isEmpty()) {
+ return null;
+ }
+ List<TableViewColumnFilter> filters = new ArrayList<>(columnViewFilters);
+ restoreFilteredRows();
+ columnViewFilters = new ArrayList<>();
+ updateFilterIndications();
+ filterMutationBatch = true;
+ return filters;
+ }
+
+ /** Apply {@code filters} again. A null list means this caller does not own
the mutation. */
+ private void endFilterMutation(List<TableViewColumnFilter> filters) {
+ if (filters == null) {
+ return;
+ }
+ filterMutationBatch = false;
+ if (!filters.isEmpty()) {
+ applyRowFilters(filters);
+ }
+ }
+
+ private String[] getHiddenRowFilterValues(HiddenRow row) {
+ String[] values = new String[columns.length];
+ for (int i = 0; i < columns.length; i++) {
+ values[i] = getHiddenFilterValue(row, i);
+ }
+ return values;
}
/**
@@ -5055,13 +5670,163 @@ public class TableView extends Composite {
}
}
+ /**
+ * Ask which values of this column to show. The list is the unique values,
sorted the way the
+ * column's data type sorts. More than {@link #FILTER_VALUE_LIST_WARN_LIMIT}
values need a
+ * confirmation first.
+ *
+ * @param parent the shell that owns the dialogs
+ * @param dataIndex the data column, not counting the row-number column
+ * @param currentValues the values already selected for this column, shown
pre-selected
+ * @return the chosen values, an empty list when the filter should be
dropped, or null when the
+ * user cancelled or the column has no values
+ */
+ public List<String> promptColumnFilterValues(
+ Shell parent, int dataIndex, List<String> currentValues) {
+ if (parent == null || parent.isDisposed() || dataIndex < 0 || dataIndex >=
columns.length) {
+ return null;
+ }
+ String columnName = Const.NVL(columns[dataIndex].getName(), "");
+ List<String> values = getUniqueColumnValues(dataIndex);
+ if (values.isEmpty()) {
+ MessageBox box = new MessageBox(parent, SWT.OK | SWT.ICON_INFORMATION);
+ box.setText(BaseMessages.getString(PKG,
"TableView.Filter.Values.Title"));
+ box.setMessage(BaseMessages.getString(PKG,
"TableView.Filter.NoValues.Message", columnName));
+ box.open();
+ return null;
+ }
+ if (values.size() > FILTER_VALUE_LIST_WARN_LIMIT) {
+ MessageBox box = new MessageBox(parent, SWT.YES | SWT.NO |
SWT.ICON_WARNING);
+ box.setText(BaseMessages.getString(PKG,
"TableView.Filter.ManyValues.Title"));
+ box.setMessage(
+ BaseMessages.getString(
+ PKG,
+ "TableView.Filter.ManyValues.Message",
+ columnName,
+ Integer.toString(values.size())));
+ if ((box.open() & SWT.YES) == 0) {
+ return null;
+ }
+ }
+
+ int[] preselect = new int[currentValues == null ? 0 :
currentValues.size()];
+ int count = 0;
+ if (currentValues != null) {
+ for (String value : currentValues) {
+ int index = values.indexOf(value);
+ if (index >= 0) {
+ preselect[count++] = index;
+ }
+ }
+ }
+ preselect = Arrays.copyOf(preselect, count);
+
+ EnterSelectionDialog dialog =
+ new EnterSelectionDialog(
+ parent,
+ values.toArray(new String[0]),
+ BaseMessages.getString(PKG, "TableView.Filter.Values.Title"),
+ BaseMessages.getString(PKG, "TableView.Filter.Values.Message",
columnName));
+ dialog.setMulti(true);
+ dialog.setSelectedNrs(preselect);
+ dialog.open();
+
+ // getSelectionIndeces() is null only when the dialog was cancelled. OK
without a selection
+ // leaves an empty array, which drops the filter on this column.
+ int[] selected = dialog.getSelectionIndeces();
+ if (selected == null) {
+ return null;
+ }
+ List<String> selectedValues = new ArrayList<>();
+ for (int index : selected) {
+ if (index >= 0 && index < values.size()) {
+ selectedValues.add(values.get(index));
+ }
+ }
+ return selectedValues;
+ }
+
+ private void addFilterForColumn(int dataIndex) {
+ if (!filtersSupported || dataIndex < 0 || dataIndex >= columns.length) {
+ return;
+ }
+ String columnName = Const.NVL(columns[dataIndex].getName(), "");
+ List<String> selected =
+ promptColumnFilterValues(getShell(), dataIndex,
filterValues(columnName));
+ if (selected == null) {
+ return;
+ }
+ replaceColumnFilter(dataIndex, selected);
+ }
+
+ private void removeFilterForColumn(int dataIndex) {
+ if (!columnHasFilter(dataIndex)) {
+ return;
+ }
+ replaceColumnFilter(dataIndex, List.of());
+ }
+
+ /**
+ * Replace the applied filter of one column and keep the columns that are
visible now. An empty
+ * value list drops the filter. Filters on other columns stay.
+ *
+ * @param dataIndex the data column, not counting the row-number column
+ * @param values the values to keep, or an empty list to drop the filter
+ */
+ void replaceColumnFilter(int dataIndex, List<String> values) {
+ if (!filtersSupported || dataIndex < 0 || dataIndex >= columns.length) {
+ return;
+ }
+ String columnName = Const.NVL(columns[dataIndex].getName(), "");
+ List<TableViewColumnFilter> filters = getAppliedColumnFilters();
+ filters.removeIf(filter -> filter != null &&
columnName.equals(filter.getColumnName()));
+ if (values != null && !values.isEmpty()) {
+ filters.add(new TableViewColumnFilter(columnName, values));
+ }
+ applyFiltersKeepingColumns(filters);
+ }
+
+ private void applyFiltersKeepingColumns(List<TableViewColumnFilter> filters)
{
+ List<String> visible = getVisibleColumnNamesInOrder();
+ if (visible.isEmpty()) {
+ visible = new ArrayList<>();
+ for (ColumnInfo column : columns) {
+ visible.add(Const.NVL(column.getName(), ""));
+ }
+ }
+ applyColumnView(visible, filters);
+ }
+
+ private boolean columnHasFilter(int dataIndex) {
+ if (dataIndex < 0 || dataIndex >= columns.length) {
+ return false;
+ }
+ String columnName = Const.NVL(columns[dataIndex].getName(), "");
+ for (TableViewColumnFilter filter : columnViewFilters) {
+ if (filter != null && columnName.equals(filter.getColumnName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private List<String> filterValues(String columnName) {
+ for (TableViewColumnFilter filter : columnViewFilters) {
+ if (filter != null
+ && columnName.equals(filter.getColumnName())
+ && filter.getValues() != null) {
+ return filter.getValues();
+ }
+ }
+ return List.of();
+ }
+
private void addHeaderContextMenu(Menu rowMenu) {
- Menu headerMenu = new Menu(table);
+ headerContextMenu = new Menu(table);
- MenuItem miHide = new MenuItem(headerMenu, SWT.NONE);
+ MenuItem miHide = new MenuItem(headerContextMenu, SWT.NONE);
miHide.setText(
OsHelper.customizeMenuitemText(BaseMessages.getString(PKG,
"TableView.menu.HideColumn")));
- final int[] headerDataColumn = {-1};
miHide.addListener(
SWT.Selection,
e -> {
@@ -5070,15 +5835,19 @@ public class TableView extends Composite {
}
});
- MenuItem miShowAll = new MenuItem(headerMenu, SWT.NONE);
+ MenuItem miShowAll = new MenuItem(headerContextMenu, SWT.NONE);
miShowAll.setText(
OsHelper.customizeMenuitemText(
BaseMessages.getString(PKG, "TableView.menu.ShowAllColumns")));
miShowAll.addListener(SWT.Selection, e -> resetColumnView());
+ if (filtersSupported) {
+ addFilterHeaderMenuItems();
+ }
+
if (!removeToolItems.contains(ID_TOOLBAR_TABLE_VIEWS)) {
- new MenuItem(headerMenu, SWT.SEPARATOR);
- MenuItem miTableViews = new MenuItem(headerMenu, SWT.NONE);
+ new MenuItem(headerContextMenu, SWT.SEPARATOR);
+ MenuItem miTableViews = new MenuItem(headerContextMenu, SWT.NONE);
miTableViews.setText(
OsHelper.customizeMenuitemText(BaseMessages.getString(PKG,
"TableView.menu.TableViews")));
miTableViews.setImage(GuiResource.getInstance().getImageView());
@@ -5100,7 +5869,11 @@ public class TableView extends Composite {
if (onHeader) {
headerDataColumn[0] = findDataColumnAtX(pt.x);
miHide.setEnabled(headerDataColumn[0] >= 0);
- table.setMenu(headerMenu);
+ if (miHeaderAddFilter != null && !miHeaderAddFilter.isDisposed()) {
+ miHeaderAddFilter.setEnabled(headerDataColumn[0] >= 0);
+
miHeaderRemoveFilter.setEnabled(columnHasFilter(headerDataColumn[0]));
+ }
+ table.setMenu(headerContextMenu);
} else {
contextMenuPoint = event.detail == SWT.MENU_KEYBOARD ? null : pt;
table.setMenu(rowMenu);
@@ -5108,6 +5881,62 @@ public class TableView extends Composite {
});
}
+ /**
+ * @param filtersSupported true to offer row filters on this grid
+ */
+ public void setFiltersSupported(boolean filtersSupported) {
+ this.filtersSupported = filtersSupported;
+ if (filtersSupported) {
+ addFilterHeaderMenuItems();
+ return;
+ }
+ if (miHeaderAddFilter != null && !miHeaderAddFilter.isDisposed()) {
+ miHeaderAddFilter.dispose();
+ miHeaderRemoveFilter.dispose();
+ }
+ miHeaderAddFilter = null;
+ miHeaderRemoveFilter = null;
+ }
+
+ /** Add the filter entries once, in front of the Table views separator when
that entry exists. */
+ private void addFilterHeaderMenuItems() {
+ if (headerContextMenu == null
+ || headerContextMenu.isDisposed()
+ || (miHeaderAddFilter != null && !miHeaderAddFilter.isDisposed())) {
+ return;
+ }
+ int index = headerContextMenu.getItemCount();
+ MenuItem[] items = headerContextMenu.getItems();
+ for (int i = 0; i < items.length; i++) {
+ if ((items[i].getStyle() & SWT.SEPARATOR) != 0) {
+ index = i;
+ break;
+ }
+ }
+ miHeaderAddFilter = new MenuItem(headerContextMenu, SWT.NONE, index);
+ miHeaderAddFilter.setText(
+ OsHelper.customizeMenuitemText(BaseMessages.getString(PKG,
"TableView.menu.AddFilter")));
+
miHeaderAddFilter.setImage(GuiResource.getInstance().getImage("ui/images/filter.svg"));
+ miHeaderAddFilter.addListener(
+ SWT.Selection,
+ e -> {
+ if (headerDataColumn[0] >= 0) {
+ addFilterForColumn(headerDataColumn[0]);
+ }
+ });
+
+ miHeaderRemoveFilter = new MenuItem(headerContextMenu, SWT.NONE, index +
1);
+ miHeaderRemoveFilter.setText(
+ OsHelper.customizeMenuitemText(BaseMessages.getString(PKG,
"TableView.menu.RemoveFilter")));
+ miHeaderRemoveFilter.addListener(
+ SWT.Selection,
+ e -> {
+ if (headerDataColumn[0] >= 0) {
+ removeFilterForColumn(headerDataColumn[0]);
+ }
+ });
+ }
+
private int findDataColumnAtX(int x) {
int pos = 0;
ScrollBar hBar = table.getHorizontalBar();
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnView.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnFilter.java
similarity index 68%
copy from
ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnView.java
copy to
ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnFilter.java
index 1286aeeda3..b376111abf 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnView.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnFilter.java
@@ -21,17 +21,19 @@ import java.util.List;
import lombok.Getter;
import lombok.Setter;
-/** A named, reusable subset and order of {@link TableView} columns. */
+/**
+ * A filter on one {@link TableView} column: only rows with one of the
selected values stay visible.
+ */
@Getter
@Setter
-public class TableViewColumnView {
- private String name;
- private List<String> columnNames = new ArrayList<>();
+public class TableViewColumnFilter {
+ private String columnName;
+ private List<String> values = new ArrayList<>();
- public TableViewColumnView() {}
+ public TableViewColumnFilter() {}
- public TableViewColumnView(String name, List<String> columnNames) {
- this.name = name;
- this.columnNames = columnNames != null ? new ArrayList<>(columnNames) :
new ArrayList<>();
+ public TableViewColumnFilter(String columnName, List<String> values) {
+ this.columnName = columnName;
+ this.values = values != null ? new ArrayList<>(values) : new ArrayList<>();
}
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnView.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnView.java
index 1286aeeda3..0bc5bcd9d9 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnView.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnView.java
@@ -21,12 +21,15 @@ import java.util.List;
import lombok.Getter;
import lombok.Setter;
-/** A named, reusable subset and order of {@link TableView} columns. */
+/**
+ * A named, reusable subset and order of {@link TableView} columns, with
optional column filters.
+ */
@Getter
@Setter
public class TableViewColumnView {
private String name;
private List<String> columnNames = new ArrayList<>();
+ private List<TableViewColumnFilter> filters = new ArrayList<>();
public TableViewColumnView() {}
@@ -34,4 +37,10 @@ public class TableViewColumnView {
this.name = name;
this.columnNames = columnNames != null ? new ArrayList<>(columnNames) :
new ArrayList<>();
}
+
+ public TableViewColumnView(
+ String name, List<String> columnNames, List<TableViewColumnFilter>
filters) {
+ this(name, columnNames);
+ this.filters = filters != null ? new ArrayList<>(filters) : new
ArrayList<>();
+ }
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViewManager.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViewManager.java
index 76d206544d..aadca81150 100644
---
a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViewManager.java
+++
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViewManager.java
@@ -37,6 +37,9 @@ import org.apache.hop.history.AuditStateMap;
public final class TableViewColumnViewManager {
public static final String AUDIT_TYPE = "table-view";
public static final String STATE_COLUMNS = "columns";
+ public static final String STATE_FILTERS = "filters";
+ public static final String FILTER_COLUMN = "column";
+ public static final String FILTER_VALUES = "values";
private TableViewColumnViewManager() {}
@@ -95,6 +98,22 @@ public final class TableViewColumnViewManager {
List<String> columns =
view.getColumnNames() != null ? new ArrayList<>(view.getColumnNames())
: new ArrayList<>();
stateMap.put(STATE_COLUMNS, columns);
+ List<Map<String, Object>> filters = new ArrayList<>();
+ if (view.getFilters() != null) {
+ for (TableViewColumnFilter filter : view.getFilters()) {
+ if (filter == null
+ || StringUtils.isEmpty(filter.getColumnName())
+ || filter.getValues() == null
+ || filter.getValues().isEmpty()) {
+ continue;
+ }
+ Map<String, Object> filterMap = new HashMap<>();
+ filterMap.put(FILTER_COLUMN, filter.getColumnName());
+ filterMap.put(FILTER_VALUES, new ArrayList<>(filter.getValues()));
+ filters.add(filterMap);
+ }
+ }
+ stateMap.put(STATE_FILTERS, filters);
AuditManager.storeState(LogChannel.UI, group, AUDIT_TYPE, view.getName(),
stateMap);
}
@@ -128,9 +147,42 @@ public final class TableViewColumnViewManager {
TableViewColumnView view = new TableViewColumnView();
view.setName(state.getName());
view.setColumnNames(extractStringList(state, STATE_COLUMNS));
+ view.setFilters(extractFilters(state));
return view;
}
+ static List<TableViewColumnFilter> extractFilters(AuditState state) {
+ List<TableViewColumnFilter> filters = new ArrayList<>();
+ if (state == null || state.getStateMap() == null) {
+ return filters;
+ }
+ Object raw = state.getStateMap().get(STATE_FILTERS);
+ if (raw instanceof Collection<?> collection) {
+ for (Object item : collection) {
+ if (!(item instanceof Map<?, ?> map)) {
+ continue;
+ }
+ Object column = map.get(FILTER_COLUMN);
+ if (column == null || StringUtils.isEmpty(String.valueOf(column))) {
+ continue;
+ }
+ List<String> values = new ArrayList<>();
+ Object rawValues = map.get(FILTER_VALUES);
+ if (rawValues instanceof Collection<?> valueCollection) {
+ for (Object value : valueCollection) {
+ if (value != null) {
+ values.add(String.valueOf(value));
+ }
+ }
+ }
+ if (!values.isEmpty()) {
+ filters.add(new TableViewColumnFilter(String.valueOf(column),
values));
+ }
+ }
+ }
+ return filters;
+ }
+
static List<String> extractStringList(AuditState state, String key) {
List<String> columns = new ArrayList<>();
if (state == null || state.getStateMap() == null) {
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViews.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViews.java
index 52833a7cf6..55c7406b82 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViews.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewColumnViews.java
@@ -16,14 +16,32 @@
*/
package org.apache.hop.ui.core.widget;
+import java.math.BigDecimal;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.value.ValueMetaBigNumber;
+import org.apache.hop.core.row.value.ValueMetaBoolean;
+import org.apache.hop.core.row.value.ValueMetaDate;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaNumber;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.row.value.ValueMetaTimestamp;
/**
- * Matching helpers for named {@link TableView} column views. Kept free of SWT
so the logic can be
- * unit tested.
+ * Matching helpers for named {@link TableView} column views and their column
filters. Kept free of
+ * SWT so the logic can be unit tested.
*/
public final class TableViewColumnViews {
+ /** Label shown in the UI for a null or empty value in a column filter. */
+ public static final String NULL_VALUE_LABEL = "<null>";
+
private TableViewColumnViews() {}
/**
@@ -60,4 +78,221 @@ public final class TableViewColumnViews {
}
return indices;
}
+
+ /**
+ * Columns the table views dialog lists under "Columns in view" when it
opens.
+ *
+ * <p>Every column is included. Columns that are on screen come first, in
that order, and the
+ * others follow in creation order. A filter from the column header does not
change this, so the
+ * dialog does not open with an empty view.
+ *
+ * @param visibleInOrder column names currently shown, in display order
+ * @param allColumns every column of the table, in creation order
+ * @return the names to put in "Columns in view"
+ */
+ public static List<String> columnsInViewOnOpen(List<String> visibleInOrder,
String[] allColumns) {
+ List<String> shown = distinctNames(visibleInOrder);
+ List<String> all = distinctNames(allColumns == null ? List.of() :
Arrays.asList(allColumns));
+ if (shown.isEmpty()) {
+ return all;
+ }
+ Set<String> seen = new LinkedHashSet<>(shown);
+ for (String name : all) {
+ if (seen.add(name)) {
+ shown.add(name);
+ }
+ }
+ return shown;
+ }
+
+ private static List<String> distinctNames(List<String> names) {
+ if (names == null || names.isEmpty()) {
+ return new ArrayList<>();
+ }
+ Set<String> seen = new LinkedHashSet<>();
+ for (String name : names) {
+ if (StringUtils.isNotEmpty(name)) {
+ seen.add(name);
+ }
+ }
+ return new ArrayList<>(seen);
+ }
+
+ /**
+ * Render a filter the way it is shown in the applied-filters list, for
example {@code Country :
+ * BE,FR,<null>}.
+ *
+ * @param filter the filter to render
+ * @return {@code "column : value1,value2"} or an empty string when the
filter is empty
+ */
+ public static String formatFilter(TableViewColumnFilter filter) {
+ if (filter == null || StringUtils.isEmpty(filter.getColumnName())) {
+ return "";
+ }
+ List<String> values = filter.getValues();
+ if (values == null || values.isEmpty()) {
+ return "";
+ }
+ return filter.getColumnName() + " : " + String.join(",", values);
+ }
+
+ /**
+ * True when a row passes every filter. A filter on a column that is not in
{@code columnNames} is
+ * ignored, as is a filter without values.
+ *
+ * <p>Row values are matched exactly on the full cell value; an empty cell
counts as {@link
+ * #NULL_VALUE_LABEL}.
+ *
+ * @param filters the filters to apply, in order
+ * @param columnNames the column names of {@code rowValues}, in the same
order
+ * @param rowValues the values of one row
+ * @return true when the row matches all applicable filters
+ */
+ public static boolean matchesFilters(
+ List<TableViewColumnFilter> filters, String[] columnNames, String[]
rowValues) {
+ if (filters == null || filters.isEmpty()) {
+ return true;
+ }
+ if (columnNames == null || rowValues == null) {
+ return true;
+ }
+ for (TableViewColumnFilter filter : filters) {
+ if (filter == null
+ || StringUtils.isEmpty(filter.getColumnName())
+ || filter.getValues() == null
+ || filter.getValues().isEmpty()) {
+ continue;
+ }
+ int index = -1;
+ for (int i = 0; i < columnNames.length; i++) {
+ if (filter.getColumnName().equals(columnNames[i])) {
+ index = i;
+ break;
+ }
+ }
+ if (index < 0 || index >= rowValues.length) {
+ continue;
+ }
+ if (!filter.getValues().contains(normalizeValue(rowValues[index]))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Map a raw cell value to its filter representation: empty or null becomes
{@link
+ * #NULL_VALUE_LABEL}, anything else is kept as-is.
+ *
+ * @param value the raw cell value
+ * @return the value to compare against filter values
+ */
+ public static String normalizeValue(String value) {
+ if (value == null || value.isEmpty()) {
+ return NULL_VALUE_LABEL;
+ }
+ return value;
+ }
+
+ /**
+ * Comparator for the unique values of a column, sorted the way that
column's value meta sorts.
+ * Numbers, dates and booleans are parsed with the meta's mask, decimal and
grouping symbols, date
+ * locale and time zone — the same conversion a column sort uses. A value
that does not parse
+ * sorts after the ones that do, and two unparsed values sort as text, so
the order stays
+ * transitive. The null label always sorts first.
+ *
+ * @param columnMeta the column's value meta, or null to sort as text
+ * @return a comparator over the displayed values
+ */
+ public static Comparator<String> valueComparator(IValueMeta columnMeta) {
+ IValueMeta parser = null;
+ if (columnMeta != null && columnMeta.getType() != IValueMeta.TYPE_STRING) {
+ parser = new ValueMetaString("filter-value");
+ parser.setConversionMetadata(columnMeta);
+ }
+ IValueMeta source = parser;
+ return (left, right) -> compareValues(source, left, right);
+ }
+
+ /**
+ * Same ordering as {@link #valueComparator(IValueMeta)} for a type and
mask. Numeric symbols are
+ * fixed to {@code '.'} and {@code ','} so the order does not follow the JVM
locale.
+ *
+ * @param valueType one of the {@link IValueMeta} TYPE_* constants
+ * @param conversionMask the conversion mask of the column, or null when it
has none
+ * @return a comparator over the displayed values
+ */
+ public static Comparator<String> valueComparator(int valueType, String
conversionMask) {
+ IValueMeta columnMeta =
+ switch (valueType) {
+ case IValueMeta.TYPE_NUMBER -> new ValueMetaNumber("value");
+ case IValueMeta.TYPE_INTEGER -> new ValueMetaInteger("value");
+ case IValueMeta.TYPE_BIGNUMBER -> new ValueMetaBigNumber("value");
+ case IValueMeta.TYPE_DATE -> new ValueMetaDate("value");
+ case IValueMeta.TYPE_TIMESTAMP -> new ValueMetaTimestamp("value");
+ case IValueMeta.TYPE_BOOLEAN -> new ValueMetaBoolean("value");
+ default -> new ValueMetaString("value");
+ };
+ if (columnMeta.isNumeric()) {
+ columnMeta.setDecimalSymbol(".");
+ columnMeta.setGroupingSymbol(",");
+ }
+ columnMeta.setConversionMask(conversionMask);
+ return valueComparator(columnMeta);
+ }
+
+ private static int compareValues(IValueMeta parser, String left, String
right) {
+ if (left == null || right == null) {
+ return left == right ? 0 : (left == null ? -1 : 1);
+ }
+ boolean leftNull = NULL_VALUE_LABEL.equals(left);
+ boolean rightNull = NULL_VALUE_LABEL.equals(right);
+ if (leftNull || rightNull) {
+ return leftNull == rightNull ? 0 : (leftNull ? -1 : 1);
+ }
+ Object leftParsed = tryParse(parser, left);
+ Object rightParsed = tryParse(parser, right);
+ if (leftParsed != null && rightParsed != null) {
+ Integer compared = compareParsed(leftParsed, rightParsed);
+ if (compared != null) {
+ return compared;
+ }
+ }
+ // One parsed value always sorts before one that did not. Two failures
sort as text.
+ if (leftParsed != null && rightParsed == null) {
+ return -1;
+ }
+ if (leftParsed == null && rightParsed != null) {
+ return 1;
+ }
+ return left.compareTo(right);
+ }
+
+ /** Parse with the column meta. Null when the meta does not apply or the
text does not parse. */
+ private static Object tryParse(IValueMeta parser, String text) {
+ if (parser == null) {
+ return null;
+ }
+ try {
+ return parser.convertDataUsingConversionMetaData(text);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static Integer compareParsed(Object left, Object right) {
+ if (left instanceof BigDecimal leftNumber && right instanceof BigDecimal
rightNumber) {
+ return leftNumber.compareTo(rightNumber);
+ }
+ if (left instanceof Number leftNumber && right instanceof Number
rightNumber) {
+ return Double.compare(leftNumber.doubleValue(),
rightNumber.doubleValue());
+ }
+ if (left instanceof Date leftDate && right instanceof Date rightDate) {
+ return leftDate.compareTo(rightDate);
+ }
+ if (left instanceof Boolean leftFlag && right instanceof Boolean
rightFlag) {
+ return Boolean.compare(leftFlag, rightFlag);
+ }
+ return null;
+ }
}
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
index d8b72da7e3..f0c1197396 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
@@ -370,14 +370,21 @@ ShowRowsDialog.CellTooltip.Length=Length: {0}
ShowRowsDialog.CellTooltip.Precision=Precision: {0}
ShowRowsDialog.CellTooltip.Origin=Origin: {0}
TableViewColumnViewDialog.AddAll.ToolTip=Add all available columns to the view
+TableViewColumnViewDialog.AddFilter.Label=Add filter
+TableViewColumnViewDialog.AddFilter.ToolTip=Add a filter on a column\: pick
the values to show
TableViewColumnViewDialog.AddOne.ToolTip=Add the selected columns to the view
TableViewColumnViewDialog.Apply.Label=&Apply
TableViewColumnViewDialog.Available.Label=Available columns
TableViewColumnViewDialog.Delete.Message=Delete view ''{0}''?
TableViewColumnViewDialog.Delete.Title=Delete view
TableViewColumnViewDialog.EmptyView.Message=Select at least one column to
apply a view.
+TableViewColumnViewDialog.Filter.Column.Message=Select the column to filter on
+TableViewColumnViewDialog.Filter.Column.Title=Filter on column
+TableViewColumnViewDialog.Filters.Label=Applied filters\:
TableViewColumnViewDialog.MoveDown.Label=Move down
+TableViewColumnViewDialog.MoveDown.ToolTip=Move the selected columns down
TableViewColumnViewDialog.MoveUp.Label=Move up
+TableViewColumnViewDialog.MoveUp.ToolTip=Move the selected columns up
TableViewColumnViewDialog.Name.Empty=Please enter a name for the view.
TableViewColumnViewDialog.Name.Message=Name of the table view
TableViewColumnViewDialog.Name.Title=View name
@@ -385,10 +392,14 @@ TableViewColumnViewDialog.NoMatch.Message=None of the
fields in this view exist
TableViewColumnViewDialog.Overwrite.Message=A view named ''{0}'' already
exists. Overwrite it?
TableViewColumnViewDialog.Overwrite.Title=Overwrite view
TableViewColumnViewDialog.RemoveAll.ToolTip=Remove all columns from the view
+TableViewColumnViewDialog.RemoveFilter.Label=Remove filter
+TableViewColumnViewDialog.RemoveFilter.ToolTip=Remove the selected filters
TableViewColumnViewDialog.RemoveOne.ToolTip=Remove the selected columns from
the view
TableViewColumnViewDialog.Rename.Label=&Rename
TableViewColumnViewDialog.ShowAll.Label=Show all columns
TableViewColumnViewDialog.Shown.Label=Columns in view (display order)
+TableViewColumnViewDialog.Status.Filters={0} filter(s) applied
+TableViewColumnViewDialog.Status.Rows={0} of {1} rows match
TableViewColumnViewDialog.Status.Missing={0} field(s) not in this table
TableViewColumnViewDialog.Status.Visible={0} of {1} columns visible
TableViewColumnViewDialog.Title=Table views
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
index f946ee498b..2800cdaa4d 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
@@ -58,6 +58,14 @@ MetadataElement.View.Tooltip=View the element in the
metadata perspective
StyledTextComp.tooltip.InsertVariable=Enter CTRL-SPACE to select a variable to
insert
TableView.ErrorDialog.description=There was an unexpected error sorting the
table view column
TableView.ErrorDialog.title=Unexpected error
+TableView.Filter.Tooltip=Filtered on\: {0}
+TableView.Filter.NoValues.Message=Column ''{0}'' has no values to filter on.
+TableView.Filter.ManyValues.Title=Many values
+TableView.Filter.ManyValues.Message=Column ''{0}'' has {1} unique values.
Building the list of values can take a while. Continue?
+TableView.Filter.Values.Title=Filter values
+TableView.Filter.Values.Message=Select the values to show for column ''{0}''
+TableView.menu.AddFilter=Add filter...
+TableView.menu.RemoveFilter=Remove filter
TableView.menu.ClearAll=Clear all
TableView.menu.ClearSelection=Clear selection\tESC
TableView.menu.CopyFieldToAllRows=Copy field value to all rows
diff --git
a/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewManagerTest.java
b/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewManagerTest.java
index a10eb630ee..803dfdfee7 100644
---
a/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewManagerTest.java
+++
b/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewManagerTest.java
@@ -75,4 +75,46 @@ class TableViewColumnViewManagerTest {
assertNull(TableViewColumnViewManager.load("group", null));
assertNull(TableViewColumnViewManager.load(null, "name"));
}
+
+ @Test
+ void filtersRoundTripThroughTheAuditStore() {
+ String group = "test-project";
+
+ TableViewColumnView view =
+ new TableViewColumnView(
+ "Filtered customers",
+ List.of("id", "name", "country"),
+ List.of(
+ new TableViewColumnFilter("country", List.of("BE", "FR",
"<null>")),
+ new TableViewColumnFilter("name", List.of("Smith"))));
+ TableViewColumnViewManager.save(group, view);
+
+ TableViewColumnView loaded = TableViewColumnViewManager.load(group,
"Filtered customers");
+ assertEquals(2, loaded.getFilters().size());
+ assertEquals("country", loaded.getFilters().get(0).getColumnName());
+ assertEquals(List.of("BE", "FR", "<null>"),
loaded.getFilters().get(0).getValues());
+ assertEquals("name", loaded.getFilters().get(1).getColumnName());
+ assertEquals(List.of("Smith"), loaded.getFilters().get(1).getValues());
+
+ // Saving the view again without filters clears them.
+ TableViewColumnViewManager.save(
+ group, new TableViewColumnView("Filtered customers", List.of("id")));
+ loaded = TableViewColumnViewManager.load(group, "Filtered customers");
+ assertTrue(loaded.getFilters().isEmpty());
+ }
+
+ @Test
+ void filtersWithoutColumnOrValuesAreNotStored() {
+ String group = "test-project";
+
+ TableViewColumnView view =
+ new TableViewColumnView(
+ "Broken filters",
+ List.of("id"),
+ List.of(new TableViewColumnFilter(), new
TableViewColumnFilter("country", List.of())));
+ TableViewColumnViewManager.save(group, view);
+
+ TableViewColumnView loaded = TableViewColumnViewManager.load(group,
"Broken filters");
+ assertTrue(loaded.getFilters().isEmpty());
+ }
}
diff --git
a/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewsTest.java
b/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewsTest.java
index 150d5451d9..adbc3115e0 100644
---
a/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewsTest.java
+++
b/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewColumnViewsTest.java
@@ -17,11 +17,18 @@
package org.apache.hop.ui.core.widget;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.Comparator;
import java.util.List;
+import java.util.Locale;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.value.ValueMetaDate;
+import org.apache.hop.core.row.value.ValueMetaNumber;
import org.junit.jupiter.api.Test;
class TableViewColumnViewsTest {
@@ -72,4 +79,211 @@ class TableViewColumnViewsTest {
assertTrue(indices.isEmpty());
}
+
+ @Test
+ void columnsInViewOnOpenKeepsTheVisibleOrderAndAddsTheRest() {
+ assertEquals(
+ List.of("email", "id", "name"),
+ TableViewColumnViews.columnsInViewOnOpen(
+ List.of("email", "id", "", "email"), new String[] {"id", "name",
"email"}));
+ }
+
+ @Test
+ void columnsInViewOnOpenUsesEveryColumnWhenNoneAreVisible() {
+ assertEquals(
+ List.of("id", "name", "email"),
+ TableViewColumnViews.columnsInViewOnOpen(
+ List.of(), new String[] {"id", "name", "", "email"}));
+ assertEquals(
+ List.of("id"), TableViewColumnViews.columnsInViewOnOpen(null, new
String[] {"id"}));
+ }
+
+ @Test
+ void formatsFilterWithColumnAndValues() {
+ TableViewColumnFilter filter =
+ new TableViewColumnFilter(
+ "Country", List.of("BE", "FR",
TableViewColumnViews.NULL_VALUE_LABEL));
+
+ assertEquals("Country : BE,FR,<null>",
TableViewColumnViews.formatFilter(filter));
+ }
+
+ @Test
+ void formatsEmptyFilterAsEmptyString() {
+ assertEquals("", TableViewColumnViews.formatFilter(null));
+ assertEquals("", TableViewColumnViews.formatFilter(new
TableViewColumnFilter()));
+ assertEquals(
+ "", TableViewColumnViews.formatFilter(new
TableViewColumnFilter("Country", List.of())));
+ }
+
+ @Test
+ void rowPassesWithoutFilters() {
+ String[] row = {"1", "Jane"};
+
+ assertTrue(TableViewColumnViews.matchesFilters(null, new String[] {"id",
"name"}, row));
+ assertTrue(
+ TableViewColumnViews.matchesFilters(
+ Collections.emptyList(), new String[] {"id", "name"}, row));
+ }
+
+ @Test
+ void rowMatchesWhenValueIsSelected() {
+ TableViewColumnFilter filter = new TableViewColumnFilter("Country",
List.of("BE", "FR"));
+ List<TableViewColumnFilter> filters = List.of(filter);
+
+ assertTrue(
+ TableViewColumnViews.matchesFilters(
+ filters, new String[] {"id", "Country"}, new String[] {"1",
"BE"}));
+ assertTrue(
+ TableViewColumnViews.matchesFilters(
+ filters, new String[] {"id", "Country"}, new String[] {"2",
"FR"}));
+ assertFalse(
+ TableViewColumnViews.matchesFilters(
+ filters, new String[] {"id", "Country"}, new String[] {"3",
"NL"}));
+ }
+
+ @Test
+ void emptyOrNullCellMatchesNullLabel() {
+ TableViewColumnFilter filter = new TableViewColumnFilter("Country",
List.of("<null>"));
+
+ assertTrue(
+ TableViewColumnViews.matchesFilters(
+ List.of(filter), new String[] {"Country"}, new String[] {""}));
+ assertTrue(
+ TableViewColumnViews.matchesFilters(
+ List.of(filter), new String[] {"Country"}, new String[] {null}));
+ assertFalse(
+ TableViewColumnViews.matchesFilters(
+ List.of(filter), new String[] {"Country"}, new String[] {"BE"}));
+ }
+
+ @Test
+ void allFiltersMustPass() {
+ List<TableViewColumnFilter> filters =
+ List.of(
+ new TableViewColumnFilter("Country", List.of("BE")),
+ new TableViewColumnFilter("Type", List.of("A", "B")));
+
+ String[] columns = {"Country", "Type"};
+ assertTrue(TableViewColumnViews.matchesFilters(filters, columns, new
String[] {"BE", "A"}));
+ assertFalse(TableViewColumnViews.matchesFilters(filters, columns, new
String[] {"BE", "C"}));
+ assertFalse(TableViewColumnViews.matchesFilters(filters, columns, new
String[] {"NL", "A"}));
+ }
+
+ @Test
+ void filterOnUnknownOrEmptyColumnIsIgnored() {
+ List<TableViewColumnFilter> filters =
+ List.of(
+ new TableViewColumnFilter("Missing", List.of("X")),
+ new TableViewColumnFilter("Country", List.of("BE")),
+ new TableViewColumnFilter(null, List.of("X")),
+ new TableViewColumnFilter("Empty", List.of()));
+
+ assertTrue(
+ TableViewColumnViews.matchesFilters(
+ filters, new String[] {"Country"}, new String[] {"BE"}));
+ assertFalse(
+ TableViewColumnViews.matchesFilters(
+ filters, new String[] {"Country"}, new String[] {"NL"}));
+ }
+
+ @Test
+ void numericValuesSortNumerically() {
+ Comparator<String> comparator =
+ TableViewColumnViews.valueComparator(IValueMeta.TYPE_NUMBER, null);
+
+ assertTrue(comparator.compare("9", "10") < 0);
+ assertTrue(comparator.compare("10", "10.0") == 0);
+ assertTrue(comparator.compare("-2", "2") < 0);
+ }
+
+ @Test
+ void nullLabelSortsFirstForEveryType() {
+ for (int type :
+ new int[] {
+ IValueMeta.TYPE_STRING,
+ IValueMeta.TYPE_NUMBER,
+ IValueMeta.TYPE_DATE,
+ IValueMeta.TYPE_BOOLEAN
+ }) {
+ Comparator<String> comparator =
TableViewColumnViews.valueComparator(type, null);
+ assertTrue(
+ comparator.compare(TableViewColumnViews.NULL_VALUE_LABEL,
"anything") < 0,
+ "type " + type);
+ assertEquals(
+ 0,
+ comparator.compare(
+ TableViewColumnViews.NULL_VALUE_LABEL,
TableViewColumnViews.NULL_VALUE_LABEL));
+ }
+ }
+
+ @Test
+ void datesSortByConversionMask() {
+ Comparator<String> comparator =
+ TableViewColumnViews.valueComparator(IValueMeta.TYPE_DATE,
"dd-MM-yyyy");
+
+ assertTrue(comparator.compare("01-03-2024", "15-02-2024") > 0);
+ assertTrue(comparator.compare("15-02-2024", "16-02-2024") < 0);
+ }
+
+ @Test
+ void datesSortByTheColumnMask() {
+ Comparator<String> comparator =
+ TableViewColumnViews.valueComparator(IValueMeta.TYPE_DATE,
"yyyy-MM-dd");
+
+ assertTrue(comparator.compare("2024-02-15", "2024-03-01") < 0);
+ }
+
+ @Test
+ void datesUseTheColumnLocale() {
+ ValueMetaDate column = new ValueMetaDate("when");
+ column.setConversionMask("dd MMM yyyy");
+ column.setDateFormatLocale(Locale.ENGLISH);
+ Comparator<String> comparator =
TableViewColumnViews.valueComparator(column);
+
+ // Text order would put "01 ..." before "15 ...". The column locale parses
the month names.
+ assertTrue(comparator.compare("15 Feb 2024", "01 Mar 2024") < 0);
+ }
+
+ @Test
+ void numbersUseTheColumnDecimalSymbolAndStayTransitive() {
+ ValueMetaNumber column = new ValueMetaNumber("amount");
+ column.setConversionMask("#,##0.0#");
+ column.setDecimalSymbol(",");
+ column.setGroupingSymbol(".");
+
+ Comparator<String> comparator =
TableViewColumnViews.valueComparator(column);
+ assertTrue(comparator.compare("2,0", "10,0") < 0);
+ assertTrue(comparator.compare("1.234,5", "10,0") > 0);
+
+ List<String> values = new ArrayList<>(List.of("10", "9", "8,5", "nope",
"2,0"));
+ values.sort(comparator);
+ assertEquals(List.of("2,0", "8,5", "9", "10", "nope"), values);
+ }
+
+ @Test
+ void unparseableValuesFallBackToText() {
+ Comparator<String> comparator =
+ TableViewColumnViews.valueComparator(IValueMeta.TYPE_DATE,
"dd-MM-yyyy");
+
+ assertTrue(comparator.compare("abc", "abd") < 0);
+ }
+
+ @Test
+ void booleansSortByTruth() {
+ Comparator<String> comparator =
+ TableViewColumnViews.valueComparator(IValueMeta.TYPE_BOOLEAN, null);
+
+ assertTrue(comparator.compare("false", "true") < 0);
+ assertTrue(comparator.compare("N", "Y") < 0);
+ assertTrue(comparator.compare("Y", "Y") == 0);
+ }
+
+ @Test
+ void textSortsNaturally() {
+ Comparator<String> comparator =
+ TableViewColumnViews.valueComparator(IValueMeta.TYPE_STRING, null);
+
+ assertTrue(comparator.compare("apple", "banana") < 0);
+ assertTrue(comparator.compare("b", "a") > 0);
+ }
}