Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Blimp,
Duplicate,
Eye,
ListFilter,
Pencil,
PlayOutline,
RefreshCw,
Expand All @@ -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. */
Expand Down Expand Up @@ -91,6 +98,7 @@ export function ContextMenu({
onViewExecution,
canViewExecution = false,
canEditCell = true,
onFilterByCellValue,
selectedRowCount = 1,
onRunWorkflows,
onRefreshWorkflows,
Expand Down Expand Up @@ -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. */}
Comment thread
waleedlatif1 marked this conversation as resolved.
{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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -84,6 +88,9 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
sourceInfo,
onOpenConfig,
onViewWorkflow,
onSortColumn,
onClearSort,
sortDirection,
isPinned,
onPinToggle,
stickyLeft,
Expand Down Expand Up @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import {
DropdownMenuTrigger,
} from '@sim/emcn'
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Eye,
EyeOff,
Pencil,
Expand All @@ -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'
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -115,6 +127,9 @@ export function ColumnOptionsMenu({
selectedRowCount = 0,
hasActiveFilter = false,
onViewWorkflow,
onSortColumn,
onClearSort,
sortDirection,
isPinned,
onPinToggle,
}: ColumnOptionsMenuProps) {
Expand Down Expand Up @@ -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 />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -438,6 +450,9 @@ export function TableGrid({
onStopRow,
onSelectionChange,
queryOptions,
onFilterByCellValue,
onSortColumn,
onClearSort,
hiddenColumns,
viewLayout,
viewLayoutKey = null,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1203,23 +1221,43 @@ 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)
}
}
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) {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading