d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
447 lines
18 KiB
TypeScript
447 lines
18 KiB
TypeScript
import { useCallback, useEffect, useRef } from 'react'
|
|
import { createLogger } from '@sim/logger'
|
|
import { TABLE_LIMITS } from '@/lib/table/constants'
|
|
import {
|
|
useAddTableColumn,
|
|
useBatchCreateTableRows,
|
|
useBatchUpdateTableRows,
|
|
useDeleteColumn,
|
|
useDeleteTableRow,
|
|
useDeleteTableRows,
|
|
useRenameTable,
|
|
useUpdateColumn,
|
|
useUpdateTableMetadata,
|
|
useUpdateTableRow,
|
|
} from '@/hooks/queries/tables'
|
|
import { runWithoutRecording, useTableUndoStore } from '@/stores/table/store'
|
|
import type { TableUndoAction } from '@/stores/table/types'
|
|
|
|
const logger = createLogger('useTableUndo')
|
|
|
|
export function extractCreatedRowId(response: Record<string, unknown>): string | undefined {
|
|
const data = response?.data as Record<string, unknown> | undefined
|
|
const row = data?.row as Record<string, unknown> | undefined
|
|
return row?.id as string | undefined
|
|
}
|
|
|
|
interface UseTableUndoProps {
|
|
workspaceId: string
|
|
tableId: string
|
|
onColumnOrderChange?: (order: string[]) => void
|
|
onColumnRename?: (oldName: string, newName: string) => void
|
|
onColumnWidthsChange?: (widths: Record<string, number>) => void
|
|
onPinnedColumnsChange?: (pinned: string[]) => void
|
|
getPinnedColumns?: () => string[]
|
|
getColumnWidths?: () => Record<string, number>
|
|
}
|
|
|
|
export function useTableUndo({
|
|
workspaceId,
|
|
tableId,
|
|
onColumnOrderChange,
|
|
onColumnRename,
|
|
onColumnWidthsChange,
|
|
onPinnedColumnsChange,
|
|
getPinnedColumns,
|
|
getColumnWidths,
|
|
}: UseTableUndoProps) {
|
|
const push = useTableUndoStore((s) => s.push)
|
|
const popUndo = useTableUndoStore((s) => s.popUndo)
|
|
const popRedo = useTableUndoStore((s) => s.popRedo)
|
|
const patchRedoRowId = useTableUndoStore((s) => s.patchRedoRowId)
|
|
const patchUndoRowId = useTableUndoStore((s) => s.patchUndoRowId)
|
|
const clear = useTableUndoStore((s) => s.clear)
|
|
const canUndo = useTableUndoStore((s) => (s.stacks[tableId]?.undo.length ?? 0) > 0)
|
|
const canRedo = useTableUndoStore((s) => (s.stacks[tableId]?.redo.length ?? 0) > 0)
|
|
|
|
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
|
|
const batchCreateRowsMutation = useBatchCreateTableRows({ workspaceId, tableId })
|
|
const batchUpdateRowsMutation = useBatchUpdateTableRows({ workspaceId, tableId })
|
|
const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId })
|
|
const deleteRowsMutation = useDeleteTableRows({ workspaceId, tableId })
|
|
const addColumnMutation = useAddTableColumn({ workspaceId, tableId })
|
|
const updateColumnMutation = useUpdateColumn({ workspaceId, tableId })
|
|
const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId })
|
|
const renameTableMutation = useRenameTable(workspaceId)
|
|
const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId })
|
|
|
|
const onColumnOrderChangeRef = useRef(onColumnOrderChange)
|
|
onColumnOrderChangeRef.current = onColumnOrderChange
|
|
const onColumnRenameRef = useRef(onColumnRename)
|
|
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
|
|
|
|
useEffect(() => {
|
|
return () => clear(tableId)
|
|
}, [clear, tableId])
|
|
|
|
const pushUndo = useCallback(
|
|
(action: TableUndoAction) => {
|
|
push(tableId, action)
|
|
},
|
|
[push, tableId]
|
|
)
|
|
|
|
const executeAction = useCallback(
|
|
async (action: TableUndoAction, direction: 'undo' | 'redo') => {
|
|
try {
|
|
switch (action.type) {
|
|
case 'update-cell': {
|
|
const value = direction === 'undo' ? action.previousValue : action.newValue
|
|
updateRowMutation.mutate({
|
|
rowId: action.rowId,
|
|
data: { [action.columnName]: value },
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'clear-cells': {
|
|
const updates = action.cells.map((cell) => ({
|
|
rowId: cell.rowId,
|
|
data:
|
|
direction === 'undo'
|
|
? cell.data
|
|
: Object.fromEntries(Object.keys(cell.data).map((k) => [k, null])),
|
|
}))
|
|
for (let i = 0; i < updates.length; i += TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
|
|
await batchUpdateRowsMutation.mutateAsync({
|
|
updates: updates.slice(i, i + TABLE_LIMITS.MAX_BULK_OPERATION_SIZE),
|
|
})
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'update-cells': {
|
|
const updates = action.cells.map((cell) => ({
|
|
rowId: cell.rowId,
|
|
data: direction === 'undo' ? cell.oldData : cell.newData,
|
|
}))
|
|
for (let i = 0; i < updates.length; i += TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
|
|
await batchUpdateRowsMutation.mutateAsync({
|
|
updates: updates.slice(i, i + TABLE_LIMITS.MAX_BULK_OPERATION_SIZE),
|
|
})
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'create-row': {
|
|
if (direction === 'undo') {
|
|
deleteRowMutation.mutate(action.rowId)
|
|
} else {
|
|
// Redo via the batch path so the saved orderKey restores exact placement.
|
|
// The single-insert API has no orderKey field, and order_key is authoritative —
|
|
// a gappy saved position would misplace the row.
|
|
batchCreateRowsMutation.mutate(
|
|
{
|
|
rows: [action.data ?? {}],
|
|
orderKeys: action.orderKey ? [action.orderKey] : undefined,
|
|
},
|
|
{
|
|
onSuccess: (response) => {
|
|
const newRowId = response?.data?.rows?.[0]?.id
|
|
if (newRowId && newRowId !== action.rowId) {
|
|
patchUndoRowId(tableId, action.rowId, newRowId)
|
|
}
|
|
},
|
|
}
|
|
)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'create-rows': {
|
|
if (direction === 'undo') {
|
|
const rowIds = action.rows.map((r) => r.rowId)
|
|
if (rowIds.length === 1) {
|
|
deleteRowMutation.mutate(rowIds[0])
|
|
} else {
|
|
deleteRowsMutation.mutate(rowIds)
|
|
}
|
|
} else {
|
|
batchCreateRowsMutation.mutate(
|
|
{
|
|
rows: action.rows.map((r) => r.data),
|
|
orderKeys: action.rows.every((r) => r.orderKey)
|
|
? action.rows.map((r) => r.orderKey as string)
|
|
: undefined,
|
|
},
|
|
{
|
|
onSuccess: (response) => {
|
|
const createdRows = response?.data?.rows ?? []
|
|
for (let i = 0; i < createdRows.length && i < action.rows.length; i++) {
|
|
if (createdRows[i].id && createdRows[i].id !== action.rows[i].rowId) {
|
|
patchUndoRowId(tableId, action.rows[i].rowId, createdRows[i].id)
|
|
}
|
|
}
|
|
},
|
|
}
|
|
)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'delete-rows': {
|
|
if (direction === 'undo') {
|
|
batchCreateRowsMutation.mutate(
|
|
{
|
|
rows: action.rows.map((row) => row.data),
|
|
orderKeys: action.rows.every((row) => row.orderKey)
|
|
? action.rows.map((row) => row.orderKey as string)
|
|
: undefined,
|
|
},
|
|
{
|
|
onSuccess: (response) => {
|
|
const createdRows = response?.data?.rows ?? []
|
|
for (let i = 0; i < createdRows.length && i < action.rows.length; i++) {
|
|
if (createdRows[i].id) {
|
|
patchRedoRowId(tableId, action.rows[i].rowId, createdRows[i].id)
|
|
}
|
|
}
|
|
},
|
|
}
|
|
)
|
|
} else {
|
|
const rowIds = action.rows.map((r) => r.rowId)
|
|
if (rowIds.length === 1) {
|
|
deleteRowMutation.mutate(rowIds[0])
|
|
} else {
|
|
deleteRowsMutation.mutate(rowIds)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'create-column': {
|
|
// Identity (delete lookup + id-keyed metadata) uses the stable id;
|
|
// re-create uses the display name.
|
|
const colKey = action.columnId ?? action.columnName
|
|
if (direction === 'undo') {
|
|
deleteColumnMutation.mutate(colKey, {
|
|
onSuccess: () => {
|
|
const metadata: Record<string, unknown> = {}
|
|
const currentWidths = getColumnWidthsRef.current?.() ?? {}
|
|
if (colKey in currentWidths) {
|
|
const { [colKey]: _, ...rest } = currentWidths
|
|
onColumnWidthsChangeRef.current?.(rest)
|
|
metadata.columnWidths = rest
|
|
}
|
|
const currentPinned = getPinnedColumnsRef.current?.() ?? []
|
|
if (currentPinned.includes(colKey)) {
|
|
const newPinned = currentPinned.filter((n) => n !== colKey)
|
|
onPinnedColumnsChangeRef.current?.(newPinned)
|
|
metadata.pinnedColumns = newPinned
|
|
}
|
|
if (Object.keys(metadata).length > 0) {
|
|
updateMetadataMutation.mutate(metadata)
|
|
}
|
|
},
|
|
})
|
|
} else {
|
|
addColumnMutation.mutate({
|
|
...(action.columnId ? { id: action.columnId } : {}),
|
|
name: action.columnName,
|
|
type: 'string',
|
|
position: action.position,
|
|
})
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'delete-column': {
|
|
// Identity (cell-data keys, id-keyed metadata, delete lookup) uses the
|
|
// stable id; re-create uses the display name.
|
|
const colKey = action.columnId ?? action.columnName
|
|
if (direction === 'undo') {
|
|
addColumnMutation.mutate(
|
|
{
|
|
// Reuse the original id so the saved (id-keyed) cell data below
|
|
// lands on the restored column.
|
|
...(action.columnId ? { id: action.columnId } : {}),
|
|
name: action.columnName,
|
|
type: action.columnType,
|
|
required: action.columnRequired,
|
|
unique: action.columnUnique,
|
|
position: action.columnPosition,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
if (action.cellData.length > 0) {
|
|
const updates = action.cellData.map((c) => ({
|
|
rowId: c.rowId,
|
|
data: { [colKey]: c.value },
|
|
}))
|
|
void (async () => {
|
|
try {
|
|
for (
|
|
let i = 0;
|
|
i < updates.length;
|
|
i += TABLE_LIMITS.MAX_BULK_OPERATION_SIZE
|
|
) {
|
|
await batchUpdateRowsMutation.mutateAsync({
|
|
updates: updates.slice(i, i + TABLE_LIMITS.MAX_BULK_OPERATION_SIZE),
|
|
})
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to restore cell data on delete-column undo', {
|
|
columnName: action.columnName,
|
|
error,
|
|
})
|
|
}
|
|
})()
|
|
}
|
|
const metadata: Record<string, unknown> = {}
|
|
if (action.previousOrder) {
|
|
onColumnOrderChangeRef.current?.(action.previousOrder)
|
|
metadata.columnOrder = action.previousOrder
|
|
}
|
|
if (action.previousWidth !== null) {
|
|
const merged = {
|
|
...(getColumnWidthsRef.current?.() ?? {}),
|
|
[colKey]: action.previousWidth,
|
|
}
|
|
metadata.columnWidths = merged
|
|
onColumnWidthsChangeRef.current?.(merged)
|
|
}
|
|
if (action.previousPinnedColumns !== null) {
|
|
const wasColumnPinned = action.previousPinnedColumns.includes(colKey)
|
|
if (wasColumnPinned) {
|
|
const currentPinned = getPinnedColumnsRef.current?.() ?? []
|
|
if (!currentPinned.includes(colKey)) {
|
|
const insertIndex = action.previousPinnedColumns.indexOf(colKey)
|
|
const restoredPinned = [...currentPinned]
|
|
restoredPinned.splice(
|
|
Math.min(insertIndex, restoredPinned.length),
|
|
0,
|
|
colKey
|
|
)
|
|
onPinnedColumnsChangeRef.current?.(restoredPinned)
|
|
metadata.pinnedColumns = restoredPinned
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(metadata).length > 0) {
|
|
updateMetadataMutation.mutate(metadata)
|
|
}
|
|
},
|
|
}
|
|
)
|
|
} else {
|
|
deleteColumnMutation.mutate(colKey, {
|
|
onSuccess: () => {
|
|
const metadata: Record<string, unknown> = {}
|
|
if (action.previousOrder) {
|
|
const newOrder = action.previousOrder.filter((n) => n !== colKey)
|
|
onColumnOrderChangeRef.current?.(newOrder)
|
|
metadata.columnOrder = newOrder
|
|
}
|
|
if (action.previousWidth !== null) {
|
|
const currentWidths = getColumnWidthsRef.current?.() ?? {}
|
|
const { [colKey]: _, ...rest } = currentWidths
|
|
metadata.columnWidths = rest
|
|
onColumnWidthsChangeRef.current?.(rest)
|
|
}
|
|
if (action.previousPinnedColumns !== null) {
|
|
const currentPinned = getPinnedColumnsRef.current?.() ?? []
|
|
if (currentPinned.includes(colKey)) {
|
|
const newPinned = currentPinned.filter((n) => n !== colKey)
|
|
onPinnedColumnsChangeRef.current?.(newPinned)
|
|
metadata.pinnedColumns = newPinned
|
|
}
|
|
}
|
|
if (Object.keys(metadata).length > 0) {
|
|
updateMetadataMutation.mutate(metadata)
|
|
}
|
|
},
|
|
})
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'rename-column': {
|
|
const fromName = direction === 'undo' ? action.newName : action.oldName
|
|
const toName = direction === 'undo' ? action.oldName : action.newName
|
|
// Look up by the stable id (falls back to the current name) so undo
|
|
// never renames the column to its internal id.
|
|
const colKey = action.columnId ?? fromName
|
|
updateColumnMutation.mutate({
|
|
columnName: colKey,
|
|
updates: { name: toName },
|
|
})
|
|
onColumnRenameRef.current?.(colKey, toName)
|
|
break
|
|
}
|
|
|
|
case 'update-column-type': {
|
|
const type = direction === 'undo' ? action.previousType : action.newType
|
|
updateColumnMutation.mutate({
|
|
columnName: action.columnName,
|
|
updates: { type },
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'toggle-column-constraint': {
|
|
const value = direction === 'undo' ? action.previousValue : action.newValue
|
|
updateColumnMutation.mutate({
|
|
columnName: action.columnName,
|
|
updates: { [action.constraint]: value },
|
|
})
|
|
break
|
|
}
|
|
|
|
case 'rename-table': {
|
|
const name = direction === 'undo' ? action.previousName : action.newName
|
|
renameTableMutation.mutate({ tableId: action.tableId, name })
|
|
break
|
|
}
|
|
|
|
case 'reorder-columns': {
|
|
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
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logger.error('Failed to execute undo/redo action', { action, direction, err })
|
|
}
|
|
},
|
|
[tableId, patchRedoRowId, patchUndoRowId]
|
|
)
|
|
|
|
const undo = useCallback(() => {
|
|
const entry = popUndo(tableId)
|
|
if (!entry) return
|
|
void runWithoutRecording(() => executeAction(entry.action, 'undo'))
|
|
}, [popUndo, tableId, executeAction])
|
|
|
|
const redo = useCallback(() => {
|
|
const entry = popRedo(tableId)
|
|
if (!entry) return
|
|
void runWithoutRecording(() => executeAction(entry.action, 'redo'))
|
|
}, [popRedo, tableId, executeAction])
|
|
|
|
return { pushUndo, undo, redo, canUndo, canRedo }
|
|
}
|