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([])
+ })
+
+ // 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.
+ 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('keeps an any-group that does not touch the column', () => {
+ const current: TablePredicate = {
+ 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
new file mode 100644
index 00000000000..dcd8f6f5978
--- /dev/null
+++ b/apps/sim/lib/table/query-builder/cell-filter.ts
@@ -0,0 +1,112 @@
+/**
+ * "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,
+ PredicateNode,
+ 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 `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
+ // 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)
+ }
+
+ // 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 }]
+}
+
+/** 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.
+ *
+ * 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 | 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] }
+ const members = 'all' in current ? current.all : [current]
+ return { all: [...members.filter((node) => !mentionsField(node, field)), ...conditions] }
+}