diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json index 99b3b82e7e8..41d47a929cc 100644 --- a/apps/docs/content/docs/en/meta.json +++ b/apps/docs/content/docs/en/meta.json @@ -13,6 +13,7 @@ "mailer", "skills", "knowledgebase", + "tables", "variables", "credentials", "execution", diff --git a/apps/docs/content/docs/en/tables/index.mdx b/apps/docs/content/docs/en/tables/index.mdx new file mode 100644 index 00000000000..3ba1f7b515d --- /dev/null +++ b/apps/docs/content/docs/en/tables/index.mdx @@ -0,0 +1,158 @@ +--- +title: Tables +description: Store, query, and manage structured data directly within your workspace +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Image } from '@/components/ui/image' +import { FAQ } from '@/components/ui/faq' + +Tables let you store and manage structured data directly in your workspace. Use them to maintain reference data, collect workflow outputs, or build lightweight databases — all without leaving Sim. + +Tables view showing structured data with typed columns for name, title, company, role, and more + +Each table has a schema of typed columns, supports filtering and sorting, and is fully accessible through the [Tables API](/docs/en/api-reference/(generated)/tables). + +## Creating a Table + +1. Open the **Tables** section from your workspace sidebar +2. Click **New table** +3. Name your table and start adding columns + +Tables start with a single text column. Add more columns by clicking **New column** in the column header area. + +## Column Types + +Each column has a type that determines how values are stored and validated. + +| Type | Description | Example Values | +|------|-------------|----------------| +| **Text** | Free-form string | `"Acme Corp"`, `"hello@example.com"` | +| **Number** | Numeric value | `42`, `3.14`, `-100` | +| **Boolean** | True or false | `true`, `false` | +| **Date** | Date value | `2026-03-16` | +| **JSON** | Structured object or array | `{"key": "value"}`, `[1, 2, 3]` | + + +Column types are enforced on input. For example, typing into a Number column is restricted to digits, dots, and minus signs. Non-numeric values entered via paste are coerced to `0`. + + +## Working with Rows + +### Adding Rows + +- Click **New row** below the last row to append a new row +- Press **Shift + Enter** while a cell is selected to insert a row below +- Paste tabular data (from a spreadsheet or TSV) to bulk-create rows + +### Editing Cells + +Click a cell to select it, then press **Enter**, **F2**, or start typing to edit. Press **Escape** to cancel, or **Tab** to save and move to the next cell. + +### Selecting Rows + +Click a row's checkbox to select it. Selecting additional checkboxes adds to the selection without clearing previous selections. + +| Action | Behavior | +|--------|----------| +| Click checkbox | Toggle that row's selection | +| Shift + click checkbox | Select range from last clicked to current | +| Click header checkbox | Select all / deselect all | +| Shift + Space | Toggle row selection from keyboard | + +### Deleting Rows + +Right-click a selected row (or group of selected rows) and choose **Delete row** from the context menu. + +## Filtering and Sorting + +Use the toolbar above the table to filter and sort your data. + +- **Filter**: Set conditions on any column (e.g., "Name contains Acme"). Multiple filters are combined with AND logic. +- **Sort**: Order rows by any column, ascending or descending. + +Filters and sorts are applied in real time and do not modify the underlying data. + +## Keyboard Shortcuts + +All shortcuts work when the table is focused and no cell is being edited. + + +**Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux. + + +### Navigation + +| Shortcut | Action | +|----------|--------| +| Arrow keys | Move one cell | +| `Mod` + Arrow keys | Jump to edge of table | +| `Tab` / `Shift` + `Tab` | Move to next / previous cell | +| `Escape` | Clear selection | + +### Selection + +| Shortcut | Action | +|----------|--------| +| `Shift` + Arrow keys | Extend selection by one cell | +| `Mod` + `Shift` + Arrow keys | Extend selection to edge | +| `Mod` + `A` | Select all rows | +| `Shift` + `Space` | Toggle current row selection | + +### Editing + +| Shortcut | Action | +|----------|--------| +| `Enter` or `F2` | Start editing selected cell | +| `Escape` | Cancel editing | +| Type any character | Start editing with that character | +| `Shift` + `Enter` | Insert new row below | +| `Space` | Expand row details | + +### Clipboard + +| Shortcut | Action | +|----------|--------| +| `Mod` + `C` | Copy selected cells | +| `Mod` + `X` | Cut selected cells | +| `Mod` + `V` | Paste | +| `Delete` / `Backspace` | Clear selected cells (all columns when using checkbox selection) | + +### History + +| Shortcut | Action | +|----------|--------| +| `Mod` + `Z` | Undo | +| `Mod` + `Shift` + `Z` | Redo | +| `Mod` + `Y` | Redo (alternative) | + +## Using Tables in Workflows + +Tables can be read from and written to within your workflows using the **Table** block. Common patterns include: + +- **Lookup**: Query a table for reference data (e.g., pricing rules, customer metadata) +- **Write-back**: Store workflow outputs in a table for later review or reporting +- **Iteration**: Process each row in a table as part of a batch workflow + +## API Access + +Tables are fully accessible through the REST API. You can create, read, update, and delete both tables and rows programmatically. + +See the [Tables API Reference](/docs/en/api-reference/(generated)/tables) for endpoints, parameters, and examples. + +## Best Practices + +- **Use typed columns** to enforce data integrity — prefer Number and Boolean over storing everything as Text +- **Name columns descriptively** so they are self-documenting when referenced in workflows +- **Use JSON columns sparingly** — they are flexible but harder to filter and sort against +- **Leverage the API** for bulk imports rather than manually entering large datasets + + diff --git a/apps/docs/public/static/tables/tables-overview.png b/apps/docs/public/static/tables/tables-overview.png new file mode 100644 index 00000000000..418510e3f79 Binary files /dev/null and b/apps/docs/public/static/tables/tables-overview.png differ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index db4aac84403..e6ce9cb47f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -84,6 +84,7 @@ interface NormalizedSelection { } const EMPTY_COLUMNS: never[] = [] +const EMPTY_CHECKED_ROWS = new Set() const COL_WIDTH = 160 const COL_WIDTH_MIN = 80 const CHECKBOX_COL_WIDTH = 40 @@ -146,6 +147,20 @@ function computeNormalizedSelection( } } +function collectRowSnapshots( + positions: Iterable, + positionMap: Map +): DeletedRowSnapshot[] { + const snapshots: DeletedRowSnapshot[] = [] + for (const pos of positions) { + const row = positionMap.get(pos) + if (row) { + snapshots.push({ rowId: row.id, data: { ...row.data }, position: row.position }) + } + } + return snapshots +} + interface TableProps { workspaceId?: string tableId?: string @@ -172,6 +187,8 @@ export function Table({ const [initialCharacter, setInitialCharacter] = useState(null) const [selectionAnchor, setSelectionAnchor] = useState(null) const [selectionFocus, setSelectionFocus] = useState(null) + const [checkedRows, setCheckedRows] = useState(EMPTY_CHECKED_ROWS) + const lastCheckboxRowRef = useRef(null) const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) const [deletingColumn, setDeletingColumn] = useState(null) @@ -256,13 +273,22 @@ export function Table({ return 0 }, [resizingColumn, columns, columnWidths]) - const isAllRowsSelected = - normalizedSelection !== null && - maxPosition >= 0 && - normalizedSelection.startRow === 0 && - normalizedSelection.endRow === maxPosition && - normalizedSelection.startCol === 0 && - normalizedSelection.endCol === columns.length - 1 + const isAllRowsSelected = useMemo(() => { + if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) { + for (const row of rows) { + if (!checkedRows.has(row.position)) return false + } + return true + } + return ( + normalizedSelection !== null && + maxPosition >= 0 && + normalizedSelection.startRow === 0 && + normalizedSelection.endRow === maxPosition && + normalizedSelection.startCol === 0 && + normalizedSelection.endCol === columns.length - 1 + ) + }, [checkedRows, normalizedSelection, maxPosition, columns.length, rows]) const isAllRowsSelectedRef = useRef(isAllRowsSelected) isAllRowsSelectedRef.current = isAllRowsSelected @@ -272,6 +298,9 @@ export function Table({ const selectionAnchorRef = useRef(selectionAnchor) const selectionFocusRef = useRef(selectionFocus) + const checkedRowsRef = useRef(checkedRows) + checkedRowsRef.current = checkedRows + columnsRef.current = columns rowsRef.current = rows selectionAnchorRef.current = selectionAnchor @@ -357,32 +386,38 @@ export function Table({ return } - const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current) - const isInSelection = - sel !== null && - contextMenu.row.position >= sel.startRow && - contextMenu.row.position <= sel.endRow + const checked = checkedRowsRef.current + const pMap = positionMapRef.current + let snapshots: DeletedRowSnapshot[] = [] - if (isInSelection && sel) { - const pMap = positionMapRef.current - const snapshots: DeletedRowSnapshot[] = [] - for (let r = sel.startRow; r <= sel.endRow; r++) { - const row = pMap.get(r) - if (row) { - snapshots.push({ rowId: row.id, data: { ...row.data }, position: row.position }) - } - } - if (snapshots.length > 0) { - setDeletingRows(snapshots) - } + if (checked.size > 0 && checked.has(contextMenu.row.position)) { + snapshots = collectRowSnapshots(checked, pMap) } else { - setDeletingRows([ - { - rowId: contextMenu.row.id, - data: { ...contextMenu.row.data }, - position: contextMenu.row.position, - }, - ]) + const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current) + const isInSelection = + sel !== null && + contextMenu.row.position >= sel.startRow && + contextMenu.row.position <= sel.endRow + + if (isInSelection && sel) { + const positions = Array.from( + { length: sel.endRow - sel.startRow + 1 }, + (_, i) => sel.startRow + i + ) + snapshots = collectRowSnapshots(positions, pMap) + } else { + snapshots = [ + { + rowId: contextMenu.row.id, + data: { ...contextMenu.row.data }, + position: contextMenu.row.position, + }, + ] + } + } + + if (snapshots.length > 0) { + setDeletingRows(snapshots) } closeContextMenu() @@ -477,6 +512,8 @@ export function Table({ const handleCellMouseDown = useCallback( (rowIndex: number, colIndex: number, shiftKey: boolean) => { + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + lastCheckboxRowRef.current = null if (shiftKey && selectionAnchorRef.current) { setSelectionFocus({ rowIndex, colIndex }) } else { @@ -494,51 +531,55 @@ export function Table({ setSelectionFocus({ rowIndex, colIndex }) }, []) - const handleRowMouseDown = useCallback((rowIndex: number, shiftKey: boolean) => { - const lastCol = columnsRef.current.length - 1 - if (lastCol < 0) return - + const handleRowToggle = useCallback((rowIndex: number, shiftKey: boolean) => { setEditingCell(null) + setSelectionAnchor(null) + setSelectionFocus(null) - if (shiftKey && selectionAnchorRef.current) { - setSelectionAnchor((prev) => (prev ? { rowIndex: prev.rowIndex, colIndex: 0 } : prev)) - setSelectionFocus({ rowIndex, colIndex: lastCol }) + if (shiftKey && lastCheckboxRowRef.current !== null) { + const from = Math.min(lastCheckboxRowRef.current, rowIndex) + const to = Math.max(lastCheckboxRowRef.current, rowIndex) + const pMap = positionMapRef.current + setCheckedRows((prev) => { + const next = new Set(prev) + for (const [pos] of pMap) { + if (pos >= from && pos <= to) next.add(pos) + } + return next + }) } else { - setSelectionAnchor({ rowIndex, colIndex: 0 }) - setSelectionFocus({ rowIndex, colIndex: lastCol }) + setCheckedRows((prev) => { + const next = new Set(prev) + if (next.has(rowIndex)) { + next.delete(rowIndex) + } else { + next.add(rowIndex) + } + return next + }) } - isDraggingRef.current = true - scrollRef.current?.focus({ preventScroll: true }) - }, []) - - const handleRowMouseEnter = useCallback((rowIndex: number) => { - if (!isDraggingRef.current) return - const lastCol = columnsRef.current.length - 1 - if (lastCol < 0) return - setSelectionFocus({ rowIndex, colIndex: lastCol }) - }, []) - - const handleRowSelect = useCallback((rowIndex: number) => { - const lastCol = columnsRef.current.length - 1 - if (lastCol < 0) return - setEditingCell(null) - setSelectionAnchor({ rowIndex, colIndex: 0 }) - setSelectionFocus({ rowIndex, colIndex: lastCol }) + lastCheckboxRowRef.current = rowIndex scrollRef.current?.focus({ preventScroll: true }) }, []) const handleClearSelection = useCallback(() => { setSelectionAnchor(null) setSelectionFocus(null) + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + lastCheckboxRowRef.current = null }, []) const handleSelectAllRows = useCallback(() => { - const lastRow = maxPositionRef.current - const lastCol = columnsRef.current.length - 1 - if (lastRow < 0 || lastCol < 0) return + const rws = rowsRef.current + if (rws.length === 0) return setEditingCell(null) - setSelectionAnchor({ rowIndex: 0, colIndex: 0 }) - setSelectionFocus({ rowIndex: lastRow, colIndex: lastCol }) + setSelectionAnchor(null) + setSelectionFocus(null) + const all = new Set() + for (const row of rws) { + all.add(row.position) + } + setCheckedRows(all) scrollRef.current?.focus({ preventScroll: true }) }, []) @@ -643,9 +684,9 @@ export function Table({ const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return - if ((e.metaKey || e.ctrlKey) && e.key === 'z') { + if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'y')) { e.preventDefault() - if (e.shiftKey) { + if (e.key === 'y' || e.shiftKey) { redoRef.current() } else { undoRef.current() @@ -653,20 +694,81 @@ export function Table({ return } - const anchor = selectionAnchorRef.current - if (!anchor || editingCellRef.current) return - - const cols = columnsRef.current - const mp = maxPositionRef.current - const totalRows = mp + 1 - if (e.key === 'Escape') { e.preventDefault() setSelectionAnchor(null) setSelectionFocus(null) + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + lastCheckboxRowRef.current = null + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'a') { + e.preventDefault() + const rws = rowsRef.current + if (rws.length > 0) { + setEditingCell(null) + setSelectionAnchor(null) + setSelectionFocus(null) + const all = new Set() + for (const row of rws) { + all.add(row.position) + } + setCheckedRows(all) + } + return + } + + if (e.key === ' ' && e.shiftKey) { + const a = selectionAnchorRef.current + if (!a || editingCellRef.current) return + e.preventDefault() + setSelectionFocus(null) + setCheckedRows((prev) => { + const next = new Set(prev) + if (next.has(a.rowIndex)) { + next.delete(a.rowIndex) + } else { + next.add(a.rowIndex) + } + return next + }) + lastCheckboxRowRef.current = a.rowIndex + return + } + + if ((e.key === 'Delete' || e.key === 'Backspace') && checkedRowsRef.current.size > 0) { + if (editingCellRef.current) return + e.preventDefault() + const checked = checkedRowsRef.current + const pMap = positionMapRef.current + const currentCols = columnsRef.current + const undoCells: Array<{ rowId: string; data: Record }> = [] + for (const pos of checked) { + const row = pMap.get(pos) + if (!row) continue + const updates: Record = {} + const previousData: Record = {} + for (const col of currentCols) { + previousData[col.name] = row.data[col.name] ?? null + updates[col.name] = null + } + undoCells.push({ rowId: row.id, data: previousData }) + mutateRef.current({ rowId: row.id, data: updates }) + } + if (undoCells.length > 0) { + pushUndoRef.current({ type: 'clear-cells', cells: undoCells }) + } return } + const anchor = selectionAnchorRef.current + if (!anchor || editingCellRef.current) return + + const cols = columnsRef.current + const mp = maxPositionRef.current + const totalRows = mp + 1 + if (e.shiftKey && e.key === 'Enter') { const row = positionMapRef.current.get(anchor.rowIndex) if (!row) return @@ -706,41 +808,46 @@ export function Table({ return } - if (e.key === 'Tab') { + if (e.key === ' ' && !e.shiftKey) { e.preventDefault() - setSelectionAnchor(moveCell(anchor, cols.length, totalRows, e.shiftKey ? -1 : 1)) - setSelectionFocus(null) + const row = positionMapRef.current.get(anchor.rowIndex) + if (row) { + setEditingRow(row) + } return } - if ((e.metaKey || e.ctrlKey) && e.key === 'a') { + if (e.key === 'Tab') { e.preventDefault() - if (mp >= 0 && cols.length > 0) { - setSelectionAnchor({ rowIndex: 0, colIndex: 0 }) - setSelectionFocus({ rowIndex: mp, colIndex: cols.length - 1 }) - } + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + lastCheckboxRowRef.current = null + setSelectionAnchor(moveCell(anchor, cols.length, totalRows, e.shiftKey ? -1 : 1)) + setSelectionFocus(null) return } if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault() + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + lastCheckboxRowRef.current = null const focus = selectionFocusRef.current ?? anchor const origin = e.shiftKey ? focus : anchor + const jump = e.metaKey || e.ctrlKey let newRow = origin.rowIndex let newCol = origin.colIndex switch (e.key) { case 'ArrowUp': - newRow = Math.max(0, newRow - 1) + newRow = jump ? 0 : Math.max(0, newRow - 1) break case 'ArrowDown': - newRow = Math.min(totalRows - 1, newRow + 1) + newRow = jump ? totalRows - 1 : Math.min(totalRows - 1, newRow + 1) break case 'ArrowLeft': - newCol = Math.max(0, newCol - 1) + newCol = jump ? 0 : Math.max(0, newCol - 1) break case 'ArrowRight': - newCol = Math.min(cols.length - 1, newCol + 1) + newCol = jump ? cols.length - 1 : Math.min(cols.length - 1, newCol + 1) break } @@ -757,7 +864,6 @@ export function Table({ e.preventDefault() const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) if (!sel) return - const pMap = positionMapRef.current const undoCells: Array<{ rowId: string; data: Record }> = [] for (let r = sel.startRow; r <= sel.endRow; r++) { @@ -799,16 +905,37 @@ export function Table({ const handleCopy = (e: ClipboardEvent) => { const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (editingCellRef.current) return + + const checked = checkedRowsRef.current + const cols = columnsRef.current + const pMap = positionMapRef.current + + if (checked.size > 0) { + e.preventDefault() + const sorted = Array.from(checked).sort((a, b) => a - b) + const lines: string[] = [] + for (const pos of sorted) { + const row = pMap.get(pos) + if (!row) continue + const cells: string[] = cols.map((col) => { + const value: unknown = row.data[col.name] + if (value === null || value === undefined) return '' + return typeof value === 'object' ? JSON.stringify(value) : String(value) + }) + lines.push(cells.join('\t')) + } + e.clipboardData?.setData('text/plain', lines.join('\n')) + return + } const anchor = selectionAnchorRef.current - if (!anchor || editingCellRef.current) return + if (!anchor) return const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) if (!sel) return e.preventDefault() - const cols = columnsRef.current - const pMap = positionMapRef.current const lines: string[] = [] for (let r = sel.startRow; r <= sel.endRow; r++) { const cells: string[] = [] @@ -826,6 +953,79 @@ export function Table({ e.clipboardData?.setData('text/plain', lines.join('\n')) } + const handleCut = (e: ClipboardEvent) => { + const tag = (e.target as HTMLElement).tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (editingCellRef.current) return + + const checked = checkedRowsRef.current + const cols = columnsRef.current + const pMap = positionMapRef.current + const undoCells: Array<{ rowId: string; data: Record }> = [] + + if (checked.size > 0) { + e.preventDefault() + const sorted = Array.from(checked).sort((a, b) => a - b) + const lines: string[] = [] + for (const pos of sorted) { + const row = pMap.get(pos) + if (!row) continue + const cells: string[] = cols.map((col) => { + const value: unknown = row.data[col.name] + if (value === null || value === undefined) return '' + return typeof value === 'object' ? JSON.stringify(value) : String(value) + }) + lines.push(cells.join('\t')) + const updates: Record = {} + const previousData: Record = {} + for (const col of cols) { + previousData[col.name] = row.data[col.name] ?? null + updates[col.name] = null + } + undoCells.push({ rowId: row.id, data: previousData }) + mutateRef.current({ rowId: row.id, data: updates }) + } + e.clipboardData?.setData('text/plain', lines.join('\n')) + } else { + const anchor = selectionAnchorRef.current + if (!anchor) return + + const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) + if (!sel) return + + e.preventDefault() + const lines: string[] = [] + for (let r = sel.startRow; r <= sel.endRow; r++) { + const row = pMap.get(r) + if (!row) continue + const cells: string[] = [] + const updates: Record = {} + const previousData: Record = {} + for (let c = sel.startCol; c <= sel.endCol; c++) { + if (c < cols.length) { + const colName = cols[c].name + const value: unknown = row.data[colName] + if (value === null || value === undefined) { + cells.push('') + } else { + cells.push(typeof value === 'object' ? JSON.stringify(value) : String(value)) + } + previousData[colName] = row.data[colName] ?? null + updates[colName] = null + } + } + lines.push(cells.join('\t')) + undoCells.push({ rowId: row.id, data: previousData }) + mutateRef.current({ rowId: row.id, data: updates }) + } + e.clipboardData?.setData('text/plain', lines.join('\n')) + } + + if (undoCells.length > 0) { + pushUndoRef.current({ type: 'clear-cells', cells: undoCells }) + } + } + const handlePaste = (e: ClipboardEvent) => { const tag = (e.target as HTMLElement).tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return @@ -934,10 +1134,12 @@ export function Table({ el.addEventListener('keydown', handleKeyDown) el.addEventListener('copy', handleCopy) + el.addEventListener('cut', handleCut) el.addEventListener('paste', handlePaste) return () => { el.removeEventListener('keydown', handleKeyDown) el.removeEventListener('copy', handleCopy) + el.removeEventListener('cut', handleCut) el.removeEventListener('paste', handlePaste) } }, []) @@ -1213,6 +1415,15 @@ export function Table({ const selectedRowCount = useMemo(() => { if (!contextMenu.isOpen || !contextMenu.row) return 1 + + if (checkedRows.size > 0 && checkedRows.has(contextMenu.row.position)) { + let count = 0 + for (const pos of checkedRows) { + if (positionMap.has(pos)) count++ + } + return Math.max(count, 1) + } + const sel = normalizedSelection if (!sel) return 1 @@ -1226,7 +1437,7 @@ export function Table({ if (positionMap.has(r)) count++ } return Math.max(count, 1) - }, [contextMenu.isOpen, contextMenu.row, normalizedSelection, positionMap]) + }, [contextMenu.isOpen, contextMenu.row, checkedRows, normalizedSelection, positionMap]) const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null @@ -1353,11 +1564,11 @@ export function Table({ startPosition={prevPosition + 1} columns={columns} normalizedSelection={normalizedSelection} + checkedRows={checkedRows} firstRowUnderHeader={prevPosition === -1} onCellMouseDown={handleCellMouseDown} onCellMouseEnter={handleCellMouseEnter} - onRowMouseDown={handleRowMouseDown} - onRowMouseEnter={handleRowMouseEnter} + onRowToggle={handleRowToggle} /> )} ) @@ -1517,109 +1726,143 @@ interface PositionGapRowsProps { startPosition: number columns: ColumnDefinition[] normalizedSelection: NormalizedSelection | null + checkedRows: Set firstRowUnderHeader?: boolean onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void onCellMouseEnter: (rowIndex: number, colIndex: number) => void - onRowMouseDown: (rowIndex: number, shiftKey: boolean) => void - onRowMouseEnter: (rowIndex: number) => void + onRowToggle: (rowIndex: number, shiftKey: boolean) => void } -const PositionGapRows = React.memo(function PositionGapRows({ - count, - startPosition, - columns, - normalizedSelection, - firstRowUnderHeader = false, - onCellMouseDown, - onCellMouseEnter, - onRowMouseDown, - onRowMouseEnter, -}: PositionGapRowsProps) { - const capped = Math.min(count, GAP_ROW_LIMIT) - const sel = normalizedSelection - const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol) +const PositionGapRows = React.memo( + function PositionGapRows({ + count, + startPosition, + columns, + normalizedSelection, + checkedRows, + firstRowUnderHeader = false, + onCellMouseDown, + onCellMouseEnter, + onRowToggle, + }: PositionGapRowsProps) { + const capped = Math.min(count, GAP_ROW_LIMIT) + const sel = normalizedSelection + const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol) - return ( - <> - {Array.from({ length: capped }).map((_, i) => { - const position = startPosition + i - return ( - - { - if (e.button !== 0) return - onRowMouseDown(position, e.shiftKey) - }} - onMouseEnter={() => onRowMouseEnter(position)} - > - - {position + 1} - -
- -
- - {columns.map((col, colIndex) => { - const inRange = - sel !== null && - position >= sel.startRow && - position <= sel.endRow && - colIndex >= sel.startCol && - colIndex <= sel.endCol - const isAnchor = - sel !== null && position === sel.anchorRow && colIndex === sel.anchorCol - - const isTopEdge = inRange && position === sel!.startRow - const isBottomEdge = inRange && position === sel!.endRow - const isLeftEdge = inRange && colIndex === sel!.startCol - const isRightEdge = inRange && colIndex === sel!.endCol - const belowHeader = firstRowUnderHeader && i === 0 - - return ( - { - if (e.button !== 0) return - onCellMouseDown(position, colIndex, e.shiftKey) - }} - onMouseEnter={() => onCellMouseEnter(position, colIndex)} + return ( + <> + {Array.from({ length: capped }).map((_, i) => { + const position = startPosition + i + const isGapChecked = checkedRows.has(position) + return ( + + { + if (e.button !== 0) return + onRowToggle(position, e.shiftKey) + }} + > + - {inRange && isMultiCell && ( -
+ {position + 1} + +
} -
- - ) - })} + > + +
+ + {columns.map((col, colIndex) => { + const inRange = + sel !== null && + position >= sel.startRow && + position <= sel.endRow && + colIndex >= sel.startCol && + colIndex <= sel.endCol + const isAnchor = + sel !== null && position === sel.anchorRow && colIndex === sel.anchorCol + const isHighlighted = inRange || isGapChecked + + const isTopEdge = inRange ? position === sel!.startRow : isGapChecked + const isBottomEdge = inRange ? position === sel!.endRow : isGapChecked + const isLeftEdge = inRange ? colIndex === sel!.startCol : colIndex === 0 + const isRightEdge = inRange + ? colIndex === sel!.endCol + : colIndex === columns.length - 1 + const belowHeader = firstRowUnderHeader && i === 0 + + return ( + { + if (e.button !== 0) return + onCellMouseDown(position, colIndex, e.shiftKey) + }} + onMouseEnter={() => onCellMouseEnter(position, colIndex)} + > + {isHighlighted && (isMultiCell || isGapChecked) && ( +
+ )} + {isAnchor &&
} +
+ + ) + })} + + ) + })} + {count > GAP_ROW_LIMIT && ( + + - ) - })} - {count > GAP_ROW_LIMIT && ( - - - - )} - - ) -}) + )} + + ) + }, + (prev, next) => { + if ( + prev.count !== next.count || + prev.startPosition !== next.startPosition || + prev.columns !== next.columns || + prev.normalizedSelection !== next.normalizedSelection || + prev.firstRowUnderHeader !== next.firstRowUnderHeader || + prev.onCellMouseDown !== next.onCellMouseDown || + prev.onCellMouseEnter !== next.onCellMouseEnter || + prev.onRowToggle !== next.onRowToggle + ) { + return false + } + const end = prev.startPosition + Math.min(prev.count, GAP_ROW_LIMIT) + for (let p = prev.startPosition; p < end; p++) { + if (prev.checkedRows.has(p) !== next.checkedRows.has(p)) return false + } + return true + } +) const TableColGroup = React.memo(function TableColGroup({ columns, @@ -1655,10 +1898,8 @@ interface DataRowProps { onContextMenu: (e: React.MouseEvent, row: TableRowType) => void onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void onCellMouseEnter: (rowIndex: number, colIndex: number) => void - onRowMouseDown: (rowIndex: number, shiftKey: boolean) => void - onRowMouseEnter: (rowIndex: number) => void - onRowSelect: (rowIndex: number) => void - onClearSelection: () => void + isRowChecked: boolean + onRowToggle: (rowIndex: number, shiftKey: boolean) => void } function rowSelectionChanged( @@ -1707,10 +1948,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.onContextMenu !== next.onContextMenu || prev.onCellMouseDown !== next.onCellMouseDown || prev.onCellMouseEnter !== next.onCellMouseEnter || - prev.onRowMouseDown !== next.onRowMouseDown || - prev.onRowMouseEnter !== next.onRowMouseEnter || - prev.onRowSelect !== next.onRowSelect || - prev.onClearSelection !== next.onClearSelection + prev.isRowChecked !== next.isRowChecked || + prev.onRowToggle !== next.onRowToggle ) { return false } @@ -1738,6 +1977,7 @@ const DataRow = React.memo(function DataRow({ initialCharacter, pendingCellValue, normalizedSelection, + isRowChecked, onClick, onDoubleClick, onSave, @@ -1745,29 +1985,26 @@ const DataRow = React.memo(function DataRow({ onContextMenu, onCellMouseDown, onCellMouseEnter, - onRowMouseDown, - onRowMouseEnter, - onRowSelect, - onClearSelection, + onRowToggle, }: DataRowProps) { const sel = normalizedSelection const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol) - const isRowSelected = + const isRowSelectedByRange = sel !== null && rowIndex >= sel.startRow && rowIndex <= sel.endRow && sel.startCol === 0 && sel.endCol === columns.length - 1 + const isRowSelected = isRowChecked || isRowSelectedByRange return ( onContextMenu(e, row)}> { - if (e.button !== 0 || isRowSelected) return - onRowMouseDown(rowIndex, e.shiftKey) + if (e.button !== 0) return + onRowToggle(rowIndex, e.shiftKey) }} - onMouseEnter={() => onRowMouseEnter(rowIndex)} > { - e.stopPropagation() - if (e.button !== 0) return - if (e.shiftKey) { - onRowMouseDown(rowIndex, true) - } else if (isRowSelected) { - onClearSelection() - } else { - onRowSelect(rowIndex) - } - }} >
@@ -1806,18 +2032,19 @@ const DataRow = React.memo(function DataRow({ colIndex <= sel.endCol const isAnchor = sel !== null && rowIndex === sel.anchorRow && colIndex === sel.anchorCol const isEditing = editingColumnName === column.name + const isHighlighted = inRange || isRowChecked - const isTopEdge = inRange && rowIndex === sel!.startRow - const isBottomEdge = inRange && rowIndex === sel!.endRow - const isLeftEdge = inRange && colIndex === sel!.startCol - const isRightEdge = inRange && colIndex === sel!.endCol + const isTopEdge = inRange ? rowIndex === sel!.startRow : isRowChecked + const isBottomEdge = inRange ? rowIndex === sel!.endRow : isRowChecked + const isLeftEdge = inRange ? colIndex === sel!.startCol : colIndex === 0 + const isRightEdge = inRange ? colIndex === sel!.endCol : colIndex === columns.length - 1 return ( { if (e.button !== 0 || isEditing) return onCellMouseDown(rowIndex, colIndex, e.shiftKey) @@ -1826,7 +2053,7 @@ const DataRow = React.memo(function DataRow({ onClick={() => onClick(row.id, column.name)} onDoubleClick={() => onDoubleClick(row.id, column.name)} > - {inRange && isMultiCell && ( + {isHighlighted && (isMultiCell || isRowChecked) && (