From 36c7bee3dbd36cdbd855165fe80f4b96f1085633 Mon Sep 17 00:00:00 2001 From: Hermann Pauly Date: Wed, 15 Jan 2025 09:45:13 +0100 Subject: [PATCH 1/3] Add `any column contains` filter --- src/app/RowFilters/index.js | 15 ++++++++++++++- src/app/constants/TableauxConstants.js | 2 +- tests/rowFilters/rowFilters.ng.test.js | 24 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/app/RowFilters/index.js b/src/app/RowFilters/index.js index 36d5d68ca..3b5f4bf10 100644 --- a/src/app/RowFilters/index.js +++ b/src/app/RowFilters/index.js @@ -1,6 +1,7 @@ import f from "lodash/fp"; import { ColumnKinds, SortValue } from "../constants/TableauxConstants"; import FilterAnnotation from "./Annotation"; +import FilterAnyColumn from "./AnyColumn"; import FilterBoolean from "./Boolean"; import FilterDate from "./Date"; import FilterDateTime from "./DateTime"; @@ -48,6 +49,7 @@ const canSortByColumnKind = canFilterByColumnKind; * * Filter := [Predicate | And | Or] * Predicate := ["value" ColumnName Operator ?OperatorValue] + * | ["any-value" Operator ?OperatorValue] * | ["row-prop" PropPath Operator ?OperatorValue] * | ["annotation" AnnotationProp FlagName Operator ?OperatorValue] * PropPath := \w+(\.\w+)* @@ -89,6 +91,8 @@ const parse = ctx => { return parseRowPropFilter(list); case "annotation": return parseAnnotationFilter(ctx, list); + case "any-value": + return parseAnyColumnFilter(ctx, list); default: throw new Error(`Could not parse filter instruction of kind ${kind}`); } @@ -96,6 +100,15 @@ const parse = ctx => { return parseImpl; }; +const parseAnyColumnFilter = (ctx, [_, op, opValue]) => { + const pred = FilterAnyColumn[op]; + if (typeof pred !== "function") + throw new Error( + `Can not test if any columns' display value "${op}", unknown operation` + ); + return pred(ctx, opValue); +}; + const parseAnnotationFilter = (ctx, [_, findBy, kind, op, opValue]) => { const find = FilterAnnotation.get[findBy]; const pred = FilterAnnotation[op]; @@ -103,7 +116,6 @@ const parseAnnotationFilter = (ctx, [_, findBy, kind, op, opValue]) => { throw new Error(`Can not find annotation by "${find}", unknown operation`); if (typeof pred !== "function") throw new Error(`Can not compare annotation by "${op}", unknown operation`); - return pred(find(kind), ctx.columns, opValue); }; @@ -221,6 +233,7 @@ const buildContext = (tableId, langtag, store) => { return { columns, + getDisplayValue: (name, row) => retrieveDisplayValue(name)(row), getValue: name => name === "rowId" ? row => row.id : lookupFn[columnKindLookup[name]](name), getValueFilter: buildValueFilter diff --git a/src/app/constants/TableauxConstants.js b/src/app/constants/TableauxConstants.js index f0ef9ad61..099d8aea9 100644 --- a/src/app/constants/TableauxConstants.js +++ b/src/app/constants/TableauxConstants.js @@ -6,7 +6,7 @@ import { getCssVar } from "../helpers/getCssVar"; * First language is default language. * Also, this is the order an expanded row shows the languages */ -let languagetags; +let languagetags = ["de-DE"]; let _config = {}; const AnnotationKind = { diff --git a/tests/rowFilters/rowFilters.ng.test.js b/tests/rowFilters/rowFilters.ng.test.js index b34a0b95f..271fb5bed 100644 --- a/tests/rowFilters/rowFilters.ng.test.js +++ b/tests/rowFilters/rowFilters.ng.test.js @@ -341,6 +341,30 @@ describe("buildContext()", () => { expect(result2).toEqual([]); }); }); + describe("AnyColumn", () => { + it("should search for values across columns (A)", () => { + const parse = RowFilters.parse(ctx); + const testAllColumns = parse(["any-value", "contains", "s"]); + const [foundRows, foundColumns] = filterStateful( + testAllColumns, + new Set() + )(rows); + // Matches "Schnappt Shortie" in row 1, columns 0, 11 + // and "Dolor sit amet" in row 2, columns 10, 11 + expect(foundRows.map(row => row.id)).toEqual([1, 2]); + expect(Array.from(foundColumns).sort()).toEqual([0, 10, 11, 12]); + }); + it("should search for values across columns (B)", () => { + const parse = RowFilters.parse(ctx); + const testAllColumns = parse(["any-value", "contains", "1"]); + const [foundRows, foundColumns] = filterStateful( + testAllColumns, + new Set() + )(rows); + expect(foundRows.map(row => row.id)).toEqual([1, 2]); + expect(Array.from(foundColumns).sort()).toEqual([5, 7, 8]); + }); + }); describe("Annotation", () => { const parse = RowFilters.parse(ctx); it("should find simple flag annotations", () => { From 4bea233eaa7f2e37e5f96c61cfa62f61ceaf419e Mon Sep 17 00:00:00 2001 From: Hermann Pauly Date: Thu, 16 Jan 2025 16:11:00 +0100 Subject: [PATCH 2/3] Include filter in popup --- src/app/RowFilters/AnyColumn.js | 47 +++++++++++++++++++ src/app/RowFilters/index.js | 1 + .../components/header/filter/FilterPopup.jsx | 14 +++++- src/app/components/header/filter/helpers.js | 4 ++ src/locales/de/table.json | 2 +- src/locales/en/table.json | 2 +- 6 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 src/app/RowFilters/AnyColumn.js diff --git a/src/app/RowFilters/AnyColumn.js b/src/app/RowFilters/AnyColumn.js new file mode 100644 index 000000000..da8e09869 --- /dev/null +++ b/src/app/RowFilters/AnyColumn.js @@ -0,0 +1,47 @@ +import { ColumnKinds } from "../constants/TableauxConstants"; +import Text from "./Text"; + +const Mode = { + contains: "contains" +}; + +const filterableColumnKinds = new Set([ + ColumnKinds.attachment, + ColumnKinds.concat, + ColumnKinds.date, + ColumnKinds.dateTime, + ColumnKinds.group, + ColumnKinds.integer, + ColumnKinds.link, + ColumnKinds.numeric, + ColumnKinds.richtext, + ColumnKinds.shorttext, + ColumnKinds.text +]); + +// We use `getDisplayValue`, so we can nly use strings +export default { + Mode, + readValue: Text.readValue, + [Mode.contains]: (ctx, query) => { + const pred = Text.contains(query); + return (row, _, columnMatches) => { + const findMatchingRows = (found, column) => { + const dv = + column.kind === "link" + ? ctx.getValue(column.name)(row) + : ctx.getDisplayValue(column.name, row); + if ( + filterableColumnKinds.has(column.kind) && + pred(Array.isArray(dv) ? dv.join(" ") : dv) + ) { + columnMatches.get().add(column.id); + return true; + } else { + return found; + } + }; + return ctx.columns.reduce(findMatchingRows, false); + }; + } +}; diff --git a/src/app/RowFilters/index.js b/src/app/RowFilters/index.js index 3b5f4bf10..d528550aa 100644 --- a/src/app/RowFilters/index.js +++ b/src/app/RowFilters/index.js @@ -20,6 +20,7 @@ export const Text = FilterText.Mode; export const RowProp = FilterRowProp.Mode; const ModesForKind = { + "any-column": FilterAnyColumn, [ColumnKinds.attachment]: null, [ColumnKinds.boolean]: FilterBoolean, [ColumnKinds.concat]: FilterText, diff --git a/src/app/components/header/filter/FilterPopup.jsx b/src/app/components/header/filter/FilterPopup.jsx index d45ccb261..a6099a75b 100644 --- a/src/app/components/header/filter/FilterPopup.jsx +++ b/src/app/components/header/filter/FilterPopup.jsx @@ -28,12 +28,19 @@ const of = el => (Array.isArray(el) ? el : [el]); const FilterPopup = ({ actions, - columns, + columns: rawColumns, langtag, onClickedOutside, currentFilter }) => { const tableId = useSelector(f.prop("tableView.currentTable")); + const anyColumnContains = { + id: -1, + name: "any-column", + kind: "any-column", + displayName: { [langtag]: t("table:filter.any-column") } + }; + const columns = useMemo(() => [anyColumnContains, ...rawColumns]); const [showFilterSavePopup, setShowFilterSavePopup] = useState(false); @@ -241,9 +248,11 @@ const settingToFilter = ({ column, mode, value }) => { const needsValueArg = RowFilters.needsFilterValue(column?.kind, mode); const hasValue = !f.isNil(value) && value !== ""; const isIncomplete = !column || !mode || (needsValueArg && !hasValue); + const isAnyColumnFilter = !isIncomplete && column.name === "any-column"; - return match({ isIncomplete })( + return match({ isIncomplete, isAnyColumnFilter })( when({ isIncomplete: true }, () => null), + when({ isAnyColumnFilter: true }, () => ["any-value", mode, value]), otherwise(() => ["value", column.name, mode, value]) ); }; @@ -315,6 +324,7 @@ const AnnotationBadge = ({ title, onClick, active, color }) => { ); }; + const ColumnFilterArea = ({ columns, filters, langtag, onChange }) => { const addFilterRow = () => onChange([...filters, {}]); const removeFilterRow = idxToRemove => () => diff --git a/src/app/components/header/filter/helpers.js b/src/app/components/header/filter/helpers.js index 4e6987d67..6cbf10db4 100644 --- a/src/app/components/header/filter/helpers.js +++ b/src/app/components/header/filter/helpers.js @@ -91,6 +91,10 @@ export const fromCombinedFilter = (columns, langtag) => { const [colName, mode, value] = rest; const column = columnLookup[colName]; rowFilters.push({ column, mode, value }); + } else if (kind === "any-value") { + const [mode, value] = rest; + const column = columnLookup["any-column"]; + rowFilters.push({ column, mode, value }); } else if (kind === "row-prop" && rest[0] === "id") { rowFilters.push(["value", "rowId", ...rest.slice(1)]); } else if (kind === "and") { diff --git a/src/locales/de/table.json b/src/locales/de/table.json index aedd0b78a..d72fbc5f7 100644 --- a/src/locales/de/table.json +++ b/src/locales/de/table.json @@ -113,7 +113,7 @@ "rows_hidden": "Es werden nur bestimmte Datensätze angezeigt", "needs_translation": "Übersetzung benötigt", "is_final": "Als freigegeben markiert", - "row-contains": "Irgendeine Spalte enthält...", + "any-column": "Irgendeine Spalte", "generic": "Allgemeine Filter", "specific": "Spaltenwerte", "toggle-list": "Bestehende Filter wählen", diff --git a/src/locales/en/table.json b/src/locales/en/table.json index 42f497275..93b2a4d10 100644 --- a/src/locales/en/table.json +++ b/src/locales/en/table.json @@ -114,7 +114,7 @@ "rows_hidden": "Only displaying specific rows", "needs_translation": "Needs translation", "is_final": "Is marked as final", - "row-contains": "Any row contains...", + "any-column": "Any column", "generic": "Generic filters", "specific": "Row values", "toggle-list": "Choose existing filter", From 1afa9428a5afba79a31b0439f65aefbd2e365d4e Mon Sep 17 00:00:00 2001 From: Hermann Date: Fri, 17 Jan 2025 11:13:53 +0100 Subject: [PATCH 3/3] Add "number contains" filter to compare as text --- src/app/RowFilters/Number.js | 6 ++++++ tests/rowFilters/rowFilters.ng.test.js | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/app/RowFilters/Number.js b/src/app/RowFilters/Number.js index fdf72b8f5..b59af2a74 100644 --- a/src/app/RowFilters/Number.js +++ b/src/app/RowFilters/Number.js @@ -1,6 +1,8 @@ import { maybe } from "../helpers/functools"; +import Text from "./Text"; const Mode = { + contains: "contains", equals: "equals", gt: "gt", gte: "gte", @@ -17,6 +19,10 @@ export default { .map(parseFloat) .filter(isFinite) .getOrElse(null), + [Mode.contains]: x => { + const test = Text.contains(String(x)); + return y => test(String(y)); + }, [Mode.equals]: x => y => y === x, [Mode.gt]: x => y => y > x, [Mode.gte]: x => y => y >= x, diff --git a/tests/rowFilters/rowFilters.ng.test.js b/tests/rowFilters/rowFilters.ng.test.js index 271fb5bed..2d9874787 100644 --- a/tests/rowFilters/rowFilters.ng.test.js +++ b/tests/rowFilters/rowFilters.ng.test.js @@ -232,6 +232,11 @@ describe("buildContext()", () => { }); describe("Number", () => { const valueOf = ctx.getValue("integer"); + it("contains", () => { + const matches = ctx.getValueFilter("integer", Number.contains, 23); + expect(matches(valueOf(rows[0]))).toBe(true); + expect(matches(valueOf(rows[1]))).toBe(false); + }); it("equals", () => { const matches = ctx.getValueFilter("integer", Number.equals, 123); expect(matches(valueOf(rows[0]))).toBe(true);