mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+17
@@ -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.
|
||||
|
||||
|
||||
+11
-1
@@ -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}
|
||||
/>
|
||||
|
||||
+47
-1
@@ -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 />
|
||||
|
||||
+52
-4
@@ -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] }
|
||||
}
|
||||
Reference in New Issue
Block a user