From 7c67bcb296b0f25cd56fae65b47253e804515258 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 28 May 2026 09:32:59 -0700 Subject: [PATCH 01/11] feat(tables): freeze columns --- .../components/table-grid/data-row.tsx | 22 +- .../table-grid/headers/column-header-menu.tsx | 26 +- .../headers/workflow-group-meta-cell.tsx | 170 ++++++------- .../components/table-grid/table-grid.tsx | 237 +++++++++++++----- apps/sim/lib/api/contracts/tables.ts | 1 + apps/sim/lib/table/types.ts | 6 +- 6 files changed, 311 insertions(+), 151 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index e228edba84..a37ac0a68e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -57,6 +57,10 @@ export interface DataRowProps { * queued indicators across page refresh during long Run-all dispatches. */ activeDispatches: ActiveDispatch[] | undefined + /** Pixel `left` value for each frozen column key; absent keys are not frozen. */ + frozenOffsets?: Map + /** Key of the rightmost frozen column, used to render a separator shadow. */ + lastFrozenColKey?: string | null } function cellRangeRowChanged( @@ -113,7 +117,9 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.onStopRow !== next.onStopRow || prev.onRunRow !== next.onRunRow || prev.workflowGroups !== next.workflowGroups || - prev.activeDispatches !== next.activeDispatches + prev.activeDispatches !== next.activeDispatches || + prev.frozenOffsets !== next.frozenOffsets || + prev.lastFrozenColKey !== next.lastFrozenColKey ) { return false } @@ -157,6 +163,8 @@ export const DataRow = React.memo(function DataRow({ onRunRow, workflowGroups, activeDispatches, + frozenOffsets, + lastFrozenColKey, }: DataRowProps) { const sel = normalizedSelection /** @@ -264,13 +272,23 @@ export const DataRow = React.memo(function DataRow({ const isLeftEdge = inRange ? colIndex === sel!.startCol : colIndex === 0 const isRightEdge = inRange ? colIndex === sel!.endCol : colIndex === columns.length - 1 + const frozenLeft = frozenOffsets?.get(column.key) + const isFrozenCell = frozenLeft !== undefined + const isFrozenSeparator = column.key === lastFrozenColKey + return ( { if (e.button !== 0 || isEditing) return onCellMouseDown(rowIndex, colIndex, e.shiftKey) 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 13010ad317..04fed2b57e 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 @@ -1,9 +1,9 @@ 'use client' import React, { useCallback, useEffect, useRef, useState } from 'react' -import { ChevronDown } from 'lucide-react' +import { ChevronDown } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' -import type { ColumnDefinition, WorkflowGroup } from '@/lib/table' +import type { WorkflowGroup } from '@/lib/table' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { COL_WIDTH, SELECTION_TINT_BG } from '../constants' import type { ColumnSourceInfo, DisplayColumn } from '../types' @@ -21,7 +21,6 @@ interface ColumnHeaderMenuProps { onRenameSubmit: () => void onRenameCancel: () => void onColumnSelect: (colIndex: number, shiftKey: boolean) => void - onChangeType: (columnName: string, newType: ColumnDefinition['type']) => void onInsertLeft: (columnName: string) => void onInsertRight: (columnName: string) => void onDeleteColumn: (columnName: string) => void @@ -42,6 +41,14 @@ 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 + /** Whether this column is currently frozen (pinned to the left). */ + isFrozen?: boolean + /** Toggle the frozen state for this column. */ + onFreezeToggle?: (columnName: string) => void + /** Left offset in pixels when frozen (drives `position: sticky`). */ + stickyLeft?: number + /** Whether this is the rightmost frozen column (renders a separator shadow). */ + isLastFrozen?: boolean } /** @@ -76,6 +83,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ sourceInfo, onOpenConfig, onViewWorkflow, + isFrozen, + onFreezeToggle, + stickyLeft, + isLastFrozen, }: ColumnHeaderMenuProps) { const renameInputRef = useRef(null) const didDragRef = useRef(false) @@ -228,7 +239,12 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ return ( onViewWorkflow(ownGroup.workflowId) : undefined } + isFrozen={isFrozen} + onFreezeToggle={onFreezeToggle} /> )} 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 211c3e0a55..edc9fe2e0e 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 @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { useCallback, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -17,9 +17,11 @@ import { ArrowRight, Eye, EyeOff, + Lock, Pencil, PlayOutline, Trash, + Unlock, } from '@/components/emcn/icons' import type { RunLimit, RunMode } from '@/lib/api/contracts/tables' import { cn } from '@/lib/core/utils/cn' @@ -67,6 +69,10 @@ interface ColumnOptionsMenuProps { /** When set, the menu surfaces a "View workflow" item that opens a popup * preview of the configured workflow. */ onViewWorkflow?: () => void + /** Whether this column is currently frozen (pinned to the left). */ + isFrozen?: boolean + /** Toggle the frozen state of this column. */ + onFreezeToggle?: (columnName: string) => void } /** @@ -93,6 +99,8 @@ export function ColumnOptionsMenu({ onRunColumnSelected, selectedRowCount = 0, onViewWorkflow, + isFrozen, + onFreezeToggle, }: ColumnOptionsMenuProps) { const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete) const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0 @@ -159,6 +167,12 @@ export function ColumnOptionsMenu({ Edit column + {onFreezeToggle && ( + onFreezeToggle(column.name)}> + {isFrozen ? : } + {isFrozen ? 'Unfreeze column' : 'Freeze column'} + + )} onInsertLeft(column.name)}> @@ -269,112 +283,94 @@ export function WorkflowGroupMetaCell({ const selectedCount = selectedRowIds?.length ?? 0 - const handleRunAll = useCallback(() => { + function handleRunAll() { if (groupId) onRunColumn?.(groupId, 'all') - }, [groupId, onRunColumn]) + } - const handleRunIncomplete = useCallback(() => { + function handleRunIncomplete() { if (groupId) onRunColumn?.(groupId, 'incomplete') - }, [groupId, onRunColumn]) + } - const handleRunSelected = useCallback(() => { + function handleRunSelected() { if (groupId && selectedRowIds && selectedRowIds.length > 0) { onRunColumn?.(groupId, 'all', selectedRowIds) } - }, [groupId, onRunColumn, selectedRowIds]) + } - const handleRunLimited = useCallback( - (max: number) => { - if (groupId) onRunColumn?.(groupId, 'incomplete', undefined, { type: 'rows', max }) - }, - [groupId, onRunColumn] - ) + function handleRunLimited(max: number) { + if (groupId) onRunColumn?.(groupId, 'incomplete', undefined, { type: 'rows', max }) + } - const handleContextMenu = useCallback( - (e: React.MouseEvent) => { - if (!column) return - e.preventDefault() - e.stopPropagation() - setOptionsMenuPosition({ x: e.clientX, y: e.clientY }) - setOptionsMenuOpen(true) - }, - [column] - ) + function handleContextMenu(e: React.MouseEvent) { + if (!column) return + e.preventDefault() + e.stopPropagation() + setOptionsMenuPosition({ x: e.clientX, y: e.clientY }) + setOptionsMenuOpen(true) + } - const selectGroupAndOpenConfig = useCallback( - (e: React.MouseEvent) => { - // Ignore clicks that landed on an interactive child (badge, play button, - // dropdown items rendered via portal). Only the bare meta-cell area - // should select the group + open the config sidebar. - const target = e.target as HTMLElement - if (target.closest('button, [role="menuitem"], [role="menu"]')) return - // Drag-vs-click guard: when a drag just ended on this cell, swallow the - // synthetic click so we don't accidentally pop open the sidebar. - if (didDragRef.current) { - didDragRef.current = false - return - } - onSelectGroup(startColIndex, size) - if (columnName) onOpenConfig(columnName) - }, - [columnName, onOpenConfig, onSelectGroup, size, startColIndex] - ) + function selectGroupAndOpenConfig(e: React.MouseEvent) { + // Ignore clicks that landed on an interactive child (badge, play button, + // dropdown items rendered via portal). Only the bare meta-cell area + // should select the group + open the config sidebar. + const target = e.target as HTMLElement + if (target.closest('button, [role="menuitem"], [role="menu"]')) return + // Drag-vs-click guard: when a drag just ended on this cell, swallow the + // synthetic click so we don't accidentally pop open the sidebar. + if (didDragRef.current) { + didDragRef.current = false + return + } + onSelectGroup(startColIndex, size) + if (columnName) onOpenConfig(columnName) + } - const handleDragStart = useCallback( - (e: React.DragEvent) => { - if (readOnly || !onDragStart || !columnName) { - e.preventDefault() - return - } - didDragRef.current = true - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/plain', columnName) + function handleDragStart(e: React.DragEvent) { + if (readOnly || !onDragStart || !columnName) { + e.preventDefault() + return + } + didDragRef.current = true + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', columnName) - const ghost = document.createElement('div') - ghost.textContent = name - ghost.style.cssText = - 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' - document.body.appendChild(ghost) - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost)) + const ghost = document.createElement('div') + ghost.textContent = name + ghost.style.cssText = + 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' + document.body.appendChild(ghost) + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost)) - onDragStart(columnName) - }, - [columnName, name, onDragStart, readOnly] - ) + onDragStart(columnName) + } - const handleDragOver = useCallback( - (e: React.DragEvent) => { - if (!onDragOver || !columnName) return - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() - const midX = rect.left + rect.width / 2 - const side = e.clientX < midX ? 'left' : 'right' - onDragOver(columnName, side) - }, - [columnName, onDragOver] - ) + function handleDragOver(e: React.DragEvent) { + if (!onDragOver || !columnName) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const midX = rect.left + rect.width / 2 + const side = e.clientX < midX ? 'left' : 'right' + onDragOver(columnName, side) + } - const handleDragEnd = useCallback(() => { + function handleDragEnd() { didDragRef.current = false onDragEnd?.() - }, [onDragEnd]) + } - const handleDragLeave = useCallback( - (e: React.DragEvent) => { - const th = e.currentTarget as HTMLElement - const related = e.relatedTarget as Node | null - if (related && th.contains(related)) return - if (related && related instanceof Element && related.closest('th')) return - onDragLeave?.() - }, - [onDragLeave] - ) + function handleDragLeave(e: React.DragEvent) { + const th = e.currentTarget as HTMLElement + const related = e.relatedTarget as Node | null + if (related && th.contains(related)) return + if (related && related instanceof Element && related.closest('th')) return + onDragLeave?.() + } - const handleDrop = useCallback((e: React.DragEvent) => { + function handleDrop(e: React.DragEvent) { e.preventDefault() - }, []) + } const isDraggable = !readOnly && Boolean(onDragStart) 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 d75b63c9eb..62d9ae207a 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 @@ -302,6 +302,9 @@ export function TableGrid({ const [dropSide, setDropSide] = useState<'left' | 'right'>('left') const dropSideRef = useRef(dropSide) dropSideRef.current = dropSide + const [frozenColumns, setFrozenColumns] = useState([]) + const frozenColumnsRef = useRef(frozenColumns) + frozenColumnsRef.current = frozenColumns const metadataSeededRef = useRef(false) const containerRef = useRef(null) const scrollRef = useRef(null) @@ -466,9 +469,13 @@ export function TableGrid({ } const updatedOrder = columnOrderRef.current?.map((n) => (n === oldName ? newName : n)) if (updatedOrder) setColumnOrder(updatedOrder) + const updatedFrozen = frozenColumnsRef.current.map((n) => (n === oldName ? newName : n)) + const frozenChanged = updatedFrozen.some((n, i) => n !== frozenColumnsRef.current[i]) + if (frozenChanged) setFrozenColumns(updatedFrozen) updateMetadataRef.current({ columnWidths: updatedWidths, ...(updatedOrder ? { columnOrder: updatedOrder } : {}), + ...(frozenChanged ? { frozenColumns: updatedFrozen } : {}), }) } // Populate the wrapper's sink so its sidebars can fire renames back into @@ -483,6 +490,43 @@ export function TableGrid({ setColumnWidths(widths) } + const handleFreezeToggle = useCallback((columnName: string) => { + const col = columnsRef.current.find((c) => c.name === columnName) + const siblings: string[] = col?.workflowGroupId + ? columnsRef.current + .filter((c) => c.workflowGroupId === col.workflowGroupId) + .map((c) => c.name) + : [columnName] + + const current = frozenColumnsRef.current + if (current.includes(columnName)) { + const newFrozen = current.filter((n) => !siblings.includes(n)) + setFrozenColumns(newFrozen) + frozenColumnsRef.current = newFrozen + updateMetadataRef.current({ + frozenColumns: newFrozen, + columnWidths: columnWidthsRef.current, + }) + } else { + const newFrozen = [...current, ...siblings.filter((n) => !current.includes(n))] + setFrozenColumns(newFrozen) + frozenColumnsRef.current = newFrozen + const currentOrder = columnOrderRef.current ?? schemaColumnsRef.current.map((c) => c.name) + const frozenSet = new Set(newFrozen) + const newOrder = [ + ...currentOrder.filter((n) => frozenSet.has(n)), + ...currentOrder.filter((n) => !frozenSet.has(n)), + ] + setColumnOrder(newOrder) + columnOrderRef.current = newOrder + updateMetadataRef.current({ + frozenColumns: newFrozen, + columnOrder: newOrder, + columnWidths: columnWidthsRef.current, + }) + } + }, []) + const { pushUndo, undo, redo } = useTableUndo({ workspaceId, tableId, @@ -530,6 +574,38 @@ export function TableGrid({ hasWorkflowColumns ) + const frozenColumnSet = useMemo(() => new Set(frozenColumns), [frozenColumns]) + + /** Frozen column key → sticky `left` px offset. */ + const frozenOffsets = useMemo>(() => { + const offsets = new Map() + let left = checkboxColWidth + for (const col of displayColumns) { + if (frozenColumnSet.has(col.name)) { + offsets.set(col.key, left) + left += columnWidths[col.key] ?? COL_WIDTH + } + } + return offsets + }, [displayColumns, frozenColumnSet, columnWidths, checkboxColWidth]) + + const lastFrozenColKey = useMemo(() => { + let last: string | null = null + for (const col of displayColumns) { + if (frozenColumnSet.has(col.name)) last = col.key + } + return last + }, [displayColumns, frozenColumnSet]) + + /** Right edge of the frozen sticky zone; used as the left inset for scroll-to-reveal. */ + const frozenStickyLeftEdge = useMemo(() => { + let edge = checkboxColWidth + for (const [key, left] of frozenOffsets) { + edge = Math.max(edge, left + (columnWidths[key] ?? COL_WIDTH)) + } + return edge + }, [frozenOffsets, columnWidths, checkboxColWidth]) + const headerGroups = useMemo( () => buildHeaderGroups(displayColumns, tableWorkflowGroups), [displayColumns, tableWorkflowGroups] @@ -606,10 +682,7 @@ export function TableGrid({ checkboxColWidth, ]) - const isAllRowsSelected = useMemo( - () => rowSelectionCoversAll(rowSelection, rows), - [rowSelection, rows] - ) + const isAllRowsSelected = rowSelectionCoversAll(rowSelection, rows) const isAllRowsSelectedRef = useRef(isAllRowsSelected) isAllRowsSelectedRef.current = isAllRowsSelected @@ -1248,17 +1321,29 @@ export function TableGrid({ ...remaining.slice(insertIndex), ] - const orderChanged = newOrder.some((name, i) => currentOrder[i] !== name) + // Re-enforce frozen-at-front: if any frozen column was dragged behind a + // non-frozen one (or vice versa), restore the frozen zone at the front + // while preserving the user's relative reorder within each zone. + let finalOrder = newOrder + const currentFrozen = frozenColumnsRef.current + if (currentFrozen.length > 0) { + const frozenSet = new Set(currentFrozen) + const frozenInNew = newOrder.filter((n) => frozenSet.has(n)) + const unfrozenInNew = newOrder.filter((n) => !frozenSet.has(n)) + finalOrder = [...frozenInNew, ...unfrozenInNew] + } + + const orderChanged = finalOrder.some((name, i) => currentOrder[i] !== name) if (orderChanged) { pushUndoRef.current({ type: 'reorder-columns', previousOrder: currentOrder, - newOrder, + newOrder: finalOrder, }) - setColumnOrder(newOrder) + setColumnOrder(finalOrder) updateMetadataRef.current({ columnWidths: columnWidthsRef.current, - columnOrder: newOrder, + columnOrder: finalOrder, }) } } @@ -1345,8 +1430,13 @@ export function TableGrid({ useEffect(() => { if (!tableData?.metadata) return - if (!tableData.metadata.columnWidths && !tableData.metadata.columnOrder) return - // First load: seed both from the server and remember we've seeded. + if ( + !tableData.metadata.columnWidths && + !tableData.metadata.columnOrder && + !tableData.metadata.frozenColumns + ) + return + // First load: seed all from the server and remember we've seeded. if (!metadataSeededRef.current) { metadataSeededRef.current = true if (tableData.metadata.columnWidths) { @@ -1355,6 +1445,9 @@ export function TableGrid({ if (tableData.metadata.columnOrder) { setColumnOrder(tableData.metadata.columnOrder) } + if (tableData.metadata.frozenColumns) { + setFrozenColumns(tableData.metadata.frozenColumns) + } return } // After first load: only re-seed `columnOrder` when the *set of columns* @@ -1528,7 +1621,7 @@ export function TableGrid({ const selector = `[data-table-scroll] [data-row="${rowIndex}"][data-col="${colIndex}"]` // `scrollIntoView` ignores the sticky `` and sticky gutter, so a cell // scrolled to the edge lands behind them. Scroll manually with insets equal - // to the sticky header height (top) and the row-number column width (left). + // to the sticky header height (top) and the full frozen left edge (left). const revealCell = (cell: HTMLElement) => { const scrollEl = scrollRef.current if (!scrollEl) return @@ -1540,10 +1633,14 @@ export function TableGrid({ } else if (rect.bottom > view.bottom) { scrollEl.scrollTop += rect.bottom - view.bottom } - if (rect.left < view.left + checkboxColWidth) { - scrollEl.scrollLeft -= view.left + checkboxColWidth - rect.left - } else if (rect.right > view.right) { - scrollEl.scrollLeft += rect.right - view.right + const targetColName = columnsRef.current[colIndex]?.name + const targetIsFrozen = targetColName ? frozenColumnSet.has(targetColName) : false + if (!targetIsFrozen) { + if (rect.left < view.left + frozenStickyLeftEdge) { + scrollEl.scrollLeft -= view.left + frozenStickyLeftEdge - rect.left + } else if (rect.right > view.right) { + scrollEl.scrollLeft += rect.right - view.right + } } } let secondRaf = 0 @@ -1565,7 +1662,14 @@ export function TableGrid({ cancelAnimationFrame(rafId) if (secondRaf) cancelAnimationFrame(secondRaf) } - }, [selectionAnchor, selectionFocus, isColumnSelection, rowVirtualizer, checkboxColWidth]) + }, [ + selectionAnchor, + selectionFocus, + isColumnSelection, + rowVirtualizer, + frozenStickyLeftEdge, + frozenColumnSet, + ]) const handleCellClick = useCallback( (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => { @@ -2744,15 +2848,25 @@ export function TableGrid({ setColumnWidths(cleanedWidths) columnWidthsRef.current = cleanedWidths + const updatedFrozen = frozenColumnsRef.current.filter((n) => n !== columnToDelete) + if (updatedFrozen.length !== frozenColumnsRef.current.length) { + setFrozenColumns(updatedFrozen) + frozenColumnsRef.current = updatedFrozen + } + if (currentOrder) { currentOrder = currentOrder.filter((n) => n !== columnToDelete) setColumnOrder(currentOrder) updateMetadataRef.current({ columnWidths: cleanedWidths, columnOrder: currentOrder, + frozenColumns: frozenColumnsRef.current, }) } else { - updateMetadataRef.current({ columnWidths: cleanedWidths }) + updateMetadataRef.current({ + columnWidths: cleanedWidths, + frozenColumns: frozenColumnsRef.current, + }) } deleteNext(index + 1) @@ -3243,45 +3357,54 @@ export function TableGrid({ checked={isAllRowsSelected} onCheckedChange={handleSelectAllToggle} /> - {displayColumns.map((column, idx) => ( - = normalizedSelection.startCol && - idx <= normalizedSelection.endCol - } - renameValue={ - columnRename.editingId === column.name ? columnRename.editValue : '' - } - onRenameValueChange={columnRename.setEditValue} - onRenameSubmit={columnRename.submitRename} - onRenameCancel={columnRename.cancelRename} - onColumnSelect={handleColumnSelect} - onChangeType={handleChangeType} - onInsertLeft={handleInsertColumnLeft} - onInsertRight={handleInsertColumnRight} - onDeleteColumn={handleDeleteColumn} - onResizeStart={handleColumnResizeStart} - onResize={handleColumnResize} - onResizeEnd={handleColumnResizeEnd} - onAutoResize={handleColumnAutoResize} - onDragStart={handleColumnDragStart} - onDragOver={handleColumnDragOver} - onDragEnd={handleColumnDragEnd} - onDragLeave={handleColumnDragLeave} - workflows={workflows} - workflowGroups={tableWorkflowGroups} - sourceInfo={columnSourceInfo.get(column.name)} - onOpenConfig={handleConfigureColumn} - onViewWorkflow={handleViewWorkflow} - /> - ))} + {displayColumns.map((column, idx) => { + const colIsFrozen = frozenColumnSet.has(column.name) + const colStickyLeft = frozenOffsets.get(column.key) + return ( + = normalizedSelection.startCol && + idx <= normalizedSelection.endCol + } + renameValue={ + columnRename.editingId === column.name ? columnRename.editValue : '' + } + onRenameValueChange={columnRename.setEditValue} + onRenameSubmit={columnRename.submitRename} + onRenameCancel={columnRename.cancelRename} + onColumnSelect={handleColumnSelect} + onInsertLeft={handleInsertColumnLeft} + onInsertRight={handleInsertColumnRight} + onDeleteColumn={handleDeleteColumn} + onResizeStart={handleColumnResizeStart} + onResize={handleColumnResize} + onResizeEnd={handleColumnResizeEnd} + onAutoResize={handleColumnAutoResize} + onDragStart={handleColumnDragStart} + onDragOver={handleColumnDragOver} + onDragEnd={handleColumnDragEnd} + onDragLeave={handleColumnDragLeave} + workflows={workflows} + workflowGroups={tableWorkflowGroups} + sourceInfo={columnSourceInfo.get(column.name)} + onOpenConfig={handleConfigureColumn} + onViewWorkflow={handleViewWorkflow} + isFrozen={colIsFrozen} + onFreezeToggle={ + userPermissions.canEdit ? handleFreezeToggle : undefined + } + stickyLeft={colStickyLeft} + isLastFrozen={column.key === lastFrozenColKey} + /> + ) + })} {userPermissions.canEdit && ( 0 ? frozenOffsets : undefined} + lastFrozenColKey={lastFrozenColKey} /> ) })} diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index aadb38f135..da35ffe2dc 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -135,6 +135,7 @@ export const deleteTableColumnBodySchema = z.object({ export const tableMetadataSchema = z.object({ columnWidths: z.record(z.string(), z.number().positive()).optional(), columnOrder: z.array(z.string()).optional(), + frozenColumns: z.array(z.string()).optional(), }) satisfies z.ZodType export const updateTableMetadataBodySchema = z.object({ diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 2df30b8f9b..05a41e8dd1 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -142,12 +142,14 @@ export interface TableSchema { /** * Table-level metadata stored alongside the table definition. UI state only - * (column widths, column order) — workflow-group concurrency is enforced at - * the trigger.dev queue layer, not via metadata. + * (column widths, column order, frozen columns) — workflow-group concurrency + * is enforced at the trigger.dev queue layer, not via metadata. */ export interface TableMetadata { columnWidths?: Record columnOrder?: string[] + /** Logical column names that are pinned to the left while scrolling horizontally. */ + frozenColumns?: string[] } export interface TableDefinition { From 08e4ff74fadce14238270f51c53f71ee66717cc7 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 28 May 2026 09:59:53 -0700 Subject: [PATCH 02/11] fix(tables): sticky meta-header row for frozen workflow groups, remove dead handleChangeType --- .../headers/workflow-group-meta-cell.tsx | 21 ++- .../components/table-grid/table-grid.tsx | 156 +++++++++--------- 2 files changed, 100 insertions(+), 77 deletions(-) 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 edc9fe2e0e..86eed1a4c2 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 @@ -233,6 +233,14 @@ interface WorkflowGroupMetaCellProps { onDragEnd?: () => void onDragLeave?: () => void readOnly?: boolean + /** Left offset in pixels when frozen (drives `position: sticky`). */ + stickyLeft?: number + /** Whether this is the rightmost frozen column group (renders a separator shadow). */ + isLastFrozen?: boolean + /** Whether this column group is currently frozen (pinned to the left). */ + isFrozen?: boolean + /** Toggle the frozen state for this column group. */ + onFreezeToggle?: (columnName: string) => void } /** @@ -266,6 +274,10 @@ export function WorkflowGroupMetaCell({ onDragEnd, onDragLeave, readOnly, + stickyLeft, + isLastFrozen, + isFrozen, + onFreezeToggle, }: WorkflowGroupMetaCellProps) { const isEnrichment = groupType === 'enrichment' const enrichment = isEnrichment ? getEnrichment(enrichmentId) : undefined @@ -385,7 +397,12 @@ export function WorkflowGroupMetaCell({ onDragEnd={isDraggable ? handleDragEnd : undefined} onDragLeave={isDraggable ? handleDragLeave : undefined} onDrop={isDraggable ? handleDrop : undefined} - className='group relative cursor-pointer border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[5px] text-left align-middle before:pointer-events-none before:absolute before:top-0 before:bottom-0 before:left-[-1px] before:w-px before:bg-[var(--border)] before:content-[""]' + className={cn( + 'group relative cursor-pointer border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[5px] text-left align-middle before:pointer-events-none before:absolute before:top-0 before:bottom-0 before:left-[-1px] before:w-px before:bg-[var(--border)] before:content-[""]', + stickyLeft !== undefined && 'z-[11]', + isLastFrozen && '[box-shadow:2px_0_0_0_var(--border)]' + )} + style={stickyLeft !== undefined ? { position: 'sticky', left: stickyLeft } : undefined} >
0 ? handleRunSelected : undefined} selectedRowCount={selectedCount} onViewWorkflow={onViewWorkflow ? () => onViewWorkflow(workflowId) : undefined} + isFrozen={isFrozen} + onFreezeToggle={onFreezeToggle} /> )} 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 62d9ae207a..cb6c0fd693 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 @@ -2601,26 +2601,6 @@ export function TableGrid({ [] ) - const handleChangeType = useCallback((columnName: string, newType: ColumnDefinition['type']) => { - const column = columnsRef.current.find((c) => c.name === columnName) - const previousType = column?.type - updateColumnMutation.mutate( - { columnName, updates: { type: newType } }, - { - onSuccess: () => { - if (previousType) { - pushUndoRef.current({ - type: 'update-column-type', - columnName, - previousType, - newType, - }) - } - }, - } - ) - }, []) - const insertColumnInOrder = useCallback( (anchorColumn: string, newColumn: string, side: 'left' | 'right') => { const order = columnOrderRef.current ?? schemaColumnsRef.current.map((c) => c.name) @@ -3287,66 +3267,90 @@ export function TableGrid({ {hasWorkflowGroup && ( - {headerGroups.map((g) => - g.kind === 'workflow' ? ( - = g.startColIndex + g.size - 1 - } - groupId={g.groupId} - groupType={workflowGroupById.get(g.groupId)?.type} - enrichmentId={workflowGroupById.get(g.groupId)?.enrichmentId} - groupName={workflowGroupById.get(g.groupId)?.name} - onSelectGroup={handleGroupSelect} - onOpenConfig={() => handleConfigureWorkflowGroup(g.groupId)} - onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined} - selectedRowIds={selectedRowIds} - onInsertLeft={ - userPermissions.canEdit ? handleInsertColumnLeft : undefined - } - onInsertRight={ - userPermissions.canEdit ? handleInsertColumnRight : undefined - } - onDeleteColumn={ - userPermissions.canEdit ? handleDeleteColumn : undefined - } - onDeleteGroup={ - userPermissions.canEdit ? handleDeleteWorkflowGroup : undefined - } - onViewWorkflow={ - workflowGroupById.get(g.groupId)?.type === 'enrichment' - ? undefined - : handleViewWorkflow - } - readOnly={!userPermissions.canEdit} - onDragStart={ - userPermissions.canEdit ? handleColumnDragStart : undefined - } - onDragOver={ - userPermissions.canEdit ? handleColumnDragOver : undefined - } - onDragEnd={userPermissions.canEdit ? handleColumnDragEnd : undefined} - onDragLeave={ - userPermissions.canEdit ? handleColumnDragLeave : undefined - } - /> - ) : ( + {headerGroups.map((g) => { + const firstCol = displayColumns[g.startColIndex] + const stickyLeft = firstCol ? frozenOffsets.get(firstCol.key) : undefined + if (g.kind === 'workflow') { + const lastCol = displayColumns[g.startColIndex + g.size - 1] + return ( + = g.startColIndex + g.size - 1 + } + groupId={g.groupId} + groupType={workflowGroupById.get(g.groupId)?.type} + enrichmentId={workflowGroupById.get(g.groupId)?.enrichmentId} + groupName={workflowGroupById.get(g.groupId)?.name} + onSelectGroup={handleGroupSelect} + onOpenConfig={() => handleConfigureWorkflowGroup(g.groupId)} + onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined} + selectedRowIds={selectedRowIds} + onInsertLeft={ + userPermissions.canEdit ? handleInsertColumnLeft : undefined + } + onInsertRight={ + userPermissions.canEdit ? handleInsertColumnRight : undefined + } + onDeleteColumn={ + userPermissions.canEdit ? handleDeleteColumn : undefined + } + onDeleteGroup={ + userPermissions.canEdit ? handleDeleteWorkflowGroup : undefined + } + onViewWorkflow={ + workflowGroupById.get(g.groupId)?.type === 'enrichment' + ? undefined + : handleViewWorkflow + } + readOnly={!userPermissions.canEdit} + onDragStart={ + userPermissions.canEdit ? handleColumnDragStart : undefined + } + onDragOver={ + userPermissions.canEdit ? handleColumnDragOver : undefined + } + onDragEnd={ + userPermissions.canEdit ? handleColumnDragEnd : undefined + } + onDragLeave={ + userPermissions.canEdit ? handleColumnDragLeave : undefined + } + isFrozen={firstCol ? frozenColumnSet.has(firstCol.name) : false} + onFreezeToggle={ + userPermissions.canEdit ? handleFreezeToggle : undefined + } + stickyLeft={stickyLeft} + isLastFrozen={lastCol?.key === lastFrozenColKey} + /> + ) + } + const isLastFrz = firstCol?.key === lastFrozenColKey + return ( ) - )} + })} {userPermissions.canEdit && ( )} From 6d19730bc11c76ec443919702228e5c096d6ce2e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 28 May 2026 10:35:19 -0700 Subject: [PATCH 03/11] fix(tables): scope frozenOffsets dep to frozen column widths only --- .../components/table-grid/table-grid.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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 cb6c0fd693..941f66ac55 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 @@ -576,18 +576,28 @@ export function TableGrid({ const frozenColumnSet = useMemo(() => new Set(frozenColumns), [frozenColumns]) + // Stable fingerprint of frozen-column widths only. Changes when a frozen + // column is resized; stays the same when a non-frozen column is resized. + // Used as the sole dep that ties frozenOffsets to column-width changes so + // that non-frozen resizes don't recreate the Map and re-render all DataRows. + const frozenWidthsKey = displayColumns + .filter((c) => frozenColumnSet.has(c.name)) + .map((c) => columnWidths[c.key] ?? COL_WIDTH) + .join(',') + /** Frozen column key → sticky `left` px offset. */ const frozenOffsets = useMemo>(() => { const offsets = new Map() let left = checkboxColWidth + const widths = columnWidthsRef.current for (const col of displayColumns) { if (frozenColumnSet.has(col.name)) { offsets.set(col.key, left) - left += columnWidths[col.key] ?? COL_WIDTH + left += widths[col.key] ?? COL_WIDTH } } return offsets - }, [displayColumns, frozenColumnSet, columnWidths, checkboxColWidth]) + }, [displayColumns, frozenColumnSet, checkboxColWidth, frozenWidthsKey]) const lastFrozenColKey = useMemo(() => { let last: string | null = null @@ -600,11 +610,12 @@ export function TableGrid({ /** Right edge of the frozen sticky zone; used as the left inset for scroll-to-reveal. */ const frozenStickyLeftEdge = useMemo(() => { let edge = checkboxColWidth + const widths = columnWidthsRef.current for (const [key, left] of frozenOffsets) { - edge = Math.max(edge, left + (columnWidths[key] ?? COL_WIDTH)) + edge = Math.max(edge, left + (widths[key] ?? COL_WIDTH)) } return edge - }, [frozenOffsets, columnWidths, checkboxColWidth]) + }, [frozenOffsets, checkboxColWidth]) const headerGroups = useMemo( () => buildHeaderGroups(displayColumns, tableWorkflowGroups), From 065dc56b5565327cc678935452a5788e8d48dbfa Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 28 May 2026 10:49:51 -0700 Subject: [PATCH 04/11] fix(tables): restore frozenColumns on delete-column undo/redo --- .../components/table-grid/table-grid.tsx | 8 ++++++++ apps/sim/hooks/use-table-undo.ts | 15 +++++++++++++++ apps/sim/stores/table/types.ts | 1 + 3 files changed, 24 insertions(+) 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 941f66ac55..72d9c0a74a 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 @@ -490,6 +490,11 @@ export function TableGrid({ setColumnWidths(widths) } + function handleFrozenColumnsChange(frozen: string[]) { + setFrozenColumns(frozen) + frozenColumnsRef.current = frozen + } + const handleFreezeToggle = useCallback((columnName: string) => { const col = columnsRef.current.find((c) => c.name === columnName) const siblings: string[] = col?.workflowGroupId @@ -533,6 +538,7 @@ export function TableGrid({ onColumnOrderChange: handleColumnOrderChange, onColumnRename: handleColumnRename, onColumnWidthsChange: handleColumnWidthsChange, + onFrozenColumnsChange: handleFrozenColumnsChange, getColumnWidths, }) const undoRef = useRef(undo) @@ -2820,6 +2826,7 @@ export function TableGrid({ .map((r) => ({ rowId: r.id, value: r.data[columnToDelete] })) const previousWidth = columnWidthsRef.current[columnToDelete] ?? null const orderSnapshot = currentOrder ? [...currentOrder] : null + const frozenSnapshot = [...frozenColumnsRef.current] const onDeleted = () => { deletedOriginalPositions.push(entry.position) @@ -2833,6 +2840,7 @@ export function TableGrid({ cellData, previousOrder: orderSnapshot, previousWidth, + previousFrozenColumns: frozenSnapshot, }) const { [columnToDelete]: _removedWidth, ...cleanedWidths } = columnWidthsRef.current diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 8a364d5469..12a657faf2 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -31,6 +31,7 @@ interface UseTableUndoProps { onColumnOrderChange?: (order: string[]) => void onColumnRename?: (oldName: string, newName: string) => void onColumnWidthsChange?: (widths: Record) => void + onFrozenColumnsChange?: (frozen: string[]) => void getColumnWidths?: () => Record } @@ -40,6 +41,7 @@ export function useTableUndo({ onColumnOrderChange, onColumnRename, onColumnWidthsChange, + onFrozenColumnsChange, getColumnWidths, }: UseTableUndoProps) { const push = useTableUndoStore((s) => s.push) @@ -69,6 +71,8 @@ export function useTableUndo({ onColumnRenameRef.current = onColumnRename const onColumnWidthsChangeRef = useRef(onColumnWidthsChange) onColumnWidthsChangeRef.current = onColumnWidthsChange + const onFrozenColumnsChangeRef = useRef(onFrozenColumnsChange) + onFrozenColumnsChangeRef.current = onFrozenColumnsChange const getColumnWidthsRef = useRef(getColumnWidths) getColumnWidthsRef.current = getColumnWidths @@ -273,6 +277,10 @@ export function useTableUndo({ metadata.columnWidths = merged onColumnWidthsChangeRef.current?.(merged) } + if (action.previousFrozenColumns !== null) { + onFrozenColumnsChangeRef.current?.(action.previousFrozenColumns) + metadata.frozenColumns = action.previousFrozenColumns + } if (Object.keys(metadata).length > 0) { updateMetadataMutation.mutate(metadata) } @@ -294,6 +302,13 @@ export function useTableUndo({ metadata.columnWidths = rest onColumnWidthsChangeRef.current?.(rest) } + if (action.previousFrozenColumns !== null) { + const newFrozen = action.previousFrozenColumns.filter( + (n) => n !== action.columnName + ) + onFrozenColumnsChangeRef.current?.(newFrozen) + metadata.frozenColumns = newFrozen + } if (Object.keys(metadata).length > 0) { updateMetadataMutation.mutate(metadata) } diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index 13f9f999c4..f399a0b6c6 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -44,6 +44,7 @@ export type TableUndoAction = cellData: Array<{ rowId: string; value: unknown }> previousOrder: string[] | null previousWidth: number | null + previousFrozenColumns: string[] | null } | { type: 'rename-column'; oldName: string; newName: string } | { From 6526129795b4792c31422e9ca397cf58ac02427f Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 28 May 2026 11:06:41 -0700 Subject: [PATCH 05/11] fix(tables): restore useMemo for isAllRowsSelected (O(n) computation) --- .../tables/[tableId]/components/table-grid/table-grid.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 72d9c0a74a..f9a7938f9f 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 @@ -699,7 +699,10 @@ export function TableGrid({ checkboxColWidth, ]) - const isAllRowsSelected = rowSelectionCoversAll(rowSelection, rows) + const isAllRowsSelected = useMemo( + () => rowSelectionCoversAll(rowSelection, rows), + [rowSelection, rows] + ) const isAllRowsSelectedRef = useRef(isAllRowsSelected) isAllRowsSelectedRef.current = isAllRowsSelected From b37a0492665f99f56838346f81a43531e7114240 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 28 May 2026 11:26:15 -0700 Subject: [PATCH 06/11] fix(tables): use current frozenColumns on delete-column redo, not stale snapshot --- .../[tableId]/components/table-grid/table-grid.tsx | 5 +++++ apps/sim/hooks/use-table-undo.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) 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 f9a7938f9f..3e5dade435 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 @@ -495,6 +495,10 @@ export function TableGrid({ frozenColumnsRef.current = frozen } + function getFrozenColumns() { + return frozenColumnsRef.current + } + const handleFreezeToggle = useCallback((columnName: string) => { const col = columnsRef.current.find((c) => c.name === columnName) const siblings: string[] = col?.workflowGroupId @@ -539,6 +543,7 @@ export function TableGrid({ onColumnRename: handleColumnRename, onColumnWidthsChange: handleColumnWidthsChange, onFrozenColumnsChange: handleFrozenColumnsChange, + getFrozenColumns, getColumnWidths, }) const undoRef = useRef(undo) diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 12a657faf2..b38a4026e5 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -32,6 +32,7 @@ interface UseTableUndoProps { onColumnRename?: (oldName: string, newName: string) => void onColumnWidthsChange?: (widths: Record) => void onFrozenColumnsChange?: (frozen: string[]) => void + getFrozenColumns?: () => string[] getColumnWidths?: () => Record } @@ -42,6 +43,7 @@ export function useTableUndo({ onColumnRename, onColumnWidthsChange, onFrozenColumnsChange, + getFrozenColumns, getColumnWidths, }: UseTableUndoProps) { const push = useTableUndoStore((s) => s.push) @@ -73,6 +75,8 @@ export function useTableUndo({ onColumnWidthsChangeRef.current = onColumnWidthsChange const onFrozenColumnsChangeRef = useRef(onFrozenColumnsChange) onFrozenColumnsChangeRef.current = onFrozenColumnsChange + const getFrozenColumnsRef = useRef(getFrozenColumns) + getFrozenColumnsRef.current = getFrozenColumns const getColumnWidthsRef = useRef(getColumnWidths) getColumnWidthsRef.current = getColumnWidths @@ -303,9 +307,8 @@ export function useTableUndo({ onColumnWidthsChangeRef.current?.(rest) } if (action.previousFrozenColumns !== null) { - const newFrozen = action.previousFrozenColumns.filter( - (n) => n !== action.columnName - ) + const currentFrozen = getFrozenColumnsRef.current?.() ?? [] + const newFrozen = currentFrozen.filter((n) => n !== action.columnName) onFrozenColumnsChangeRef.current?.(newFrozen) metadata.frozenColumns = newFrozen } From 8b40c46284dcbcd048668928826011a128d49cf2 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 28 May 2026 11:44:54 -0700 Subject: [PATCH 07/11] fix(tables): clean up frozenColumns on create-column undo --- apps/sim/hooks/use-table-undo.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index b38a4026e5..5a4cb872e5 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -214,11 +214,21 @@ export function useTableUndo({ if (direction === 'undo') { deleteColumnMutation.mutate(action.columnName, { onSuccess: () => { + const metadata: Record = {} const currentWidths = getColumnWidthsRef.current?.() ?? {} if (action.columnName in currentWidths) { const { [action.columnName]: _, ...rest } = currentWidths onColumnWidthsChangeRef.current?.(rest) - updateMetadataMutation.mutate({ columnWidths: rest }) + metadata.columnWidths = rest + } + const currentFrozen = getFrozenColumnsRef.current?.() ?? [] + if (currentFrozen.includes(action.columnName)) { + const newFrozen = currentFrozen.filter((n) => n !== action.columnName) + onFrozenColumnsChangeRef.current?.(newFrozen) + metadata.frozenColumns = newFrozen + } + if (Object.keys(metadata).length > 0) { + updateMetadataMutation.mutate(metadata) } }, }) From 92363219f853271eb892e4f48f890a5ff522033e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 28 May 2026 12:12:11 -0700 Subject: [PATCH 08/11] fix(tables): merge frozen state on delete-column undo instead of overwriting --- apps/sim/hooks/use-table-undo.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 5a4cb872e5..edd998a19f 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -292,8 +292,25 @@ export function useTableUndo({ onColumnWidthsChangeRef.current?.(merged) } if (action.previousFrozenColumns !== null) { - onFrozenColumnsChangeRef.current?.(action.previousFrozenColumns) - metadata.frozenColumns = action.previousFrozenColumns + const wasColumnFrozen = action.previousFrozenColumns.includes( + action.columnName + ) + if (wasColumnFrozen) { + const currentFrozen = getFrozenColumnsRef.current?.() ?? [] + if (!currentFrozen.includes(action.columnName)) { + const insertIndex = action.previousFrozenColumns.indexOf( + action.columnName + ) + const restoredFrozen = [...currentFrozen] + restoredFrozen.splice( + Math.min(insertIndex, restoredFrozen.length), + 0, + action.columnName + ) + onFrozenColumnsChangeRef.current?.(restoredFrozen) + metadata.frozenColumns = restoredFrozen + } + } } if (Object.keys(metadata).length > 0) { updateMetadataMutation.mutate(metadata) From 641c03145b7cb4a568a8845ba32a260bc390e22e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 28 May 2026 12:21:09 -0700 Subject: [PATCH 09/11] fix(tables): add previousFrozenColumns to test fixture for delete-column action --- apps/sim/hooks/use-table-undo.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/hooks/use-table-undo.test.ts b/apps/sim/hooks/use-table-undo.test.ts index 7a5e2db347..c70c4e1923 100644 --- a/apps/sim/hooks/use-table-undo.test.ts +++ b/apps/sim/hooks/use-table-undo.test.ts @@ -189,6 +189,7 @@ describe('useTableUndo – delete-column undo cell restore chunking', () => { cellData: [], previousOrder: null, previousWidth: null, + previousFrozenColumns: null, } it('does not call mutateAsync when cellData is empty', async () => { From c9b848e8b645502e511f92cd155558648cd36cd8 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 28 May 2026 12:26:04 -0700 Subject: [PATCH 10/11] fix(tables): skip frozen state update on delete-column redo when column was not frozen --- apps/sim/hooks/use-table-undo.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index edd998a19f..367a5f98bb 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -335,9 +335,11 @@ export function useTableUndo({ } if (action.previousFrozenColumns !== null) { const currentFrozen = getFrozenColumnsRef.current?.() ?? [] - const newFrozen = currentFrozen.filter((n) => n !== action.columnName) - onFrozenColumnsChangeRef.current?.(newFrozen) - metadata.frozenColumns = newFrozen + if (currentFrozen.includes(action.columnName)) { + const newFrozen = currentFrozen.filter((n) => n !== action.columnName) + onFrozenColumnsChangeRef.current?.(newFrozen) + metadata.frozenColumns = newFrozen + } } if (Object.keys(metadata).length > 0) { updateMetadataMutation.mutate(metadata) From e5199a1dd7584d40549111c0aadabafdbf0f9f49 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 28 May 2026 15:28:56 -0700 Subject: [PATCH 11/11] refactor(tables): rename frozen columns to pinned, fix sticky-zone UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rename frozenColumns → pinnedColumns across types, contract, undo actions, grid state/refs/props, and dropdown labels - add Pin / PinOff emcn icons; use them in the column menu in place of Lock / Unlock - pinned body cells render at z-[6], above the cell selection border (z-[5]), so the blue selection border can't draw on top of the sticky-left zone - restrict column drag-reorder to within the pinned or unpinned zone in both handleColumnDragOver and handleScrollDragOver; cross-zone drop indicators are suppressed - on unpin, slide the column to the first unpinned slot so the sticky zone stays contiguous; consolidates pin and unpin into one branch that always re-enforces pinned-at-front Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/table-grid/data-row.tsx | 28 +-- .../table-grid/headers/column-header-menu.tsx | 26 +-- .../headers/workflow-group-meta-cell.tsx | 50 ++-- .../components/table-grid/table-grid.tsx | 213 ++++++++++-------- apps/sim/components/emcn/icons/index.ts | 2 + apps/sim/components/emcn/icons/pin-off.tsx | 28 +++ apps/sim/components/emcn/icons/pin.tsx | 26 +++ apps/sim/hooks/use-table-undo.test.ts | 2 +- apps/sim/hooks/use-table-undo.ts | 60 ++--- apps/sim/lib/api/contracts/tables.ts | 2 +- apps/sim/lib/table/types.ts | 4 +- apps/sim/stores/table/types.ts | 2 +- 12 files changed, 259 insertions(+), 184 deletions(-) create mode 100644 apps/sim/components/emcn/icons/pin-off.tsx create mode 100644 apps/sim/components/emcn/icons/pin.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index a37ac0a68e..3bc1d46577 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -57,10 +57,10 @@ export interface DataRowProps { * queued indicators across page refresh during long Run-all dispatches. */ activeDispatches: ActiveDispatch[] | undefined - /** Pixel `left` value for each frozen column key; absent keys are not frozen. */ - frozenOffsets?: Map - /** Key of the rightmost frozen column, used to render a separator shadow. */ - lastFrozenColKey?: string | null + /** Pixel `left` value for each pinned column key; absent keys are not pinned. */ + pinnedOffsets?: Map + /** Key of the rightmost pinned column, used to render a separator shadow. */ + lastPinnedColKey?: string | null } function cellRangeRowChanged( @@ -118,8 +118,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.onRunRow !== next.onRunRow || prev.workflowGroups !== next.workflowGroups || prev.activeDispatches !== next.activeDispatches || - prev.frozenOffsets !== next.frozenOffsets || - prev.lastFrozenColKey !== next.lastFrozenColKey + prev.pinnedOffsets !== next.pinnedOffsets || + prev.lastPinnedColKey !== next.lastPinnedColKey ) { return false } @@ -163,8 +163,8 @@ export const DataRow = React.memo(function DataRow({ onRunRow, workflowGroups, activeDispatches, - frozenOffsets, - lastFrozenColKey, + pinnedOffsets, + lastPinnedColKey, }: DataRowProps) { const sel = normalizedSelection /** @@ -272,9 +272,9 @@ export const DataRow = React.memo(function DataRow({ const isLeftEdge = inRange ? colIndex === sel!.startCol : colIndex === 0 const isRightEdge = inRange ? colIndex === sel!.endCol : colIndex === columns.length - 1 - const frozenLeft = frozenOffsets?.get(column.key) - const isFrozenCell = frozenLeft !== undefined - const isFrozenSeparator = column.key === lastFrozenColKey + const pinnedLeft = pinnedOffsets?.get(column.key) + const isPinnedCell = pinnedLeft !== undefined + const isPinnedSeparator = column.key === lastPinnedColKey return ( { if (e.button !== 0 || isEditing) return onCellMouseDown(rowIndex, colIndex, e.shiftKey) 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 04fed2b57e..7a76d6ee9b 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 @@ -41,14 +41,14 @@ 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 - /** Whether this column is currently frozen (pinned to the left). */ - isFrozen?: boolean - /** Toggle the frozen state for this column. */ - onFreezeToggle?: (columnName: string) => void - /** Left offset in pixels when frozen (drives `position: sticky`). */ + /** Whether this column is currently pinned to the left. */ + isPinned?: boolean + /** Toggle the pinned state for this column. */ + onPinToggle?: (columnName: string) => void + /** Left offset in pixels when pinned (drives `position: sticky`). */ stickyLeft?: number - /** Whether this is the rightmost frozen column (renders a separator shadow). */ - isLastFrozen?: boolean + /** Whether this is the rightmost pinned column (renders a separator shadow). */ + isLastPinned?: boolean } /** @@ -83,10 +83,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ sourceInfo, onOpenConfig, onViewWorkflow, - isFrozen, - onFreezeToggle, + isPinned, + onPinToggle, stickyLeft, - isLastFrozen, + isLastPinned, }: ColumnHeaderMenuProps) { const renameInputRef = useRef(null) const didDragRef = useRef(false) @@ -242,7 +242,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ className={cn( 'group relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle', stickyLeft !== undefined && 'z-[11]', - isLastFrozen && '[box-shadow:2px_0_0_0_var(--border)]' + isLastPinned && '[box-shadow:2px_0_0_0_var(--border)]' )} style={stickyLeft !== undefined ? { position: 'sticky', left: stickyLeft } : undefined} draggable={!readOnly && !isRenaming} @@ -332,8 +332,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onViewWorkflow={ onViewWorkflow && ownGroup ? () => onViewWorkflow(ownGroup.workflowId) : undefined } - isFrozen={isFrozen} - onFreezeToggle={onFreezeToggle} + 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 86eed1a4c2..56468fb1f6 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 @@ -17,11 +17,11 @@ import { ArrowRight, Eye, EyeOff, - Lock, Pencil, + Pin, + PinOff, PlayOutline, Trash, - Unlock, } from '@/components/emcn/icons' import type { RunLimit, RunMode } from '@/lib/api/contracts/tables' import { cn } from '@/lib/core/utils/cn' @@ -69,10 +69,10 @@ interface ColumnOptionsMenuProps { /** When set, the menu surfaces a "View workflow" item that opens a popup * preview of the configured workflow. */ onViewWorkflow?: () => void - /** Whether this column is currently frozen (pinned to the left). */ - isFrozen?: boolean - /** Toggle the frozen state of this column. */ - onFreezeToggle?: (columnName: string) => void + /** Whether this column is currently pinned to the left. */ + isPinned?: boolean + /** Toggle the pinned state of this column. */ + onPinToggle?: (columnName: string) => void } /** @@ -99,8 +99,8 @@ export function ColumnOptionsMenu({ onRunColumnSelected, selectedRowCount = 0, onViewWorkflow, - isFrozen, - onFreezeToggle, + isPinned, + onPinToggle, }: ColumnOptionsMenuProps) { const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete) const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0 @@ -167,10 +167,10 @@ export function ColumnOptionsMenu({ Edit column
- {onFreezeToggle && ( - onFreezeToggle(column.name)}> - {isFrozen ? : } - {isFrozen ? 'Unfreeze column' : 'Freeze column'} + {onPinToggle && ( + onPinToggle(column.name)}> + {isPinned ? : } + {isPinned ? 'Unpin column' : 'Pin column'} )} @@ -233,14 +233,14 @@ interface WorkflowGroupMetaCellProps { onDragEnd?: () => void onDragLeave?: () => void readOnly?: boolean - /** Left offset in pixels when frozen (drives `position: sticky`). */ + /** Left offset in pixels when pinned (drives `position: sticky`). */ stickyLeft?: number - /** Whether this is the rightmost frozen column group (renders a separator shadow). */ - isLastFrozen?: boolean - /** Whether this column group is currently frozen (pinned to the left). */ - isFrozen?: boolean - /** Toggle the frozen state for this column group. */ - onFreezeToggle?: (columnName: string) => void + /** Whether this is the rightmost pinned column group (renders a separator shadow). */ + isLastPinned?: boolean + /** Whether this column group is currently pinned to the left. */ + isPinned?: boolean + /** Toggle the pinned state for this column group. */ + onPinToggle?: (columnName: string) => void } /** @@ -275,9 +275,9 @@ export function WorkflowGroupMetaCell({ onDragLeave, readOnly, stickyLeft, - isLastFrozen, - isFrozen, - onFreezeToggle, + isLastPinned, + isPinned, + onPinToggle, }: WorkflowGroupMetaCellProps) { const isEnrichment = groupType === 'enrichment' const enrichment = isEnrichment ? getEnrichment(enrichmentId) : undefined @@ -400,7 +400,7 @@ export function WorkflowGroupMetaCell({ className={cn( 'group relative cursor-pointer border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[5px] text-left align-middle before:pointer-events-none before:absolute before:top-0 before:bottom-0 before:left-[-1px] before:w-px before:bg-[var(--border)] before:content-[""]', stickyLeft !== undefined && 'z-[11]', - isLastFrozen && '[box-shadow:2px_0_0_0_var(--border)]' + isLastPinned && '[box-shadow:2px_0_0_0_var(--border)]' )} style={stickyLeft !== undefined ? { position: 'sticky', left: stickyLeft } : undefined} > @@ -488,8 +488,8 @@ export function WorkflowGroupMetaCell({ onRunColumnSelected={onRunColumn && selectedCount > 0 ? handleRunSelected : undefined} selectedRowCount={selectedCount} onViewWorkflow={onViewWorkflow ? () => onViewWorkflow(workflowId) : undefined} - isFrozen={isFrozen} - onFreezeToggle={onFreezeToggle} + isPinned={isPinned} + onPinToggle={onPinToggle} /> )} 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 3e5dade435..a49d42d0bf 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 @@ -302,9 +302,9 @@ export function TableGrid({ const [dropSide, setDropSide] = useState<'left' | 'right'>('left') const dropSideRef = useRef(dropSide) dropSideRef.current = dropSide - const [frozenColumns, setFrozenColumns] = useState([]) - const frozenColumnsRef = useRef(frozenColumns) - frozenColumnsRef.current = frozenColumns + const [pinnedColumns, setPinnedColumns] = useState([]) + const pinnedColumnsRef = useRef(pinnedColumns) + pinnedColumnsRef.current = pinnedColumns const metadataSeededRef = useRef(false) const containerRef = useRef(null) const scrollRef = useRef(null) @@ -469,13 +469,13 @@ export function TableGrid({ } const updatedOrder = columnOrderRef.current?.map((n) => (n === oldName ? newName : n)) if (updatedOrder) setColumnOrder(updatedOrder) - const updatedFrozen = frozenColumnsRef.current.map((n) => (n === oldName ? newName : n)) - const frozenChanged = updatedFrozen.some((n, i) => n !== frozenColumnsRef.current[i]) - if (frozenChanged) setFrozenColumns(updatedFrozen) + const updatedPinned = pinnedColumnsRef.current.map((n) => (n === oldName ? newName : n)) + const pinnedChanged = updatedPinned.some((n, i) => n !== pinnedColumnsRef.current[i]) + if (pinnedChanged) setPinnedColumns(updatedPinned) updateMetadataRef.current({ columnWidths: updatedWidths, ...(updatedOrder ? { columnOrder: updatedOrder } : {}), - ...(frozenChanged ? { frozenColumns: updatedFrozen } : {}), + ...(pinnedChanged ? { pinnedColumns: updatedPinned } : {}), }) } // Populate the wrapper's sink so its sidebars can fire renames back into @@ -490,16 +490,16 @@ export function TableGrid({ setColumnWidths(widths) } - function handleFrozenColumnsChange(frozen: string[]) { - setFrozenColumns(frozen) - frozenColumnsRef.current = frozen + function handlePinnedColumnsChange(pinned: string[]) { + setPinnedColumns(pinned) + pinnedColumnsRef.current = pinned } - function getFrozenColumns() { - return frozenColumnsRef.current + function getPinnedColumns() { + return pinnedColumnsRef.current } - const handleFreezeToggle = useCallback((columnName: string) => { + const handlePinToggle = useCallback((columnName: string) => { const col = columnsRef.current.find((c) => c.name === columnName) const siblings: string[] = col?.workflowGroupId ? columnsRef.current @@ -507,33 +507,33 @@ export function TableGrid({ .map((c) => c.name) : [columnName] - const current = frozenColumnsRef.current - if (current.includes(columnName)) { - const newFrozen = current.filter((n) => !siblings.includes(n)) - setFrozenColumns(newFrozen) - frozenColumnsRef.current = newFrozen - updateMetadataRef.current({ - frozenColumns: newFrozen, - columnWidths: columnWidthsRef.current, - }) - } else { - const newFrozen = [...current, ...siblings.filter((n) => !current.includes(n))] - setFrozenColumns(newFrozen) - frozenColumnsRef.current = newFrozen - const currentOrder = columnOrderRef.current ?? schemaColumnsRef.current.map((c) => c.name) - const frozenSet = new Set(newFrozen) - const newOrder = [ - ...currentOrder.filter((n) => frozenSet.has(n)), - ...currentOrder.filter((n) => !frozenSet.has(n)), - ] + const current = pinnedColumnsRef.current + const newPinned = current.includes(columnName) + ? current.filter((n) => !siblings.includes(n)) + : [...current, ...siblings.filter((n) => !current.includes(n))] + setPinnedColumns(newPinned) + pinnedColumnsRef.current = newPinned + + // Re-enforce pinned-at-front. Pinning pulls the column into the sticky + // zone; unpinning ejects it to the first unpinned slot. Without this on + // unpin, the unpinned column would stay sandwiched between still-pinned + // siblings and the sticky zone would render with a gap. + const currentOrder = columnOrderRef.current ?? schemaColumnsRef.current.map((c) => c.name) + const pinnedSet = new Set(newPinned) + const newOrder = [ + ...currentOrder.filter((n) => pinnedSet.has(n)), + ...currentOrder.filter((n) => !pinnedSet.has(n)), + ] + const orderChanged = newOrder.some((n, i) => n !== currentOrder[i]) + if (orderChanged) { setColumnOrder(newOrder) columnOrderRef.current = newOrder - updateMetadataRef.current({ - frozenColumns: newFrozen, - columnOrder: newOrder, - columnWidths: columnWidthsRef.current, - }) } + updateMetadataRef.current({ + pinnedColumns: newPinned, + ...(orderChanged ? { columnOrder: newOrder } : {}), + columnWidths: columnWidthsRef.current, + }) }, []) const { pushUndo, undo, redo } = useTableUndo({ @@ -542,8 +542,8 @@ export function TableGrid({ onColumnOrderChange: handleColumnOrderChange, onColumnRename: handleColumnRename, onColumnWidthsChange: handleColumnWidthsChange, - onFrozenColumnsChange: handleFrozenColumnsChange, - getFrozenColumns, + onPinnedColumnsChange: handlePinnedColumnsChange, + getPinnedColumns, getColumnWidths, }) const undoRef = useRef(undo) @@ -585,48 +585,48 @@ export function TableGrid({ hasWorkflowColumns ) - const frozenColumnSet = useMemo(() => new Set(frozenColumns), [frozenColumns]) + const pinnedColumnSet = useMemo(() => new Set(pinnedColumns), [pinnedColumns]) - // Stable fingerprint of frozen-column widths only. Changes when a frozen - // column is resized; stays the same when a non-frozen column is resized. - // Used as the sole dep that ties frozenOffsets to column-width changes so - // that non-frozen resizes don't recreate the Map and re-render all DataRows. - const frozenWidthsKey = displayColumns - .filter((c) => frozenColumnSet.has(c.name)) + // Stable fingerprint of pinned-column widths only. Changes when a pinned + // column is resized; stays the same when an unpinned column is resized. + // Used as the sole dep that ties pinnedOffsets to column-width changes so + // that unpinned resizes don't recreate the Map and re-render all DataRows. + const pinnedWidthsKey = displayColumns + .filter((c) => pinnedColumnSet.has(c.name)) .map((c) => columnWidths[c.key] ?? COL_WIDTH) .join(',') - /** Frozen column key → sticky `left` px offset. */ - const frozenOffsets = useMemo>(() => { + /** Pinned column key → sticky `left` px offset. */ + const pinnedOffsets = useMemo>(() => { const offsets = new Map() let left = checkboxColWidth const widths = columnWidthsRef.current for (const col of displayColumns) { - if (frozenColumnSet.has(col.name)) { + if (pinnedColumnSet.has(col.name)) { offsets.set(col.key, left) left += widths[col.key] ?? COL_WIDTH } } return offsets - }, [displayColumns, frozenColumnSet, checkboxColWidth, frozenWidthsKey]) + }, [displayColumns, pinnedColumnSet, checkboxColWidth, pinnedWidthsKey]) - const lastFrozenColKey = useMemo(() => { + const lastPinnedColKey = useMemo(() => { let last: string | null = null for (const col of displayColumns) { - if (frozenColumnSet.has(col.name)) last = col.key + if (pinnedColumnSet.has(col.name)) last = col.key } return last - }, [displayColumns, frozenColumnSet]) + }, [displayColumns, pinnedColumnSet]) - /** Right edge of the frozen sticky zone; used as the left inset for scroll-to-reveal. */ - const frozenStickyLeftEdge = useMemo(() => { + /** Right edge of the pinned sticky zone; used as the left inset for scroll-to-reveal. */ + const pinnedStickyLeftEdge = useMemo(() => { let edge = checkboxColWidth const widths = columnWidthsRef.current - for (const [key, left] of frozenOffsets) { + for (const [key, left] of pinnedOffsets) { edge = Math.max(edge, left + (widths[key] ?? COL_WIDTH)) } return edge - }, [frozenOffsets, checkboxColWidth]) + }, [pinnedOffsets, checkboxColWidth]) const headerGroups = useMemo( () => buildHeaderGroups(displayColumns, tableWorkflowGroups), @@ -1225,6 +1225,17 @@ export function TableGrid({ } } + // Pinned columns reorder only within the pinned zone; unpinned only within + // the unpinned zone. Cross-zone drops are silently dropped so the indicator + // never lies about an insertion that would just get snapped back. + if (dragged) { + const pinned = pinnedColumnsRef.current + if (pinned.includes(dragged) !== pinned.includes(columnName)) { + if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) + return + } + } + // Workflow groups: skip per-`` writes and let `handleScrollDragOver` // do the bookkeeping. The scroll handler computes side from the group's // full bounds, so it stays stable across sibling cursor moves; the per-th @@ -1346,16 +1357,17 @@ export function TableGrid({ ...remaining.slice(insertIndex), ] - // Re-enforce frozen-at-front: if any frozen column was dragged behind a - // non-frozen one (or vice versa), restore the frozen zone at the front + // Re-enforce pinned-at-front: if any pinned column was dragged behind an + // unpinned one (or vice versa), restore the pinned zone at the front // while preserving the user's relative reorder within each zone. + // Defense in depth — dragover already blocks cross-zone drops. let finalOrder = newOrder - const currentFrozen = frozenColumnsRef.current - if (currentFrozen.length > 0) { - const frozenSet = new Set(currentFrozen) - const frozenInNew = newOrder.filter((n) => frozenSet.has(n)) - const unfrozenInNew = newOrder.filter((n) => !frozenSet.has(n)) - finalOrder = [...frozenInNew, ...unfrozenInNew] + const currentPinned = pinnedColumnsRef.current + if (currentPinned.length > 0) { + const pinnedSet = new Set(currentPinned) + const pinnedInNew = newOrder.filter((n) => pinnedSet.has(n)) + const unpinnedInNew = newOrder.filter((n) => !pinnedSet.has(n)) + finalOrder = [...pinnedInNew, ...unpinnedInNew] } const orderChanged = finalOrder.some((name, i) => currentOrder[i] !== name) @@ -1412,6 +1424,13 @@ export function TableGrid({ if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) return } + // Cross-zone (pinned ↔ unpinned) → no-op drop, no indicator. + const pinned = pinnedColumnsRef.current + const draggedName = dragColumnNameRef.current + if (draggedName && pinned.includes(draggedName) !== pinned.includes(col.name)) { + if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) + return + } const midX = left + groupWidth / 2 const side = cursorX < midX ? 'left' : 'right' if (col.name !== dropTargetColumnNameRef.current || side !== dropSideRef.current) { @@ -1458,7 +1477,7 @@ export function TableGrid({ if ( !tableData.metadata.columnWidths && !tableData.metadata.columnOrder && - !tableData.metadata.frozenColumns + !tableData.metadata.pinnedColumns ) return // First load: seed all from the server and remember we've seeded. @@ -1470,8 +1489,8 @@ export function TableGrid({ if (tableData.metadata.columnOrder) { setColumnOrder(tableData.metadata.columnOrder) } - if (tableData.metadata.frozenColumns) { - setFrozenColumns(tableData.metadata.frozenColumns) + if (tableData.metadata.pinnedColumns) { + setPinnedColumns(tableData.metadata.pinnedColumns) } return } @@ -1646,7 +1665,7 @@ export function TableGrid({ const selector = `[data-table-scroll] [data-row="${rowIndex}"][data-col="${colIndex}"]` // `scrollIntoView` ignores the sticky `` and sticky gutter, so a cell // scrolled to the edge lands behind them. Scroll manually with insets equal - // to the sticky header height (top) and the full frozen left edge (left). + // to the sticky header height (top) and the full pinned left edge (left). const revealCell = (cell: HTMLElement) => { const scrollEl = scrollRef.current if (!scrollEl) return @@ -1659,10 +1678,10 @@ export function TableGrid({ scrollEl.scrollTop += rect.bottom - view.bottom } const targetColName = columnsRef.current[colIndex]?.name - const targetIsFrozen = targetColName ? frozenColumnSet.has(targetColName) : false - if (!targetIsFrozen) { - if (rect.left < view.left + frozenStickyLeftEdge) { - scrollEl.scrollLeft -= view.left + frozenStickyLeftEdge - rect.left + const targetIsPinned = targetColName ? pinnedColumnSet.has(targetColName) : false + if (!targetIsPinned) { + if (rect.left < view.left + pinnedStickyLeftEdge) { + scrollEl.scrollLeft -= view.left + pinnedStickyLeftEdge - rect.left } else if (rect.right > view.right) { scrollEl.scrollLeft += rect.right - view.right } @@ -1692,8 +1711,8 @@ export function TableGrid({ selectionFocus, isColumnSelection, rowVirtualizer, - frozenStickyLeftEdge, - frozenColumnSet, + pinnedStickyLeftEdge, + pinnedColumnSet, ]) const handleCellClick = useCallback( @@ -2834,7 +2853,7 @@ export function TableGrid({ .map((r) => ({ rowId: r.id, value: r.data[columnToDelete] })) const previousWidth = columnWidthsRef.current[columnToDelete] ?? null const orderSnapshot = currentOrder ? [...currentOrder] : null - const frozenSnapshot = [...frozenColumnsRef.current] + const pinnedSnapshot = [...pinnedColumnsRef.current] const onDeleted = () => { deletedOriginalPositions.push(entry.position) @@ -2848,17 +2867,17 @@ export function TableGrid({ cellData, previousOrder: orderSnapshot, previousWidth, - previousFrozenColumns: frozenSnapshot, + previousPinnedColumns: pinnedSnapshot, }) const { [columnToDelete]: _removedWidth, ...cleanedWidths } = columnWidthsRef.current setColumnWidths(cleanedWidths) columnWidthsRef.current = cleanedWidths - const updatedFrozen = frozenColumnsRef.current.filter((n) => n !== columnToDelete) - if (updatedFrozen.length !== frozenColumnsRef.current.length) { - setFrozenColumns(updatedFrozen) - frozenColumnsRef.current = updatedFrozen + const updatedPinned = pinnedColumnsRef.current.filter((n) => n !== columnToDelete) + if (updatedPinned.length !== pinnedColumnsRef.current.length) { + setPinnedColumns(updatedPinned) + pinnedColumnsRef.current = updatedPinned } if (currentOrder) { @@ -2867,12 +2886,12 @@ export function TableGrid({ updateMetadataRef.current({ columnWidths: cleanedWidths, columnOrder: currentOrder, - frozenColumns: frozenColumnsRef.current, + pinnedColumns: pinnedColumnsRef.current, }) } else { updateMetadataRef.current({ columnWidths: cleanedWidths, - frozenColumns: frozenColumnsRef.current, + pinnedColumns: pinnedColumnsRef.current, }) } @@ -3296,7 +3315,7 @@ export function TableGrid({ {headerGroups.map((g) => { const firstCol = displayColumns[g.startColIndex] - const stickyLeft = firstCol ? frozenOffsets.get(firstCol.key) : undefined + const stickyLeft = firstCol ? pinnedOffsets.get(firstCol.key) : undefined if (g.kind === 'workflow') { const lastCol = displayColumns[g.startColIndex + g.size - 1] return ( @@ -3352,16 +3371,16 @@ export function TableGrid({ onDragLeave={ userPermissions.canEdit ? handleColumnDragLeave : undefined } - isFrozen={firstCol ? frozenColumnSet.has(firstCol.name) : false} - onFreezeToggle={ - userPermissions.canEdit ? handleFreezeToggle : undefined + isPinned={firstCol ? pinnedColumnSet.has(firstCol.name) : false} + onPinToggle={ + userPermissions.canEdit ? handlePinToggle : undefined } stickyLeft={stickyLeft} - isLastFrozen={lastCol?.key === lastFrozenColKey} + isLastPinned={lastCol?.key === lastPinnedColKey} /> ) } - const isLastFrz = firstCol?.key === lastFrozenColKey + const isLastFrz = firstCol?.key === lastPinnedColKey return ( {displayColumns.map((column, idx) => { - const colIsFrozen = frozenColumnSet.has(column.name) - const colStickyLeft = frozenOffsets.get(column.key) + const colIsPinned = pinnedColumnSet.has(column.name) + const colStickyLeft = pinnedOffsets.get(column.key) return ( ) })} @@ -3515,8 +3534,8 @@ export function TableGrid({ onRunRow={onRunRow} workflowGroups={tableWorkflowGroups} activeDispatches={activeDispatches} - frozenOffsets={frozenOffsets.size > 0 ? frozenOffsets : undefined} - lastFrozenColKey={lastFrozenColKey} + pinnedOffsets={pinnedOffsets.size > 0 ? pinnedOffsets : undefined} + lastPinnedColKey={lastPinnedColKey} /> ) })} diff --git a/apps/sim/components/emcn/icons/index.ts b/apps/sim/components/emcn/icons/index.ts index 83c087a599..3d1cb1fdaf 100644 --- a/apps/sim/components/emcn/icons/index.ts +++ b/apps/sim/components/emcn/icons/index.ts @@ -60,6 +60,8 @@ export { PanelLeft } from './panel-left' export { Pause } from './pause' export { Pencil } from './pencil' export { PillsRing } from './pills-ring' +export { Pin } from './pin' +export { PinOff } from './pin-off' export { Play, PlayOutline } from './play' export { Plus } from './plus' export { Redo } from './redo' diff --git a/apps/sim/components/emcn/icons/pin-off.tsx b/apps/sim/components/emcn/icons/pin-off.tsx new file mode 100644 index 0000000000..0f1bf60627 --- /dev/null +++ b/apps/sim/components/emcn/icons/pin-off.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * PinOff icon component - thumbtack pin with diagonal strike-through + * @param props - SVG properties including className, fill, etc. + */ +export function PinOff(props: SVGProps) { + return ( + + ) +} diff --git a/apps/sim/components/emcn/icons/pin.tsx b/apps/sim/components/emcn/icons/pin.tsx new file mode 100644 index 0000000000..0e9fbfec2a --- /dev/null +++ b/apps/sim/components/emcn/icons/pin.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react' + +/** + * Pin icon component - thumbtack pin + * @param props - SVG properties including className, fill, etc. + */ +export function Pin(props: SVGProps) { + return ( + + ) +} diff --git a/apps/sim/hooks/use-table-undo.test.ts b/apps/sim/hooks/use-table-undo.test.ts index c70c4e1923..3caee9dcca 100644 --- a/apps/sim/hooks/use-table-undo.test.ts +++ b/apps/sim/hooks/use-table-undo.test.ts @@ -189,7 +189,7 @@ describe('useTableUndo – delete-column undo cell restore chunking', () => { cellData: [], previousOrder: null, previousWidth: null, - previousFrozenColumns: null, + previousPinnedColumns: null, } it('does not call mutateAsync when cellData is empty', async () => { diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 367a5f98bb..4f818b5d04 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -31,8 +31,8 @@ interface UseTableUndoProps { onColumnOrderChange?: (order: string[]) => void onColumnRename?: (oldName: string, newName: string) => void onColumnWidthsChange?: (widths: Record) => void - onFrozenColumnsChange?: (frozen: string[]) => void - getFrozenColumns?: () => string[] + onPinnedColumnsChange?: (pinned: string[]) => void + getPinnedColumns?: () => string[] getColumnWidths?: () => Record } @@ -42,8 +42,8 @@ export function useTableUndo({ onColumnOrderChange, onColumnRename, onColumnWidthsChange, - onFrozenColumnsChange, - getFrozenColumns, + onPinnedColumnsChange, + getPinnedColumns, getColumnWidths, }: UseTableUndoProps) { const push = useTableUndoStore((s) => s.push) @@ -73,10 +73,10 @@ export function useTableUndo({ onColumnRenameRef.current = onColumnRename const onColumnWidthsChangeRef = useRef(onColumnWidthsChange) onColumnWidthsChangeRef.current = onColumnWidthsChange - const onFrozenColumnsChangeRef = useRef(onFrozenColumnsChange) - onFrozenColumnsChangeRef.current = onFrozenColumnsChange - const getFrozenColumnsRef = useRef(getFrozenColumns) - getFrozenColumnsRef.current = getFrozenColumns + const onPinnedColumnsChangeRef = useRef(onPinnedColumnsChange) + onPinnedColumnsChangeRef.current = onPinnedColumnsChange + const getPinnedColumnsRef = useRef(getPinnedColumns) + getPinnedColumnsRef.current = getPinnedColumns const getColumnWidthsRef = useRef(getColumnWidths) getColumnWidthsRef.current = getColumnWidths @@ -221,11 +221,11 @@ export function useTableUndo({ onColumnWidthsChangeRef.current?.(rest) metadata.columnWidths = rest } - const currentFrozen = getFrozenColumnsRef.current?.() ?? [] - if (currentFrozen.includes(action.columnName)) { - const newFrozen = currentFrozen.filter((n) => n !== action.columnName) - onFrozenColumnsChangeRef.current?.(newFrozen) - metadata.frozenColumns = newFrozen + const currentPinned = getPinnedColumnsRef.current?.() ?? [] + if (currentPinned.includes(action.columnName)) { + const newPinned = currentPinned.filter((n) => n !== action.columnName) + onPinnedColumnsChangeRef.current?.(newPinned) + metadata.pinnedColumns = newPinned } if (Object.keys(metadata).length > 0) { updateMetadataMutation.mutate(metadata) @@ -291,24 +291,24 @@ export function useTableUndo({ metadata.columnWidths = merged onColumnWidthsChangeRef.current?.(merged) } - if (action.previousFrozenColumns !== null) { - const wasColumnFrozen = action.previousFrozenColumns.includes( + if (action.previousPinnedColumns !== null) { + const wasColumnPinned = action.previousPinnedColumns.includes( action.columnName ) - if (wasColumnFrozen) { - const currentFrozen = getFrozenColumnsRef.current?.() ?? [] - if (!currentFrozen.includes(action.columnName)) { - const insertIndex = action.previousFrozenColumns.indexOf( + if (wasColumnPinned) { + const currentPinned = getPinnedColumnsRef.current?.() ?? [] + if (!currentPinned.includes(action.columnName)) { + const insertIndex = action.previousPinnedColumns.indexOf( action.columnName ) - const restoredFrozen = [...currentFrozen] - restoredFrozen.splice( - Math.min(insertIndex, restoredFrozen.length), + const restoredPinned = [...currentPinned] + restoredPinned.splice( + Math.min(insertIndex, restoredPinned.length), 0, action.columnName ) - onFrozenColumnsChangeRef.current?.(restoredFrozen) - metadata.frozenColumns = restoredFrozen + onPinnedColumnsChangeRef.current?.(restoredPinned) + metadata.pinnedColumns = restoredPinned } } } @@ -333,12 +333,12 @@ export function useTableUndo({ metadata.columnWidths = rest onColumnWidthsChangeRef.current?.(rest) } - if (action.previousFrozenColumns !== null) { - const currentFrozen = getFrozenColumnsRef.current?.() ?? [] - if (currentFrozen.includes(action.columnName)) { - const newFrozen = currentFrozen.filter((n) => n !== action.columnName) - onFrozenColumnsChangeRef.current?.(newFrozen) - metadata.frozenColumns = newFrozen + if (action.previousPinnedColumns !== null) { + const currentPinned = getPinnedColumnsRef.current?.() ?? [] + if (currentPinned.includes(action.columnName)) { + const newPinned = currentPinned.filter((n) => n !== action.columnName) + onPinnedColumnsChangeRef.current?.(newPinned) + metadata.pinnedColumns = newPinned } } if (Object.keys(metadata).length > 0) { diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index da35ffe2dc..f56c22a122 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -135,7 +135,7 @@ export const deleteTableColumnBodySchema = z.object({ export const tableMetadataSchema = z.object({ columnWidths: z.record(z.string(), z.number().positive()).optional(), columnOrder: z.array(z.string()).optional(), - frozenColumns: z.array(z.string()).optional(), + pinnedColumns: z.array(z.string()).optional(), }) satisfies z.ZodType export const updateTableMetadataBodySchema = z.object({ diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 05a41e8dd1..bef5b8abbd 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -142,14 +142,14 @@ export interface TableSchema { /** * Table-level metadata stored alongside the table definition. UI state only - * (column widths, column order, frozen columns) — workflow-group concurrency + * (column widths, column order, pinned columns) — workflow-group concurrency * is enforced at the trigger.dev queue layer, not via metadata. */ export interface TableMetadata { columnWidths?: Record columnOrder?: string[] /** Logical column names that are pinned to the left while scrolling horizontally. */ - frozenColumns?: string[] + pinnedColumns?: string[] } export interface TableDefinition { diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index f399a0b6c6..68496d3cc8 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -44,7 +44,7 @@ export type TableUndoAction = cellData: Array<{ rowId: string; value: unknown }> previousOrder: string[] | null previousWidth: number | null - previousFrozenColumns: string[] | null + previousPinnedColumns: string[] | null } | { type: 'rename-column'; oldName: string; newName: string } | {