feat(tables): filter by cell value from the cell menu, sort from the column menu (#6719)

* feat(tables): filter by cell value from the cell menu, sort from the column menu

* fix(tables): drop nested same-column conditions when filtering by cell value

* fix(tables): refuse cell-value filters on json columns before the array branch
This commit is contained in:
Waleed
2026-08-14 18:13:39 -07:00
committed by GitHub
parent ee1fc379a4
commit 2a7abfdd17
7 changed files with 470 additions and 15 deletions
@@ -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
</DropdownMenuItem>
)}
{/* 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 && (
<DropdownMenuItem onSelect={onFilterByCellValue}>
<ListFilter />
Filter by cell value
</DropdownMenuItem>
)}
{/* Run, Re-run, Stop, then View execution — the order the action bar
presents the same four, so the user reads one sequence in both.
@@ -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}
/>
@@ -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({
<DropdownMenuSeparator />
</>
)}
{/* 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 && (
<DropdownMenuItem onSelect={onClearSort}>
<X />
Clear sort
</DropdownMenuItem>
)}
<DropdownMenuItem
active={sortDirection === 'asc'}
onSelect={() => onSortColumn(column.key, 'asc')}
>
<ArrowUp />
Sort ascending
</DropdownMenuItem>
<DropdownMenuItem
active={sortDirection === 'desc'}
onSelect={() => onSortColumn(column.key, 'desc')}
>
<ArrowDown />
Sort descending
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{onViewWorkflow && (
<DropdownMenuItem onSelect={() => onViewWorkflow()}>
<Eye />
@@ -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<Record<string, number>> = 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
@@ -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<TablePredicate | null>(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<string[]>([])
@@ -423,6 +428,20 @@ export function Table({
*/
const pendingCreatedViewIdRef = useRef<string | null>(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<SortConfig>(
() => ({
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 && (
<TableFilter
key={filterSeed}
columns={columns}
filter={effectiveFilter}
onApply={handleFilterApply}
@@ -1481,6 +1522,9 @@ export function Table({
onRequestDeleteRows={onRequestDeleteRows}
onRequestDeleteAllByFilter={onRequestDeleteAllByFilter}
onRequestDeleteColumns={onRequestDeleteColumns}
onFilterByCellValue={handleFilterByCellValue}
onSortColumn={handleSortColumn}
onClearSort={handleClearSort}
onRunColumn={onRunColumn}
onRunRow={onRunRow}
onRunRows={onRunRows}
@@ -0,0 +1,178 @@
/**
* @vitest-environment node
*
* "Filter by cell value" unit tests. The property that matters throughout: the
* row the user right-clicked must survive the filter its own cell produced.
*/
import { describe, expect, it } from 'vitest'
import {
cellValueFilterConditions,
withCellValueFilter,
} from '@/lib/table/query-builder/cell-filter'
import type { ColumnDefinition, TablePredicate } from '@/lib/table/types'
function column(overrides: Partial<ColumnDefinition> = {}): 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()
})
})
@@ -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] }
}