From 2201895900792bce19a70ef60fc9194547477d62 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 17:56:07 -0700 Subject: [PATCH 1/3] feat(tables): filter by cell value from the cell menu, sort from the column menu --- .../components/context-menu/context-menu.tsx | 17 +++ .../table-grid/headers/column-header-menu.tsx | 12 +- .../headers/workflow-group-meta-cell.tsx | 48 +++++- .../components/table-grid/table-grid.tsx | 56 ++++++- .../[workspaceId]/tables/[tableId]/table.tsx | 62 ++++++-- .../__tests__/cell-filter.test.ts | 139 ++++++++++++++++++ .../lib/table/query-builder/cell-filter.ts | 95 ++++++++++++ 7 files changed, 414 insertions(+), 15 deletions(-) create mode 100644 apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts create mode 100644 apps/sim/lib/table/query-builder/cell-filter.ts diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index dd89bcd56a4..1b5ebe5ce06 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -11,6 +11,7 @@ import { Blimp, Duplicate, Eye, + ListFilter, Pencil, PlayOutline, RefreshCw, @@ -37,6 +38,12 @@ interface ContextMenuProps { onViewExecution?: () => void canViewExecution?: boolean canEditCell?: boolean + /** + * Narrows the table to rows whose cell in this column reads the same as the + * one under the cursor. Omit when the cell cannot be expressed as a filter + * (a structured value, or an operator its column type rejects). + */ + onFilterByCellValue?: () => void selectedRowCount?: number /** Fires every workflow group on the row(s), skipping already-completed * cells. Mirrors the action bar's Play. */ @@ -91,6 +98,7 @@ export function ContextMenu({ onViewExecution, canViewExecution = false, canEditCell = true, + onFilterByCellValue, selectedRowCount = 1, onRunWorkflows, onRefreshWorkflows, @@ -175,6 +183,15 @@ export function ContextMenu({ Edit cell )} + {/* Cell-scoped like Edit cell above it, and a read action every viewer + can take — deliberately not gated on `disableEdit`. The grid only + supplies the handler for a cell that has a filter to offer. */} + {onFilterByCellValue && ( + + + Filter by cell value + + )} {/* Run, Re-run, Stop, then View execution — the order the action bar presents the same four, so the user reads one sequence in both. diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index d18e69cbc14..74b6e297b7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { ChevronDown } from '@sim/emcn/icons' -import type { WorkflowGroup } from '@/lib/table' +import type { SortDirection, WorkflowGroup } from '@/lib/table' import { HeaderLabel } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { COL_WIDTH, SELECTION_TINT_BG } from '../constants' @@ -42,6 +42,10 @@ interface ColumnHeaderMenuProps { /** Opens a popup preview of the column's underlying workflow. Surfaced in * the chevron menu for workflow-output columns. */ onViewWorkflow?: (workflowId: string) => void + onSortColumn?: (columnId: string, direction: SortDirection) => void + onClearSort?: () => void + /** This column's active sort direction. Absent when another column owns the sort. */ + sortDirection?: SortDirection /** Whether this column is currently pinned to the left. */ isPinned?: boolean /** Toggle the pinned state for this column. */ @@ -84,6 +88,9 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ sourceInfo, onOpenConfig, onViewWorkflow, + onSortColumn, + onClearSort, + sortDirection, isPinned, onPinToggle, stickyLeft, @@ -343,6 +350,9 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onViewWorkflow={ onViewWorkflow && ownGroup ? () => onViewWorkflow(ownGroup.workflowId) : undefined } + onSortColumn={onSortColumn} + onClearSort={onClearSort} + sortDirection={sortDirection} isPinned={isPinned} onPinToggle={onPinToggle} /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index cf495235221..7d680610643 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -14,8 +14,10 @@ import { DropdownMenuTrigger, } from '@sim/emcn' import { + ArrowDown, ArrowLeft, ArrowRight, + ArrowUp, Eye, EyeOff, Pencil, @@ -24,9 +26,10 @@ import { PlayOutline, Trash, Workflow, + X, } from '@sim/emcn/icons' import type { RunLimit, RunMode } from '@/lib/api/contracts/tables' -import type { WorkflowGroupType } from '@/lib/table' +import type { SortDirection, WorkflowGroupType } from '@/lib/table' import { HeaderLabel } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label' import { getEnrichment } from '@/enrichments/registry' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -84,6 +87,15 @@ interface ColumnOptionsMenuProps { /** When set, the menu surfaces a "View workflow" item that opens a popup * preview of the configured workflow. */ onViewWorkflow?: () => void + /** Sorts the table by this column. Omit to hide the sort items — the + * workflow-group meta header spans several columns, so there is no single + * column for it to sort by. */ + onSortColumn?: (columnId: string, direction: SortDirection) => void + /** Clears the sort. Only rendered while {@link ColumnOptionsMenuProps.sortDirection} + * says this column owns it. */ + onClearSort?: () => void + /** This column's active sort direction. Absent when it is not the sorted one. */ + sortDirection?: SortDirection /** Whether this column is currently pinned to the left. */ isPinned?: boolean /** Toggle the pinned state of this column. */ @@ -115,6 +127,9 @@ export function ColumnOptionsMenu({ selectedRowCount = 0, hasActiveFilter = false, onViewWorkflow, + onSortColumn, + onClearSort, + sortDirection, isPinned, onPinToggle, }: ColumnOptionsMenuProps) { @@ -174,6 +189,37 @@ export function ColumnOptionsMenu({ )} + {/* Sort leads the column-scoped block: the options bar reads Filter · + Sort · Columns, and this menu carries no Filter item, so Sort is the + first of that set to appear — a column-scoped Filter item added later + belongs ABOVE it. Direction words, not "A to Z": the same items sort + dates and numbers, and the options-bar Sort menu already speaks + ascending/descending. */} + {onSortColumn && ( + <> + {sortDirection && onClearSort && ( + + + Clear sort + + )} + onSortColumn(column.key, 'asc')} + > + + Sort ascending + + onSortColumn(column.key, 'desc')} + > + + Sort descending + + + + )} {onViewWorkflow && ( onViewWorkflow()}> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 3471f73e419..59222045098 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -15,6 +15,8 @@ import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection- import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, + Predicate, + SortDirection, TableLocks, TableMetadata, TablePredicate, @@ -24,6 +26,7 @@ import type { import { getColumnId } from '@/lib/table/column-keys' import { columnTypeOf } from '@/lib/table/column-types' import { TABLE_LIMITS } from '@/lib/table/constants' +import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' @@ -91,6 +94,7 @@ const logger = createLogger('TableView') const EMPTY_RUNNING_BY_ROW: Readonly> = Object.freeze({}) const EMPTY_FIND_MATCHES: readonly TableFindMatch[] = Object.freeze([]) +const EMPTY_FILTER_CONDITIONS: readonly Predicate[] = Object.freeze([]) const COL_WIDTH_MIN = 80 const COL_WIDTH_AUTO_FIT_MAX = 1000 @@ -238,6 +242,14 @@ interface TableGridProps { onSelectionChange: (state: SelectionSnapshot) => void /** Filter + sort. Lifted to wrapper so a single `useTable` call serves both. */ queryOptions: QueryOptions + /** + * Narrows the active filter with the conditions matching one cell's value + * ("Filter by cell value"). The wrapper owns the filter, so the grid only + * reports the conditions the clicked cell produced. + */ + onFilterByCellValue?: (conditions: readonly Predicate[]) => void + onSortColumn?: (columnId: string, direction: SortDirection) => void + onClearSort?: () => void /** * **Column ids** to hide from the grid. Owned by the wrapper because the filter * panel's Columns section edits the same list and the active view persists it. @@ -438,6 +450,9 @@ export function TableGrid({ onStopRow, onSelectionChange, queryOptions, + onFilterByCellValue, + onSortColumn, + onClearSort, hiddenColumns, viewLayout, viewLayoutKey = null, @@ -556,6 +571,9 @@ export function TableGrid({ filter: effectiveFilter, } = useTable({ workspaceId, tableId, queryOptions }) + /** Sort is single-column, so only the first spec entry can be active. */ + const activeSort = queryOptions.sort?.[0] + const { data: tableRunState } = useTableRunState(tableId) const activeDispatches = tableRunState?.dispatches const runningByRowId = tableRunState?.runningByRowId ?? EMPTY_RUNNING_BY_ROW @@ -1203,16 +1221,21 @@ export function TableGrid({ [] ) + /** The right-clicked cell's column. One lookup shared by every menu item that + * needs it, rather than a scan per item. */ + const contextMenuColumn = contextMenu.columnName + ? columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName) + : undefined + function handleContextMenuEditCell() { if (contextMenu.row && contextMenu.columnName) { - const column = columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName) - if (column && columnTypeOf(column).editor === 'toggle') { + if (contextMenuColumn && columnTypeOf(contextMenuColumn).editor === 'toggle') { toggleBooleanCell( contextMenu.row.id, contextMenu.columnName, contextMenu.row.data[contextMenu.columnName] ) - } else if (column) { + } else if (contextMenuColumn) { setEditingCell({ rowId: contextMenu.row.id, columnName: contextMenu.columnName }) setInitialCharacter(null) } @@ -1220,6 +1243,21 @@ export function TableGrid({ closeContextMenu() } + /** Conditions matching the right-clicked cell; empty when it has none the + * filter grammar can express (see `cellValueFilterConditions`). Gated on + * `isOpen` because closing the menu leaves `row`/`columnName` set, and this + * would otherwise rebuild on every render of the grid for the rest of the + * session. */ + const contextMenuFilterConditions = + contextMenu.isOpen && contextMenu.row && contextMenu.columnName + ? cellValueFilterConditions(contextMenuColumn, contextMenu.row.data[contextMenu.columnName]) + : EMPTY_FILTER_CONDITIONS + + function handleContextMenuFilterByCellValue() { + onFilterByCellValue?.(contextMenuFilterConditions) + closeContextMenu() + } + function handleContextMenuDelete() { const contextRow = contextMenu.row if (!contextRow) { @@ -1301,7 +1339,7 @@ export function TableGrid({ // cascade re-runs dependents on its own) instead of every group on the row. let contextMenuGroupId: string | null = null if (contextMenu.row && contextMenu.columnName) { - const _col = columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName) + const _col = contextMenuColumn const _gid = _col?.workflowGroupId if (_col && _gid) { const _exec = contextMenu.row.executions?.[_gid] @@ -4402,6 +4440,11 @@ export function TableGrid({ sourceInfo={columnSourceInfo.get(column.key)} onOpenConfig={handleConfigureColumn} onViewWorkflow={handleViewWorkflow} + onSortColumn={onSortColumn} + onClearSort={onClearSort} + sortDirection={ + activeSort?.field === column.key ? activeSort.direction : undefined + } isPinned={colIsPinned} onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined} stickyLeft={colStickyLeft} @@ -4574,6 +4617,11 @@ export function TableGrid({ Boolean(contextMenuEnrichment) } canEditCell={!contextMenuIsWorkflowColumn} + onFilterByCellValue={ + onFilterByCellValue && contextMenuFilterConditions.length > 0 + ? handleContextMenuFilterByCellValue + : undefined + } selectedRowCount={selectedRowCount} onRunWorkflows={ userPermissions.canEdit && hasWorkflowColumns && contextMenuStats.hasIncompleteOrFailed diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 8db321108dc..7a2ba8f792e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -12,6 +12,7 @@ import type { RunLimit, RunMode, TableViewWire } from '@/lib/api/contracts/table import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, + Predicate, SortDirection, SortSpec, TableMetadata, @@ -21,6 +22,7 @@ import type { WorkflowGroup, } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' +import { withCellValueFilter } from '@/lib/table/query-builder/cell-filter' import { type BreadcrumbItem, type ColumnOption, @@ -270,6 +272,9 @@ export function Table({ }) const [filter, setFilter] = useState(null) const [filterOpen, setFilterOpen] = useState(false) + /** Bumped whenever the filter is replaced from outside the panel, to re-seed + * its rule rows. See {@link replaceFilter}. */ + const [filterSeed, setFilterSeed] = useState(0) /** Hidden **column ids**. Lives here (not in the grid) because the filter * panel's Columns section edits it and the active view persists it. */ const [hiddenColumns, setHiddenColumns] = useState([]) @@ -423,6 +428,20 @@ export function Table({ */ const pendingCreatedViewIdRef = useRef(null) + /** + * Replaces the filter from OUTSIDE the filter panel — a view switch, or + * "Filter by cell value". Bumps {@link filterSeed} so the panel re-seeds: it + * builds its draft rule rows from the predicate once at mount, so without + * this an open panel keeps showing the rules of the filter it replaced. + * + * The remount discards an unapplied draft, which is the point — the rules on + * screen must be the rules in effect. + */ + const replaceFilter = useCallback((next: TablePredicate | null) => { + setFilter(next) + setFilterSeed((seed) => seed + 1) + }, []) + /** * Applies a view's config to the live state. `keep` marks slices the user has * already set by hand, which win over the view's stored values on the FIRST @@ -436,7 +455,7 @@ export function Table({ config: TableViewConfig | null, keep?: { sort?: boolean; filter?: boolean; hiddenColumns?: boolean } ) => { - if (!keep?.filter) setFilter(config?.filter ?? null) + if (!keep?.filter) replaceFilter(config?.filter ?? null) if (!keep?.hiddenColumns) setHiddenColumns(config?.hiddenColumns ?? []) if (keep?.sort) return const sortEntry = config?.sort?.[0] @@ -445,7 +464,7 @@ export function Table({ dir: sortEntry ? (sortEntry.direction as SortDirection) : null, }) }, - [setTableParams] + [replaceFilter, setTableParams] ) /** Reader for the grid's CURRENT column layout, populated by the grid itself. @@ -1103,24 +1122,45 @@ export function Table({ [columns] ) + const handleSortColumn = useCallback( + (column: string, direction: SortDirection) => setTableParams({ sort: column, dir: direction }), + [setTableParams] + ) + + /** + * Clearing writes the default direction (stripped by clearOnDefault) and + * drops the column, leaving a clean URL with no active sort. + */ + const handleClearSort = useCallback( + () => setTableParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }), + [setTableParams] + ) + const sortConfig = useMemo( () => ({ options: columnOptions, active: sortColumn ? { column: sortColumn, direction: sortDirection } : null, - onSort: (column, direction) => setTableParams({ sort: column, dir: direction }), - /** - * Clearing writes the default direction (stripped by clearOnDefault) and - * drops the column, leaving a clean URL with no active sort. - */ - onClear: () => setTableParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }), + onSort: handleSortColumn, + onClear: handleClearSort, }), - [columnOptions, sortColumn, sortDirection, setTableParams] + [columnOptions, sortColumn, sortDirection, handleSortColumn, handleClearSort] ) const handleFilterApply = (next: TablePredicate | null) => { setFilter(next) } + /** + * "Filter by cell value" from the grid's cell context menu. Narrows the + * PRUNED filter, so a condition the current schema already invalidated is not + * resurrected, and opens the panel — a silently narrowed table would leave the + * user no way to see what was applied. + */ + const handleFilterByCellValue = (conditions: readonly Predicate[]) => { + replaceFilter(withCellValueFilter(effectiveFilter, conditions)) + setFilterOpen(true) + } + const breadcrumbs = useMemo( (): BreadcrumbItem[] => folderBreadcrumbItems({ @@ -1448,6 +1488,7 @@ export function Table({ /> {filterOpen && ( = {}): ColumnDefinition { + return { id: 'col_a', name: 'Name', type: 'string', ...overrides } as ColumnDefinition +} + +describe('cellValueFilterConditions', () => { + it('offers nothing without a column', () => { + expect(cellValueFilterConditions(undefined, 'x')).toEqual([]) + }) + + it('keys conditions on the column id, not its display name', () => { + expect(cellValueFilterConditions(column({ id: 'col_a', name: 'Name' }), 'Ada')).toEqual([ + { field: 'col_a', op: 'eq', value: 'Ada' }, + ]) + }) + + it('falls back to the name when the column carries no id', () => { + const legacy = { name: 'Name', type: 'string' } as ColumnDefinition + expect(cellValueFilterConditions(legacy, 'Ada')).toEqual([ + { field: 'Name', op: 'eq', value: 'Ada' }, + ]) + }) + + it('carries scalars through untouched rather than via text', () => { + expect(cellValueFilterConditions(column({ type: 'number' }), 8)).toEqual([ + { field: 'col_a', op: 'eq', value: 8 }, + ]) + expect(cellValueFilterConditions(column({ type: 'boolean' }), false)).toEqual([ + { field: 'col_a', op: 'eq', value: false }, + ]) + // A numeric-looking string cell stays a string — text round-tripping would + // coerce it to 8 and stop matching the stored value. + expect(cellValueFilterConditions(column(), '8')).toEqual([ + { field: 'col_a', op: 'eq', value: '8' }, + ]) + }) + + it('keeps a date cell byte-exact', () => { + const stored = '2024-01-31T10:00:00.000Z' + expect(cellValueFilterConditions(column({ type: 'date' }), stored)).toEqual([ + { field: 'col_a', op: 'eq', value: stored }, + ]) + }) + + it.each([[null], [undefined], ['']])('maps %p onto isEmpty', (value) => { + expect(cellValueFilterConditions(column(), value)).toEqual([{ field: 'col_a', op: 'isEmpty' }]) + }) + + it('compares a single-select by option id', () => { + const col = column({ type: 'select', options: [{ id: 'opt_a', name: 'Alpha' }] }) + expect(cellValueFilterConditions(col, 'opt_a')).toEqual([ + { field: 'col_a', op: 'eq', value: 'opt_a' }, + ]) + }) + + it('asks a multi-select about membership, one condition per option', () => { + const col = column({ + type: 'select', + multiple: true, + options: [ + { id: 'opt_a', name: 'Alpha' }, + { id: 'opt_b', name: 'Beta' }, + ], + }) + expect(cellValueFilterConditions(col, ['opt_a', 'opt_b'])).toEqual([ + { field: 'col_a', op: 'contains', value: 'opt_a' }, + { field: 'col_a', op: 'contains', value: 'opt_b' }, + ]) + }) + + it('treats an empty multi-select cell as empty', () => { + const col = column({ type: 'select', multiple: true, options: [{ id: 'opt_a', name: 'A' }] }) + expect(cellValueFilterConditions(col, [])).toEqual([{ field: 'col_a', op: 'isEmpty' }]) + }) + + it('refuses an operator the column type rejects', () => { + // A multi-select accepts contains/ncontains/empty only — `eq` against the + // array cell can never be true, so a scalar reading has no filter to offer. + const multi = column({ type: 'select', multiple: true, options: [{ id: 'opt_a', name: 'A' }] }) + expect(cellValueFilterConditions(multi, 'opt_a')).toEqual([]) + }) + + it('refuses a structured value with no meaningful equality', () => { + expect(cellValueFilterConditions(column({ type: 'json' }), { a: 1 })).toEqual([]) + expect(cellValueFilterConditions(column({ type: 'json' }), [1, 2])).toEqual([]) + }) + + // `json.coerce` accepts anything, so a json cell legitimately holds a scalar. + // The server rejects eq/ne/in/nin on a json column, and the rejected filter + // would stick in state and 400 every later refetch. + it.each([['hello'], [42], [true]])('refuses eq on a json cell holding %p', (value) => { + expect(cellValueFilterConditions(column({ type: 'json' }), value)).toEqual([]) + }) + + it('still offers isEmpty on an empty json cell', () => { + expect(cellValueFilterConditions(column({ type: 'json' }), null)).toEqual([ + { field: 'col_a', op: 'isEmpty' }, + ]) + }) +}) + +describe('withCellValueFilter', () => { + const eqA = { field: 'col_a', op: 'eq', value: 'x' } as const + + it('starts a new filter when none is active', () => { + expect(withCellValueFilter(null, [eqA])).toEqual({ all: [eqA] }) + }) + + it('keeps conditions on other columns', () => { + const current: TablePredicate = { all: [{ field: 'col_b', op: 'eq', value: 1 }] } + expect(withCellValueFilter(current, [eqA])).toEqual({ + all: [{ field: 'col_b', op: 'eq', value: 1 }, eqA], + }) + }) + + it('replaces an earlier condition on the same column instead of AND-ing it', () => { + const current: TablePredicate = { all: [{ field: 'col_a', op: 'eq', value: 'old' }] } + expect(withCellValueFilter(current, [eqA])).toEqual({ all: [eqA] }) + }) + + it('narrows an any-group without reaching inside it', () => { + const current: TablePredicate = { + any: [{ all: [{ field: 'col_a', op: 'eq', value: 'old' }] }], + } + expect(withCellValueFilter(current, [eqA])).toEqual({ all: [current, eqA] }) + }) +}) diff --git a/apps/sim/lib/table/query-builder/cell-filter.ts b/apps/sim/lib/table/query-builder/cell-filter.ts new file mode 100644 index 00000000000..be84712287b --- /dev/null +++ b/apps/sim/lib/table/query-builder/cell-filter.ts @@ -0,0 +1,95 @@ +/** + * "Filter by cell value" — turns one cell into the filter conditions that keep + * its row, and merges them into the active filter. + * + * Deliberately NOT in `converters.ts`: this reads the column-type registry, + * which carries React icon references, and `converters.ts` is re-exported from + * the `@/lib/table` barrel that server modules import. Import this module by + * its own path. + */ + +import { getColumnId } from '@/lib/table/column-keys' +import { filterOperatorsFor } from '@/lib/table/column-types/registry' +import { isEmptyCellValue } from '@/lib/table/deps' +import { UI_TO_WIRE_OPERATOR } from '@/lib/table/query-builder/constants' +import type { + ColumnDefinition, + FilterOp, + JsonValue, + Predicate, + TablePredicate, +} from '@/lib/table/types' + +/** + * Builds the conditions that match every row whose `column` cell reads the same + * as `value`. Empty when this cell cannot be expressed as a filter — an unknown + * column, a structured value with no meaningful equality, or a column type that + * rejects the operator the value needs. + * + * The raw stored value is carried through untouched rather than being rendered + * to text and re-parsed: a `select`'s option id, a `date`'s stored string, and a + * numeric-looking `string` cell all compare byte-exactly against what the write + * path stored, which text round-tripping would coerce away. + */ +export function cellValueFilterConditions( + column: ColumnDefinition | undefined, + value: unknown +): Predicate[] { + if (!column) return [] + + const field = getColumnId(column) + // `filterOperatorsFor` answers in wire operators (`$eq`), the filter grammar + // in bare ones (`eq`) — `UI_TO_WIRE_OPERATOR` is the existing bridge. + const allowed = filterOperatorsFor(column) + const supports = (op: FilterOp) => !allowed || allowed.has(UI_TO_WIRE_OPERATOR[op] ?? `$${op}`) + + // An empty cell asks about emptiness — `''`, a JSON null and an emptied + // multi-select's `[]` are all what the server's `isEmpty` matches. + if (isEmptyCellValue(value)) { + return supports('isEmpty') ? [{ field, op: 'isEmpty' }] : [] + } + + // A multi-select cell holds several option ids, so "the same as this cell" + // is one membership test per id — equality against the whole array can never + // be true. Every id must be filterable, or the row the user clicked would + // not survive its own filter. + if (Array.isArray(value)) { + if (!supports('contains')) return [] + if (!value.every((id) => typeof id === 'string')) return [] + return value.map((id) => ({ field, op: 'contains', value: id }) satisfies Predicate) + } + + // No equality to offer: `validateLeaf` in `query-builder/validate.ts` rejects + // the containment operators on a `json` column outright, and a rejected + // predicate would stay in state and 400 every later refetch. `json.coerce` + // accepts anything, so its cells hold scalars too — the type, not the value + // shape, is what decides. The `typeof` guard covers a structured value + // reaching any other type. + if (column.type === 'json' || typeof value === 'object') return [] + if (!supports('eq')) return [] + return [{ field, op: 'eq', value: value as JsonValue }] +} + +/** + * Narrows `current` with one cell's conditions. + * + * Existing top-level conditions on the same column are dropped first, so + * filtering twice on one column swaps the value instead of ANDing two + * equalities into a guaranteed-empty result. Conditions on other columns are + * kept — the action narrows what the user is looking at rather than replacing + * it. + */ +export function withCellValueFilter( + current: TablePredicate | null, + conditions: readonly Predicate[] +): TablePredicate { + if (!current) return { all: [...conditions] } + if ('all' in current) { + const field = conditions[0]?.field + const kept = current.all.filter((node) => !('field' in node && node.field === field)) + return { all: [...kept, ...conditions] } + } + // An `any` group is a whole disjunction — narrowing it means AND-ing the new + // conditions onto the group, not reaching inside it. + return { all: [current, ...conditions] } +} From e079fa47ad78d387230a5d2e7014bc4e75cb5890 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 18:02:11 -0700 Subject: [PATCH 2/3] fix(tables): drop nested same-column conditions when filtering by cell value --- .../__tests__/cell-filter.test.ts | 36 ++++++++++++++++- .../lib/table/query-builder/cell-filter.ts | 40 ++++++++++++------- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts b/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts index 68b9927d46b..e4ff2fe7ba0 100644 --- a/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts +++ b/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts @@ -130,10 +130,42 @@ describe('withCellValueFilter', () => { expect(withCellValueFilter(current, [eqA])).toEqual({ all: [eqA] }) }) - it('narrows an any-group without reaching inside it', () => { + it('keeps an any-group that does not touch the column', () => { const current: TablePredicate = { - any: [{ all: [{ field: 'col_a', op: 'eq', value: 'old' }] }], + any: [{ all: [{ field: 'col_b', op: 'eq', value: 'x' }] }], } expect(withCellValueFilter(current, [eqA])).toEqual({ all: [current, eqA] }) }) + + // Reachable from the panel: an `or` rule produces an `any` group, and a cell + // filter on a column inside it would otherwise AND against the disjunction + // and empty the table. + it('drops a nested group that constrains the same column', () => { + const current: TablePredicate = { + any: [ + { all: [{ field: 'col_a', op: 'eq', value: 'old' }] }, + { all: [{ field: 'col_b', op: 'eq', value: 'keep' }] }, + ], + } + expect(withCellValueFilter(current, [eqA])).toEqual({ all: [eqA] }) + }) + + it('drops a same-column leaf nested inside an all-group', () => { + const current: TablePredicate = { + all: [ + { all: [{ field: 'col_a', op: 'eq', value: 'old' }] }, + { field: 'col_b', op: 'eq', value: 'keep' }, + ], + } + expect(withCellValueFilter(current, [eqA])).toEqual({ + all: [{ field: 'col_b', op: 'eq', value: 'keep' }, eqA], + }) + }) + + // An `{ all: [] }` group is not a valid predicate — the server rejects it. + it('leaves the filter untouched when there are no conditions', () => { + const current: TablePredicate = { all: [{ field: 'col_a', op: 'eq', value: 'x' }] } + expect(withCellValueFilter(current, [])).toBe(current) + expect(withCellValueFilter(null, [])).toBeNull() + }) }) diff --git a/apps/sim/lib/table/query-builder/cell-filter.ts b/apps/sim/lib/table/query-builder/cell-filter.ts index be84712287b..ee803885dfc 100644 --- a/apps/sim/lib/table/query-builder/cell-filter.ts +++ b/apps/sim/lib/table/query-builder/cell-filter.ts @@ -17,6 +17,7 @@ import type { FilterOp, JsonValue, Predicate, + PredicateNode, TablePredicate, } from '@/lib/table/types' @@ -70,26 +71,37 @@ export function cellValueFilterConditions( return [{ field, op: 'eq', value: value as JsonValue }] } +/** True when any leaf anywhere under `node` filters on `field`. */ +function mentionsField(node: PredicateNode, field: string): boolean { + if ('field' in node) return node.field === field + const members = 'all' in node ? node.all : node.any + return members.some((member) => mentionsField(member, field)) +} + /** * Narrows `current` with one cell's conditions. * - * Existing top-level conditions on the same column are dropped first, so - * filtering twice on one column swaps the value instead of ANDing two - * equalities into a guaranteed-empty result. Conditions on other columns are - * kept — the action narrows what the user is looking at rather than replacing - * it. + * Anything already constraining this column is dropped first, so filtering + * twice on one column swaps the value instead of ANDing two conditions the + * same row cannot satisfy — which would empty the table the user is looking at + * and give them no clue why. Conditions on other columns are kept: the action + * narrows the current view rather than replacing it. + * + * A whole nested group is dropped when it mentions the column ANYWHERE, not + * just its top-level leaves. Reaching inside an `any` group to pull one leaf + * out would silently WIDEN the user's disjunction — dropping the group loses + * the other columns it mentioned, but it is visible in the panel afterwards + * and never contradicts what was just asked for. */ export function withCellValueFilter( current: TablePredicate | null, conditions: readonly Predicate[] -): TablePredicate { +): TablePredicate | null { + // Nothing to add leaves the filter exactly as it was — an `{ all: [] }` group + // is not a valid predicate and the server rejects it. + const field = conditions[0]?.field + if (field === undefined) return current if (!current) return { all: [...conditions] } - if ('all' in current) { - const field = conditions[0]?.field - const kept = current.all.filter((node) => !('field' in node && node.field === field)) - return { all: [...kept, ...conditions] } - } - // An `any` group is a whole disjunction — narrowing it means AND-ing the new - // conditions onto the group, not reaching inside it. - return { all: [current, ...conditions] } + const members = 'all' in current ? current.all : [current] + return { all: [...members.filter((node) => !mentionsField(node, field)), ...conditions] } } From 01df1eefe9e619ca4d7b216c73dbfcd91b9f09b9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 18:09:59 -0700 Subject: [PATCH 3/3] fix(tables): refuse cell-value filters on json columns before the array branch --- .../__tests__/cell-filter.test.ts | 7 +++++++ .../lib/table/query-builder/cell-filter.ts | 19 ++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts b/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts index e4ff2fe7ba0..9a38c70cb88 100644 --- a/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts +++ b/apps/sim/lib/table/query-builder/__tests__/cell-filter.test.ts @@ -97,6 +97,13 @@ describe('cellValueFilterConditions', () => { expect(cellValueFilterConditions(column({ type: 'json' }), [1, 2])).toEqual([]) }) + // A json cell holding a STRING array is shaped exactly like a multi-select + // cell. The server accepts `contains` on json and compiles it to an ILIKE + // substring match, so letting it through would quietly match unrelated rows. + it('refuses a json cell holding a string array', () => { + expect(cellValueFilterConditions(column({ type: 'json' }), ['a', 'b'])).toEqual([]) + }) + // `json.coerce` accepts anything, so a json cell legitimately holds a scalar. // The server rejects eq/ne/in/nin on a json column, and the rejected filter // would stick in state and 400 every later refetch. diff --git a/apps/sim/lib/table/query-builder/cell-filter.ts b/apps/sim/lib/table/query-builder/cell-filter.ts index ee803885dfc..dcd8f6f5978 100644 --- a/apps/sim/lib/table/query-builder/cell-filter.ts +++ b/apps/sim/lib/table/query-builder/cell-filter.ts @@ -50,6 +50,16 @@ export function cellValueFilterConditions( return supports('isEmpty') ? [{ field, op: 'isEmpty' }] : [] } + // A `json` column has no same-value filter to offer, whatever the cell holds. + // It must be checked BEFORE the shape branches below: `json.coerce` accepts + // anything, so a json cell holds arrays and scalars alike, and letting a + // string array through the multi-select branch would emit `contains` — which + // the server accepts on json and compiles to ILIKE substring matching, so + // unrelated rows would match. `eq` there is refused outright by `validateLeaf` + // in `query-builder/validate.ts`, and a refused predicate would stay in state + // and 400 every later refetch. Only the emptiness check above survives. + if (column.type === 'json') return [] + // A multi-select cell holds several option ids, so "the same as this cell" // is one membership test per id — equality against the whole array can never // be true. Every id must be filterable, or the row the user clicked would @@ -60,13 +70,8 @@ export function cellValueFilterConditions( return value.map((id) => ({ field, op: 'contains', value: id }) satisfies Predicate) } - // No equality to offer: `validateLeaf` in `query-builder/validate.ts` rejects - // the containment operators on a `json` column outright, and a rejected - // predicate would stay in state and 400 every later refetch. `json.coerce` - // accepts anything, so its cells hold scalars too — the type, not the value - // shape, is what decides. The `typeof` guard covers a structured value - // reaching any other type. - if (column.type === 'json' || typeof value === 'object') return [] + // A structured value on any other column type has no meaningful equality. + if (typeof value === 'object') return [] if (!supports('eq')) return [] return [{ field, op: 'eq', value: value as JsonValue }] }