mirror of
https://github.com/supabase/supabase.git
synced 2026-09-07 10:29:14 +08:00
## 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? Bug fix ## What is the current behavior? Fixes #47318 Supabase Studio's batched table edit queue has a few related row identity issues: - Editing a row's primary key can make later queued edits or deletes lose track of the original row. - Editing a primary key and another column in the same row before saving can save only the primary key change, because later updates still use the old primary key in the `WHERE` clause. - Adding a row in batched edit mode and then deleting it before saving may not remove the pending row correctly. ## What is the new behavior? - Preserves the original row identity for queued operations after primary key edits. - Applies multiple queued edits for the same row as a single update when saving. - Correctly deletes newly added pending rows before they are saved. - Adds regression coverage for these batched table edit cases. ## Additional context https://github.com/user-attachments/assets/75672361-d781-4fe5-a542-071574ad57bd <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved row identity handling for grid edits, optimistic updates, and queued operations so changes stay correctly attached when primary keys are edited, reverted, or “taken” by another row. * Updated header row deletion to delete from the currently visible/targeted rows rather than relying on the full dataset. * Reduced retry noise for missing tables by clearing conflicting sorts and preventing repeated retries for the same “does not exist” error. * More reliably consolidated queued edits for the same row into fewer combined save statements. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
723 lines
20 KiB
TypeScript
723 lines
20 KiB
TypeScript
import { describe, expect, test } from 'vitest'
|
|
|
|
import {
|
|
operationMatchesRow,
|
|
resolveDeleteRowConflicts,
|
|
resolveEditCellConflicts,
|
|
upsertOperation,
|
|
} from './queueConflictResolution'
|
|
import {
|
|
QueuedOperation,
|
|
QueuedOperationType,
|
|
type NewAddRowOperation,
|
|
type NewDeleteRowOperation,
|
|
type NewEditCellContentOperation,
|
|
} 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 remove operation when value is reverted to original', () => {
|
|
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: 'original',
|
|
newValue: 'changed',
|
|
table: mockTable,
|
|
},
|
|
}
|
|
|
|
const otherOp: 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, otherOp]
|
|
const newOperation: NewEditCellContentOperation = {
|
|
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
|
tableId: 1,
|
|
payload: {
|
|
rowIdentifiers: { id: 1 },
|
|
columnName: 'name',
|
|
oldValue: 'changed',
|
|
newValue: 'original',
|
|
table: mockTable,
|
|
},
|
|
}
|
|
|
|
const result = upsertOperation(operations, newOperation)
|
|
|
|
expect(result.operations).toHaveLength(1)
|
|
expect(result.operations[0]).toEqual(otherOp)
|
|
})
|
|
|
|
test('should remove primary key edit when reverted using original row identifiers', () => {
|
|
const existingOp: QueuedOperation = {
|
|
id: 'edit_cell_content:1:id:id:1',
|
|
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
|
tableId: 1,
|
|
timestamp: Date.now() - 1000,
|
|
payload: {
|
|
rowIdentifiers: { id: 1 },
|
|
columnName: 'id',
|
|
oldValue: 1,
|
|
newValue: 3,
|
|
table: mockTable,
|
|
},
|
|
}
|
|
|
|
const operations = [existingOp]
|
|
const newOperation: NewEditCellContentOperation = {
|
|
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
|
tableId: 1,
|
|
payload: {
|
|
rowIdentifiers: { id: 1 },
|
|
columnName: 'id',
|
|
oldValue: 3,
|
|
newValue: 1,
|
|
table: mockTable,
|
|
},
|
|
}
|
|
|
|
const result = upsertOperation(operations, newOperation)
|
|
|
|
expect(result.operations).toHaveLength(0)
|
|
})
|
|
|
|
test('should remove operation when number oldValue matches string newValue', () => {
|
|
const existingOp: QueuedOperation = {
|
|
id: 'edit_cell_content:1:age:id:1',
|
|
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
|
tableId: 1,
|
|
timestamp: Date.now() - 1000,
|
|
payload: {
|
|
rowIdentifiers: { id: 1 },
|
|
columnName: 'age',
|
|
oldValue: 42,
|
|
newValue: 'changed',
|
|
table: mockTable,
|
|
},
|
|
}
|
|
|
|
const operations = [existingOp]
|
|
const newOperation: NewEditCellContentOperation = {
|
|
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
|
tableId: 1,
|
|
payload: {
|
|
rowIdentifiers: { id: 1 },
|
|
columnName: 'age',
|
|
oldValue: 'changed',
|
|
newValue: '42',
|
|
table: mockTable,
|
|
},
|
|
}
|
|
|
|
const result = upsertOperation(operations, newOperation)
|
|
|
|
expect(result.operations).toHaveLength(0)
|
|
})
|
|
|
|
test('should remove operation when string oldValue matches JSON object newValue', () => {
|
|
const existingOp: QueuedOperation = {
|
|
id: 'edit_cell_content:1:metadata:id:1',
|
|
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
|
tableId: 1,
|
|
timestamp: Date.now() - 1000,
|
|
payload: {
|
|
rowIdentifiers: { id: 1 },
|
|
columnName: 'metadata',
|
|
oldValue: '{"key":"value","count":3}',
|
|
newValue: 'changed',
|
|
table: mockTable,
|
|
},
|
|
}
|
|
|
|
const operations = [existingOp]
|
|
const newOperation: NewEditCellContentOperation = {
|
|
type: QueuedOperationType.EDIT_CELL_CONTENT,
|
|
tableId: 1,
|
|
payload: {
|
|
rowIdentifiers: { id: 1 },
|
|
columnName: 'metadata',
|
|
oldValue: 'changed',
|
|
newValue: { key: 'value', count: 3 },
|
|
table: mockTable,
|
|
},
|
|
}
|
|
|
|
const result = upsertOperation(operations, newOperation)
|
|
|
|
expect(result.operations).toHaveLength(0)
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|