Skip to content

Commit a89ee35

Browse files
committed
improvement(tables): cleanup — extract components, stabilize callbacks, fix ref sync
1 parent 0396f3f commit a89ee35

7 files changed

Lines changed: 624 additions & 603 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]'
55
* been measured yet and as the initial width for newly-added columns. */
66
export const COL_WIDTH = 160
77

8+
/** Width of the "add column" placeholder column in pixels. */
9+
export const ADD_COL_WIDTH = 120
10+
811
/** Column config sidebar width in pixels — drives both the sidebar's own width
912
* and the table's reserved padding-right while a sidebar is open. */
1013
export const COLUMN_SIDEBAR_WIDTH = 400
14+
15+
/** Number of skeleton rows shown while the table body is loading. */
16+
export const SKELETON_ROW_COUNT = 10
17+
18+
export const CELL =
19+
'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
20+
export const CELL_CHECKBOX =
21+
'sticky left-0 z-[6] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] align-middle select-none'
22+
export const CELL_HEADER_CHECKBOX =
23+
'sticky left-0 z-[12] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle'
24+
/** Fixed height (not min-) so a Badge-rendered status pill doesn't make the row grow vs a plain-text neighbor. */
25+
export const CELL_CONTENT =
26+
'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small'
27+
export const SELECTION_OVERLAY =
28+
'pointer-events-none absolute -top-px -right-px -bottom-px z-[5] border-[2px] border-[var(--selection)]'
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
'use client'
2+
3+
import React from 'react'
4+
import { Button, Checkbox } from '@/components/emcn'
5+
import { PlayOutline, Square } from '@/components/emcn/icons'
6+
import { cn } from '@/lib/core/utils/cn'
7+
import type { TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
8+
import { getUnmetGroupDeps } from '@/lib/table/deps'
9+
import type { SaveReason } from '../../types'
10+
import { CellContent } from './cells'
11+
import {
12+
CELL,
13+
CELL_CHECKBOX,
14+
CELL_CONTENT,
15+
SELECTION_OVERLAY,
16+
SELECTION_TINT_BG,
17+
} from './constants'
18+
import type { DisplayColumn } from './types'
19+
import { type NormalizedSelection, readExecution } from './utils'
20+
21+
export interface DataRowProps {
22+
row: TableRowType
23+
columns: DisplayColumn[]
24+
rowIndex: number
25+
isFirstRow: boolean
26+
editingColumnName: string | null
27+
initialCharacter: string | null
28+
pendingCellValue: Record<string, unknown> | null
29+
normalizedSelection: NormalizedSelection | null
30+
onClick: (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => void
31+
onDoubleClick: (rowId: string, columnName: string, columnKey: string) => void
32+
onSave: (rowId: string, columnName: string, value: unknown, reason: SaveReason) => void
33+
onCancel: () => void
34+
onContextMenu: (e: React.MouseEvent, row: TableRowType) => void
35+
onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void
36+
onCellMouseEnter: (rowIndex: number, colIndex: number) => void
37+
isRowChecked: boolean
38+
onRowToggle: (rowIndex: number, shiftKey: boolean) => void
39+
/** Number of workflow cells in this row currently in a running/queued state. */
40+
runningCount: number
41+
/** Whether the table has at least one workflow column — controls whether a run/stop icon is rendered. */
42+
hasWorkflowColumns: boolean
43+
/** Width of the row-number inner div in px, derived from the table's maxRows digit count. */
44+
numDivWidth: number
45+
onStopRow: (rowId: string) => void
46+
onRunRow: (rowId: string) => void
47+
/**
48+
* The table's workflow groups, used to compute per-row "Waiting on …" labels
49+
* for empty workflow-output cells whose group has unmet dependencies.
50+
*/
51+
workflowGroups: WorkflowGroup[]
52+
}
53+
54+
function cellRangeRowChanged(
55+
rowIndex: number,
56+
colCount: number,
57+
prev: NormalizedSelection | null,
58+
next: NormalizedSelection | null
59+
): boolean {
60+
const pIn = prev !== null && rowIndex >= prev.startRow && rowIndex <= prev.endRow
61+
const nIn = next !== null && rowIndex >= next.startRow && rowIndex <= next.endRow
62+
const pAnchor = prev !== null && rowIndex === prev.anchorRow
63+
const nAnchor = next !== null && rowIndex === next.anchorRow
64+
65+
if (!pIn && !nIn && !pAnchor && !nAnchor) return false
66+
if (pIn !== nIn || pAnchor !== nAnchor) return true
67+
68+
if (pIn && nIn) {
69+
if (prev!.startCol !== next!.startCol || prev!.endCol !== next!.endCol) return true
70+
if ((rowIndex === prev!.startRow) !== (rowIndex === next!.startRow)) return true
71+
if ((rowIndex === prev!.endRow) !== (rowIndex === next!.endRow)) return true
72+
const pMulti = prev!.startRow !== prev!.endRow || prev!.startCol !== prev!.endCol
73+
const nMulti = next!.startRow !== next!.endRow || next!.startCol !== next!.endCol
74+
if (pMulti !== nMulti) return true
75+
const pFull = prev!.startCol === 0 && prev!.endCol === colCount - 1
76+
const nFull = next!.startCol === 0 && next!.endCol === colCount - 1
77+
if (pFull !== nFull) return true
78+
}
79+
80+
if (pAnchor && nAnchor && prev!.anchorCol !== next!.anchorCol) return true
81+
82+
return false
83+
}
84+
85+
function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
86+
if (
87+
prev.row !== next.row ||
88+
prev.columns !== next.columns ||
89+
prev.rowIndex !== next.rowIndex ||
90+
prev.isFirstRow !== next.isFirstRow ||
91+
prev.editingColumnName !== next.editingColumnName ||
92+
prev.pendingCellValue !== next.pendingCellValue ||
93+
prev.onClick !== next.onClick ||
94+
prev.onDoubleClick !== next.onDoubleClick ||
95+
prev.onSave !== next.onSave ||
96+
prev.onCancel !== next.onCancel ||
97+
prev.onContextMenu !== next.onContextMenu ||
98+
prev.onCellMouseDown !== next.onCellMouseDown ||
99+
prev.onCellMouseEnter !== next.onCellMouseEnter ||
100+
prev.isRowChecked !== next.isRowChecked ||
101+
prev.onRowToggle !== next.onRowToggle ||
102+
prev.runningCount !== next.runningCount ||
103+
prev.hasWorkflowColumns !== next.hasWorkflowColumns ||
104+
prev.numDivWidth !== next.numDivWidth ||
105+
prev.onStopRow !== next.onStopRow ||
106+
prev.onRunRow !== next.onRunRow ||
107+
prev.workflowGroups !== next.workflowGroups
108+
) {
109+
return false
110+
}
111+
if (
112+
(prev.editingColumnName !== null || next.editingColumnName !== null) &&
113+
prev.initialCharacter !== next.initialCharacter
114+
) {
115+
return false
116+
}
117+
118+
return !cellRangeRowChanged(
119+
prev.rowIndex,
120+
prev.columns.length,
121+
prev.normalizedSelection,
122+
next.normalizedSelection
123+
)
124+
}
125+
126+
export const DataRow = React.memo(function DataRow({
127+
row,
128+
columns,
129+
rowIndex,
130+
isFirstRow,
131+
editingColumnName,
132+
initialCharacter,
133+
pendingCellValue,
134+
normalizedSelection,
135+
isRowChecked,
136+
onClick,
137+
onDoubleClick,
138+
onSave,
139+
onCancel,
140+
onContextMenu,
141+
onCellMouseDown,
142+
onCellMouseEnter,
143+
onRowToggle,
144+
runningCount,
145+
hasWorkflowColumns,
146+
numDivWidth,
147+
onStopRow,
148+
onRunRow,
149+
workflowGroups,
150+
}: DataRowProps) {
151+
const sel = normalizedSelection
152+
/**
153+
* Per-row "Waiting on …" labels keyed by group id. A group has labels iff
154+
* at least one of its dependencies is unmet for this row — drives the
155+
* "Waiting" pill rendered by `CellContent` for empty workflow-output cells.
156+
* Computed once per render rather than per cell so all cells in a group
157+
* share the same array reference.
158+
*/
159+
const waitingByGroupId = React.useMemo(() => {
160+
if (workflowGroups.length === 0) return null
161+
const map = new Map<string, string[]>()
162+
for (const group of workflowGroups) {
163+
// autoRun=false groups never fire from the scheduler — there's nothing
164+
// to wait on. The cell stays empty until the user clicks Run manually.
165+
if (group.autoRun === false) continue
166+
const unmet = getUnmetGroupDeps(group, row)
167+
if (unmet.columns.length === 0) continue
168+
map.set(group.id, unmet.columns)
169+
}
170+
return map
171+
}, [workflowGroups, row])
172+
const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol)
173+
const isRowSelected = isRowChecked
174+
175+
return (
176+
<tr onContextMenu={(e) => onContextMenu(e, row)}>
177+
<td className={cn(CELL_CHECKBOX, 'cursor-pointer')}>
178+
<div
179+
className={cn(
180+
'flex items-center gap-1',
181+
hasWorkflowColumns ? 'justify-between' : 'justify-center'
182+
)}
183+
>
184+
<div
185+
className='group/checkbox flex h-[20px] shrink-0 items-center justify-center'
186+
style={{ width: numDivWidth }}
187+
onMouseDown={(e) => {
188+
if (e.button !== 0) return
189+
onRowToggle(rowIndex, e.shiftKey)
190+
}}
191+
>
192+
<span
193+
className={cn(
194+
'text-center text-[var(--text-tertiary)] text-xs tabular-nums',
195+
isRowSelected ? 'hidden' : 'block group-hover/checkbox:hidden'
196+
)}
197+
>
198+
{rowIndex + 1}
199+
</span>
200+
<div
201+
className={cn(
202+
'items-center justify-end',
203+
isRowSelected ? 'flex' : 'hidden group-hover/checkbox:flex'
204+
)}
205+
>
206+
<Checkbox size='sm' checked={isRowSelected} className='pointer-events-none' />
207+
</div>
208+
</div>
209+
{hasWorkflowColumns && (
210+
<Button
211+
type='button'
212+
variant='ghost'
213+
size='sm'
214+
aria-label={runningCount > 0 ? `Stop ${runningCount} running` : 'Run row'}
215+
title={runningCount > 0 ? `Stop ${runningCount} running` : 'Run row'}
216+
// mr-px keeps the hover bg off the cell's right border — without
217+
// it the rounded-rect background paints over the divider line
218+
// while the button is hovered.
219+
className='mr-px h-[20px] w-[20px] shrink-0 px-0 py-0 text-[var(--text-primary)] hover-hover:bg-[var(--surface-2)]'
220+
onClick={() => {
221+
if (runningCount > 0) {
222+
onStopRow(row.id)
223+
} else {
224+
onRunRow(row.id)
225+
}
226+
}}
227+
>
228+
{runningCount > 0 ? (
229+
<Square className='h-[12px] w-[12px]' />
230+
) : (
231+
<PlayOutline className='h-[12px] w-[12px]' />
232+
)}
233+
</Button>
234+
)}
235+
</div>
236+
</td>
237+
{columns.map((column, colIndex) => {
238+
const inRange =
239+
sel !== null &&
240+
rowIndex >= sel.startRow &&
241+
rowIndex <= sel.endRow &&
242+
colIndex >= sel.startCol &&
243+
colIndex <= sel.endCol
244+
const isAnchor = sel !== null && rowIndex === sel.anchorRow && colIndex === sel.anchorCol
245+
const isEditing = editingColumnName === column.name
246+
const isHighlighted = inRange || isRowChecked
247+
248+
const isTopEdge = inRange ? rowIndex === sel!.startRow : isRowChecked
249+
const isBottomEdge = inRange ? rowIndex === sel!.endRow : isRowChecked
250+
const isLeftEdge = inRange ? colIndex === sel!.startCol : colIndex === 0
251+
const isRightEdge = inRange ? colIndex === sel!.endCol : colIndex === columns.length - 1
252+
253+
return (
254+
<td
255+
key={column.key}
256+
data-row={rowIndex}
257+
data-row-id={row.id}
258+
data-col={colIndex}
259+
className={cn(CELL, (isHighlighted || isAnchor || isEditing) && 'relative')}
260+
onMouseDown={(e) => {
261+
if (e.button !== 0 || isEditing) return
262+
onCellMouseDown(rowIndex, colIndex, e.shiftKey)
263+
}}
264+
onMouseEnter={() => onCellMouseEnter(rowIndex, colIndex)}
265+
onClick={(e) =>
266+
onClick(row.id, column.name, {
267+
toggleBoolean:
268+
!e.shiftKey &&
269+
Boolean((e.target as HTMLElement).closest('[data-boolean-cell-toggle]')),
270+
})
271+
}
272+
onDoubleClick={() => onDoubleClick(row.id, column.name, column.key)}
273+
>
274+
{isHighlighted && (isMultiCell || isRowChecked) && (
275+
<div
276+
className={cn(
277+
'-top-px -right-px -bottom-px pointer-events-none absolute z-[4]',
278+
colIndex === 0 ? 'left-0' : '-left-px',
279+
SELECTION_TINT_BG,
280+
isFirstRow && isTopEdge && 'top-0',
281+
isTopEdge && 'border-t border-t-[var(--selection)]',
282+
isBottomEdge && 'border-b border-b-[var(--selection)]',
283+
isLeftEdge && 'border-l border-l-[var(--selection)]',
284+
isRightEdge && 'border-r border-r-[var(--selection)]'
285+
)}
286+
/>
287+
)}
288+
{isAnchor && (
289+
<div
290+
className={cn(
291+
SELECTION_OVERLAY,
292+
colIndex === 0 ? 'left-0' : '-left-px',
293+
isFirstRow && 'top-0'
294+
)}
295+
/>
296+
)}
297+
<div className={CELL_CONTENT}>
298+
<CellContent
299+
value={
300+
pendingCellValue && column.name in pendingCellValue
301+
? pendingCellValue[column.name]
302+
: row.data[column.name]
303+
}
304+
exec={readExecution(row, column.workflowGroupId)}
305+
column={column}
306+
isEditing={isEditing}
307+
initialCharacter={isEditing ? initialCharacter : undefined}
308+
onSave={(value, reason) => onSave(row.id, column.name, value, reason)}
309+
onCancel={onCancel}
310+
waitingOnLabels={
311+
column.workflowGroupId
312+
? (waitingByGroupId?.get(column.workflowGroupId) ?? undefined)
313+
: undefined
314+
}
315+
/>
316+
</div>
317+
</td>
318+
)
319+
})}
320+
</tr>
321+
)
322+
}, dataRowPropsAreEqual)

0 commit comments

Comments
 (0)