|
| 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