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);
+  }
 }

Reply via email to