Skip to content

Commit 2a7abfd

Browse files
authored
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
1 parent ee1fc37 commit 2a7abfd

7 files changed

Lines changed: 470 additions & 15 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Blimp,
1212
Duplicate,
1313
Eye,
14+
ListFilter,
1415
Pencil,
1516
PlayOutline,
1617
RefreshCw,
@@ -37,6 +38,12 @@ interface ContextMenuProps {
3738
onViewExecution?: () => void
3839
canViewExecution?: boolean
3940
canEditCell?: boolean
41+
/**
42+
* Narrows the table to rows whose cell in this column reads the same as the
43+
* one under the cursor. Omit when the cell cannot be expressed as a filter
44+
* (a structured value, or an operator its column type rejects).
45+
*/
46+
onFilterByCellValue?: () => void
4047
selectedRowCount?: number
4148
/** Fires every workflow group on the row(s), skipping already-completed
4249
* cells. Mirrors the action bar's Play. */
@@ -91,6 +98,7 @@ export function ContextMenu({
9198
onViewExecution,
9299
canViewExecution = false,
93100
canEditCell = true,
101+
onFilterByCellValue,
94102
selectedRowCount = 1,
95103
onRunWorkflows,
96104
onRefreshWorkflows,
@@ -175,6 +183,15 @@ export function ContextMenu({
175183
Edit cell
176184
</DropdownMenuItem>
177185
)}
186+
{/* Cell-scoped like Edit cell above it, and a read action every viewer
187+
can take — deliberately not gated on `disableEdit`. The grid only
188+
supplies the handler for a cell that has a filter to offer. */}
189+
{onFilterByCellValue && (
190+
<DropdownMenuItem onSelect={onFilterByCellValue}>
191+
<ListFilter />
192+
Filter by cell value
193+
</DropdownMenuItem>
194+
)}
178195
{/* Run, Re-run, Stop, then View execution — the order the action bar
179196
presents the same four, so the user reads one sequence in both.
180197

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import React, { useCallback, useEffect, useRef, useState } from 'react'
44
import { cn } from '@sim/emcn'
55
import { ChevronDown } from '@sim/emcn/icons'
6-
import type { WorkflowGroup } from '@/lib/table'
6+
import type { SortDirection, WorkflowGroup } from '@/lib/table'
77
import { HeaderLabel } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label'
88
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
99
import { COL_WIDTH, SELECTION_TINT_BG } from '../constants'
@@ -42,6 +42,10 @@ interface ColumnHeaderMenuProps {
4242
/** Opens a popup preview of the column's underlying workflow. Surfaced in
4343
* the chevron menu for workflow-output columns. */
4444
onViewWorkflow?: (workflowId: string) => void
45+
onSortColumn?: (columnId: string, direction: SortDirection) => void
46+
onClearSort?: () => void
47+
/** This column's active sort direction. Absent when another column owns the sort. */
48+
sortDirection?: SortDirection
4549
/** Whether this column is currently pinned to the left. */
4650
isPinned?: boolean
4751
/** Toggle the pinned state for this column. */
@@ -84,6 +88,9 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
8488
sourceInfo,
8589
onOpenConfig,
8690
onViewWorkflow,
91+
onSortColumn,
92+
onClearSort,
93+
sortDirection,
8794
isPinned,
8895
onPinToggle,
8996
stickyLeft,
@@ -343,6 +350,9 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
343350
onViewWorkflow={
344351
onViewWorkflow && ownGroup ? () => onViewWorkflow(ownGroup.workflowId) : undefined
345352
}
353+
onSortColumn={onSortColumn}
354+
onClearSort={onClearSort}
355+
sortDirection={sortDirection}
346356
isPinned={isPinned}
347357
onPinToggle={onPinToggle}
348358
/>

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
DropdownMenuTrigger,
1515
} from '@sim/emcn'
1616
import {
17+
ArrowDown,
1718
ArrowLeft,
1819
ArrowRight,
20+
ArrowUp,
1921
Eye,
2022
EyeOff,
2123
Pencil,
@@ -24,9 +26,10 @@ import {
2426
PlayOutline,
2527
Trash,
2628
Workflow,
29+
X,
2730
} from '@sim/emcn/icons'
2831
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
29-
import type { WorkflowGroupType } from '@/lib/table'
32+
import type { SortDirection, WorkflowGroupType } from '@/lib/table'
3033
import { HeaderLabel } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label'
3134
import { getEnrichment } from '@/enrichments/registry'
3235
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
@@ -84,6 +87,15 @@ interface ColumnOptionsMenuProps {
8487
/** When set, the menu surfaces a "View workflow" item that opens a popup
8588
* preview of the configured workflow. */
8689
onViewWorkflow?: () => void
90+
/** Sorts the table by this column. Omit to hide the sort items — the
91+
* workflow-group meta header spans several columns, so there is no single
92+
* column for it to sort by. */
93+
onSortColumn?: (columnId: string, direction: SortDirection) => void
94+
/** Clears the sort. Only rendered while {@link ColumnOptionsMenuProps.sortDirection}
95+
* says this column owns it. */
96+
onClearSort?: () => void
97+
/** This column's active sort direction. Absent when it is not the sorted one. */
98+
sortDirection?: SortDirection
8799
/** Whether this column is currently pinned to the left. */
88100
isPinned?: boolean
89101
/** Toggle the pinned state of this column. */
@@ -115,6 +127,9 @@ export function ColumnOptionsMenu({
115127
selectedRowCount = 0,
116128
hasActiveFilter = false,
117129
onViewWorkflow,
130+
onSortColumn,
131+
onClearSort,
132+
sortDirection,
118133
isPinned,
119134
onPinToggle,
120135
}: ColumnOptionsMenuProps) {
@@ -174,6 +189,37 @@ export function ColumnOptionsMenu({
174189
<DropdownMenuSeparator />
175190
</>
176191
)}
192+
{/* Sort leads the column-scoped block: the options bar reads Filter ·
193+
Sort · Columns, and this menu carries no Filter item, so Sort is the
194+
first of that set to appear — a column-scoped Filter item added later
195+
belongs ABOVE it. Direction words, not "A to Z": the same items sort
196+
dates and numbers, and the options-bar Sort menu already speaks
197+
ascending/descending. */}
198+
{onSortColumn && (
199+
<>
200+
{sortDirection && onClearSort && (
201+
<DropdownMenuItem onSelect={onClearSort}>
202+
<X />
203+
Clear sort
204+
</DropdownMenuItem>
205+
)}
206+
<DropdownMenuItem
207+
active={sortDirection === 'asc'}
208+
onSelect={() => onSortColumn(column.key, 'asc')}
209+
>
210+
<ArrowUp />
211+
Sort ascending
212+
</DropdownMenuItem>
213+
<DropdownMenuItem
214+
active={sortDirection === 'desc'}
215+
onSelect={() => onSortColumn(column.key, 'desc')}
216+
>
217+
<ArrowDown />
218+
Sort descending
219+
</DropdownMenuItem>
220+
<DropdownMenuSeparator />
221+
</>
222+
)}
177223
{onViewWorkflow && (
178224
<DropdownMenuItem onSelect={() => onViewWorkflow()}>
179225
<Eye />

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-
1515
import { captureEvent } from '@/lib/posthog/client'
1616
import type {
1717
ColumnDefinition,
18+
Predicate,
19+
SortDirection,
1820
TableLocks,
1921
TableMetadata,
2022
TablePredicate,
@@ -24,6 +26,7 @@ import type {
2426
import { getColumnId } from '@/lib/table/column-keys'
2527
import { columnTypeOf } from '@/lib/table/column-types'
2628
import { TABLE_LIMITS } from '@/lib/table/constants'
29+
import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter'
2730
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2831
import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room'
2932
import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy'
@@ -91,6 +94,7 @@ const logger = createLogger('TableView')
9194

9295
const EMPTY_RUNNING_BY_ROW: Readonly<Record<string, number>> = Object.freeze({})
9396
const EMPTY_FIND_MATCHES: readonly TableFindMatch[] = Object.freeze([])
97+
const EMPTY_FILTER_CONDITIONS: readonly Predicate[] = Object.freeze([])
9498

9599
const COL_WIDTH_MIN = 80
96100
const COL_WIDTH_AUTO_FIT_MAX = 1000
@@ -238,6 +242,14 @@ interface TableGridProps {
238242
onSelectionChange: (state: SelectionSnapshot) => void
239243
/** Filter + sort. Lifted to wrapper so a single `useTable` call serves both. */
240244
queryOptions: QueryOptions
245+
/**
246+
* Narrows the active filter with the conditions matching one cell's value
247+
* ("Filter by cell value"). The wrapper owns the filter, so the grid only
248+
* reports the conditions the clicked cell produced.
249+
*/
250+
onFilterByCellValue?: (conditions: readonly Predicate[]) => void
251+
onSortColumn?: (columnId: string, direction: SortDirection) => void
252+
onClearSort?: () => void
241253
/**
242254
* **Column ids** to hide from the grid. Owned by the wrapper because the filter
243255
* panel's Columns section edits the same list and the active view persists it.
@@ -438,6 +450,9 @@ export function TableGrid({
438450
onStopRow,
439451
onSelectionChange,
440452
queryOptions,
453+
onFilterByCellValue,
454+
onSortColumn,
455+
onClearSort,
441456
hiddenColumns,
442457
viewLayout,
443458
viewLayoutKey = null,
@@ -556,6 +571,9 @@ export function TableGrid({
556571
filter: effectiveFilter,
557572
} = useTable({ workspaceId, tableId, queryOptions })
558573

574+
/** Sort is single-column, so only the first spec entry can be active. */
575+
const activeSort = queryOptions.sort?.[0]
576+
559577
const { data: tableRunState } = useTableRunState(tableId)
560578
const activeDispatches = tableRunState?.dispatches
561579
const runningByRowId = tableRunState?.runningByRowId ?? EMPTY_RUNNING_BY_ROW
@@ -1203,23 +1221,43 @@ export function TableGrid({
12031221
[]
12041222
)
12051223

1224+
/** The right-clicked cell's column. One lookup shared by every menu item that
1225+
* needs it, rather than a scan per item. */
1226+
const contextMenuColumn = contextMenu.columnName
1227+
? columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName)
1228+
: undefined
1229+
12061230
function handleContextMenuEditCell() {
12071231
if (contextMenu.row && contextMenu.columnName) {
1208-
const column = columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName)
1209-
if (column && columnTypeOf(column).editor === 'toggle') {
1232+
if (contextMenuColumn && columnTypeOf(contextMenuColumn).editor === 'toggle') {
12101233
toggleBooleanCell(
12111234
contextMenu.row.id,
12121235
contextMenu.columnName,
12131236
contextMenu.row.data[contextMenu.columnName]
12141237
)
1215-
} else if (column) {
1238+
} else if (contextMenuColumn) {
12161239
setEditingCell({ rowId: contextMenu.row.id, columnName: contextMenu.columnName })
12171240
setInitialCharacter(null)
12181241
}
12191242
}
12201243
closeContextMenu()
12211244
}
12221245

1246+
/** Conditions matching the right-clicked cell; empty when it has none the
1247+
* filter grammar can express (see `cellValueFilterConditions`). Gated on
1248+
* `isOpen` because closing the menu leaves `row`/`columnName` set, and this
1249+
* would otherwise rebuild on every render of the grid for the rest of the
1250+
* session. */
1251+
const contextMenuFilterConditions =
1252+
contextMenu.isOpen && contextMenu.row && contextMenu.columnName
1253+
? cellValueFilterConditions(contextMenuColumn, contextMenu.row.data[contextMenu.columnName])
1254+
: EMPTY_FILTER_CONDITIONS
1255+
1256+
function handleContextMenuFilterByCellValue() {
1257+
onFilterByCellValue?.(contextMenuFilterConditions)
1258+
closeContextMenu()
1259+
}
1260+
12231261
function handleContextMenuDelete() {
12241262
const contextRow = contextMenu.row
12251263
if (!contextRow) {
@@ -1301,7 +1339,7 @@ export function TableGrid({
13011339
// cascade re-runs dependents on its own) instead of every group on the row.
13021340
let contextMenuGroupId: string | null = null
13031341
if (contextMenu.row && contextMenu.columnName) {
1304-
const _col = columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName)
1342+
const _col = contextMenuColumn
13051343
const _gid = _col?.workflowGroupId
13061344
if (_col && _gid) {
13071345
const _exec = contextMenu.row.executions?.[_gid]
@@ -4402,6 +4440,11 @@ export function TableGrid({
44024440
sourceInfo={columnSourceInfo.get(column.key)}
44034441
onOpenConfig={handleConfigureColumn}
44044442
onViewWorkflow={handleViewWorkflow}
4443+
onSortColumn={onSortColumn}
4444+
onClearSort={onClearSort}
4445+
sortDirection={
4446+
activeSort?.field === column.key ? activeSort.direction : undefined
4447+
}
44054448
isPinned={colIsPinned}
44064449
onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined}
44074450
stickyLeft={colStickyLeft}
@@ -4574,6 +4617,11 @@ export function TableGrid({
45744617
Boolean(contextMenuEnrichment)
45754618
}
45764619
canEditCell={!contextMenuIsWorkflowColumn}
4620+
onFilterByCellValue={
4621+
onFilterByCellValue && contextMenuFilterConditions.length > 0
4622+
? handleContextMenuFilterByCellValue
4623+
: undefined
4624+
}
45774625
selectedRowCount={selectedRowCount}
45784626
onRunWorkflows={
45794627
userPermissions.canEdit && hasWorkflowColumns && contextMenuStats.hasIncompleteOrFailed

0 commit comments

Comments
 (0)