mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
473619bece
commit
1696262088
27 files changed
+1861
-229
No files matched your search
@@ -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}
|
||||
|
||||
+86
@@ -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>
|
||||
)
|
||||
}
|
||||
+76
@@ -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>
|
||||
)
|
||||
}
|
||||
+66
-15
@@ -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>
|
||||
)
|
||||
}
|
||||
+8
-4
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+35
-8
@@ -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,
|
||||
|
||||
+104
-1
@@ -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()
|
||||
})
|
||||
})
|
||||
+37
-7
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user