Skip to content

Commit 7c67bcb

Browse files
committed
feat(tables): freeze columns
1 parent 7ddd90b commit 7c67bcb

6 files changed

Lines changed: 311 additions & 151 deletions

File tree

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export interface DataRowProps {
5757
* queued indicators across page refresh during long Run-all dispatches.
5858
*/
5959
activeDispatches: ActiveDispatch[] | undefined
60+
/** Pixel `left` value for each frozen column key; absent keys are not frozen. */
61+
frozenOffsets?: Map<string, number>
62+
/** Key of the rightmost frozen column, used to render a separator shadow. */
63+
lastFrozenColKey?: string | null
6064
}
6165

6266
function cellRangeRowChanged(
@@ -113,7 +117,9 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
113117
prev.onStopRow !== next.onStopRow ||
114118
prev.onRunRow !== next.onRunRow ||
115119
prev.workflowGroups !== next.workflowGroups ||
116-
prev.activeDispatches !== next.activeDispatches
120+
prev.activeDispatches !== next.activeDispatches ||
121+
prev.frozenOffsets !== next.frozenOffsets ||
122+
prev.lastFrozenColKey !== next.lastFrozenColKey
117123
) {
118124
return false
119125
}
@@ -157,6 +163,8 @@ export const DataRow = React.memo(function DataRow({
157163
onRunRow,
158164
workflowGroups,
159165
activeDispatches,
166+
frozenOffsets,
167+
lastFrozenColKey,
160168
}: DataRowProps) {
161169
const sel = normalizedSelection
162170
/**
@@ -264,13 +272,23 @@ export const DataRow = React.memo(function DataRow({
264272
const isLeftEdge = inRange ? colIndex === sel!.startCol : colIndex === 0
265273
const isRightEdge = inRange ? colIndex === sel!.endCol : colIndex === columns.length - 1
266274

275+
const frozenLeft = frozenOffsets?.get(column.key)
276+
const isFrozenCell = frozenLeft !== undefined
277+
const isFrozenSeparator = column.key === lastFrozenColKey
278+
267279
return (
268280
<td
269281
key={column.key}
270282
data-row={rowIndex}
271283
data-row-id={row.id}
272284
data-col={colIndex}
273-
className={cn(CELL, (isHighlighted || isAnchor || isEditing) && 'relative')}
285+
className={cn(
286+
CELL,
287+
(isHighlighted || isAnchor || isEditing) && 'relative',
288+
isFrozenCell && 'z-[5] bg-[var(--bg)]',
289+
isFrozenSeparator && '[box-shadow:2px_0_0_0_var(--border)]'
290+
)}
291+
style={isFrozenCell ? { position: 'sticky', left: frozenLeft } : undefined}
274292
onMouseDown={(e) => {
275293
if (e.button !== 0 || isEditing) return
276294
onCellMouseDown(rowIndex, colIndex, e.shiftKey)

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
'use client'
22

33
import React, { useCallback, useEffect, useRef, useState } from 'react'
4-
import { ChevronDown } from 'lucide-react'
4+
import { ChevronDown } from '@/components/emcn/icons'
55
import { cn } from '@/lib/core/utils/cn'
6-
import type { ColumnDefinition, WorkflowGroup } from '@/lib/table'
6+
import type { WorkflowGroup } from '@/lib/table'
77
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
88
import { COL_WIDTH, SELECTION_TINT_BG } from '../constants'
99
import type { ColumnSourceInfo, DisplayColumn } from '../types'
@@ -21,7 +21,6 @@ interface ColumnHeaderMenuProps {
2121
onRenameSubmit: () => void
2222
onRenameCancel: () => void
2323
onColumnSelect: (colIndex: number, shiftKey: boolean) => void
24-
onChangeType: (columnName: string, newType: ColumnDefinition['type']) => void
2524
onInsertLeft: (columnName: string) => void
2625
onInsertRight: (columnName: string) => void
2726
onDeleteColumn: (columnName: string) => void
@@ -42,6 +41,14 @@ interface ColumnHeaderMenuProps {
4241
/** Opens a popup preview of the column's underlying workflow. Surfaced in
4342
* the chevron menu for workflow-output columns. */
4443
onViewWorkflow?: (workflowId: string) => void
44+
/** Whether this column is currently frozen (pinned to the left). */
45+
isFrozen?: boolean
46+
/** Toggle the frozen state for this column. */
47+
onFreezeToggle?: (columnName: string) => void
48+
/** Left offset in pixels when frozen (drives `position: sticky`). */
49+
stickyLeft?: number
50+
/** Whether this is the rightmost frozen column (renders a separator shadow). */
51+
isLastFrozen?: boolean
4552
}
4653

4754
/**
@@ -76,6 +83,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
7683
sourceInfo,
7784
onOpenConfig,
7885
onViewWorkflow,
86+
isFrozen,
87+
onFreezeToggle,
88+
stickyLeft,
89+
isLastFrozen,
7990
}: ColumnHeaderMenuProps) {
8091
const renameInputRef = useRef<HTMLInputElement>(null)
8192
const didDragRef = useRef(false)
@@ -228,7 +239,12 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
228239

229240
return (
230241
<th
231-
className='group relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'
242+
className={cn(
243+
'group relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
244+
stickyLeft !== undefined && 'z-[11]',
245+
isLastFrozen && '[box-shadow:2px_0_0_0_var(--border)]'
246+
)}
247+
style={stickyLeft !== undefined ? { position: 'sticky', left: stickyLeft } : undefined}
232248
draggable={!readOnly && !isRenaming}
233249
onDragStart={handleDragStart}
234250
onDragEnd={handleDragEnd}
@@ -316,6 +332,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
316332
onViewWorkflow={
317333
onViewWorkflow && ownGroup ? () => onViewWorkflow(ownGroup.workflowId) : undefined
318334
}
335+
isFrozen={isFrozen}
336+
onFreezeToggle={onFreezeToggle}
319337
/>
320338
</div>
321339
)}

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

Lines changed: 83 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import type React from 'react'
4-
import { useCallback, useRef, useState } from 'react'
4+
import { useRef, useState } from 'react'
55
import {
66
DropdownMenu,
77
DropdownMenuContent,
@@ -17,9 +17,11 @@ import {
1717
ArrowRight,
1818
Eye,
1919
EyeOff,
20+
Lock,
2021
Pencil,
2122
PlayOutline,
2223
Trash,
24+
Unlock,
2325
} from '@/components/emcn/icons'
2426
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
2527
import { cn } from '@/lib/core/utils/cn'
@@ -67,6 +69,10 @@ interface ColumnOptionsMenuProps {
6769
/** When set, the menu surfaces a "View workflow" item that opens a popup
6870
* preview of the configured workflow. */
6971
onViewWorkflow?: () => void
72+
/** Whether this column is currently frozen (pinned to the left). */
73+
isFrozen?: boolean
74+
/** Toggle the frozen state of this column. */
75+
onFreezeToggle?: (columnName: string) => void
7076
}
7177

