From 919fa5274bfebfbe5916ac0caa7311b7cbdbd3e1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 31 May 2026 22:49:15 -0700 Subject: [PATCH] feat(tables): expand filter operators (not-contains, starts/ends-with, not-in, empty) (#4827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add does-not-contain ($ncontains), starts-with ($startsWith), ends-with ($endsWith), not-in-array ($nin, previously executed server-side but unexposed in the UI), and is-empty/is-not-empty ($empty) filter operators end-to-end — SQL builder, condition types, query-builder converters/constants, the filter UI, the Table tools/block descriptions, and docs. Also fix correctness bugs in the filter builder surfaced by the wider operator set: - Same-column AND rules (e.g. age > 18 AND age < 65, or name startsWith 'A' AND name endsWith 'Z') silently overwrote each other because the AND group was keyed by column name. They now merge into one operator object, which also makes Filter -> rules -> Filter round-trip losslessly for multi-operator columns. - $nin values were not split into an array like $in, and textual-match values like "123" were numeric-coerced (breaking the ILIKE path). - A non-boolean $empty operand from the raw API silently inverted the check; it now coerces 'true'/'false' strings and otherwise returns a 400. --- apps/docs/content/docs/en/tools/table.mdx | 6 +- .../components/table-filter/table-filter.tsx | 30 +++-- apps/sim/blocks/blocks/table.ts | 8 ++ apps/sim/lib/table/__tests__/sql.test.ts | 66 ++++++++++ .../__tests__/converters.test.ts | 119 ++++++++++++++++++ apps/sim/lib/table/query-builder/constants.ts | 15 ++- .../sim/lib/table/query-builder/converters.ts | 58 ++++++++- apps/sim/lib/table/sql.ts | 98 ++++++++++++++- apps/sim/lib/table/types.ts | 8 ++ apps/sim/tools/table/delete_rows_by_filter.ts | 3 +- apps/sim/tools/table/query_rows.ts | 2 +- apps/sim/tools/table/update_rows_by_filter.ts | 3 +- 12 files changed, 388 insertions(+), 28 deletions(-) create mode 100644 apps/sim/lib/table/query-builder/__tests__/converters.test.ts diff --git a/apps/docs/content/docs/en/tools/table.mdx b/apps/docs/content/docs/en/tools/table.mdx index 388005b772..e7cf599370 100644 --- a/apps/docs/content/docs/en/tools/table.mdx +++ b/apps/docs/content/docs/en/tools/table.mdx @@ -275,7 +275,11 @@ Filters use MongoDB-style operators for flexible querying: | `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` | | `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` | | `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` | -| `$contains` | String contains | `{"email": {"$contains": "@gmail.com"}}` | +| `$contains` | String contains (case-insensitive) | `{"email": {"$contains": "@gmail.com"}}` | +| `$ncontains` | Does not contain (case-insensitive; matches empty cells) | `{"email": {"$ncontains": "@spam.com"}}` | +| `$startsWith` | Starts with (case-insensitive) | `{"name": {"$startsWith": "Dr."}}` | +| `$endsWith` | Ends with (case-insensitive) | `{"file": {"$endsWith": ".pdf"}}` | +| `$empty` | Cell is empty (`true`) or non-empty (`false`) | `{"phone": {"$empty": true}}` | ### Combining Filters diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index bd683c6251..12f5bb10fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -12,7 +12,7 @@ import { } from '@/components/emcn' import { ChevronDown, Plus } from '@/components/emcn/icons' import type { Filter, FilterRule } from '@/lib/table' -import { COMPARISON_OPERATORS } from '@/lib/table/query-builder/constants' +import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants' import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters' const OPERATOR_LABELS = Object.fromEntries( @@ -71,7 +71,9 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr }, []) const handleApply = useCallback(() => { - const validRules = rulesRef.current.filter((r) => r.column && r.value) + const validRules = rulesRef.current.filter( + (r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator)) + ) onApply(filterRulesToFilter(validRules)) }, [onApply]) @@ -197,16 +199,20 @@ const FilterRuleRow = memo(function FilterRuleRow({ - onUpdate(rule.id, 'value', e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') onApply() - }} - placeholder='Enter a value' - className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]' - /> + {VALUELESS_OPERATORS.has(rule.operator) ? ( +
+ ) : ( + onUpdate(rule.id, 'value', e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onApply() + }} + placeholder='Enter a value' + className='h-[28px] flex-1 rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] hover-hover:border-[var(--border-1)] focus:border-[var(--border-1)]' + /> + )}