Feat: Insert and delete rows for batch operations on table editor (#42288)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Completion of batch edits on the table editor

## Demo


https://github.com/user-attachments/assets/ab5a7112-3dcc-456a-a5fc-1c9a99fccf34







<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Queued add/edit/delete operations with optimistic UI, conflict
resolution, and queue-based flows
  * Side-panel items showing queued add/delete row previews

* **UI**
* Pending-add placeholders plus a visible "DEFAULT" marker in grid cells
* Visual row states: green for pending adds, red with strike-through for
pending deletes
* Queue-based deletes can bypass confirmation when queue mode is enabled

* **Tests**
* Expanded tests covering queue conflict resolution and queue utilities
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <a@alaisteryoung.com>
This commit is contained in:
Ali WaseemandAlaister Young authored and GitHub committed 2026-02-04 11:15:55 -07:00
1 parent 473619bece
commit 1696262088
27 files changed
+1861 -229

No files matched your search

+5 -1
View File
@@ -98,13 +98,16 @@ export const SupabaseGrid = ({
// Re-apply optimistic updates when table data is loaded/refetched
// This ensures pending changes remain visible when switching tabs or after data refresh
// Skip re-applying during save to avoid race condition where refetch completes before queue clears
const isSaving = tableEditorSnap.operationQueue.status === 'saving'
useEffect(() => {
if (
isSuccess &&
project?.ref &&
tableId &&
isQueueOperationsEnabled &&
tableEditorSnap.hasPendingOperations
tableEditorSnap.hasPendingOperations &&
!isSaving
) {
reapplyOptimisticUpdates({
queryClient,
@@ -122,6 +125,7 @@ export const SupabaseGrid = ({
tableEditorSnap.hasPendingOperations,
tableEditorSnap.operationQueue.operations,
queryClient,
isSaving,
])
const rows = data?.rows ?? EMPTY_ARR
@@ -0,0 +1,3 @@
export const DefaultValue = () => {
return <span className="sb-grid-default-value opacity-50">DEFAULT</span>
}
@@ -20,10 +20,10 @@ import { ref as valtioRef } from 'valtio'
import { useTableFilter } from '../../hooks/useTableFilter'
import type { GridProps, SupaRow } from '../../types'
import { isPendingAddRow, isPendingDeleteRow } from '../../types'
import { useOnRowsChange } from './Grid.utils'
import { GridError } from './GridError'
import RowRenderer from './RowRenderer'
import { QueuedOperationType } from '@/state/table-editor-operation-queue.types'
import { ResponseError } from '@/types'
const rowKeyGetter = (row: SupaRow) => {
@@ -194,9 +194,7 @@ export const Grid = memo(
}
// Check if this cell has pending changes
// Since we are checking for cell changes, we need to use the EDIT_CELL_CONTENT type
const isDirty = tableEditorSnap.hasPendingCellChange(
QueuedOperationType.EDIT_CELL_CONTENT,
snap.table.id,
rowIdentifiers,
col.key
@@ -207,6 +205,29 @@ export const Grid = memo(
})
}, [snap.gridColumns, snap.originalTable, snap.table.id, tableEditorSnap])
// Compute rowClass function to style pending add/delete rows
const computedRowClass = useMemo(() => {
return (row: SupaRow) => {
const classes: string[] = []
// Call the original rowClass if provided
if (rowClass) {
const originalClass = rowClass(row)
if (originalClass) {
classes.push(originalClass)
}
}
if (isPendingAddRow(row)) {
classes.push('rdg-row--added')
}
if (isPendingDeleteRow(row)) {
classes.push('rdg-row--deleted')
}
return classes.length > 0 ? classes.join(' ') : undefined
}
}, [rowClass])
return (
<div
className={cn('flex flex-col relative transition-colors', containerClass)}
@@ -314,7 +335,7 @@ export const Grid = memo(
<DataGrid
ref={ref}
className={`${gridClass} flex-grow`}
rowClass={rowClass}
rowClass={computedRowClass}
columns={columnsWithDirtyCellClass}
rows={rows ?? []}
renderers={{ renderRow: RowRenderer }}
@@ -1,8 +1,4 @@
import { QueryKey, useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { RowsChangeData } from 'react-data-grid'
import { toast } from 'sonner'
import { SupaRow } from 'components/grid/types'
import { queueCellEditWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
@@ -14,9 +10,13 @@ import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-muta
import type { TableRowsData } from 'data/table-rows/table-rows-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { DOCS_URL } from 'lib/constants'
import { useCallback } from 'react'
import { RowsChangeData } from 'react-data-grid'
import { toast } from 'sonner'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import type { Dictionary } from 'types'
import { useTableEditorStateSnapshot } from '@/state/table-editor'
export function useOnRowsChange(rows: SupaRow[]) {
@@ -100,6 +100,7 @@ export function useOnRowsChange(rows: SupaRow[]) {
.map((column) => column.name)
const identifiers = {} as Dictionary<any>
isTableLike(snap.originalTable) &&
snap.originalTable.primary_keys.forEach((column) => {
const col = snap.originalTable.columns.find((c) => c.name === column.name)
@@ -134,6 +135,7 @@ export function useOnRowsChange(rows: SupaRow[]) {
projectRef: project.ref,
tableId: snap.table.id,
table: snap.originalTable,
row: previousRow,
rowIdentifiers: identifiers,
columnName: changedColumn,
oldValue: previousRow[changedColumn],
@@ -1,12 +1,10 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ArrowUp, ChevronDown, FileText, Trash } from 'lucide-react'
import { ReactNode, useState } from 'react'
import { toast } from 'sonner'
import { keepPreviousData } from '@tanstack/react-query'
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { GridHeaderActions } from 'components/interfaces/TableGridEditor/GridHeaderActions'
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
import {
@@ -22,6 +20,9 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { RoleImpersonationState } from 'lib/role-impersonation'
import { ArrowUp, ChevronDown, FileText, Trash } from 'lucide-react'
import { ReactNode, useState } from 'react'
import { toast } from 'sonner'
import {
useRoleImpersonationStateSnapshot,
useSubscribeToImpersonatedRole,
@@ -30,17 +31,18 @@ import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
cn,
copyToClipboard,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Separator,
cn,
copyToClipboard,
} from 'ui'
import { ExportDialog } from './ExportDialog'
import { FilterPopover } from './filter/FilterPopover'
import { formatRowsForCSV } from './Header.utils'
import { FilterPopover } from './filter/FilterPopover'
import { SortPopover } from './sort/SortPopover'
export type HeaderProps = {
@@ -220,9 +222,11 @@ type RowHeaderProps = {
}
const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const isImpersonatingRole = roleImpersonationState.role !== undefined
@@ -267,15 +271,28 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
}
const onRowsDelete = () => {
const numRows = snap.allRowsSelected ? totalRows : snap.selectedRows.size
const rowIdxs = Array.from(snap.selectedRows) as number[]
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
// Queue delete operations directly if queue mode is enabled (and not all rows selected)
if (isQueueOperationsEnabled && !snap.allRowsSelected) {
queueRowDeletesWithOptimisticUpdate({
rows,
table: snap.originalTable,
queryClient,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project?.ref,
})
snap.resetSelectedRows()
return
}
// Fall back to confirmation dialog
tableEditorSnap.onDeleteRows(rows, {
allRowsSelected: snap.allRowsSelected,
numRows,
numRows: snap.allRowsSelected ? totalRows : rows.length,
callback: () => {
snap.setSelectedRows(new Set())
snap.resetSelectedRows()
},
})
}
@@ -379,13 +396,9 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
setIsExporting(false)
}
function deselectRows() {
snap.setSelectedRows(new Set())
}
useSubscribeToImpersonatedRole(() => {
if (snap.allRowsSelected || snap.selectedRows.size > 0) {
deselectRows()
snap.resetSelectedRows()
}
})
@@ -1,8 +1,9 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { keepPreviousData } from '@tanstack/react-query'
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { useTableFilterNew } from 'components/grid/hooks/useTableFilterNew'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { GridHeaderActions } from 'components/interfaces/TableGridEditor/GridHeaderActions'
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
import {
@@ -221,9 +222,11 @@ type RowHeaderProps = {
}
const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const isImpersonatingRole = roleImpersonationState.role !== undefined
@@ -268,15 +271,28 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
}
const onRowsDelete = () => {
const numRows = snap.allRowsSelected ? totalRows : snap.selectedRows.size
const rowIdxs = Array.from(snap.selectedRows) as number[]
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
// Queue delete operations directly if queue mode is enabled (and not all rows selected)
if (isQueueOperationsEnabled && !snap.allRowsSelected) {
queueRowDeletesWithOptimisticUpdate({
rows,
table: snap.originalTable,
queryClient,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project?.ref,
})
snap.resetSelectedRows()
return
}
const numRows = snap.allRowsSelected ? totalRows : snap.selectedRows.size
tableEditorSnap.onDeleteRows(rows, {
allRowsSelected: snap.allRowsSelected,
numRows,
callback: () => {
snap.setSelectedRows(new Set())
snap.resetSelectedRows()
},
})
}
@@ -380,13 +396,9 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => {
setIsExporting(false)
}
function deselectRows() {
snap.setSelectedRows(new Set())
}
useSubscribeToImpersonatedRole(() => {
if (snap.allRowsSelected || snap.selectedRows.size > 0) {
deselectRows()
snap.resetSelectedRows()
}
})
@@ -1,13 +1,17 @@
import { useQueryClient } from '@tanstack/react-query'
import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants'
import type { SupaRow } from 'components/grid/types'
import { queueRowDeletesWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Copy, Edit, Trash } from 'lucide-react'
import { useCallback } from 'react'
import { Item, ItemParams, Menu } from 'react-contexify'
import { toast } from 'sonner'
import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants'
import type { SupaRow } from 'components/grid/types'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import { copyToClipboard, DialogSectionSeparator } from 'ui'
import { DialogSectionSeparator, copyToClipboard } from 'ui'
import { formatClipboardValue } from '../../utils/common'
type RowContextMenuProps = {
@@ -17,15 +21,34 @@ type RowContextMenuProps = {
type RowContextMenuItemProps = ItemParams<{ rowIdx: number }, string>
export const RowContextMenu = ({ rows }: RowContextMenuProps) => {
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
function onDeleteRow(p: RowContextMenuItemProps) {
const rowIdx = p.props?.rowIdx
if (rowIdx === undefined || rowIdx === null) return
const row = rows[rowIdx]
if (row) tableEditorSnap.onDeleteRows([row])
if (!row) {
toast.error('Row not found')
return
}
if (isQueueOperationsEnabled) {
queueRowDeletesWithOptimisticUpdate({
rows: [row],
table: snap.originalTable,
queryClient,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project?.ref,
})
return
}
tableEditorSnap.onDeleteRows([row])
}
function onEditRowClick(p: RowContextMenuItemProps) {
@@ -3,3 +3,5 @@ export type * from './base'
export type * from './grid'
export type * from './service'
export type * from './table'
export { isPendingAddRow, isPendingDeleteRow } from './table'
@@ -1,5 +1,6 @@
import type { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import type { Dictionary } from 'types'
import { GridForeignKey } from './base'
export interface SupaColumn {
@@ -34,3 +35,25 @@ export interface SupaTable {
export interface SupaRow extends Dictionary<any> {
readonly idx: number
}
// Row markers for queue operations
interface PendingAddMarker {
__tempId: string
}
interface PendingDeleteMarker {
__isDeleted: true
}
export type PendingAddRow = SupaRow & PendingAddMarker
export type PendingDeleteRow = SupaRow & PendingDeleteMarker
export function isPendingAddRow(row: SupaRow): row is PendingAddRow {
return '__tempId' in row && typeof (row as PendingAddRow).__tempId === 'string'
}
/** Check if row is pending deletion (has __isDeleted marker) */
export function isPendingDeleteRow(row: SupaRow): row is PendingDeleteRow {
return '__isDeleted' in row && (row as PendingDeleteRow).__isDeleted === true
}
@@ -1,6 +1,8 @@
import { CalculatedColumn } from 'react-data-grid'
import { COLUMN_MIN_WIDTH } from 'components/grid/constants'
import { CalculatedColumn, RenderCellProps } from 'react-data-grid'
import { DefaultValue } from '../components/common/DefaultValue'
import { NullValue } from '../components/common/NullValue'
import { BooleanEditor } from '../components/editor/BooleanEditor'
import { DateTimeEditor } from '../components/editor/DateTimeEditor'
import { JsonEditor } from '../components/editor/JsonEditor'
@@ -16,7 +18,13 @@ import { JsonFormatter } from '../components/formatter/JsonFormatter'
import { AddColumn } from '../components/grid/AddColumn'
import { ColumnHeader } from '../components/grid/ColumnHeader'
import { SelectColumn } from '../components/grid/SelectColumn'
import type { ColumnType, SupaColumn, SupaRow, SupaTable } from '../types'
import {
type ColumnType,
type SupaColumn,
type SupaRow,
type SupaTable,
isPendingAddRow,
} from '../types'
import {
isArrayColumn,
isBinaryColumn,
@@ -185,33 +193,65 @@ function getCellEditor(
}
}
function withPendingAddPlaceholders(
Formatter: React.ComponentType<RenderCellProps<SupaRow, unknown>>,
columnDef: SupaColumn
) {
return function PendingAwareFormatter(props: RenderCellProps<SupaRow, unknown>) {
const value = props.row[props.column.key]
if (isPendingAddRow(props.row) && (value === undefined || value === null || value === '')) {
if (columnDef.defaultValue !== undefined && columnDef.defaultValue !== null) {
return <DefaultValue />
}
if (columnDef.isIdentity || columnDef.isGeneratable) {
return <DefaultValue />
}
if (columnDef.isNullable) {
return <NullValue />
}
}
return <Formatter {...props} />
}
}
function getCellRenderer(
columnDef: SupaColumn,
columnType: ColumnType,
metadata: { tableId?: number }
) {
let formatter: React.ComponentType<RenderCellProps<SupaRow, unknown>>
switch (columnType) {
case 'boolean': {
return BooleanFormatter
formatter = BooleanFormatter
break
}
case 'foreign_key': {
if (!columnDef.isUpdatable) {
return DefaultFormatter
formatter = DefaultFormatter
} else {
// eslint-disable-next-line react/display-name
return (p: any) => <ForeignKeyFormatter {...p} tableId={metadata.tableId} />
formatter = (p: any) => <ForeignKeyFormatter {...p} tableId={metadata.tableId} />
}
break
}
case 'binary': {
return BinaryFormatter
formatter = BinaryFormatter
break
}
case 'json': {
return JsonFormatter
formatter = JsonFormatter
break
}
default: {
return DefaultFormatter
formatter = DefaultFormatter
}
}
// Wrap all formatters to handle pending add row placeholders
return withPendingAddPlaceholders(formatter, columnDef)
}
function getColumnType(columnDef: SupaColumn): ColumnType {
@@ -0,0 +1,575 @@
import { describe, expect, test } from 'vitest'
import {
operationMatchesRow,
resolveDeleteRowConflicts,
resolveEditCellConflicts,
upsertOperation,
} from './queueConflictResolution'
import {
type NewAddRowOperation,
type NewDeleteRowOperation,
type NewEditCellContentOperation,
QueuedOperation,
QueuedOperationType,
} from '@/state/table-editor-operation-queue.types'
describe('operationMatchesRow', () => {
const mockTable = {} as any
test('should match EDIT_CELL_CONTENT operation with same row identifiers', () => {
const operation: QueuedOperation = {
id: 'edit_cell_content:1:name:id:1',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'old',
newValue: 'new',
table: mockTable,
},
}
expect(operationMatchesRow(operation, 1, { id: 1 })).toBe(true)
})
test('should not match EDIT_CELL_CONTENT operation with different row identifiers', () => {
const operation: QueuedOperation = {
id: 'edit_cell_content:1:name:id:1',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'old',
newValue: 'new',
table: mockTable,
},
}
expect(operationMatchesRow(operation, 1, { id: 2 })).toBe(false)
})
test('should not match operation from different table', () => {
const operation: QueuedOperation = {
id: 'edit_cell_content:1:name:id:1',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'old',
newValue: 'new',
table: mockTable,
},
}
expect(operationMatchesRow(operation, 2, { id: 1 })).toBe(false)
})
test('should match DELETE_ROW operation with same row identifiers', () => {
const operation: QueuedOperation = {
id: 'delete_row:1:id:1',
type: QueuedOperationType.DELETE_ROW,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 1 },
originalRow: { idx: 1, id: 1, name: 'test' },
table: mockTable,
},
}
expect(operationMatchesRow(operation, 1, { id: 1 })).toBe(true)
})
test('should return false for ADD_ROW operations', () => {
const operation: QueuedOperation = {
id: 'add_row:1:temp123',
type: QueuedOperationType.ADD_ROW,
tableId: 1,
timestamp: Date.now(),
payload: {
tempId: 'temp123',
rowData: { idx: 1, __tempId: '1', name: 'new' },
table: mockTable,
},
}
expect(operationMatchesRow(operation, 1, { id: 1 })).toBe(false)
})
})
describe('resolveDeleteRowConflicts', () => {
const mockTable = {} as any
test('should skip delete and remove ADD_ROW when deleting a newly added row', () => {
const addRowOp: QueuedOperation = {
id: 'add_row:1:-12345',
type: QueuedOperationType.ADD_ROW,
tableId: 1,
timestamp: Date.now(),
payload: {
tempId: '-12345',
rowData: { idx: -12345, __tempId: '-12345', name: 'new row' },
table: mockTable,
},
}
const operations = [addRowOp]
const deleteOperation: NewDeleteRowOperation = {
type: QueuedOperationType.DELETE_ROW,
tableId: 1,
payload: {
rowIdentifiers: { __tempId: '-12345' },
originalRow: { idx: -12345, __tempId: '-12345', name: 'new row' },
table: mockTable,
},
}
const result = resolveDeleteRowConflicts(operations, deleteOperation)
expect(result.action).toBe('skip')
expect(result.filteredOperations).toEqual([])
})
test('should skip delete and remove ADD_ROW and related EDIT_CELLs when deleting a newly added row', () => {
const addRowOp: QueuedOperation = {
id: 'add_row:1:-12345',
type: QueuedOperationType.ADD_ROW,
tableId: 1,
timestamp: Date.now(),
payload: {
tempId: '-12345',
rowData: { idx: -12345, __tempId: '-12345', name: 'new row' },
table: mockTable,
},
}
const editCellOp: QueuedOperation = {
id: 'edit_cell_content:1:name:__tempId:-12345',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { __tempId: '-12345' },
columnName: 'name',
oldValue: 'new row',
newValue: 'edited',
table: mockTable,
},
}
const otherEditOp: QueuedOperation = {
id: 'edit_cell_content:1:name:id:99',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 99 },
columnName: 'name',
oldValue: 'original',
newValue: 'changed',
table: mockTable,
},
}
const operations = [addRowOp, editCellOp, otherEditOp]
const deleteOperation: NewDeleteRowOperation = {
type: QueuedOperationType.DELETE_ROW,
tableId: 1,
payload: {
rowIdentifiers: { __tempId: '-12345' },
originalRow: { idx: -12345, __tempId: '-12345', name: 'new row' },
table: mockTable,
},
}
const result = resolveDeleteRowConflicts(operations, deleteOperation)
expect(result.action).toBe('skip')
expect(result.filteredOperations).toHaveLength(1)
expect(result.filteredOperations[0]).toEqual(otherEditOp)
})
test('should add delete and remove EDIT_CELLs for existing row being deleted', () => {
const editCellOp: QueuedOperation = {
id: 'edit_cell_content:1:name:id:1',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'original',
newValue: 'edited',
table: mockTable,
},
}
const otherEditOp: QueuedOperation = {
id: 'edit_cell_content:1:name:id:2',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 2 },
columnName: 'name',
oldValue: 'other',
newValue: 'changed',
table: mockTable,
},
}
const operations = [editCellOp, otherEditOp]
const deleteOperation: NewDeleteRowOperation = {
type: QueuedOperationType.DELETE_ROW,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
originalRow: { idx: 1, id: 1, name: 'original' },
table: mockTable,
},
}
const result = resolveDeleteRowConflicts(operations, deleteOperation)
expect(result.action).toBe('add')
expect(result.filteredOperations).toHaveLength(1)
expect(result.filteredOperations[0]).toEqual(otherEditOp)
})
test('should add delete with no changes when there are no conflicts', () => {
const otherEditOp: QueuedOperation = {
id: 'edit_cell_content:1:name:id:2',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 2 },
columnName: 'name',
oldValue: 'other',
newValue: 'changed',
table: mockTable,
},
}
const operations = [otherEditOp]
const deleteOperation: NewDeleteRowOperation = {
type: QueuedOperationType.DELETE_ROW,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
originalRow: { idx: 1, id: 1, name: 'original' },
table: mockTable,
},
}
const result = resolveDeleteRowConflicts(operations, deleteOperation)
expect(result.action).toBe('add')
expect(result.filteredOperations).toEqual(operations)
})
})
describe('resolveEditCellConflicts', () => {
const mockTable = {} as any
test('should reject edit on a row pending deletion', () => {
const deleteOp: QueuedOperation = {
id: 'delete_row:1:id:1',
type: QueuedOperationType.DELETE_ROW,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 1 },
originalRow: { idx: 1, id: 1, name: 'to delete' },
table: mockTable,
},
}
const operations = [deleteOp]
const editOperation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'to delete',
newValue: 'changed',
table: mockTable,
},
}
const result = resolveEditCellConflicts(operations, editOperation)
expect(result.action).toBe('reject')
if (result.action === 'reject') {
expect(result.reason).toContain('pending deletion')
}
})
test('should merge edit into ADD_ROW for a newly added row', () => {
const addRowOp: QueuedOperation = {
id: 'add_row:1:-12345',
type: QueuedOperationType.ADD_ROW,
tableId: 1,
timestamp: Date.now(),
payload: {
tempId: '-12345',
rowData: { idx: -12345, __tempId: '-12345', name: 'new row' },
table: mockTable,
},
}
const operations = [addRowOp]
const editOperation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: { __tempId: '-12345' },
columnName: 'name',
oldValue: 'new row',
newValue: 'edited value',
table: mockTable,
},
}
const result = resolveEditCellConflicts(operations, editOperation)
expect(result.action).toBe('merge')
if (result.action === 'merge') {
expect(result.updatedOperations).toHaveLength(1)
const updatedAddRow = result.updatedOperations[0]
expect(updatedAddRow.type).toBe(QueuedOperationType.ADD_ROW)
expect((updatedAddRow.payload as any).rowData.name).toBe('edited value')
}
})
test('should return add action for normal edit on existing row', () => {
const otherOp: QueuedOperation = {
id: 'edit_cell_content:1:email:id:2',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 2 },
columnName: 'email',
oldValue: 'old@test.com',
newValue: 'new@test.com',
table: mockTable,
},
}
const operations = [otherOp]
const editOperation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'original',
newValue: 'changed',
table: mockTable,
},
}
const result = resolveEditCellConflicts(operations, editOperation)
expect(result.action).toBe('add')
})
test('should return add when editing tempId row but ADD_ROW not found', () => {
const operations: QueuedOperation[] = []
const editOperation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: { __tempId: '-99999' },
columnName: 'name',
oldValue: 'original',
newValue: 'changed',
table: mockTable,
},
}
const result = resolveEditCellConflicts(operations, editOperation)
expect(result.action).toBe('add')
})
})
describe('upsertOperation', () => {
const mockTable = {} as any
test('should add new operation to empty queue', () => {
const operations: QueuedOperation[] = []
const newOperation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'original',
newValue: 'changed',
table: mockTable,
},
}
const result = upsertOperation(operations, newOperation)
expect(result.operations).toHaveLength(1)
expect(result.operations[0].type).toBe(QueuedOperationType.EDIT_CELL_CONTENT)
expect(result.operations[0].id).toBe('edit_cell_content:1:name:id:1')
})
test('should add new operation to existing queue', () => {
const existingOp: QueuedOperation = {
id: 'edit_cell_content:1:email:id:1',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 1 },
columnName: 'email',
oldValue: 'old@test.com',
newValue: 'new@test.com',
table: mockTable,
},
}
const operations = [existingOp]
const newOperation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'original',
newValue: 'changed',
table: mockTable,
},
}
const result = upsertOperation(operations, newOperation)
expect(result.operations).toHaveLength(2)
})
test('should update existing EDIT_CELL operation and preserve original oldValue', () => {
const existingOp: QueuedOperation = {
id: 'edit_cell_content:1:name:id:1',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now() - 1000,
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'very first value',
newValue: 'intermediate',
table: mockTable,
},
}
const operations = [existingOp]
const newOperation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'intermediate',
newValue: 'final value',
table: mockTable,
},
}
const result = upsertOperation(operations, newOperation)
expect(result.operations).toHaveLength(1)
const updated = result.operations[0]
expect((updated.payload as any).oldValue).toBe('very first value')
expect((updated.payload as any).newValue).toBe('final value')
})
test('should update existing DELETE_ROW operation', () => {
const existingOp: QueuedOperation = {
id: 'delete_row:1:id:1',
type: QueuedOperationType.DELETE_ROW,
tableId: 1,
timestamp: Date.now() - 1000,
payload: {
rowIdentifiers: { id: 1 },
originalRow: { idx: 1, id: 1, name: 'old data' },
table: mockTable,
},
}
const operations = [existingOp]
const newOperation: NewDeleteRowOperation = {
type: QueuedOperationType.DELETE_ROW,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
originalRow: { idx: 1, id: 1, name: 'updated data' },
table: mockTable,
},
}
const result = upsertOperation(operations, newOperation)
expect(result.operations).toHaveLength(1)
expect((result.operations[0].payload as any).originalRow.name).toBe('updated data')
})
test('should not mutate original operations array', () => {
const existingOp: QueuedOperation = {
id: 'edit_cell_content:1:name:id:1',
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
timestamp: Date.now(),
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'original',
newValue: 'changed',
table: mockTable,
},
}
const operations = [existingOp]
const originalOperations = [...operations]
const newOperation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: { id: 2 },
columnName: 'name',
oldValue: 'original2',
newValue: 'changed2',
table: mockTable,
},
}
upsertOperation(operations, newOperation)
expect(operations).toEqual(originalOperations)
})
test('should handle ADD_ROW operation', () => {
const operations: QueuedOperation[] = []
const newOperation: NewAddRowOperation = {
type: QueuedOperationType.ADD_ROW,
tableId: 1,
payload: {
tempId: '-12345',
rowData: { idx: -12345, __tempId: '-12345', name: 'new row' },
table: mockTable,
},
}
const result = upsertOperation(operations, newOperation)
expect(result.operations).toHaveLength(1)
expect(result.operations[0].id).toBe('add_row:1:-12345')
})
})
@@ -0,0 +1,175 @@
import { isPendingAddRow } from '../types'
import { generateTableChangeKey, rowMatchesIdentifiers } from './queueOperationUtils'
import {
type NewDeleteRowOperation,
type NewEditCellContentOperation,
NewQueuedOperation,
QueuedOperation,
QueuedOperationType,
isDeleteRowOperation,
isEditCellContentOperation,
} from '@/state/table-editor-operation-queue.types'
export type DeleteConflictResult =
| { action: 'skip'; filteredOperations: QueuedOperation[] }
| { action: 'add'; filteredOperations: QueuedOperation[] }
export type EditConflictResult =
| { action: 'reject'; reason: string }
| { action: 'merge'; updatedOperations: QueuedOperation[] }
| { action: 'add' }
export type UpsertResult = {
operations: QueuedOperation[]
}
function editOperationMatchesTempId(operation: QueuedOperation, tempId: string): boolean {
if (!isEditCellContentOperation(operation)) return false
return operation.payload.rowIdentifiers.__tempId === tempId
}
export function operationMatchesRow(
operation: QueuedOperation,
tableId: number,
rowIdentifiers: Record<string, unknown>
): boolean {
if (operation.tableId !== tableId) return false
if (
operation.type === QueuedOperationType.EDIT_CELL_CONTENT ||
operation.type === QueuedOperationType.DELETE_ROW
) {
return rowMatchesIdentifiers(operation.payload.rowIdentifiers, rowIdentifiers)
}
return false
}
export function resolveDeleteRowConflicts(
operations: readonly QueuedOperation[],
deleteOperation: NewDeleteRowOperation
): DeleteConflictResult {
const rowIdentifiers = deleteOperation.payload.rowIdentifiers
// Check if this row was newly added (by tempId)
// If deleting a newly added row, filter out the ADD_ROW operation
const originalRow = deleteOperation.payload.originalRow
if (isPendingAddRow(originalRow)) {
const tempId = originalRow.__tempId
const addRowKey = generateTableChangeKey({
type: QueuedOperationType.ADD_ROW,
tableId: deleteOperation.tableId,
payload: {
tempId,
rowData: originalRow,
table: deleteOperation.payload.table,
},
})
let filteredOperations = operations
.filter((op) => op.id !== addRowKey)
.filter((op) => !editOperationMatchesTempId(op, tempId))
return { action: 'skip', filteredOperations }
}
// For existing rows, remove any pending EDIT_CELL operations for the row being deleted
const filteredOperations = operations.filter(
(op) => !operationMatchesRow(op, deleteOperation.tableId, rowIdentifiers)
)
return { action: 'add', filteredOperations }
}
export function resolveEditCellConflicts(
operations: readonly QueuedOperation[],
editOperation: NewEditCellContentOperation
): EditConflictResult {
const rowIdentifiers = editOperation.payload.rowIdentifiers
// Check if this row is pending deletion
const isPendingDeletion = operations.filter(isDeleteRowOperation).some((op) => {
if (op.tableId === editOperation.tableId) {
return Object.entries(op.payload.rowIdentifiers).every(
([key, value]) => rowIdentifiers[key] === value
)
}
return false
})
if (isPendingDeletion) {
return {
action: 'reject',
reason:
'Cannot edit a cell on a row that is pending deletion. Remove the delete operation first.',
}
}
// Check if this edit is on a newly added row (by tempId)
const tempId = rowIdentifiers.__tempId
if (tempId) {
const addRowIndex = operations.findIndex((op) => {
if (op.type === QueuedOperationType.ADD_ROW && op.tableId === editOperation.tableId) {
return op.payload.tempId === tempId
}
return false
})
if (addRowIndex >= 0) {
// Merge the edit into the ADD_ROW's rowData
const updatedOperations = [...operations]
const addOp = updatedOperations[addRowIndex]
if (addOp.type === QueuedOperationType.ADD_ROW) {
const addPayload = { ...addOp.payload }
addPayload.rowData = {
...addPayload.rowData,
[editOperation.payload.columnName]: editOperation.payload.newValue,
}
updatedOperations[addRowIndex] = {
...addOp,
payload: addPayload,
timestamp: Date.now(),
}
}
return { action: 'merge', updatedOperations }
}
}
return { action: 'add' }
}
export function upsertOperation(
operations: readonly QueuedOperation[],
newOperation: NewQueuedOperation
): UpsertResult {
const operationKey = generateTableChangeKey(newOperation)
const existingOpIndex = operations.findIndex((op) => op.id === operationKey)
const queuedOperation: QueuedOperation = {
...newOperation,
id: operationKey,
timestamp: Date.now(),
}
if (existingOpIndex >= 0) {
const updatedOperations = [...operations]
// Keep the old value of the operation that is being overwritten
// When a user edits the same cell multiple times before saving,
// we need to preserve the original "before edit" value
const existingOp = operations[existingOpIndex]
if (
queuedOperation.type === QueuedOperationType.EDIT_CELL_CONTENT &&
existingOp.type === QueuedOperationType.EDIT_CELL_CONTENT
) {
queuedOperation.payload.oldValue = existingOp.payload.oldValue
}
updatedOperations[existingOpIndex] = queuedOperation
return { operations: updatedOperations }
}
return { operations: [...operations, queuedOperation] }
}
@@ -1,47 +1,15 @@
import { describe, test, expect } from 'vitest'
import { generateTableChangeKey, rowMatchesIdentifiers, applyCellEdit } from './queueOperationUtils'
import {
generateTableChangeKey,
generateTableChangeKeyFromOperation,
rowMatchesIdentifiers,
applyCellEdit,
} from './queueOperationUtils'
import { QueuedOperationType } from '@/state/table-editor-operation-queue.types'
type NewEditCellContentOperation,
type NewAddRowOperation,
type NewDeleteRowOperation,
QueuedOperationType,
} from '@/state/table-editor-operation-queue.types'
describe('generateTableChangeKey', () => {
test('should generate key with row identifiers', () => {
const key = generateTableChangeKey({
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
columnName: 'name',
rowIdentifiers: { id: 1 },
})
expect(key).toBe('edit_cell_content:1:name:id:1')
})
test('should generate key with empty row identifiers', () => {
const key = generateTableChangeKey({
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
columnName: 'name',
rowIdentifiers: {},
})
expect(key).toBe('edit_cell_content:1:name:')
})
test('should generate key with multiple row identifiers sorted alphabetically', () => {
const key = generateTableChangeKey({
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
columnName: 'name',
rowIdentifiers: { z_id: 3, a_id: 1 },
})
expect(key).toBe('edit_cell_content:1:name:a_id:1|z_id:3')
})
})
describe('generateTableChangeKeyFromOperation', () => {
test('should generate key from EDIT_CELL_CONTENT operation', () => {
const operation = {
test('should generate key for EDIT_CELL_CONTENT with row identifiers', () => {
const operation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
@@ -52,10 +20,70 @@ describe('generateTableChangeKeyFromOperation', () => {
table: {} as any,
},
}
const key = generateTableChangeKeyFromOperation(operation)
const key = generateTableChangeKey(operation)
expect(key).toBe('edit_cell_content:1:name:id:1')
})
test('should generate key for EDIT_CELL_CONTENT with empty row identifiers', () => {
const operation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: {},
columnName: 'name',
oldValue: 'old',
newValue: 'new',
table: {} as any,
},
}
const key = generateTableChangeKey(operation)
expect(key).toBe('edit_cell_content:1:name:')
})
test('should generate key with multiple row identifiers sorted alphabetically', () => {
const operation: NewEditCellContentOperation = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: { z_id: 3, a_id: 1 },
columnName: 'name',
oldValue: 'old',
newValue: 'new',
table: {} as any,
},
}
const key = generateTableChangeKey(operation)
expect(key).toBe('edit_cell_content:1:name:a_id:1|z_id:3')
})
test('should generate key for ADD_ROW operation', () => {
const operation: NewAddRowOperation = {
type: QueuedOperationType.ADD_ROW,
tableId: 1,
payload: {
tempId: 'temp-123',
rowData: { idx: -1, __tempId: 'temp-123' },
table: {} as any,
},
}
const key = generateTableChangeKey(operation)
expect(key).toBe('add_row:1:temp-123')
})
test('should generate key for DELETE_ROW operation', () => {
const operation: NewDeleteRowOperation = {
type: QueuedOperationType.DELETE_ROW,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
originalRow: { idx: 0, id: 1 },
table: {} as any,
},
}
const key = generateTableChangeKey(operation)
expect(key).toBe('delete_row:1:id:1')
})
test('should throw error for unknown operation type', () => {
const operation = {
type: 'unknown' as any,
@@ -68,7 +96,7 @@ describe('generateTableChangeKeyFromOperation', () => {
table: {} as any,
},
}
expect(() => generateTableChangeKeyFromOperation(operation)).toThrow('Unknown operation type')
expect(() => generateTableChangeKey(operation)).toThrow('Unknown operation type')
})
})
@@ -1,49 +1,55 @@
import type { QueryClient } from '@tanstack/react-query'
import type { Entity } from 'data/table-editor/table-editor-types'
import { type Entity, isTableLike } from 'data/table-editor/table-editor-types'
import { tableRowKeys } from 'data/table-rows/keys'
import type { TableRowsData } from 'data/table-rows/table-rows-query'
import type { Dictionary } from 'types'
import { PendingAddRow, PendingDeleteRow, SupaRow, isPendingAddRow } from '../types'
import {
EditCellContentOperation,
NewQueuedOperation,
QueuedOperation,
QueuedOperationType,
type EditCellContentPayload,
} from '@/state/table-editor-operation-queue.types'
import type { Dictionary } from 'types'
import { SupaRow } from '../types'
interface GenerateTableChangeKeyArgs {
type: QueuedOperationType
interface EditCellKeyOperation
extends Omit<EditCellContentOperation, 'payload' | 'id' | 'timestamp'> {
type: QueuedOperationType.EDIT_CELL_CONTENT
tableId: number
columnName?: string
rowIdentifiers?: Record<string, unknown>
payload: {
columnName: string
rowIdentifiers: Dictionary<unknown>
}
}
export function generateTableChangeKeyFromOperation(operation: NewQueuedOperation): string {
export function generateTableChangeKey(
operation: NewQueuedOperation | EditCellKeyOperation
): string {
if (operation.type === QueuedOperationType.EDIT_CELL_CONTENT) {
return generateTableChangeKey({
type: operation.type,
tableId: operation.tableId,
columnName: operation.payload.columnName,
rowIdentifiers: operation.payload.rowIdentifiers,
})
const { columnName, rowIdentifiers } = operation.payload
const rowIdentifiersKey = Object.entries(rowIdentifiers)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${value}`)
.join('|')
return `${operation.type}:${operation.tableId}:${columnName}:${rowIdentifiersKey}`
}
// Need to explicitly handle other operations
throw new Error(`Unknown operation type: ${operation.type}`)
}
if (operation.type === QueuedOperationType.ADD_ROW) {
return `${operation.type}:${operation.tableId}:${operation.payload.tempId}`
}
export function generateTableChangeKey({
rowIdentifiers,
columnName,
tableId,
type,
}: GenerateTableChangeKeyArgs): string {
const rowIdentifiersKey = Object.entries(rowIdentifiers ?? {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${value}`)
.join('|')
return `${type}:${tableId}:${columnName}:${rowIdentifiersKey}`
if (operation.type === QueuedOperationType.DELETE_ROW) {
const { rowIdentifiers } = operation.payload
const rowIdentifiersKey = Object.entries(rowIdentifiers)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${value}`)
.join('|')
return `${operation.type}:${operation.tableId}:${rowIdentifiersKey}`
}
// Exhaustive check - TypeScript will error if we miss a case
const _exhaustiveCheck: never = operation
throw new Error(`Unknown operation type: ${(_exhaustiveCheck as { type: string }).type}`)
}
export function rowMatchesIdentifiers(
@@ -70,12 +76,56 @@ export function applyCellEdit(
})
}
export function applyRowAdd(
rows: SupaRow[],
tempId: string,
idx: number,
rowData: Dictionary<unknown>
): (PendingAddRow | SupaRow)[] {
// Check if row with this tempId already exists
const existingIndex = rows.findIndex((row) => isPendingAddRow(row) && row.__tempId === tempId)
if (existingIndex >= 0) {
// Update existing row in place
return rows.map((row, index) => {
if (index === existingIndex) {
return { ...row, ...rowData, __tempId: tempId }
}
return row
})
}
const newRow: PendingAddRow = {
idx,
...rowData,
__tempId: tempId,
}
return [newRow, ...rows]
}
export function markRowAsDeleted(
rows: SupaRow[],
rowIdentifiers: Dictionary<unknown>
): (PendingDeleteRow | SupaRow)[] {
return rows.map((row): PendingDeleteRow | SupaRow => {
const rowMatches = rowMatchesIdentifiers(row, rowIdentifiers)
if (rowMatches) {
return { ...row, __isDeleted: true }
}
return row
})
}
export function removeRow(rows: SupaRow[], rowIdentifiers: Dictionary<unknown>): SupaRow[] {
return rows.filter((row) => !rowMatchesIdentifiers(row, rowIdentifiers))
}
interface QueueCellEditParams {
queryClient: QueryClient
queueOperation: (operation: NewQueuedOperation) => void
projectRef: string
tableId: number
table: Entity
row: SupaRow
rowIdentifiers: Dictionary<unknown>
columnName: string
oldValue: unknown
@@ -89,12 +139,19 @@ export function queueCellEditWithOptimisticUpdate({
projectRef,
tableId,
table,
rowIdentifiers,
row,
rowIdentifiers: callerRowIdentifiers,
columnName,
oldValue,
newValue,
enumArrayColumns,
}: QueueCellEditParams) {
// Updated row identifiers to include __tempId for pending add rows so edits merge into ADD_ROW operation
const rowIdentifiers: Dictionary<unknown> = { ...callerRowIdentifiers }
if (isPendingAddRow(row)) {
rowIdentifiers.__tempId = row.__tempId
}
// Queue the operation
queueOperation({
type: QueuedOperationType.EDIT_CELL_CONTENT,
@@ -120,6 +177,52 @@ export function queueCellEditWithOptimisticUpdate({
})
}
interface QueueRowAddParams {
queryClient: QueryClient
queueOperation: (operation: NewQueuedOperation) => void
projectRef: string
tableId: number
table: Entity
rowData: PendingAddRow
enumArrayColumns?: string[]
}
export function queueRowAddWithOptimisticUpdate({
queryClient,
queueOperation,
projectRef,
tableId,
table,
rowData,
enumArrayColumns,
}: QueueRowAddParams) {
// Generate unique idx and tempId for this pending row
const idx = -Date.now()
const tempId = String(idx)
// Queue the operation
queueOperation({
type: QueuedOperationType.ADD_ROW,
tableId,
payload: {
tempId,
rowData,
table,
enumArrayColumns,
},
})
// Apply optimistic update to the UI
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } })
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
if (!old) return old
return {
...old,
rows: applyRowAdd(old.rows, tempId, idx, rowData),
}
})
}
interface ReapplyOptimisticUpdatesParams {
queryClient: QueryClient
projectRef: string
@@ -144,14 +247,25 @@ export function reapplyOptimisticUpdates({
for (const operation of tableOperations) {
switch (operation.type) {
case QueuedOperationType.EDIT_CELL_CONTENT: {
const { rowIdentifiers, columnName, newValue } =
operation.payload as EditCellContentPayload
const { rowIdentifiers, columnName, newValue } = operation.payload
rows = applyCellEdit(rows, columnName, rowIdentifiers, newValue)
break
}
case QueuedOperationType.ADD_ROW: {
const { tempId, rowData } = operation.payload
// Derive idx from tempId (tempId is stringified negative timestamp)
const idx = Number(tempId)
rows = applyRowAdd(rows, tempId, idx, rowData)
break
}
case QueuedOperationType.DELETE_ROW: {
const { rowIdentifiers } = operation.payload
rows = markRowAsDeleted(rows, rowIdentifiers)
break
}
default: {
// Need to explicitly handle other operations
throw new Error(`Unknown operation type: ${operation.type}`)
throw new Error(`Unknown operation type: ${(operation as never)['type']}`)
}
}
}
@@ -159,3 +273,76 @@ export function reapplyOptimisticUpdates({
return { ...old, rows }
})
}
interface QueueRowDeletesParams {
rows: SupaRow[]
table: Entity
queryClient: QueryClient
queueOperation: (operation: NewQueuedOperation) => void
projectRef: string | undefined
}
/**
* Queue multiple row delete operations with optimistic updates.
* Caller is responsible for checking if queue mode is enabled before calling.
*/
export function queueRowDeletesWithOptimisticUpdate({
rows,
table,
queryClient,
queueOperation,
projectRef,
}: QueueRowDeletesParams): void {
// [Ali] We can handle these better in the future
// right now this is a pretty abnormal case of this occurring
if (!projectRef) {
console.error('Cannot queue row deletes: projectRef is required')
return
}
if (!isTableLike(table)) {
console.error('Cannot queue row deletes: table must be a TableLike entity')
return
}
if (table.primary_keys.length === 0) {
console.error('Cannot queue row deletes: table has no primary keys')
return
}
for (const row of rows) {
const rowIdentifiers: Record<string, unknown> = {}
table.primary_keys.forEach((pk) => {
rowIdentifiers[pk.name] = row[pk.name]
})
queueOperation({
type: QueuedOperationType.DELETE_ROW,
tableId: table.id,
payload: {
rowIdentifiers,
originalRow: row,
table,
},
})
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: table.id } })
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
if (!old) return old
// For pending add rows, remove completely
if (isPendingAddRow(row)) {
return {
...old,
rows: removeRow(old.rows, rowIdentifiers),
}
}
// For existing rows, mark as deleted
return {
...old,
rows: markRowAsDeleted(old.rows, rowIdentifiers),
}
})
}
}
@@ -1,6 +1,5 @@
import Image from 'next/image'
import { BASE_PATH } from 'lib/constants'
import Image from 'next/image'
export const QueueOperationsPreview = () => {
return (
@@ -10,10 +9,6 @@ export const QueueOperationsPreview = () => {
This gives you more control over when changes are committed, allowing you to batch multiple
edits and review them together.
</p>
<div className="bg-yellow-300/15 border-l-4 border-yellow-500 p-3 text-yellow-1100 text-sm rounded mb-2">
<strong>Note:</strong> We are currently working to add all CRUD operations to the queue.
Right now, only cell edits are supported.
</div>
<Image
src={`${BASE_PATH}/img/previews/queue-operations-table-preview.png`}
width={1296}
@@ -0,0 +1,86 @@
import { useQueryClient } from '@tanstack/react-query'
import { tableRowKeys } from 'data/table-rows/keys'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Plus, X } from 'lucide-react'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { Button } from 'ui'
import { formatOperationItemValue } from './OperationQueueSidePanel.utils'
import { AddRowPayload } from '@/state/table-editor-operation-queue.types'
interface AddRowOperationItemProps {
operationId: string
tableId: number
content: AddRowPayload
}
export const AddRowOperationItem = ({
operationId,
tableId,
content,
}: AddRowOperationItemProps) => {
const { table, rowData } = content
const tableSchema = table.schema
const tableName = table.name
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const snap = useTableEditorStateSnapshot()
const fullTableName = `${tableSchema}.${tableName}`
// Get first 3 column values for preview
const columns = Object.entries(rowData).filter(([key]) => !key.startsWith('__') && key !== 'idx')
const previewColumns = columns.slice(0, 3)
const remainingCount = columns.length - previewColumns.length
const handleDelete = () => {
// Remove the operation from the queue
snap.removeOperation(operationId)
// Invalidate the query to revert the optimistic update
if (project) {
queryClient.invalidateQueries({
queryKey: tableRowKeys.tableRowsAndCount(project.ref, tableId),
})
}
}
return (
<div className="border rounded-md overflow-hidden bg-surface-100 border-l-4 border-l-brand-500">
<div className="px-3 py-2 border-b border-default bg-surface-200 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 flex items-start gap-2">
<Plus size={14} className="text-brand-500 mt-0.5 shrink-0" />
<div>
<div className="text-xs text-foreground font-mono">{fullTableName}</div>
<div className="text-sm text-foreground-muted mt-0.5">
<span className="font-medium text-foreground">New row</span>
</div>
</div>
</div>
<Button
type="text"
size="tiny"
icon={<X size={14} />}
onClick={handleDelete}
className="shrink-0"
aria-label="Remove operation"
/>
</div>
<div className="px-3 py-2 text-xs font-mono space-y-1 bg-brand-100/30">
{previewColumns.map(([key, value]) => (
<div key={key} className="flex items-start gap-2 text-foreground">
<span className="text-foreground-light">{key}:</span>
<span className="truncate" title={formatOperationItemValue(value)}>
{formatOperationItemValue(value)}
</span>
</div>
))}
{remainingCount > 0 && (
<div className="text-foreground-light">+{remainingCount} more column(s)</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,76 @@
import { useQueryClient } from '@tanstack/react-query'
import { Trash2, X } from 'lucide-react'
import { Button } from 'ui'
import { tableRowKeys } from 'data/table-rows/keys'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { DeleteRowPayload } from '@/state/table-editor-operation-queue.types'
import { formatOperationItemValue } from './OperationQueueSidePanel.utils'
interface DeleteRowOperationItemProps {
operationId: string
tableId: number
content: DeleteRowPayload
}
export const DeleteRowOperationItem = ({
operationId,
tableId,
content,
}: DeleteRowOperationItemProps) => {
const { table, rowIdentifiers } = content
const tableSchema = table.schema
const tableName = table.name
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const snap = useTableEditorStateSnapshot()
const fullTableName = `${tableSchema}.${tableName}`
const whereClause = Object.entries(rowIdentifiers)
.map(([key, value]) => `${key} = ${formatOperationItemValue(value)}`)
.join(', ')
const handleDelete = () => {
// Remove the operation from the queue
snap.removeOperation(operationId)
// Invalidate the query to revert the optimistic update
if (project) {
queryClient.invalidateQueries({
queryKey: tableRowKeys.tableRowsAndCount(project.ref, tableId),
})
}
}
return (
<div className="border rounded-md overflow-hidden bg-surface-100 border-l-4 border-l-destructive-500">
<div className="px-3 py-2 border-b border-default bg-surface-200 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 flex items-start gap-2">
<Trash2 size={14} className="text-destructive-500 mt-0.5 shrink-0" />
<div>
<div className="text-xs text-foreground font-mono">{fullTableName}</div>
<div className="text-sm text-foreground-muted mt-0.5">
<span className="font-medium text-foreground">Delete row</span>
<span className="text-foreground-muted mx-2">·</span>
<span className="text-foreground text-xs">where {whereClause}</span>
</div>
</div>
</div>
<Button
type="text"
size="tiny"
icon={<X size={14} />}
onClick={handleDelete}
className="shrink-0"
aria-label="Remove operation"
/>
</div>
<div className="px-3 py-2 text-xs font-mono bg-destructive-100/30">
<div className="text-destructive-500 line-through opacity-70">Row will be deleted</div>
</div>
</div>
)
}
@@ -1,5 +1,12 @@
import { QueuedOperation, QueuedOperationType } from 'state/table-editor-operation-queue.types'
import {
QueuedOperation,
isAddRowOperation,
isDeleteRowOperation,
isEditCellContentOperation,
} from 'state/table-editor-operation-queue.types'
import { AddRowOperationItem } from './AddRowOperationItem'
import { DeleteRowOperationItem } from './DeleteRowOperationItem'
import { OperationItem } from './OperationItem'
interface OperationListProps {
@@ -11,21 +18,65 @@ export const OperationList = ({ operations }: OperationListProps) => {
return <p className="text-sm text-foreground-light">No pending changes</p>
}
const addOperations = operations.filter(isAddRowOperation)
const deleteOperations = operations.filter(isDeleteRowOperation)
const editOperations = operations.filter(isEditCellContentOperation)
return (
<div className="space-y-4">
{operations.map((op) => {
if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) {
return (
<OperationItem
key={op.id}
operationId={op.id}
tableId={op.tableId}
content={op.payload}
/>
)
}
return null
})}
<div className="space-y-6">
{deleteOperations.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground-light">
Rows to Delete ({deleteOperations.length})
</h3>
<div className="space-y-3">
{deleteOperations.map((op) => (
<DeleteRowOperationItem
key={op.id}
operationId={op.id}
tableId={op.tableId}
content={op.payload}
/>
))}
</div>
</div>
)}
{addOperations.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground-light">
Rows to Add ({addOperations.length})
</h3>
<div className="space-y-3">
{addOperations.map((op) => (
<AddRowOperationItem
key={op.id}
operationId={op.id}
tableId={op.tableId}
content={op.payload}
/>
))}
</div>
</div>
)}
{editOperations.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground-light">
Cell Edits ({editOperations.length})
</h3>
<div className="space-y-3">
{editOperations.map((op) => (
<OperationItem
key={op.id}
operationId={op.id}
tableId={op.tableId}
content={op.payload}
/>
))}
</div>
</div>
)}
</div>
)
}
@@ -1,10 +1,10 @@
import { MAX_ARRAY_SIZE, MAX_CHARACTERS } from '@supabase/pg-meta/src/query/table-row-query'
import type { PostgresColumn, PostgresRelationship, PostgresTable } from '@supabase/postgres-meta'
import dayjs from 'dayjs'
import { minifyJSON, tryParseJson } from 'lib/helpers'
import { compact, isEqual, isNull, isString, omitBy } from 'lodash'
import type { Dictionary } from 'types'
import { MAX_ARRAY_SIZE, MAX_CHARACTERS } from '@supabase/pg-meta/src/query/table-row-query'
import { minifyJSON, tryParseJson } from 'lib/helpers'
import { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types'
import {
DATETIME_TYPES,
@@ -250,8 +250,12 @@ export const generateUpdateRowPayload = (originalRow: any, fields: RowField[]) =
if (!isTruncated) {
payload[property] = rowObject[property]
}
} else if (!isEqual(originalRow[property], rowObject[property])) {
payload[property] = rowObject[property]
} else {
const originalValue = originalRow[property] === undefined ? null : originalRow[property]
const newValue = rowObject[property]
if (!isEqual(originalValue, newValue)) {
payload[property] = newValue
}
}
})
@@ -4,7 +4,10 @@ import { isEmpty, isUndefined, noop } from 'lodash'
import { useState } from 'react'
import { toast } from 'sonner'
import { queueCellEditWithOptimisticUpdate } from 'components/grid/utils/queueOperationUtils'
import {
queueCellEditWithOptimisticUpdate,
queueRowAddWithOptimisticUpdate,
} from 'components/grid/utils/queueOperationUtils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useTableApiAccessPrivilegesMutation } from '@/data/privileges/table-api-access-mutation'
import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled'
@@ -57,6 +60,7 @@ import {
createColumn,
createTable,
duplicateTable,
getRowFromSidePanel,
insertRowsViaSpreadsheet,
insertTableRows,
updateColumn,
@@ -271,6 +275,25 @@ export const SidePanelEditor = ({
let saveRowError: Error | undefined
if (isNewRecord) {
// Queue the ADD_ROW operation if queue operations feature is enabled
if (isQueueOperationsEnabled && selectedTable.primary_keys.length > 0) {
queueRowAddWithOptimisticUpdate({
queryClient,
queueOperation: snap.queueOperation,
projectRef: project.ref,
tableId: selectedTable.id,
table: selectedTable as unknown as Entity,
rowData: payload,
enumArrayColumns,
})
// Close panel immediately without error
onComplete()
setIsEdited(false)
snap.closeSidePanel()
return
}
try {
await createTableRows({
projectRef: project.ref,
@@ -297,13 +320,16 @@ export const SidePanelEditor = ({
return
}
const row =
snap.sidePanel?.type === 'json'
? snap.sidePanel.jsonValue.row
: snap.sidePanel?.type === 'cell'
? snap.sidePanel.value?.row
: undefined
const oldValue = row?.[changedColumn]
const row = getRowFromSidePanel(snap.sidePanel)
if (!row) {
saveRowError = new Error('No row found')
toast.error('No row found')
onComplete(saveRowError)
return
}
const oldValue = row[changedColumn]
queueCellEditWithOptimisticUpdate({
queryClient,
@@ -312,6 +338,7 @@ export const SidePanelEditor = ({
tableId: selectedTable.id,
// Cast to Entity - the queue save mutation only uses id, name, schema
table: selectedTable as unknown as Entity,
row,
rowIdentifiers: configuration.identifiers,
columnName: changedColumn,
oldValue: oldValue,
@@ -1,5 +1,7 @@
import { describe, expect, test } from 'vitest'
import { formatRowsForInsert } from './SidePanelEditor.utils'
import { formatRowsForInsert, getRowFromSidePanel } from './SidePanelEditor.utils'
import type { SidePanel } from 'state/table-editor'
import type { SupaRow } from 'components/grid/types'
describe('SidePanelEditor.utils.test.ts', () => {
test('formatRowsForInsert should for format rows with basic data types correctly', () => {
@@ -86,3 +88,104 @@ describe('SidePanelEditor.utils.test.ts', () => {
expect(formattedRows).toEqual([{ id: 1, value: ['item1', 'item2', 'item3'] }])
})
})
describe('getRowFromSidePanel', () => {
const mockRow: SupaRow = { idx: 1, id: 123, name: 'Test Row' }
test('returns undefined when sidePanel is undefined', () => {
expect(getRowFromSidePanel(undefined)).toBeUndefined()
})
test('returns row from json side panel', () => {
const sidePanel: SidePanel = {
type: 'json',
jsonValue: { row: mockRow, column: 'data', value: '{}' },
}
expect(getRowFromSidePanel(sidePanel)).toEqual(mockRow)
})
test('returns row from cell side panel', () => {
const sidePanel: SidePanel = {
type: 'cell',
value: { row: mockRow, column: 'name' },
}
expect(getRowFromSidePanel(sidePanel)).toEqual(mockRow)
})
test('returns undefined from cell side panel when value is undefined', () => {
const sidePanel: SidePanel = {
type: 'cell',
}
expect(getRowFromSidePanel(sidePanel)).toBeUndefined()
})
test('returns row from row side panel', () => {
const sidePanel: SidePanel = {
type: 'row',
row: mockRow,
}
expect(getRowFromSidePanel(sidePanel)).toEqual(mockRow)
})
test('returns undefined from row side panel when row is undefined', () => {
const sidePanel: SidePanel = {
type: 'row',
}
expect(getRowFromSidePanel(sidePanel)).toBeUndefined()
})
test('returns row from foreign-row-selector side panel', () => {
const sidePanel: SidePanel = {
type: 'foreign-row-selector',
foreignKey: {
foreignKey: {
schema: 'public',
table: 'users',
columns: [{ source: 'user_id', target: 'id' }],
deletionAction: 'NO ACTION',
updateAction: 'NO ACTION',
},
row: mockRow,
column: { name: 'user_id' } as any,
},
}
expect(getRowFromSidePanel(sidePanel)).toEqual(mockRow)
})
test('returns undefined for table side panel', () => {
const sidePanel: SidePanel = {
type: 'table',
mode: 'new',
}
expect(getRowFromSidePanel(sidePanel)).toBeUndefined()
})
test('returns undefined for column side panel', () => {
const sidePanel: SidePanel = {
type: 'column',
}
expect(getRowFromSidePanel(sidePanel)).toBeUndefined()
})
test('returns undefined for schema side panel', () => {
const sidePanel: SidePanel = {
type: 'schema',
mode: 'new',
}
expect(getRowFromSidePanel(sidePanel)).toBeUndefined()
})
test('returns undefined for csv-import side panel', () => {
const sidePanel: SidePanel = {
type: 'csv-import',
}
expect(getRowFromSidePanel(sidePanel)).toBeUndefined()
})
test('returns undefined for operation-queue side panel', () => {
const sidePanel: SidePanel = {
type: 'operation-queue',
}
expect(getRowFromSidePanel(sidePanel)).toBeUndefined()
})
})
@@ -1,10 +1,7 @@
import pgMeta from '@supabase/pg-meta'
import type { PostgresPrimaryKey } from '@supabase/postgres-meta'
import { chunk, find, isEmpty, isEqual } from 'lodash'
import Papa from 'papaparse'
import { toast } from 'sonner'
import { Query } from '@supabase/pg-meta/src/query'
import type { PostgresPrimaryKey } from '@supabase/postgres-meta'
import type { SupaRow } from 'components/grid/types'
import { GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils'
import SparkBar from 'components/ui/SparkBar'
import { createDatabaseColumn } from 'data/database-columns/database-column-create-mutation'
@@ -26,10 +23,10 @@ import { tableRowKeys } from 'data/table-rows/keys'
import { executeWithRetry } from 'data/table-rows/table-rows-query'
import { tableKeys } from 'data/tables/keys'
import {
RetrieveTableResult,
RetrievedTableColumn,
getTable,
getTableQuery,
RetrievedTableColumn,
RetrieveTableResult,
} from 'data/tables/table-retrieve-query'
import {
UpdateTableBody,
@@ -38,6 +35,11 @@ import {
import { getTables } from 'data/tables/tables-query'
import { sendEvent } from 'data/telemetry/send-event-mutation'
import { timeout, tryParseJson } from 'lib/helpers'
import { chunk, find, isEmpty, isEqual } from 'lodash'
import Papa from 'papaparse'
import { toast } from 'sonner'
import type { SidePanel } from 'state/table-editor'
import {
generateCreateColumnPayload,
generateUpdateColumnPayload,
@@ -46,10 +48,38 @@ import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types'
import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types'
import { checkIfRelationChanged } from './TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils'
import type { ImportContent } from './TableEditor/TableEditor.types'
import type { DeepReadonly } from '@/lib/type-helpers'
const BATCH_SIZE = 1000
const CHUNK_SIZE = 1024 * 1024 * 0.1 // 0.1MB
/**
* Extracts the row data from the current side panel state.
* Used when queuing cell edit operations to get the row being edited.
* Accepts both mutable and readonly (valtio snapshot) versions of SidePanel.
*
* @param sidePanel - The current side panel state (can be readonly from valtio snapshot)
* @returns The row data if available, undefined otherwise
*/
export function getRowFromSidePanel(
sidePanel: SidePanel | DeepReadonly<SidePanel> | undefined
): SupaRow | undefined {
if (!sidePanel) return undefined
switch (sidePanel.type) {
case 'json':
return sidePanel.jsonValue.row as SupaRow | undefined
case 'cell':
return sidePanel.value?.row as SupaRow | undefined
case 'row':
return sidePanel.row as SupaRow | undefined
case 'foreign-row-selector':
return sidePanel.foreignKey.row as SupaRow | undefined
default:
return undefined
}
}
/**
* The functions below are basically just queries but may be supported directly
* from the pg-meta library in the future
@@ -1,17 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import type { PendingAddRow } from 'components/grid/types'
import { executeSql } from 'data/sql/execute-sql-query'
import { wrapWithTransaction } from 'data/sql/utils/transaction'
import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation'
import { toast } from 'sonner'
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
import {
EditCellContentPayload,
QueuedOperation,
QueuedOperationType,
} from 'state/table-editor-operation-queue.types'
import { QueuedOperation, QueuedOperationType } from 'state/table-editor-operation-queue.types'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { tableRowKeys } from './keys'
import { getTableRowCreateSql } from './table-row-create-mutation'
import { getTableRowDeleteSql } from './table-row-delete-mutation'
import { getTableRowUpdateSql } from './table-row-update-mutation'
export type OperationQueueSaveVariables = {
@@ -28,7 +27,7 @@ export type OperationQueueSaveVariables = {
function getOperationSql(operation: QueuedOperation): string {
switch (operation.type) {
case QueuedOperationType.EDIT_CELL_CONTENT: {
const payload = operation.payload as EditCellContentPayload
const { payload } = operation
return getTableRowUpdateSql({
table: {
id: payload.table.id,
@@ -41,11 +40,46 @@ function getOperationSql(operation: QueuedOperation): string {
returning: false,
})
}
default:
throw new Error(`Unknown operation type: ${(operation as QueuedOperation).type}`)
case QueuedOperationType.ADD_ROW: {
const { payload } = operation
// Clean internal fields before SQL generation
const { __tempId, idx, ...cleanRowData } = payload.rowData as PendingAddRow
return getTableRowCreateSql({
table: { id: payload.table.id, name: payload.table.name, schema: payload.table.schema },
payload: cleanRowData,
enumArrayColumns: payload.enumArrayColumns ?? [],
returning: false,
})
}
case QueuedOperationType.DELETE_ROW: {
const { payload } = operation
// Create a mock row with the row identifiers for the delete SQL
const mockRow = { idx: 0, ...payload.rowIdentifiers }
return getTableRowDeleteSql({
table: payload.table,
rows: [mockRow],
})
}
default: {
// Error should never happen, but we'll handle it anyway. cast to never for exhaustive check.
const _exhaustiveCheck: never = operation
throw new Error(`Unknown operation: ${(_exhaustiveCheck as { type: string }).type}`)
}
}
}
function sortOperations(operations: readonly QueuedOperation[]): QueuedOperation[] {
const operationOrder: Record<QueuedOperationType, number> = {
[QueuedOperationType.DELETE_ROW]: 0,
[QueuedOperationType.ADD_ROW]: 1,
[QueuedOperationType.EDIT_CELL_CONTENT]: 2,
}
return [...operations].sort((a, b) => {
return operationOrder[a.type] - operationOrder[b.type]
})
}
/**
* Saves all queued operations in a single database transaction.
* If any operation fails, the entire transaction is rolled back.
@@ -60,16 +94,14 @@ export async function saveOperationQueue({
return { result: [] }
}
// Generate SQL for each operation, stripping trailing semicolons to avoid double semicolons when joining
const statements = operations.map((op) => {
const sortedOperations = sortOperations(operations)
const statements = sortedOperations.map((op) => {
const sql = getOperationSql(op)
return sql.endsWith(';') ? sql.slice(0, -1) : sql
})
// Combine all statements into a single transaction
const transactionSql = wrapWithTransaction(statements.join(';\n') + ';')
// Wrap with role impersonation if enabled
const sql = wrapWithRoleImpersonation(transactionSql, roleImpersonationState)
const { result } = await executeSql({
@@ -1,13 +1,12 @@
import type { Entity } from 'data/table-editor/table-editor-types'
import type { Dictionary } from 'types'
/**
* Extensible enum for queued operation types.
* Add new operation types here as we expand the queuing system.
*/
import { PendingAddRow, SupaRow } from '@/components/grid/types'
export enum QueuedOperationType {
EDIT_CELL_CONTENT = 'edit_cell_content',
// Future: DELETE_ROW, ADD_ROW, EDIT_COLUMN, etc.
ADD_ROW = 'add_row',
DELETE_ROW = 'delete_row',
}
/**
@@ -24,23 +23,72 @@ export interface EditCellContentPayload {
}
/**
* Union type for all operation payloads.
* Extend this as new operation types are added.
* Payload for ADD_ROW operations
*/
export type QueuedOperationPayload = EditCellContentPayload
/**
* Individual queued operation
*/
export interface QueuedOperation {
id: string
type: QueuedOperationType
tableId: number // Which table this operation belongs to
timestamp: number
payload: QueuedOperationPayload
export interface AddRowPayload {
tempId: string // Client-generated UUID (row has no PK yet)
rowData: PendingAddRow // Column values for the new row
table: Entity
enumArrayColumns?: string[]
}
export type NewQueuedOperation = Omit<QueuedOperation, 'id' | 'timestamp'>
/**
* Payload for DELETE_ROW operations
*/
export interface DeleteRowPayload {
rowIdentifiers: Dictionary<unknown> // Primary key values
originalRow: SupaRow // Full row for display/undo
table: Entity
}
export type QueuedOperationPayload = EditCellContentPayload | AddRowPayload | DeleteRowPayload
interface QueuedOperationBase {
id: string
tableId: number // Which table this operation belongs to
timestamp: number
}
export interface EditCellContentOperation extends QueuedOperationBase {
type: QueuedOperationType.EDIT_CELL_CONTENT
payload: EditCellContentPayload
}
export interface AddRowOperation extends QueuedOperationBase {
type: QueuedOperationType.ADD_ROW
payload: AddRowPayload
}
export interface DeleteRowOperation extends QueuedOperationBase {
type: QueuedOperationType.DELETE_ROW
payload: DeleteRowPayload
}
export type QueuedOperation = EditCellContentOperation | AddRowOperation | DeleteRowOperation
interface NewQueuedOperationBase {
tableId: number
}
export interface NewEditCellContentOperation extends NewQueuedOperationBase {
type: QueuedOperationType.EDIT_CELL_CONTENT
payload: EditCellContentPayload
}
export interface NewAddRowOperation extends NewQueuedOperationBase {
type: QueuedOperationType.ADD_ROW
payload: AddRowPayload
}
export interface NewDeleteRowOperation extends NewQueuedOperationBase {
type: QueuedOperationType.DELETE_ROW
payload: DeleteRowPayload
}
export type NewQueuedOperation =
| NewEditCellContentOperation
| NewAddRowOperation
| NewDeleteRowOperation
/**
* Status of the overall operation queue
@@ -54,3 +102,15 @@ export interface OperationQueueState {
operations: QueuedOperation[]
status: QueueStatus
}
export function isDeleteRowOperation(op: QueuedOperation): op is DeleteRowOperation {
return op.type === QueuedOperationType.DELETE_ROW
}
export function isAddRowOperation(op: QueuedOperation): op is AddRowOperation {
return op.type === QueuedOperationType.ADD_ROW
}
export function isEditCellContentOperation(op: QueuedOperation): op is EditCellContentOperation {
return op.type === QueuedOperationType.EDIT_CELL_CONTENT
}
+4
View File
@@ -84,6 +84,10 @@ export const createTableEditorTableState = ({
state.allRowsSelected = selectAll ?? false
state.selectedRows = proxySet(rows)
},
resetSelectedRows: () => {
state.allRowsSelected = false
state.selectedRows = proxySet(new Set())
},
/* Columns */
gridColumns,
+48 -31
View File
@@ -2,9 +2,11 @@ import type { PostgresColumn } from '@supabase/postgres-meta'
import { useConstant } from 'common'
import type { SupaRow } from 'components/grid/types'
import {
generateTableChangeKey,
generateTableChangeKeyFromOperation,
} from 'components/grid/utils/queueOperationUtils'
resolveDeleteRowConflicts,
resolveEditCellConflicts,
upsertOperation,
} from 'components/grid/utils/queueConflictResolution'
import { generateTableChangeKey } from 'components/grid/utils/queueOperationUtils'
import { ForeignKey } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types'
import type { EditValue } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types'
import type { TableField } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types'
@@ -16,7 +18,6 @@ import {
NewQueuedOperation,
type OperationQueueState,
type QueueStatus,
type QueuedOperation,
QueuedOperationType,
} from './table-editor-operation-queue.types'
@@ -236,34 +237,49 @@ export const createTableEditorState = () => {
/**
* Queue a new operation for later processing.
* If an operation with the same key already exists, it will be overwritten.
* Handles conflict resolution:
* - DELETE_ROW on a row: remove any pending EDIT_CELL ops for that row
* - EDIT_CELL on a row pending deletion: reject (console.warn)
* - EDIT_CELL on a newly added row: merge edit into ADD_ROW's rowData
* - DELETE_ROW on a newly added row: cancel both operations
*/
queueOperation: (operation: NewQueuedOperation) => {
const operationKey = generateTableChangeKeyFromOperation(operation)
const existingOpIndex = state.operationQueue.operations.findIndex(
(op) => op.id === operationKey
)
const newOperation: QueuedOperation = {
...operation,
id: operationKey,
timestamp: Date.now(),
}
if (existingOpIndex >= 0) {
// [Ali] Keep the old value of the operation that is being overwritten, in case someone edits the cell again, it should reference the original value.
// When a user edits the same cell multiple times before saving, we need to preserve the original "before edit" value, not the intermediate value from the previous queued edit
if (newOperation.type === QueuedOperationType.EDIT_CELL_CONTENT) {
newOperation.payload.oldValue =
state.operationQueue.operations[existingOpIndex].payload.oldValue
const updateQueueStatus = () => {
if (state.operationQueue.operations.length === 0) {
state.operationQueue.status = 'idle'
} else if (state.operationQueue.status === 'idle') {
state.operationQueue.status = 'pending'
}
state.operationQueue.operations[existingOpIndex] = newOperation
} else {
state.operationQueue.operations.push(newOperation)
}
if (state.operationQueue.status === 'idle') {
state.operationQueue.status = 'pending'
// Handle DELETE_ROW conflicts
if (operation.type === QueuedOperationType.DELETE_ROW) {
const result = resolveDeleteRowConflicts(state.operationQueue.operations, operation)
state.operationQueue.operations = result.filteredOperations
if (result.action === 'skip') {
updateQueueStatus()
return
}
}
// Handle EDIT_CELL_CONTENT conflicts
if (operation.type === QueuedOperationType.EDIT_CELL_CONTENT) {
const result = resolveEditCellConflicts(state.operationQueue.operations, operation)
if (result.action === 'reject') {
console.warn(result.reason)
return
}
if (result.action === 'merge') {
state.operationQueue.operations = result.updatedOperations
updateQueueStatus()
return
}
}
// Normal upsert
const { operations } = upsertOperation(state.operationQueue.operations, operation)
state.operationQueue.operations = operations
updateQueueStatus()
},
/**
@@ -301,16 +317,17 @@ export const createTableEditorState = () => {
},
hasPendingCellChange: (
type: QueuedOperationType,
tableId: number,
rowIdentifiers: Record<string, unknown>,
rowIdentifiers: Dictionary<unknown>,
columnName: string
): boolean => {
const key = generateTableChangeKey({
type,
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId,
columnName,
rowIdentifiers,
payload: {
columnName,
rowIdentifiers,
},
})
return state.operationQueue.operations.some((op) => op.id === key)
},
+39
View File
@@ -46,6 +46,45 @@
box-shadow: inset 0 0 0 2px hsl(var(--warning-default));
}
// Row pending addition - green background
.rdg-row.rdg-row--added {
background-color: hsl(var(--brand-200) / 0.3);
&:hover {
background-color: hsl(var(--brand-200) / 0.5);
}
.rdg-cell {
border-left-color: hsl(var(--brand-default));
}
// First cell gets a stronger left border to indicate new row
.rdg-cell:first-child {
box-shadow: inset 3px 0 0 0 hsl(var(--brand-default));
}
}
// Row pending deletion - red background with strikethrough effect
.rdg-row.rdg-row--deleted {
background-color: hsl(var(--destructive-200) / 0.3);
opacity: 0.6;
&:hover {
background-color: hsl(var(--destructive-200) / 0.5);
}
.rdg-cell {
text-decoration: line-through;
text-decoration-color: hsl(var(--destructive-default));
border-left-color: hsl(var(--destructive-default));
}
// First cell gets a stronger left border to indicate deleted row
.rdg-cell:first-child {
box-shadow: inset 3px 0 0 0 hsl(var(--destructive-default));
}
}
.rdg {
@apply box-border select-none overflow-x-auto overflow-y-scroll bg-dash-canvas;
@apply border-t border-r-0 border-l-0;