From 16962620887d1dfcd7db8f9c8aafcee245da3ab4 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 4 Feb 2026 11:15:55 -0700 Subject: [PATCH] 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 ## 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 --------- Co-authored-by: Alaister Young --- apps/studio/components/grid/SupabaseGrid.tsx | 6 +- .../grid/components/common/DefaultValue.tsx | 3 + .../components/grid/components/grid/Grid.tsx | 29 +- .../grid/components/grid/Grid.utils.tsx | 10 +- .../grid/components/header/Header.tsx | 45 +- .../grid/components/header/HeaderNew.tsx | 30 +- .../grid/components/menu/RowContextMenu.tsx | 33 +- apps/studio/components/grid/types/index.ts | 2 + apps/studio/components/grid/types/table.ts | 23 + .../components/grid/utils/gridColumns.tsx | 58 +- .../utils/queueConflictResolution.test.ts | 575 ++++++++++++++++++ .../grid/utils/queueConflictResolution.ts | 175 ++++++ .../grid/utils/queueOperationUtils.test.ts | 112 ++-- .../grid/utils/queueOperationUtils.ts | 255 ++++++-- .../FeaturePreview/QueueOperationsPreview.tsx | 7 +- .../AddRowOperationItem.tsx | 86 +++ .../DeleteRowOperationItem.tsx | 76 +++ .../OperationQueueSidePanel/OperationList.tsx | 81 ++- .../RowEditor/RowEditor.utils.ts | 12 +- .../SidePanelEditor/SidePanelEditor.tsx | 43 +- .../SidePanelEditor.utils.test.ts | 105 +++- .../SidePanelEditor/SidePanelEditor.utils.tsx | 44 +- .../operation-queue-save-mutation.ts | 60 +- .../table-editor-operation-queue.types.ts | 98 ++- apps/studio/state/table-editor-table.tsx | 4 + apps/studio/state/table-editor.tsx | 79 ++- apps/studio/styles/grid.scss | 39 ++ 27 files changed, 1861 insertions(+), 229 deletions(-) create mode 100644 apps/studio/components/grid/components/common/DefaultValue.tsx create mode 100644 apps/studio/components/grid/utils/queueConflictResolution.test.ts create mode 100644 apps/studio/components/grid/utils/queueConflictResolution.ts create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/AddRowOperationItem.tsx create mode 100644 apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index ae19845de9f..69ee974334e 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -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 diff --git a/apps/studio/components/grid/components/common/DefaultValue.tsx b/apps/studio/components/grid/components/common/DefaultValue.tsx new file mode 100644 index 00000000000..6a94b026097 --- /dev/null +++ b/apps/studio/components/grid/components/common/DefaultValue.tsx @@ -0,0 +1,3 @@ +export const DefaultValue = () => { + return DEFAULT +} diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 0a0ea847b82..963db56e73b 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -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 (
column.name) const identifiers = {} as Dictionary + 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], diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 654b3d4af7e..90721b2c0c1 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -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() } }) diff --git a/apps/studio/components/grid/components/header/HeaderNew.tsx b/apps/studio/components/grid/components/header/HeaderNew.tsx index 915668e3175..16b646bf522 100644 --- a/apps/studio/components/grid/components/header/HeaderNew.tsx +++ b/apps/studio/components/grid/components/header/HeaderNew.tsx @@ -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() } }) diff --git a/apps/studio/components/grid/components/menu/RowContextMenu.tsx b/apps/studio/components/grid/components/menu/RowContextMenu.tsx index 97b17a89771..47dbc996972 100644 --- a/apps/studio/components/grid/components/menu/RowContextMenu.tsx +++ b/apps/studio/components/grid/components/menu/RowContextMenu.tsx @@ -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) { diff --git a/apps/studio/components/grid/types/index.ts b/apps/studio/components/grid/types/index.ts index 4d0a436e956..fc0ad96d5ba 100644 --- a/apps/studio/components/grid/types/index.ts +++ b/apps/studio/components/grid/types/index.ts @@ -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' diff --git a/apps/studio/components/grid/types/table.ts b/apps/studio/components/grid/types/table.ts index b9179c00257..544dc149b81 100644 --- a/apps/studio/components/grid/types/table.ts +++ b/apps/studio/components/grid/types/table.ts @@ -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 { 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 +} diff --git a/apps/studio/components/grid/utils/gridColumns.tsx b/apps/studio/components/grid/utils/gridColumns.tsx index 19cb4a6a87d..9187f236f7e 100644 --- a/apps/studio/components/grid/utils/gridColumns.tsx +++ b/apps/studio/components/grid/utils/gridColumns.tsx @@ -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>, + columnDef: SupaColumn +) { + return function PendingAwareFormatter(props: RenderCellProps) { + const value = props.row[props.column.key] + + if (isPendingAddRow(props.row) && (value === undefined || value === null || value === '')) { + if (columnDef.defaultValue !== undefined && columnDef.defaultValue !== null) { + return + } + if (columnDef.isIdentity || columnDef.isGeneratable) { + return + } + if (columnDef.isNullable) { + return + } + } + + return + } +} + function getCellRenderer( columnDef: SupaColumn, columnType: ColumnType, metadata: { tableId?: number } ) { + let formatter: React.ComponentType> + 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) => + formatter = (p: any) => } + 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 { diff --git a/apps/studio/components/grid/utils/queueConflictResolution.test.ts b/apps/studio/components/grid/utils/queueConflictResolution.test.ts new file mode 100644 index 00000000000..7df3199c4bc --- /dev/null +++ b/apps/studio/components/grid/utils/queueConflictResolution.test.ts @@ -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') + }) +}) diff --git a/apps/studio/components/grid/utils/queueConflictResolution.ts b/apps/studio/components/grid/utils/queueConflictResolution.ts new file mode 100644 index 00000000000..4b96f38c40e --- /dev/null +++ b/apps/studio/components/grid/utils/queueConflictResolution.ts @@ -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 +): 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] } +} diff --git a/apps/studio/components/grid/utils/queueOperationUtils.test.ts b/apps/studio/components/grid/utils/queueOperationUtils.test.ts index a5c00e6842e..3905ca2d764 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.test.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.test.ts @@ -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') }) }) diff --git a/apps/studio/components/grid/utils/queueOperationUtils.ts b/apps/studio/components/grid/utils/queueOperationUtils.ts index 394aafa6d6d..801cbf037ed 100644 --- a/apps/studio/components/grid/utils/queueOperationUtils.ts +++ b/apps/studio/components/grid/utils/queueOperationUtils.ts @@ -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 { + type: QueuedOperationType.EDIT_CELL_CONTENT tableId: number - columnName?: string - rowIdentifiers?: Record + payload: { + columnName: string + rowIdentifiers: Dictionary + } } -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 +): (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 +): (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): 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 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 = { ...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({ 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 = {} + 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({ 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), + } + }) + } +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/QueueOperationsPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/QueueOperationsPreview.tsx index e17fc79413d..12c1450e7a7 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/QueueOperationsPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/QueueOperationsPreview.tsx @@ -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.

