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 e228edba84d..3bc1d465774 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 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( @@ -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.pinnedOffsets !== next.pinnedOffsets || + prev.lastPinnedColKey !== next.lastPinnedColKey ) { return false } @@ -157,6 +163,8 @@ export const DataRow = React.memo(function DataRow({ onRunRow, workflowGroups, activeDispatches, + pinnedOffsets, + lastPinnedColKey, }: 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 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 13010ad3179..7a76d6ee9be 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 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 pinned column (renders a separator shadow). */ + isLastPinned?: boolean } /** @@ -76,6 +83,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ sourceInfo, onOpenConfig, onViewWorkflow, + isPinned, + onPinToggle, + stickyLeft, + isLastPinned, }: 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 } + 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 211c3e0a55a..56468fb1f61 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, @@ -18,6 +18,8 @@ import { Eye, EyeOff, Pencil, + Pin, + PinOff, PlayOutline, Trash, } from '@/components/emcn/icons' @@ -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 pinned to the left. */ + isPinned?: boolean + /** Toggle the pinned state of this column. */ + onPinToggle?: (columnName: string) => void } /** @@ -93,6 +99,8 @@ export function ColumnOptionsMenu({ onRunColumnSelected, selectedRowCount = 0, onViewWorkflow, + isPinned, + onPinToggle, }: ColumnOptionsMenuProps) { const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete) const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0 @@ -159,6 +167,12 @@ export function ColumnOptionsMenu({ Edit column + {onPinToggle && ( + onPinToggle(column.name)}> + {isPinned ? : } + {isPinned ? 'Unpin column' : 'Pin column'} + + )} onInsertLeft(column.name)}> @@ -219,6 +233,14 @@ interface WorkflowGroupMetaCellProps { onDragEnd?: () => void onDragLeave?: () => void readOnly?: boolean + /** Left offset in pixels when pinned (drives `position: sticky`). */ + stickyLeft?: number + /** 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 } /** @@ -252,6 +274,10 @@ export function WorkflowGroupMetaCell({ onDragEnd, onDragLeave, readOnly, + stickyLeft, + isLastPinned, + isPinned, + onPinToggle, }: WorkflowGroupMetaCellProps) { const isEnrichment = groupType === 'enrichment' const enrichment = isEnrichment ? getEnrichment(enrichmentId) : undefined @@ -269,112 +295,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) @@ -389,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]', + isLastPinned && '[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} + 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 d75b63c9ebb..b060d9f2734 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 [pinnedColumns, setPinnedColumns] = useState([]) + const pinnedColumnsRef = useRef(pinnedColumns) + pinnedColumnsRef.current = pinnedColumns 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 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 } : {}), + ...(pinnedChanged ? { pinnedColumns: updatedPinned } : {}), }) } // Populate the wrapper's sink so its sidebars can fire renames back into @@ -483,12 +490,61 @@ export function TableGrid({ setColumnWidths(widths) } + function handlePinnedColumnsChange(pinned: string[]) { + setPinnedColumns(pinned) + pinnedColumnsRef.current = pinned + } + + function getPinnedColumns() { + return pinnedColumnsRef.current + } + + const handlePinToggle = 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 = 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 + + // Pinned-at-front is an invariant the rest of the grid relies on (sticky + // offsets walk displayColumns left→right and stop at the first unpinned + // entry). On unpin we must re-sort so the unpinned column doesn't stay + // sandwiched between still-pinned siblings, which would render the sticky + // zone 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({ + pinnedColumns: newPinned, + ...(orderChanged ? { columnOrder: newOrder } : {}), + columnWidths: columnWidthsRef.current, + }) + }, []) + const { pushUndo, undo, redo } = useTableUndo({ workspaceId, tableId, onColumnOrderChange: handleColumnOrderChange, onColumnRename: handleColumnRename, onColumnWidthsChange: handleColumnWidthsChange, + onPinnedColumnsChange: handlePinnedColumnsChange, + getPinnedColumns, getColumnWidths, }) const undoRef = useRef(undo) @@ -530,6 +586,49 @@ export function TableGrid({ hasWorkflowColumns ) + const pinnedColumnSet = useMemo(() => new Set(pinnedColumns), [pinnedColumns]) + + // 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(',') + + /** 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 (pinnedColumnSet.has(col.name)) { + offsets.set(col.key, left) + left += widths[col.key] ?? COL_WIDTH + } + } + return offsets + }, [displayColumns, pinnedColumnSet, checkboxColWidth, pinnedWidthsKey]) + + const lastPinnedColKey = useMemo(() => { + let last: string | null = null + for (const col of displayColumns) { + if (pinnedColumnSet.has(col.name)) last = col.key + } + return last + }, [displayColumns, pinnedColumnSet]) + + /** 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 pinnedOffsets) { + edge = Math.max(edge, left + (widths[key] ?? COL_WIDTH)) + } + return edge + }, [pinnedOffsets, checkboxColWidth]) + const headerGroups = useMemo( () => buildHeaderGroups(displayColumns, tableWorkflowGroups), [displayColumns, tableWorkflowGroups] @@ -1127,6 +1226,16 @@ export function TableGrid({ } } + // Reorder is restricted to within a single zone so a cross-zone drop + // indicator never appears for an insertion the grid would refuse. + 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 @@ -1248,17 +1357,29 @@ export function TableGrid({ ...remaining.slice(insertIndex), ] - const orderChanged = newOrder.some((name, i) => currentOrder[i] !== name) + // Belt-and-suspenders re-sort: dragover already blocks cross-zone drops, + // but if anything ever slips through, the pinned-at-front invariant gets + // restored here (relative order within each zone is preserved). + let finalOrder = newOrder + 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) 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, }) } } @@ -1302,6 +1423,12 @@ export function TableGrid({ if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) return } + 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) { @@ -1345,8 +1472,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.pinnedColumns + ) + 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 +1487,9 @@ export function TableGrid({ if (tableData.metadata.columnOrder) { setColumnOrder(tableData.metadata.columnOrder) } + if (tableData.metadata.pinnedColumns) { + setPinnedColumns(tableData.metadata.pinnedColumns) + } return } // After first load: only re-seed `columnOrder` when the *set of columns* @@ -1528,7 +1663,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 pinned left edge (left). const revealCell = (cell: HTMLElement) => { const scrollEl = scrollRef.current if (!scrollEl) return @@ -1540,10 +1675,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 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 + } } } let secondRaf = 0 @@ -1565,7 +1704,14 @@ export function TableGrid({ cancelAnimationFrame(rafId) if (secondRaf) cancelAnimationFrame(secondRaf) } - }, [selectionAnchor, selectionFocus, isColumnSelection, rowVirtualizer, checkboxColWidth]) + }, [ + selectionAnchor, + selectionFocus, + isColumnSelection, + rowVirtualizer, + pinnedStickyLeftEdge, + pinnedColumnSet, + ]) const handleCellClick = useCallback( (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => { @@ -2497,26 +2643,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) @@ -2725,6 +2851,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 pinnedSnapshot = [...pinnedColumnsRef.current] const onDeleted = () => { deletedOriginalPositions.push(entry.position) @@ -2738,21 +2865,32 @@ export function TableGrid({ cellData, previousOrder: orderSnapshot, previousWidth, + previousPinnedColumns: pinnedSnapshot, }) const { [columnToDelete]: _removedWidth, ...cleanedWidths } = columnWidthsRef.current setColumnWidths(cleanedWidths) columnWidthsRef.current = cleanedWidths + const updatedPinned = pinnedColumnsRef.current.filter((n) => n !== columnToDelete) + if (updatedPinned.length !== pinnedColumnsRef.current.length) { + setPinnedColumns(updatedPinned) + pinnedColumnsRef.current = updatedPinned + } + if (currentOrder) { currentOrder = currentOrder.filter((n) => n !== columnToDelete) setColumnOrder(currentOrder) updateMetadataRef.current({ columnWidths: cleanedWidths, columnOrder: currentOrder, + pinnedColumns: pinnedColumnsRef.current, }) } else { - updateMetadataRef.current({ columnWidths: cleanedWidths }) + updateMetadataRef.current({ + columnWidths: cleanedWidths, + pinnedColumns: pinnedColumnsRef.current, + }) } deleteNext(index + 1) @@ -3173,66 +3311,88 @@ 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 ? pinnedOffsets.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 + } + isPinned={firstCol ? pinnedColumnSet.has(firstCol.name) : false} + onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined} + stickyLeft={stickyLeft} + isLastPinned={lastCol?.key === lastPinnedColKey} + /> + ) + } + const isLastFrz = firstCol?.key === lastPinnedColKey + return ( ) - )} + })} {userPermissions.canEdit && ( )} @@ -3243,45 +3403,52 @@ 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 colIsPinned = pinnedColumnSet.has(column.name) + const colStickyLeft = pinnedOffsets.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} + isPinned={colIsPinned} + onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined} + stickyLeft={colStickyLeft} + isLastPinned={column.key === lastPinnedColKey} + /> + ) + })} {userPermissions.canEdit && ( 0 ? pinnedOffsets : undefined} + lastPinnedColKey={lastPinnedColKey} /> ) })} diff --git a/apps/sim/components/emcn/icons/index.ts b/apps/sim/components/emcn/icons/index.ts index 83c087a599e..3d1cb1fdaf6 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 00000000000..0f1bf606275 --- /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 00000000000..0e9fbfec2a0 --- /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 7a5e2db347d..3caee9dcca7 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, + 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 8a364d54691..289fcc01c70 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -31,6 +31,8 @@ interface UseTableUndoProps { onColumnOrderChange?: (order: string[]) => void onColumnRename?: (oldName: string, newName: string) => void onColumnWidthsChange?: (widths: Record) => void + onPinnedColumnsChange?: (pinned: string[]) => void + getPinnedColumns?: () => string[] getColumnWidths?: () => Record } @@ -40,6 +42,8 @@ export function useTableUndo({ onColumnOrderChange, onColumnRename, onColumnWidthsChange, + onPinnedColumnsChange, + getPinnedColumns, getColumnWidths, }: UseTableUndoProps) { const push = useTableUndoStore((s) => s.push) @@ -69,6 +73,10 @@ export function useTableUndo({ onColumnRenameRef.current = onColumnRename const onColumnWidthsChangeRef = useRef(onColumnWidthsChange) onColumnWidthsChangeRef.current = onColumnWidthsChange + const onPinnedColumnsChangeRef = useRef(onPinnedColumnsChange) + onPinnedColumnsChangeRef.current = onPinnedColumnsChange + const getPinnedColumnsRef = useRef(getPinnedColumns) + getPinnedColumnsRef.current = getPinnedColumns const getColumnWidthsRef = useRef(getColumnWidths) getColumnWidthsRef.current = getColumnWidths @@ -206,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 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) } }, }) @@ -273,6 +291,27 @@ export function useTableUndo({ metadata.columnWidths = merged onColumnWidthsChangeRef.current?.(merged) } + if (action.previousPinnedColumns !== null) { + const wasColumnPinned = action.previousPinnedColumns.includes( + action.columnName + ) + if (wasColumnPinned) { + const currentPinned = getPinnedColumnsRef.current?.() ?? [] + if (!currentPinned.includes(action.columnName)) { + const insertIndex = action.previousPinnedColumns.indexOf( + action.columnName + ) + const restoredPinned = [...currentPinned] + restoredPinned.splice( + Math.min(insertIndex, restoredPinned.length), + 0, + action.columnName + ) + onPinnedColumnsChangeRef.current?.(restoredPinned) + metadata.pinnedColumns = restoredPinned + } + } + } if (Object.keys(metadata).length > 0) { updateMetadataMutation.mutate(metadata) } @@ -294,6 +333,14 @@ export function useTableUndo({ metadata.columnWidths = rest onColumnWidthsChangeRef.current?.(rest) } + 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) { updateMetadataMutation.mutate(metadata) } @@ -339,7 +386,21 @@ export function useTableUndo({ } case 'reorder-columns': { - const order = direction === 'undo' ? action.previousOrder : action.newOrder + const restored = direction === 'undo' ? action.previousOrder : action.newOrder + // The user may have pinned/unpinned since the original reorder; + // restoring the raw snapshot can leave a currently-pinned column + // in the middle, which breaks the sticky-offset walk in + // pinnedOffsets and causes the column to jump over its left + // neighbors on scroll. + const pinned = getPinnedColumnsRef.current?.() ?? [] + let order = restored + if (pinned.length > 0) { + const pinnedSet = new Set(pinned) + order = [ + ...restored.filter((n) => pinnedSet.has(n)), + ...restored.filter((n) => !pinnedSet.has(n)), + ] + } onColumnOrderChangeRef.current?.(order) updateMetadataMutation.mutate({ columnOrder: order }) break diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index aadb38f1352..f56c22a1222 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(), + 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 2df30b8f9b4..bef5b8abbde 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, 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. */ + pinnedColumns?: string[] } export interface TableDefinition { diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index 13f9f999c43..68496d3cc81 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 + previousPinnedColumns: string[] | null } | { type: 'rename-column'; oldName: string; newName: string } | {