chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user