queueConflictResolution.ts190 lines · main
| 1 | // @ts-nocheck |
| 2 | import { isEqual } from 'lodash' |
| 3 | |
| 4 | import { isPendingAddRow } from '../types' |
| 5 | import { generateTableChangeKey, rowMatchesIdentifiers } from './queueOperationUtils' |
| 6 | import { tryParseJson } from '@/lib/helpers' |
| 7 | import { |
| 8 | isDeleteRowOperation, |
| 9 | isEditCellContentOperation, |
| 10 | NewQueuedOperation, |
| 11 | QueuedOperation, |
| 12 | QueuedOperationType, |
| 13 | type NewDeleteRowOperation, |
| 14 | type NewEditCellContentOperation, |
| 15 | } from '@/state/table-editor-operation-queue.types' |
| 16 | |
| 17 | export type DeleteConflictResult = |
| 18 | | { action: 'skip'; filteredOperations: QueuedOperation[] } |
| 19 | | { action: 'add'; filteredOperations: QueuedOperation[] } |
| 20 | |
| 21 | export type EditConflictResult = |
| 22 | | { action: 'reject'; reason: string } |
| 23 | | { action: 'merge'; updatedOperations: QueuedOperation[] } |
| 24 | | { action: 'add' } |
| 25 | |
| 26 | export type UpsertResult = { |
| 27 | operations: QueuedOperation[] |
| 28 | } |
| 29 | |
| 30 | function editOperationMatchesTempId(operation: QueuedOperation, tempId: string): boolean { |
| 31 | if (!isEditCellContentOperation(operation)) return false |
| 32 | return operation.payload.rowIdentifiers.__tempId === tempId |
| 33 | } |
| 34 | |
| 35 | export function operationMatchesRow( |
| 36 | operation: QueuedOperation, |
| 37 | tableId: number, |
| 38 | rowIdentifiers: Record<string, unknown> |
| 39 | ): boolean { |
| 40 | if (operation.tableId !== tableId) return false |
| 41 | |
| 42 | if ( |
| 43 | operation.type === QueuedOperationType.EDIT_CELL_CONTENT || |
| 44 | operation.type === QueuedOperationType.DELETE_ROW |
| 45 | ) { |
| 46 | return rowMatchesIdentifiers(operation.payload.rowIdentifiers, rowIdentifiers) |
| 47 | } |
| 48 | |
| 49 | return false |
| 50 | } |
| 51 | |
| 52 | export function resolveDeleteRowConflicts( |
| 53 | operations: readonly QueuedOperation[], |
| 54 | deleteOperation: NewDeleteRowOperation |
| 55 | ): DeleteConflictResult { |
| 56 | const rowIdentifiers = deleteOperation.payload.rowIdentifiers |
| 57 | |
| 58 | // Check if this row was newly added (by tempId) |
| 59 | // If deleting a newly added row, filter out the ADD_ROW operation |
| 60 | const originalRow = deleteOperation.payload.originalRow |
| 61 | if (isPendingAddRow(originalRow)) { |
| 62 | const tempId = originalRow.__tempId |
| 63 | const addRowKey = generateTableChangeKey({ |
| 64 | type: QueuedOperationType.ADD_ROW, |
| 65 | tableId: deleteOperation.tableId, |
| 66 | payload: { |
| 67 | tempId, |
| 68 | rowData: originalRow, |
| 69 | table: deleteOperation.payload.table, |
| 70 | }, |
| 71 | }) |
| 72 | |
| 73 | let filteredOperations = operations |
| 74 | .filter((op) => op.id !== addRowKey) |
| 75 | .filter((op) => !editOperationMatchesTempId(op, tempId)) |
| 76 | |
| 77 | return { action: 'skip', filteredOperations } |
| 78 | } |
| 79 | |
| 80 | // For existing rows, remove any pending EDIT_CELL operations for the row being deleted |
| 81 | const filteredOperations = operations.filter( |
| 82 | (op) => !operationMatchesRow(op, deleteOperation.tableId, rowIdentifiers) |
| 83 | ) |
| 84 | |
| 85 | return { action: 'add', filteredOperations } |
| 86 | } |
| 87 | |
| 88 | export function resolveEditCellConflicts( |
| 89 | operations: readonly QueuedOperation[], |
| 90 | editOperation: NewEditCellContentOperation |
| 91 | ): EditConflictResult { |
| 92 | const rowIdentifiers = editOperation.payload.rowIdentifiers |
| 93 | |
| 94 | // Check if this row is pending deletion |
| 95 | const isPendingDeletion = operations.filter(isDeleteRowOperation).some((op) => { |
| 96 | if (op.tableId === editOperation.tableId) { |
| 97 | return Object.entries(op.payload.rowIdentifiers).every( |
| 98 | ([key, value]) => rowIdentifiers[key] === value |
| 99 | ) |
| 100 | } |
| 101 | return false |
| 102 | }) |
| 103 | |
| 104 | if (isPendingDeletion) { |
| 105 | return { |
| 106 | action: 'reject', |
| 107 | reason: |
| 108 | 'Cannot edit a cell on a row that is pending deletion. Remove the delete operation first.', |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // Check if this edit is on a newly added row (by tempId) |
| 113 | const tempId = rowIdentifiers.__tempId |
| 114 | if (tempId) { |
| 115 | const addRowIndex = operations.findIndex((op) => { |
| 116 | if (op.type === QueuedOperationType.ADD_ROW && op.tableId === editOperation.tableId) { |
| 117 | return op.payload.tempId === tempId |
| 118 | } |
| 119 | return false |
| 120 | }) |
| 121 | |
| 122 | if (addRowIndex >= 0) { |
| 123 | // Merge the edit into the ADD_ROW's rowData |
| 124 | const updatedOperations = [...operations] |
| 125 | const addOp = updatedOperations[addRowIndex] |
| 126 | if (addOp.type === QueuedOperationType.ADD_ROW) { |
| 127 | const addPayload = { ...addOp.payload } |
| 128 | addPayload.rowData = { |
| 129 | ...addPayload.rowData, |
| 130 | [editOperation.payload.columnName]: editOperation.payload.newValue, |
| 131 | } |
| 132 | |
| 133 | updatedOperations[addRowIndex] = { |
| 134 | ...addOp, |
| 135 | payload: addPayload, |
| 136 | timestamp: Date.now(), |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | return { action: 'merge', updatedOperations } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | return { action: 'add' } |
| 145 | } |
| 146 | |
| 147 | export function upsertOperation( |
| 148 | operations: readonly QueuedOperation[], |
| 149 | newOperation: NewQueuedOperation |
| 150 | ): UpsertResult { |
| 151 | const operationKey = generateTableChangeKey(newOperation) |
| 152 | const existingOpIndex = operations.findIndex((op) => op.id === operationKey) |
| 153 | |
| 154 | const queuedOperation: QueuedOperation = { |
| 155 | ...newOperation, |
| 156 | id: operationKey, |
| 157 | timestamp: Date.now(), |
| 158 | } |
| 159 | |
| 160 | if (existingOpIndex >= 0) { |
| 161 | const updatedOperations = [...operations] |
| 162 | |
| 163 | // Keep the old value of the operation that is being overwritten |
| 164 | // When a user edits the same cell multiple times before saving, |
| 165 | // we need to preserve the original "before edit" value |
| 166 | const existingOp = operations[existingOpIndex] |
| 167 | if ( |
| 168 | queuedOperation.type === QueuedOperationType.EDIT_CELL_CONTENT && |
| 169 | existingOp.type === QueuedOperationType.EDIT_CELL_CONTENT |
| 170 | ) { |
| 171 | queuedOperation.payload.oldValue = existingOp.payload.oldValue |
| 172 | |
| 173 | const { oldValue, newValue } = queuedOperation.payload |
| 174 | // [Joshen] These comparisons by data type are because of how the table editor renders the values |
| 175 | if ( |
| 176 | (typeof oldValue === 'number' && Number(oldValue) === Number(newValue)) || |
| 177 | (typeof newValue === 'object' && isEqual(tryParseJson(oldValue), newValue)) || |
| 178 | oldValue === newValue |
| 179 | ) { |
| 180 | updatedOperations.splice(existingOpIndex, 1) |
| 181 | return { operations: updatedOperations } |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | updatedOperations[existingOpIndex] = queuedOperation |
| 186 | return { operations: updatedOperations } |
| 187 | } |
| 188 | |
| 189 | return { operations: [...operations, queuedOperation] } |
| 190 | } |