feature: batch and save operations for cell content updates (#42120)

* added initial queue operations and feature flag

* updated types

* added dirty state tracking on columns

* updated queue operations

* updated operation types and queue

* updated spacing

* removed on cancel

* updated to support saving

* updated to include eye details

* updated spacing for orders

* updated to support shortcuts

* added feature preview

* updated to unify queue methods

* added key generation

* used unique keys rather than random uuid

* updated based on code review

* operation key

* updated handle cancel

* updated remove operation button

* updated views for toast

* updated logic to support optimistic updates

* updated types

* code cleanup: remove LLM slop

* updated PR bug

* updated preview for logout

* updated based on code review

* removed use effect as it was causing problems

* fixed toast mounting away from sql editor

* removed toast for dedicated action bar

* cleaned up logic

* updated queue operations

* renamed method

* updated name for types

* updated comment

* fixed code rabbit solution

* added check for changed column

* added tests
This commit is contained in:
Ali Waseem authored and GitHub committed 2026-01-28 06:54:30 -07:00
1 parent f6441d8d60
commit ea1b95d29b
25 files changed
+1369 -26

No files matched your search

+36 -1
View File
@@ -12,6 +12,7 @@ import { EMPTY_ARR } from 'lib/void'
import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import { QueuedOperation } from 'state/table-editor-operation-queue.types'
import { Shortcuts } from './components/common/Shortcuts'
import { Footer } from './components/footer/Footer'
@@ -20,12 +21,14 @@ import { Header, HeaderProps } from './components/header/Header'
import { HeaderNew } from './components/header/HeaderNew'
import { RowContextMenu } from './components/menu/RowContextMenu'
import { GridProps } from './types'
import { reapplyOptimisticUpdates } from './utils/queueOperationUtils'
import { keepPreviousData } from '@tanstack/react-query'
import { keepPreviousData, useQueryClient } from '@tanstack/react-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useTableFilter } from './hooks/useTableFilter'
import { useTableSort } from './hooks/useTableSort'
import { validateMsSqlSorting } from './MsSqlValidation'
import { useIsQueueOperationsEnabled } from '../interfaces/App/FeaturePreview/FeaturePreviewContext'
export const SupabaseGrid = ({
customHeader,
@@ -39,6 +42,9 @@ export const SupabaseGrid = ({
const { id: _id } = useParams()
const tableId = _id ? Number(_id) : undefined
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
@@ -65,6 +71,7 @@ export const SupabaseGrid = ({
isError,
isPending: isLoading,
isRefetching,
dataUpdatedAt,
} = useTableRowsQuery(
{
projectRef: project?.ref,
@@ -91,6 +98,34 @@ export const SupabaseGrid = ({
if (!mounted) setMounted(true)
}, [])
// Re-apply optimistic updates when table data is loaded/refetched
// This ensures pending changes remain visible when switching tabs or after data refresh
useEffect(() => {
if (
isSuccess &&
project?.ref &&
tableId &&
isQueueOperationsEnabled &&
tableEditorSnap.hasPendingOperations
) {
reapplyOptimisticUpdates({
queryClient,
projectRef: project.ref,
tableId,
operations: tableEditorSnap.operationQueue.operations as readonly QueuedOperation[],
})
}
}, [
isSuccess,
dataUpdatedAt,
project?.ref,
tableId,
isQueueOperationsEnabled,
tableEditorSnap.hasPendingOperations,
tableEditorSnap.operationQueue.operations,
queryClient,
])
const rows = data?.rows ?? EMPTY_ARR
const HeaderComponent = newFilterBarEnabled ? HeaderNew : Header
@@ -4,6 +4,7 @@ import type { RenderEditCellProps } from 'react-data-grid'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { isValueTruncated } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { isTableLike } from 'data/table-editor/table-editor-types'
@@ -44,6 +45,7 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({
const [isPopoverOpen, setIsPopoverOpen] = useState(true)
const [value, setValue] = useState<string | null>(initialValue)
const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false)
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const { mutate: getCellValue, isPending, isSuccess } = useGetCellValueMutation()
@@ -169,7 +171,14 @@ export const TextEditor = <TRow, TSummaryRow = unknown>({
size="tiny"
type="default"
htmlType="button"
onClick={() => setIsConfirmNextModalOpen(true)}
onClick={() => {
if (isQueueOperationsEnabled) {
// Skip confirmation when queue mode is enabled - changes can be reviewed/cancelled
saveChanges(null)
} else {
setIsConfirmNextModalOpen(true)
}
}}
>
Set to NULL
</Button>
@@ -0,0 +1,78 @@
import { Eye } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { createPortal } from 'react-dom'
import { Button } from 'ui'
import {
useOperationQueueShortcuts,
getModKey,
} from 'components/grid/hooks/useOperationQueueShortcuts'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions'
export const SaveQueueActionBar = () => {
const snap = useTableEditorStateSnapshot()
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const { handleSave } = useOperationQueueActions()
const operationCount = snap.operationQueue.operations.length
const isSaving = snap.operationQueue.status === 'saving'
const isOperationQueuePanelOpen = snap.sidePanel?.type === 'operation-queue'
const isVisible =
isQueueOperationsEnabled && snap.hasPendingOperations && !isOperationQueuePanelOpen
useOperationQueueShortcuts({
enabled: isQueueOperationsEnabled && snap.hasPendingOperations,
onSave: handleSave,
onTogglePanel: () => snap.onViewOperationQueue(),
isSaving,
hasOperations: operationCount > 0,
})
const modKey = getModKey()
const content = (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.2 }}
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50"
>
<div className="flex items-center gap-8 px-4 py-3 bg-surface-100 border rounded-lg shadow-lg">
<span className="text-sm text-foreground">
{operationCount} pending change{operationCount !== 1 ? 's' : ''}
</span>
<div className="flex items-center gap-3">
<button
onClick={() => snap.onViewOperationQueue()}
className="text-foreground-light hover:text-foreground transition-colors flex items-center"
aria-label="View Details"
>
<Eye size={14} />
<span className="text-foreground-lighter text-[10px] ml-1">{`${modKey}.`}</span>
</button>
<Button
size="tiny"
type="primary"
onClick={handleSave}
disabled={isSaving}
loading={isSaving}
>
Save
<span className="text-foreground-lighter text-[10px] ml-1">{`${modKey}S`}</span>
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
if (typeof document === 'undefined') return null
return createPortal(content, document.body)
}
@@ -1,4 +1,5 @@
import { forwardRef, memo, Ref, useRef } from 'react'
import type { PostgresColumn } from '@supabase/postgres-meta'
import { forwardRef, memo, Ref, useMemo, useRef } from 'react'
import DataGrid, { CalculatedColumn, DataGridHandle } from 'react-data-grid'
import { ref as valtioRef } from 'valtio'
@@ -7,6 +8,7 @@ import { handleCopyCell } from 'components/grid/SupabaseGrid.utils'
import { formatForeignKeys } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils'
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
import { isTableLike } from 'data/table-editor/table-editor-types'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
@@ -19,14 +21,15 @@ import type { GridProps, SupaRow } from '../../types'
import { useOnRowsChange } from './Grid.utils'
import { GridError } from './GridError'
import RowRenderer from './RowRenderer'
import { QueuedOperationType } from '@/state/table-editor-operation-queue.types'
const rowKeyGetter = (row: SupaRow) => {
return row?.idx ?? -1
}
interface IGrid extends GridProps {
rows: any[]
error: any
rows: SupaRow[]
error: Error | null
isDisabled?: boolean
isLoading: boolean
isSuccess: boolean
@@ -65,9 +68,17 @@ export const Grid = memo(
snap.setSelectedRows(selectedRows)
}
const selectedCellRef = useRef<{ rowIdx: number; row: any; column: any } | null>(null)
const selectedCellRef = useRef<{
rowIdx: number
row: SupaRow
column: CalculatedColumn<SupaRow, unknown>
} | null>(null)
function onSelectedCellChange(args: { rowIdx: number; row: any; column: any }) {
function onSelectedCellChange(args: {
rowIdx: number
row: SupaRow
column: CalculatedColumn<SupaRow, unknown>
}) {
selectedCellRef.current = args
snap.setSelectedCellPosition({ idx: args.column.idx, rowIdx: args.rowIdx })
}
@@ -122,20 +133,60 @@ export const Grid = memo(
return fk !== undefined ? formatForeignKeys([fk])[0] : undefined
}
function onRowDoubleClick(row: any, column: any) {
function onRowDoubleClick(row: SupaRow, column: { name: string }) {
const foreignKey = getColumnForeignKey(column.name)
if (foreignKey) {
tableEditorSnap.onEditForeignKeyColumnValue({
foreignKey,
row,
column,
column: column as unknown as PostgresColumn,
})
}
}
const removeAllFilters = () => onApplyFilters([])
// Compute columns with cellClass for dirty cells
// This needs to be computed at render time so it reacts to operation queue changes
const columnsWithDirtyCellClass = useMemo(() => {
const primaryKeys = isTableLike(snap.originalTable) ? snap.originalTable.primary_keys : []
const pendingOperations = tableEditorSnap.operationQueue.operations
// If no pending operations, return columns as-is
if (pendingOperations.length === 0) {
return snap.gridColumns as CalculatedColumn<SupaRow, unknown>[]
}
return (snap.gridColumns as CalculatedColumn<SupaRow, unknown>[]).map((col) => {
// Skip special columns like select column
if (col.key === 'select-row' || col.key === 'add-column') {
return col
}
return {
...col,
cellClass: (row: SupaRow) => {
// Build row identifiers from primary keys
const rowIdentifiers: Record<string, unknown> = {}
for (const pk of primaryKeys) {
rowIdentifiers[pk.name] = row[pk.name]
}
// 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
)
return isDirty ? 'rdg-cell--dirty' : undefined
},
}
})
}, [snap.gridColumns, snap.originalTable, snap.table.id, tableEditorSnap])
return (
<div
className={cn('flex flex-col relative transition-colors', containerClass)}
@@ -245,7 +296,7 @@ export const Grid = memo(
ref={ref}
className={`${gridClass} flex-grow`}
rowClass={rowClass}
columns={snap.gridColumns as CalculatedColumn<any, any>[]}
columns={columnsWithDirtyCellClass}
rows={rows ?? []}
renderers={{ renderRow: RowRenderer }}
rowKeyGetter={rowKeyGetter}
@@ -254,7 +305,11 @@ export const Grid = memo(
onRowsChange={onRowsChange}
onSelectedCellChange={onSelectedCellChange}
onSelectedRowsChange={onSelectedRowsChange}
onCellDoubleClick={(props) => onRowDoubleClick(props.row, props.column)}
onCellDoubleClick={(props) => {
if (typeof props.column.name === 'string') {
onRowDoubleClick(props.row, { name: props.column.name })
}
}}
onCellKeyDown={handleCopyCell}
/>
</div>
@@ -4,6 +4,8 @@ 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'
import { convertByteaToHex } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils'
import { DocsButton } from 'components/ui/DocsButton'
import { isTableLike } from 'data/table-editor/table-editor-types'
@@ -15,11 +17,14 @@ import { DOCS_URL } from 'lib/constants'
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[]) {
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const snap = useTableEditorTableStateSnapshot()
const tableEditorSnap = useTableEditorStateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const { mutate: mutateUpdateTableRow } = useTableRowUpdateMutation({
@@ -88,8 +93,6 @@ export function useOnRowsChange(rows: SupaRow[]) {
if (!previousRow || !changedColumn) return
const updatedData = { [changedColumn]: rowData[changedColumn] }
const enumArrayColumns = snap.originalTable.columns
?.filter((column) => {
return (column?.enums ?? []).length > 0 && column.data_type.toLowerCase() === 'array'
@@ -106,7 +109,6 @@ export function useOnRowsChange(rows: SupaRow[]) {
: previousRow[column.name]
})
const configuration = { identifiers }
if (Object.keys(identifiers).length === 0) {
return toast('Unable to update row as table has no primary keys', {
description: (
@@ -123,16 +125,46 @@ export function useOnRowsChange(rows: SupaRow[]) {
})
}
mutateUpdateTableRow({
projectRef: project.ref,
connectionString: project.connectionString,
table: snap.originalTable,
configuration,
payload: updatedData,
enumArrayColumns,
roleImpersonationState: getImpersonatedRoleState(),
})
const configuration = { identifiers }
if (isQueueOperationsEnabled) {
queueCellEditWithOptimisticUpdate({
queryClient,
queueOperation: tableEditorSnap.queueOperation,
projectRef: project.ref,
tableId: snap.table.id,
table: snap.originalTable,
rowIdentifiers: identifiers,
columnName: changedColumn,
oldValue: previousRow[changedColumn],
newValue: rowData[changedColumn],
enumArrayColumns,
})
} else {
// Default behavior: immediately save the change
const updatedData = { [changedColumn]: rowData[changedColumn] }
mutateUpdateTableRow({
projectRef: project.ref,
connectionString: project.connectionString,
table: snap.originalTable,
configuration,
payload: updatedData,
enumArrayColumns,
roleImpersonationState: getImpersonatedRoleState(),
})
}
},
[getImpersonatedRoleState, mutateUpdateTableRow, project, rows, snap.originalTable]
[
getImpersonatedRoleState,
isQueueOperationsEnabled,
mutateUpdateTableRow,
project,
rows,
snap.originalTable,
snap.table.id,
tableEditorSnap,
queryClient,
]
)
}
@@ -0,0 +1,83 @@
import { useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { toast } from 'sonner'
import { tableRowKeys } from 'data/table-rows/keys'
import { useOperationQueueSaveMutation } from 'data/table-rows/operation-queue-save-mutation'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { QueuedOperation } from 'state/table-editor-operation-queue.types'
interface UseOperationQueueActionsOptions {
onSaveSuccess?: () => void
onCancelSuccess?: () => void
}
/**
* Hook that provides save and cancel actions for the operation queue.
* Consolidates the logic used by both the useSaveQueueToast hook and OperationQueueSidePanel.
*/
export function useOperationQueueActions(options: UseOperationQueueActionsOptions = {}) {
const { onSaveSuccess, onCancelSuccess } = options
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const snap = useTableEditorStateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const { mutate: saveOperationQueue, isPending: isMutationPending } =
useOperationQueueSaveMutation({
onSuccess: () => {
snap.clearQueue()
toast.success('Changes saved successfully')
onSaveSuccess?.()
},
onError: (error) => {
snap.setQueueStatus('idle')
toast.error(`Failed to save changes: ${error.message}`)
},
})
const isSaving = snap.operationQueue.status === 'saving' || isMutationPending
const handleSave = useCallback(() => {
if (!project) return
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
if (operations.length === 0) return
snap.setQueueStatus('saving')
saveOperationQueue({
projectRef: project.ref,
connectionString: project.connectionString,
operations,
roleImpersonationState: getImpersonatedRoleState(),
})
}, [snap, project, saveOperationQueue, getImpersonatedRoleState])
const handleCancel = useCallback(() => {
// Get unique table IDs from the queue before clearing
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
const tableIds = [...new Set(operations.map((op) => op.tableId))]
// Clear the queue and invalidate queries to revert optimistic updates
snap.clearQueue()
if (project) {
// Invalidate queries for each table that had pending operations
tableIds.forEach((tableId) => {
queryClient.invalidateQueries({
queryKey: tableRowKeys.tableRowsAndCount(project.ref, tableId),
})
})
}
onCancelSuccess?.()
}, [snap, project, queryClient, onCancelSuccess])
return {
handleSave,
handleCancel,
isSaving,
}
}
@@ -0,0 +1,68 @@
import { useCallback, useEffect } from 'react'
import { detectOS } from 'lib/helpers'
export function getModKey() {
const os = detectOS()
return os === 'macos' ? '⌘' : 'Ctrl+'
}
interface UseOperationQueueShortcutsOptions {
enabled: boolean
onSave: () => void
onTogglePanel: () => void
isSaving?: boolean
hasOperations?: boolean
}
/**
* Hook that provides keyboard shortcuts for the operation queue.
*
* Shortcuts:
* - Cmd/Ctrl + S: Save all pending changes
* - Cmd/Ctrl + .: Toggle the operation queue side panel
*
* These shortcuts are registered on the capture phase to ensure they fire
* before the data grid handles the keyboard event.
*/
export function useOperationQueueShortcuts({
enabled,
onSave,
onTogglePanel,
isSaving = false,
hasOperations = true,
}: UseOperationQueueShortcutsOptions) {
const os = detectOS()
const modKey = os === 'macos' ? '⌘' : 'Ctrl+'
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const isMod = os === 'macos' ? event.metaKey : event.ctrlKey
if (isMod && event.key === 's') {
event.preventDefault()
event.stopPropagation()
if (!isSaving && hasOperations) {
onSave()
}
} else if (isMod && event.key === '.') {
event.preventDefault()
event.stopPropagation()
onTogglePanel()
}
},
[os, isSaving, hasOperations, onSave, onTogglePanel]
)
// Use capture phase to intercept events before the grid handles them
useEffect(() => {
if (enabled) {
window.addEventListener('keydown', handleKeyDown, true)
return () => {
window.removeEventListener('keydown', handleKeyDown, true)
}
}
}, [enabled, handleKeyDown])
return { modKey }
}
@@ -0,0 +1,217 @@
import { describe, test, expect } from 'vitest'
import {
generateTableChangeKey,
generateTableChangeKeyFromOperation,
rowMatchesIdentifiers,
applyCellEdit,
} from './queueOperationUtils'
import { 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 = {
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'old',
newValue: 'new',
table: {} as any,
},
}
const key = generateTableChangeKeyFromOperation(operation)
expect(key).toBe('edit_cell_content:1:name:id:1')
})
test('should throw error for unknown operation type', () => {
const operation = {
type: 'unknown' as any,
tableId: 1,
payload: {
rowIdentifiers: { id: 1 },
columnName: 'name',
oldValue: 'old',
newValue: 'new',
table: {} as any,
},
}
expect(() => generateTableChangeKeyFromOperation(operation)).toThrow('Unknown operation type')
})
})
describe('rowMatchesIdentifiers', () => {
test('should return false for empty row identifiers', () => {
const result = rowMatchesIdentifiers({ id: 1 }, {})
expect(result).toBe(false)
})
test('should match row with single identifier', () => {
const result = rowMatchesIdentifiers({ id: 1 }, { id: 1 })
expect(result).toBe(true)
})
test('should match row with multiple identifiers', () => {
const result = rowMatchesIdentifiers(
{ id: 1, email: 'test@test.com' },
{ id: 1, email: 'test@test.com' }
)
expect(result).toBe(true)
})
test('should not match row with different values', () => {
const result = rowMatchesIdentifiers({ id: 2 }, { id: 1 })
expect(result).toBe(false)
})
test('should not match row with missing identifier keys', () => {
const result = rowMatchesIdentifiers({ id: 1 }, { id: 1, email: 'test@test.com' })
expect(result).toBe(false)
})
test('should match row with extra keys', () => {
const result = rowMatchesIdentifiers({ id: 1, name: 'John', age: 30 }, { id: 1 })
expect(result).toBe(true)
})
test('should match with null values', () => {
const result = rowMatchesIdentifiers({ id: null }, { id: null })
expect(result).toBe(true)
})
test('should not match with undefined values in row', () => {
const result = rowMatchesIdentifiers({ id: undefined, name: 'test' }, { id: 1 })
expect(result).toBe(false)
})
})
describe('applyCellEdit', () => {
test('should apply cell edit to matching row', () => {
const rows = [
{ idx: 0, id: 1, name: 'old' },
{ idx: 1, id: 2, name: 'test' },
]
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
expect(result).toEqual([
{ idx: 0, id: 1, name: 'new' },
{ idx: 1, id: 2, name: 'test' },
])
})
test('should not affect non-matching rows', () => {
const rows = [
{ idx: 0, id: 1, name: 'old' },
{ idx: 1, id: 2, name: 'test' },
]
const result = applyCellEdit(rows, 'name', { id: 3 }, 'new')
expect(result).toEqual([
{ idx: 0, id: 1, name: 'old' },
{ idx: 1, id: 2, name: 'test' },
])
})
test('should create new row instances for matching row', () => {
const rows = [{ idx: 0, id: 1, name: 'old' }]
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
expect(result[0]).not.toBe(rows[0])
expect(result[0]).toEqual({ idx: 0, id: 1, name: 'new' })
})
test('should not modify original array', () => {
const rows = [{ idx: 0, id: 1, name: 'old' }]
const originalRows = [...rows]
applyCellEdit(rows, 'name', { id: 1 }, 'new')
expect(rows).toEqual(originalRows)
})
test('should handle multiple matching rows with composite keys', () => {
const rows = [
{ idx: 0, id: 1, org_id: 10, name: 'old1' },
{ idx: 1, id: 1, org_id: 20, name: 'old2' },
{ idx: 2, id: 2, org_id: 10, name: 'old3' },
]
const result = applyCellEdit(rows, 'name', { id: 1, org_id: 10 }, 'new')
expect(result).toEqual([
{ idx: 0, id: 1, org_id: 10, name: 'new' },
{ idx: 1, id: 1, org_id: 20, name: 'old2' },
{ idx: 2, id: 2, org_id: 10, name: 'old3' },
])
})
test('should handle setting value to null', () => {
const rows = [{ idx: 0, id: 1, name: 'test' }]
const result = applyCellEdit(rows, 'name', { id: 1 }, null)
expect(result).toEqual([{ idx: 0, id: 1, name: null }])
})
test('should handle setting value to undefined', () => {
const rows = [{ idx: 0, id: 1, name: 'test' }]
const result = applyCellEdit(rows, 'name', { id: 1 }, undefined)
expect(result).toEqual([{ idx: 0, id: 1, name: undefined }])
})
test('should handle numeric values', () => {
const rows = [{ idx: 0, id: 1, count: 0 }]
const result = applyCellEdit(rows, 'count', { id: 1 }, 42)
expect(result).toEqual([{ idx: 0, id: 1, count: 42 }])
})
test('should handle object values', () => {
const rows = [{ idx: 0, id: 1, data: null }]
const newValue = { nested: { value: 123 } }
const result = applyCellEdit(rows, 'data', { id: 1 }, newValue)
expect(result).toEqual([{ idx: 0, id: 1, data: newValue }])
})
test('should handle empty rows array', () => {
const rows: any[] = []
const result = applyCellEdit(rows, 'name', { id: 1 }, 'new')
expect(result).toEqual([])
})
test('should update all matching rows with same identifier', () => {
const rows = [
{ idx: 0, id: 1, name: 'row1' },
{ idx: 1, id: 1, name: 'row2' },
{ idx: 2, id: 2, name: 'row3' },
]
const result = applyCellEdit(rows, 'name', { id: 1 }, 'updated')
expect(result).toEqual([
{ idx: 0, id: 1, name: 'updated' },
{ idx: 1, id: 1, name: 'updated' },
{ idx: 2, id: 2, name: 'row3' },
])
})
})
@@ -0,0 +1,161 @@
import type { QueryClient } from '@tanstack/react-query'
import type { Entity } 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 {
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
tableId: number
columnName?: string
rowIdentifiers?: Record<string, unknown>
}
export function generateTableChangeKeyFromOperation(operation: NewQueuedOperation): string {
if (operation.type === QueuedOperationType.EDIT_CELL_CONTENT) {
return generateTableChangeKey({
type: operation.type,
tableId: operation.tableId,
columnName: operation.payload.columnName,
rowIdentifiers: operation.payload.rowIdentifiers,
})
}
// Need to explicitly handle other operations
throw new Error(`Unknown operation type: ${operation.type}`)
}
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}`
}
export function rowMatchesIdentifiers(
row: Dictionary<unknown>,
rowIdentifiers: Dictionary<unknown>
): boolean {
const identifierEntries = Object.entries(rowIdentifiers)
if (identifierEntries.length === 0) return false
return identifierEntries.every(([key, value]) => row[key] === value)
}
export function applyCellEdit(
rows: SupaRow[],
columnName: string,
rowIdentifiers: Dictionary<unknown>,
newValue: unknown
): SupaRow[] {
return rows.map((row) => {
const rowMatches = rowMatchesIdentifiers(row, rowIdentifiers)
if (rowMatches) {
return { ...row, [columnName]: newValue }
}
return row
})
}
interface QueueCellEditParams {
queryClient: QueryClient
queueOperation: (operation: NewQueuedOperation) => void
projectRef: string
tableId: number
table: Entity
rowIdentifiers: Dictionary<unknown>
columnName: string
oldValue: unknown
newValue: unknown
enumArrayColumns?: string[]
}
export function queueCellEditWithOptimisticUpdate({
queryClient,
queueOperation,
projectRef,
tableId,
table,
rowIdentifiers,
columnName,
oldValue,
newValue,
enumArrayColumns,
}: QueueCellEditParams) {
// Queue the operation
queueOperation({
type: QueuedOperationType.EDIT_CELL_CONTENT,
tableId,
payload: {
rowIdentifiers,
columnName,
oldValue,
newValue,
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: applyCellEdit(old.rows, columnName, rowIdentifiers, newValue),
}
})
}
interface ReapplyOptimisticUpdatesParams {
queryClient: QueryClient
projectRef: string
tableId: number
operations: readonly QueuedOperation[]
}
export function reapplyOptimisticUpdates({
queryClient,
projectRef,
tableId,
operations,
}: ReapplyOptimisticUpdatesParams) {
const tableOperations = operations.filter((op) => op.tableId === tableId)
if (tableOperations.length === 0) return
const queryKey = tableRowKeys.tableRows(projectRef, { table: { id: tableId } })
queryClient.setQueriesData<TableRowsData>({ queryKey }, (old) => {
if (!old) return old
let rows = [...old.rows]
for (const operation of tableOperations) {
switch (operation.type) {
case QueuedOperationType.EDIT_CELL_CONTENT: {
const { rowIdentifiers, columnName, newValue } =
operation.payload as EditCellContentPayload
rows = applyCellEdit(rows, columnName, rowIdentifiers, newValue)
break
}
default: {
// Need to explicitly handle other operations
throw new Error(`Unknown operation type: ${operation.type}`)
}
}
}
return { ...old, rows }
})
}
@@ -36,4 +36,11 @@ export const FEATURE_PREVIEWS = [
isNew: false,
isPlatformOnly: false,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS,
name: 'Queue table operations',
discussionsUrl: undefined,
isNew: true,
isPlatformOnly: false,
},
] as const
@@ -105,6 +105,11 @@ export const useIsAdvisorRulesEnabled = () => {
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_ADVISOR_RULES]
}
export const useIsQueueOperationsEnabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]
}
export const useFeaturePreviewModal = () => {
const [featurePreviewModal, setFeaturePreviewModal] = useQueryState('featurePreviewModal')
@@ -13,6 +13,7 @@ import { Branching2Preview } from './Branching2Preview'
import { CLSPreview } from './CLSPreview'
import { FEATURE_PREVIEWS } from './FeaturePreview.constants'
import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext'
import { QueueOperationsPreview } from './QueueOperationsPreview'
import { UnifiedLogsPreview } from './UnifiedLogsPreview'
const FEATURE_PREVIEW_KEY_TO_CONTENT: {
@@ -23,6 +24,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: <APISidePanelPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: <CLSPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: <UnifiedLogsPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: <QueueOperationsPreview />,
}
const FeaturePreviewModal = () => {
@@ -0,0 +1,34 @@
import Image from 'next/image'
import { BASE_PATH } from 'lib/constants'
export const QueueOperationsPreview = () => {
return (
<div className="flex flex-col gap-2">
<p className="text-foreground-light text-sm mb-4">
Queue your table edits and review all pending changes before saving them to your database.
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}
height={900}
alt="queue-operations-preview"
className="rounded border"
/>
<div className="space-y-2 !mt-4">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>Queue cell edits in the Table Editor instead of saving them immediately</li>
<li>Show a panel to review all pending changes before committing them</li>
<li>Allow you to cancel individual changes or save all changes at once</li>
</ul>
</div>
</div>
)
}
@@ -0,0 +1,84 @@
import { useQueryClient } from '@tanstack/react-query'
import { 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 { EditCellContentPayload } from '@/state/table-editor-operation-queue.types'
import { formatOperationItemValue } from './OperationQueueSidePanel.utils'
interface OperationItemProps {
operationId: string
tableId: number
content: EditCellContentPayload
}
export const OperationItem = ({ operationId, tableId, content }: OperationItemProps) => {
const { table, columnName, oldValue, newValue, 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 formattedOldValue = formatOperationItemValue(oldValue)
const formattedNewValue = formatOperationItemValue(newValue)
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">
<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">
<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">{columnName}</span>
<span className="text-foreground-muted mx-2">·</span>
<span className="text-foreground text-xs">where {whereClause}</span>
</div>
</div>
<Button
type="text"
size="tiny"
icon={<X size={14} />}
onClick={handleDelete}
className="shrink-0"
aria-label="Remove operation"
/>
</div>
<div className="font-mono text-xs">
<div className="flex items-start gap-2 px-3 py-0.5 bg-red-400/20">
<span className="text-red-900 select-none font-bold">-</span>
<span className="text-red-900 truncate max-w-full" title={formattedOldValue}>
{formattedOldValue}
</span>
</div>
<div className="flex items-start gap-2 px-3 py-0.5 bg-green-400/20">
<span className="text-green-900 select-none font-bold">+</span>
<span className="text-green-900 truncate max-w-full" title={formattedNewValue}>
{formattedNewValue}
</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,31 @@
import { QueuedOperation, QueuedOperationType } from 'state/table-editor-operation-queue.types'
import { OperationItem } from './OperationItem'
interface OperationListProps {
operations: readonly QueuedOperation[]
}
export const OperationList = ({ operations }: OperationListProps) => {
if (operations.length === 0) {
return <p className="text-sm text-foreground-light">No pending changes</p>
}
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>
)
}
@@ -0,0 +1,74 @@
import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions'
import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { Button, SidePanel } from 'ui'
import { OperationList } from './OperationList'
import { QueuedOperation } from '@/state/table-editor-operation-queue.types'
interface OperationQueueSidePanelProps {
visible: boolean
closePanel: () => void
}
export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueSidePanelProps) => {
const snap = useTableEditorStateSnapshot()
const operations = snap.operationQueue.operations as readonly QueuedOperation[]
const { handleSave, handleCancel, isSaving } = useOperationQueueActions({
onSaveSuccess: closePanel,
onCancelSuccess: closePanel,
})
const { modKey } = useOperationQueueShortcuts({
enabled: visible,
onSave: handleSave,
onTogglePanel: closePanel,
isSaving,
hasOperations: operations.length > 0,
})
return (
<SidePanel
size="large"
visible={visible}
onCancel={closePanel}
header={
<div className="flex items-center justify-between w-full">
<div className="flex flex-col gap-1">
<span>Pending Changes</span>
<span className="text-xs text-foreground-light">
{operations.length} operation{operations.length !== 1 ? 's' : ''}
</span>
</div>
</div>
}
customFooter={
<div className="flex w-full justify-between border-t border-default px-3 py-4">
<Button type="default" onClick={closePanel}>
Close
<span className="text-foreground-lighter text-xs ml-1.5">{modKey}.</span>
</Button>
<div className="flex space-x-3">
<Button type="default" onClick={handleCancel} disabled={isSaving}>
Cancel All
</Button>
<Button
onClick={handleSave}
disabled={isSaving || operations.length === 0}
loading={isSaving}
>
Save All
<span className="text-foreground-lighter text-xs ml-1.5">{modKey}S</span>
</Button>
</div>
</div>
}
>
<SidePanel.Content className="py-4">
<OperationList operations={operations} />
</SidePanel.Content>
</SidePanel>
)
}
@@ -0,0 +1,10 @@
/**
* Formats a value for display in the operation queue.
* Handles null, undefined, objects, and primitive values.
*/
export const formatOperationItemValue = (value: unknown): string => {
if (value === null) return 'NULL'
if (value === undefined) return 'UNDEFINED'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
@@ -4,6 +4,8 @@ import { isEmpty, isUndefined, noop } from 'lodash'
import { useState } from 'react'
import { toast } from 'sonner'
import { queueCellEditWithOptimisticUpdate } 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'
import { type ApiPrivilegesByRole } from '@/lib/data-api-types'
@@ -22,7 +24,7 @@ import { entityTypeKeys } from 'data/entity-types/keys'
import { lintKeys } from 'data/lint/keys'
import { privilegeKeys } from 'data/privileges/keys'
import { tableEditorKeys } from 'data/table-editor/keys'
import { isTableLike } from 'data/table-editor/table-editor-types'
import { isTableLike, type Entity } from 'data/table-editor/table-editor-types'
import { tableRowKeys } from 'data/table-rows/keys'
import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation'
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
@@ -67,6 +69,7 @@ import {
} from './TableEditor/ApiAccessToggle'
import { TableEditor } from './TableEditor/TableEditor'
import type { ImportContent } from './TableEditor/TableEditor.types'
import { OperationQueueSidePanel } from './OperationQueueSidePanel/OperationQueueSidePanel'
export type SaveTableParams =
| SaveTableParamsNew
@@ -200,6 +203,7 @@ export const SidePanelEditor = ({
const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled()
const generatePoliciesFlag = usePHFlag<string>('tableCreateGeneratePolicies')
const isQueueOperationsEnabled = useIsQueueOperationsEnabled()
const [isEdited, setIsEdited] = useState<boolean>(false)
@@ -283,6 +287,45 @@ export const SidePanelEditor = ({
const hasChanges = !isEmpty(payload)
if (hasChanges) {
if (selectedTable.primary_keys.length > 0) {
// Queue the operation if queue operations feature is enabled
if (isQueueOperationsEnabled) {
const changedColumn = Object.keys(payload)[0]
if (!changedColumn) {
saveRowError = new Error('No changed column')
toast.error('No changed column')
onComplete(saveRowError)
return
}
const row =
snap.sidePanel?.type === 'json'
? snap.sidePanel.jsonValue.row
: snap.sidePanel?.type === 'cell'
? snap.sidePanel.value?.row
: undefined
const oldValue = row?.[changedColumn]
queueCellEditWithOptimisticUpdate({
queryClient,
queueOperation: snap.queueOperation,
projectRef: project.ref,
tableId: selectedTable.id,
// Cast to Entity - the queue save mutation only uses id, name, schema
table: selectedTable as unknown as Entity,
rowIdentifiers: configuration.identifiers,
columnName: changedColumn,
oldValue: oldValue,
newValue: payload[changedColumn],
enumArrayColumns,
})
// Close panel immediately without error
onComplete()
setIsEdited(false)
snap.closeSidePanel()
return
}
try {
await updateTableRow({
projectRef: project.ref,
@@ -936,6 +979,10 @@ export const SidePanelEditor = ({
closePanel={onClosePanel}
updateEditorDirty={setIsEdited}
/>
<OperationQueueSidePanel
visible={snap.sidePanel?.type === 'operation-queue'}
closePanel={snap.closeSidePanel}
/>
<CloseConfirmationModal {...closeConfirmationModalProps} />
</>
)
@@ -1,6 +1,7 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { PropsWithChildren } from 'react'
import { SaveQueueActionBar } from '@/components/grid/components/footer/operations/SaveQueueActionBar'
import NoPermission from 'components/ui/NoPermission'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { ProjectLayoutWithAuth } from '../ProjectLayout'
@@ -19,5 +20,10 @@ export const TableEditorLayout = ({ children }: PropsWithChildren<{}>) => {
)
}
return children
return (
<>
{children}
<SaveQueueActionBar />
</>
)
}
@@ -0,0 +1,126 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { executeSql } from 'data/sql/execute-sql-query'
import { wrapWithTransaction } from 'data/sql/utils/transaction'
import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation'
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
import {
EditCellContentPayload,
QueuedOperation,
QueuedOperationType,
} from 'state/table-editor-operation-queue.types'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { tableRowKeys } from './keys'
import { getTableRowUpdateSql } from './table-row-update-mutation'
export type OperationQueueSaveVariables = {
projectRef: string
connectionString?: string | null
operations: readonly QueuedOperation[]
roleImpersonationState?: RoleImpersonationState
}
/**
* Generates SQL for a single queued operation.
* Extend this function as new operation types are added.
*/
function getOperationSql(operation: QueuedOperation): string {
switch (operation.type) {
case QueuedOperationType.EDIT_CELL_CONTENT: {
const payload = operation.payload as EditCellContentPayload
return getTableRowUpdateSql({
table: {
id: payload.table.id,
name: payload.table.name,
schema: payload.table.schema,
},
configuration: { identifiers: payload.rowIdentifiers },
payload: { [payload.columnName]: payload.newValue },
enumArrayColumns: payload.enumArrayColumns ?? [],
returning: false,
})
}
default:
throw new Error(`Unknown operation type: ${(operation as QueuedOperation).type}`)
}
}
/**
* Saves all queued operations in a single database transaction.
* If any operation fails, the entire transaction is rolled back.
*/
export async function saveOperationQueue({
projectRef,
connectionString,
operations,
roleImpersonationState,
}: OperationQueueSaveVariables) {
if (operations.length === 0) {
return { result: [] }
}
// Generate SQL for each operation, stripping trailing semicolons to avoid double semicolons when joining
const statements = operations.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({
projectRef,
connectionString,
sql,
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
queryKey: ['operation-queue-save'],
})
return { result }
}
type OperationQueueSaveData = Awaited<ReturnType<typeof saveOperationQueue>>
export const useOperationQueueSaveMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<OperationQueueSaveData, ResponseError, OperationQueueSaveVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<OperationQueueSaveData, ResponseError, OperationQueueSaveVariables>({
mutationFn: (vars) => saveOperationQueue(vars),
async onSuccess(data, variables, context) {
const { projectRef, operations } = variables
// Collect all unique table IDs that were affected
const affectedTableIds = [...new Set(operations.map((op) => op.tableId))]
// Invalidate queries for all affected tables (both rows and count)
await Promise.all(
affectedTableIds.map((tableId) =>
queryClient.invalidateQueries({
queryKey: tableRowKeys.tableRowsAndCount(projectRef, tableId),
})
)
)
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to save changes: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,56 @@
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.
*/
export enum QueuedOperationType {
EDIT_CELL_CONTENT = 'edit_cell_content',
// Future: DELETE_ROW, ADD_ROW, EDIT_COLUMN, etc.
}
/**
* Payload for EDIT_CELL_CONTENT operations
*/
export interface EditCellContentPayload {
rowIdentifiers: Dictionary<unknown> // Primary key values to identify the row
columnName: string
oldValue: unknown
newValue: unknown
// For mutation support
table: Entity
enumArrayColumns?: string[]
}
/**
* Union type for all operation payloads.
* Extend this as new operation types are added.
*/
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 type NewQueuedOperation = Omit<QueuedOperation, 'id' | 'timestamp'>
/**
* Status of the overall operation queue
*/
export type QueueStatus = 'idle' | 'pending' | 'saving' | 'error'
/**
* Operation queue state structure
*/
export interface OperationQueueState {
operations: QueuedOperation[]
status: QueueStatus
}
+111
View File
@@ -4,11 +4,24 @@ import { proxy, useSnapshot } from 'valtio'
import { useConstant } from 'common'
import type { SupaRow } from 'components/grid/types'
import {
generateTableChangeKey,
generateTableChangeKeyFromOperation,
} 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'
import type { Dictionary } from 'types'
import {
NewQueuedOperation,
QueuedOperationType,
type EditCellContentPayload,
type OperationQueueState,
type QueuedOperation,
type QueueStatus,
} from './table-editor-operation-queue.types'
export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100
type ForeignKeyState = {
@@ -29,6 +42,7 @@ export type SidePanel =
foreignKey: ForeignKeyState
}
| { type: 'csv-import'; file?: File }
| { type: 'operation-queue' }
export type ConfirmationDialog =
| { type: 'table'; isDeleteWithCascade: boolean }
@@ -189,6 +203,12 @@ export const createTableEditorState = () => {
sidePanel: { type: 'csv-import', file },
}
},
onViewOperationQueue: () => {
state.ui = {
open: 'side-panel',
sidePanel: { type: 'operation-queue' },
}
},
/* Utils */
toggleConfirmationIsWithCascade: (overrideIsDeleteWithCascade?: boolean) => {
@@ -201,6 +221,97 @@ export const createTableEditorState = () => {
overrideIsDeleteWithCascade ?? !state.ui.confirmationDialog.isDeleteWithCascade
}
},
// ========================================================================
// Operation Queue
// ========================================================================
operationQueue: {
operations: [],
status: 'idle',
} as OperationQueueState,
/**
* Queue a new operation for later processing.
* If an operation with the same key already exists, it will be overwritten.
*/
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
}
state.operationQueue.operations[existingOpIndex] = newOperation
} else {
state.operationQueue.operations.push(newOperation)
}
if (state.operationQueue.status === 'idle') {
state.operationQueue.status = 'pending'
}
},
/**
* Clear all operations from the queue
*/
clearQueue: () => {
state.operationQueue.operations = []
state.operationQueue.status = 'idle'
},
/**
* Remove a specific operation from the queue
*/
removeOperation: (operationId: string) => {
state.operationQueue.operations = state.operationQueue.operations.filter(
(op) => op.id !== operationId
)
if (state.operationQueue.operations.length === 0) {
state.operationQueue.status = 'idle'
}
},
/**
* Update the queue status
*/
setQueueStatus: (status: QueueStatus) => {
state.operationQueue.status = status
},
/**
* Check if there are any pending operations in the queue
*/
get hasPendingOperations(): boolean {
return state.operationQueue.operations.length > 0
},
hasPendingCellChange: (
type: QueuedOperationType,
tableId: number,
rowIdentifiers: Record<string, unknown>,
columnName: string
): boolean => {
const key = generateTableChangeKey({
type,
tableId,
columnName,
rowIdentifiers,
})
return state.operationQueue.operations.some((op) => op.id === key)
},
})
return state
+10
View File
@@ -36,6 +36,16 @@
box-shadow: inset 0 0 0 1px #24b47e;
}
// Cell with unsaved changes - yellow/amber border
.rdg-cell.rdg-cell--dirty {
box-shadow: inset 0 0 0 2px hsl(var(--warning-default));
}
// When a dirty cell is also selected, keep the amber border
.rdg-cell.rdg-cell--dirty[aria-selected='true'] {
box-shadow: inset 0 0 0 2px hsl(var(--warning-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;
@@ -17,6 +17,7 @@ export const LOCAL_STORAGE_KEYS = {
UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown',
UI_PREVIEW_BRANCHING_2_0: 'supabase-ui-branching-2-0',
UI_PREVIEW_ADVISOR_RULES: 'supabase-ui-advisor-rules',
UI_PREVIEW_QUEUE_OPERATIONS: 'supabase-ui-queue-operations',
NEW_LAYOUT_NOTICE_ACKNOWLEDGED: 'new-layout-notice-acknowledge',
TABS_INTERFACE_ACKNOWLEDGED: 'tabs-interface-acknowledge',
@@ -135,6 +136,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR,
LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS,
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
LOCAL_STORAGE_KEYS.BLOG_VIEW,