- {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;