7278
/**
@@ -93,6 +99,8 @@ export function ColumnOptionsMenu({
9399
onRunColumnSelected,
94100
selectedRowCount = 0,
95101
onViewWorkflow,
102+
isFrozen,
103+
onFreezeToggle,
96104
}: ColumnOptionsMenuProps) {
97105
const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete)
98106
const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0
@@ -159,6 +167,12 @@ export function ColumnOptionsMenu({
159167
<Pencil />
160168
Edit column
161169
</DropdownMenuItem>
170+
{onFreezeToggle && (
171+
<DropdownMenuItem onSelect={() => onFreezeToggle(column.name)}>
172+
{isFrozen ? <Unlock /> : <Lock />}
173+
{isFrozen ? 'Unfreeze column' : 'Freeze column'}
174+
</DropdownMenuItem>
175+
)}
162176
<DropdownMenuSeparator />
163177
<DropdownMenuItem onSelect={() => onInsertLeft(column.name)}>
164178
<ArrowLeft />
@@ -269,112 +283,94 @@ export function WorkflowGroupMetaCell({
269283

270284
const selectedCount = selectedRowIds?.length ?? 0
271285

272-
const handleRunAll = useCallback(() => {
286+
function handleRunAll() {
273287
if (groupId) onRunColumn?.(groupId, 'all')
274-
}, [groupId, onRunColumn])
288+
}
275289

276-
const handleRunIncomplete = useCallback(() => {
290+
function handleRunIncomplete() {
277291
if (groupId) onRunColumn?.(groupId, 'incomplete')
278-
}, [groupId, onRunColumn])
292+
}
279293

280-
const handleRunSelected = useCallback(() => {
294+
function handleRunSelected() {
281295
if (groupId && selectedRowIds && selectedRowIds.length > 0) {
282296
onRunColumn?.(groupId, 'all', selectedRowIds)
283297
}
284-
}, [groupId, onRunColumn, selectedRowIds])
298+
}
285299

286-
const handleRunLimited = useCallback(
287-
(max: number) => {
288-
if (groupId) onRunColumn?.(groupId, 'incomplete', undefined, { type: 'rows', max })
289-
},
290-
[groupId, onRunColumn]
291-
)
300+
function handleRunLimited(max: number) {
301+
if (groupId) onRunColumn?.(groupId, 'incomplete', undefined, { type: 'rows', max })
302+
}
292303

293-
const handleContextMenu = useCallback(
294-
(e: React.MouseEvent) => {
295-
if (!column) return
296-
e.preventDefault()
297-
e.stopPropagation()
298-
setOptionsMenuPosition({ x: e.clientX, y: e.clientY })
299-
setOptionsMenuOpen(true)
300-
},
301-
[column]
302-
)
304+
function handleContextMenu(e: React.MouseEvent) {
305+
if (!column) return
306+
e.preventDefault()
307+
e.stopPropagation()
308+
setOptionsMenuPosition({ x: e.clientX, y: e.clientY })
309+
setOptionsMenuOpen(true)
310+
}
303311

304-
const selectGroupAndOpenConfig = useCallback(
305-
(e: React.MouseEvent<HTMLTableCellElement>) => {
306-
// Ignore clicks that landed on an interactive child (badge, play button,
307-
// dropdown items rendered via portal). Only the bare meta-cell area
308-
// should select the group + open the config sidebar.
309-
const target = e.target as HTMLElement
310-
if (target.closest('button, [role="menuitem"], [role="menu"]')) return
311-
// Drag-vs-click guard: when a drag just ended on this cell, swallow the
312-
// synthetic click so we don't accidentally pop open the sidebar.
313-
if (didDragRef.current) {
314-
didDragRef.current = false
315-
return
316-
}
317-
onSelectGroup(startColIndex, size)
318-
if (columnName) onOpenConfig(columnName)
319-
},
320-
[columnName, onOpenConfig, onSelectGroup, size, startColIndex]
321-
)
312+
function selectGroupAndOpenConfig(e: React.MouseEvent<HTMLTableCellElement>) {
313+
// Ignore clicks that landed on an interactive child (badge, play button,
314+
// dropdown items rendered via portal). Only the bare meta-cell area
315+
// should select the group + open the config sidebar.
316+
const target = e.target as HTMLElement
317+
if (target.closest('button, [role="menuitem"], [role="menu"]')) return
318+
// Drag-vs-click guard: when a drag just ended on this cell, swallow the
319+
// synthetic click so we don't accidentally pop open the sidebar.
320+
if (didDragRef.current) {
321+
didDragRef.current = false
322+
return
323+
}
324+
onSelectGroup(startColIndex, size)
325+
if (columnName) onOpenConfig(columnName)
326+
}
322327

323-
const handleDragStart = useCallback(
324-
(e: React.DragEvent) => {
325-
if (readOnly || !onDragStart || !columnName) {
326-
e.preventDefault()
327-
return
328-
}
329-
didDragRef.current = true
330-
e.dataTransfer.effectAllowed = 'move'
331-
e.dataTransfer.setData('text/plain', columnName)
328+
function handleDragStart(e: React.DragEvent) {
329+
if (readOnly || !onDragStart || !columnName) {
330+
e.preventDefault()
331+
return
332+
}
333+
didDragRef.current = true
334+
e.dataTransfer.effectAllowed = 'move'
335+
e.dataTransfer.setData('text/plain', columnName)
332336

333-
const ghost = document.createElement('div')
334-
ghost.textContent = name
335-
ghost.style.cssText =
336-
'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)'
337-
document.body.appendChild(ghost)
338-
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
339-
requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost))
337+
const ghost = document.createElement('div')
338+
ghost.textContent = name
339+
ghost.style.cssText =
340+
'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)'
341+
document.body.appendChild(ghost)
342+
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
343+
requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost))
340344

341-
onDragStart(columnName)
342-
},
343-
[columnName, name, onDragStart, readOnly]
344-
)
345+
onDragStart(columnName)
346+
}
345347

346-
const handleDragOver = useCallback(
347-
(e: React.DragEvent) => {
348-
if (!onDragOver || !columnName) return
349-
e.preventDefault()
350-
e.dataTransfer.dropEffect = 'move'
351-
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
352-
const midX = rect.left + rect.width / 2
353-
const side = e.clientX < midX ? 'left' : 'right'
354-
onDragOver(columnName, side)
355-
},
356-
[columnName, onDragOver]
357-
)
348+
function handleDragOver(e: React.DragEvent) {
349+
if (!onDragOver || !columnName) return
350+
e.preventDefault()
351+
e.dataTransfer.dropEffect = 'move'
352+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
353+
const midX = rect.left + rect.width / 2
354+
const side = e.clientX < midX ? 'left' : 'right'
355+
onDragOver(columnName, side)
356+
}
358357

359-
const handleDragEnd = useCallback(() => {
358+
function handleDragEnd() {
360359
didDragRef.current = false
361360
onDragEnd?.()
362-
}, [onDragEnd])
361+
}
363362

364-
const handleDragLeave = useCallback(
365-
(e: React.DragEvent) => {
366-
const th = e.currentTarget as HTMLElement
367-
const related = e.relatedTarget as Node | null
368-
if (related && th.contains(related)) return
369-
if (related && related instanceof Element && related.closest('th')) return
370-
onDragLeave?.()
371-
},
372-
[onDragLeave]
373-
)
363+
function handleDragLeave(e: React.DragEvent) {
364+
const th = e.currentTarget as HTMLElement
365+
const related = e.relatedTarget as Node | null
366+
if (related && th.contains(related)) return
367+
if (related && related instanceof Element && related.closest('th')) return
368+
onDragLeave?.()
369+
}
374370

375-
const handleDrop = useCallback((e: React.DragEvent) => {
371+
function handleDrop(e: React.DragEvent) {
376372
e.preventDefault()
377-
}, [])
373+
}
378374

379375
const isDraggable = !readOnly && Boolean(onDragStart)
380376

0 commit comments

Comments
 (0)