queueConflictResolution.ts190 lines · main
1// @ts-nocheck
2import { isEqual } from 'lodash'
3
4import { isPendingAddRow } from '../types'
5import { generateTableChangeKey, rowMatchesIdentifiers } from './queueOperationUtils'
6import { tryParseJson } from '@/lib/helpers'
7import {
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
17export type DeleteConflictResult =
18 | { action: 'skip'; filteredOperations: QueuedOperation[] }
19 | { action: 'add'; filteredOperations: QueuedOperation[] }
20
21export type EditConflictResult =
22 | { action: 'reject'; reason: string }
23 | { action: 'merge'; updatedOperations: QueuedOperation[] }
24 | { action: 'add' }
25
26export type UpsertResult = {
27 operations: QueuedOperation[]
28}
29
30function editOperationMatchesTempId(operation: QueuedOperation, tempId: string): boolean {
31 if (!isEditCellContentOperation(operation)) return false
32 return operation.payload.rowIdentifiers.__tempId === tempId
33}
34
35export 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
52export 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
88export 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
147export 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}