-
- Note: We are currently working to add all CRUD operations to the queue. - Right now, only cell edits are supported. -
{ + 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 ( +
+
+
+ +
+
{fullTableName}
+
+ New row +
+
+
+
+ +
+ {previewColumns.map(([key, value]) => ( +
+ {key}: + + {formatOperationItemValue(value)} + +
+ ))} + {remainingCount > 0 && ( +
+{remainingCount} more column(s)
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx new file mode 100644 index 00000000000..8e641460788 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/DeleteRowOperationItem.tsx @@ -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 ( +
+
+
+ +
+
{fullTableName}
+
+ Delete row + ยท + where {whereClause} +
+
+
+
+ +
+
Row will be deleted
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx index d05d45e40a6..0468be02806 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationList.tsx @@ -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

No pending changes

} + const addOperations = operations.filter(isAddRowOperation) + const deleteOperations = operations.filter(isDeleteRowOperation) + const editOperations = operations.filter(isEditCellContentOperation) + return ( -
- {operations.map((op) => { - if (op.type === QueuedOperationType.EDIT_CELL_CONTENT) { - return ( - - ) - } - return null - })} +
+ {deleteOperations.length > 0 && ( +
+

+ Rows to Delete ({deleteOperations.length}) +

+
+ {deleteOperations.map((op) => ( + + ))} +
+
+ )} + + {addOperations.length > 0 && ( +
+

+ Rows to Add ({addOperations.length}) +

+
+ {addOperations.map((op) => ( + + ))} +
+
+ )} + + {editOperations.length > 0 && ( +
+

+ Cell Edits ({editOperations.length}) +

+
+ {editOperations.map((op) => ( + + ))} +
+
+ )}
) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts index 03236f0f374..9146454fdfb 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts @@ -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 + } } }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 6920b069738..0d46fcf6610 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -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, diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.test.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.test.ts index a6f24e4f38d..372797a0bfb 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.test.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.test.ts @@ -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() + }) +}) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index 54a4831deaf..09ecf6c2ba3 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -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 | 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 diff --git a/apps/studio/data/table-rows/operation-queue-save-mutation.ts b/apps/studio/data/table-rows/operation-queue-save-mutation.ts index ac48b8bb630..d78eaf7569e 100644 --- a/apps/studio/data/table-rows/operation-queue-save-mutation.ts +++ b/apps/studio/data/table-rows/operation-queue-save-mutation.ts @@ -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.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({ diff --git a/apps/studio/state/table-editor-operation-queue.types.ts b/apps/studio/state/table-editor-operation-queue.types.ts index d52898f85f8..e6d78a8ed76 100644 --- a/apps/studio/state/table-editor-operation-queue.types.ts +++ b/apps/studio/state/table-editor-operation-queue.types.ts @@ -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 +/** + * Payload for DELETE_ROW operations + */ +export interface DeleteRowPayload { + rowIdentifiers: Dictionary // 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 +} diff --git a/apps/studio/state/table-editor-table.tsx b/apps/studio/state/table-editor-table.tsx index d5a02dd8f28..65c41ddd676 100644 --- a/apps/studio/state/table-editor-table.tsx +++ b/apps/studio/state/table-editor-table.tsx @@ -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, diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index 0bd5be50c42..297e977fde7 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -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, + rowIdentifiers: Dictionary, 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) }, diff --git a/apps/studio/styles/grid.scss b/apps/studio/styles/grid.scss index 8811784daf3..8664d99a400 100644 --- a/apps/studio/styles/grid.scss +++ b/apps/studio/styles/grid.scss @@ -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;