queueOperationUtils.test.ts452 lines · main
| 1 | // @ts-nocheck |
| 2 | import { describe, expect, test } from 'vitest' |
| 3 | |
| 4 | import type { SupaRow } from '../types' |
| 5 | import { |
| 6 | formatGridDataWithOperationValues, |
| 7 | generateTableChangeKey, |
| 8 | rowMatchesIdentifiers, |
| 9 | } from './queueOperationUtils' |
| 10 | import { |
| 11 | QueuedOperationType, |
| 12 | type NewAddRowOperation, |
| 13 | type NewDeleteRowOperation, |
| 14 | type NewEditCellContentOperation, |
| 15 | type QueuedOperation, |
| 16 | } from '@/state/table-editor-operation-queue.types' |
| 17 | |
| 18 | describe('generateTableChangeKey', () => { |
| 19 | test('should generate key for EDIT_CELL_CONTENT with row identifiers', () => { |
| 20 | const operation: NewEditCellContentOperation = { |
| 21 | type: QueuedOperationType.EDIT_CELL_CONTENT, |
| 22 | tableId: 1, |
| 23 | payload: { |
| 24 | rowIdentifiers: { id: 1 }, |
| 25 | columnName: 'name', |
| 26 | oldValue: 'old', |
| 27 | newValue: 'new', |
| 28 | table: {} as any, |
| 29 | }, |
| 30 | } |
| 31 | const key = generateTableChangeKey(operation) |
| 32 | expect(key).toBe('edit_cell_content:1:name:id:1') |
| 33 | }) |
| 34 | |
| 35 | test('should generate key for EDIT_CELL_CONTENT with empty row identifiers', () => { |
| 36 | const operation: NewEditCellContentOperation = { |
| 37 | type: QueuedOperationType.EDIT_CELL_CONTENT, |
| 38 | tableId: 1, |
| 39 | payload: { |
| 40 | rowIdentifiers: {}, |
| 41 | columnName: 'name', |
| 42 | oldValue: 'old', |
| 43 | newValue: 'new', |
| 44 | table: {} as any, |
| 45 | }, |
| 46 | } |
| 47 | const key = generateTableChangeKey(operation) |
| 48 | expect(key).toBe('edit_cell_content:1:name:') |
| 49 | }) |
| 50 | |
| 51 | test('should generate key with multiple row identifiers sorted alphabetically', () => { |
| 52 | const operation: NewEditCellContentOperation = { |
| 53 | type: QueuedOperationType.EDIT_CELL_CONTENT, |
| 54 | tableId: 1, |
| 55 | payload: { |
| 56 | rowIdentifiers: { z_id: 3, a_id: 1 }, |
| 57 | columnName: 'name', |
| 58 | oldValue: 'old', |
| 59 | newValue: 'new', |
| 60 | table: {} as any, |
| 61 | }, |
| 62 | } |
| 63 | const key = generateTableChangeKey(operation) |
| 64 | expect(key).toBe('edit_cell_content:1:name:a_id:1|z_id:3') |
| 65 | }) |
| 66 | |
| 67 | test('should generate key for ADD_ROW operation', () => { |
| 68 | const operation: NewAddRowOperation = { |
| 69 | type: QueuedOperationType.ADD_ROW, |
| 70 | tableId: 1, |
| 71 | payload: { |
| 72 | tempId: 'temp-123', |
| 73 | rowData: { idx: -1, __tempId: 'temp-123' }, |
| 74 | table: {} as any, |
| 75 | }, |
| 76 | } |
| 77 | const key = generateTableChangeKey(operation) |
| 78 | expect(key).toBe('add_row:1:temp-123') |
| 79 | }) |
| 80 | |
| 81 | test('should generate key for DELETE_ROW operation', () => { |
| 82 | const operation: NewDeleteRowOperation = { |
| 83 | type: QueuedOperationType.DELETE_ROW, |
| 84 | tableId: 1, |
| 85 | payload: { |
| 86 | rowIdentifiers: { id: 1 }, |
| 87 | originalRow: { idx: 0, id: 1 }, |
| 88 | table: {} as any, |
| 89 | }, |
| 90 | } |
| 91 | const key = generateTableChangeKey(operation) |
| 92 | expect(key).toBe('delete_row:1:id:1') |
| 93 | }) |
| 94 | |
| 95 | test('should throw error for unknown operation type', () => { |
| 96 | const operation = { |
| 97 | type: 'unknown' as any, |
| 98 | tableId: 1, |
| 99 | payload: { |
| 100 | rowIdentifiers: { id: 1 }, |
| 101 | columnName: 'name', |
| 102 | oldValue: 'old', |
| 103 | newValue: 'new', |
| 104 | table: {} as any, |
| 105 | }, |
| 106 | } |
| 107 | expect(() => generateTableChangeKey(operation)).toThrow('Unknown operation type') |
| 108 | }) |
| 109 | }) |
| 110 | |
| 111 | describe('rowMatchesIdentifiers', () => { |
| 112 | test('should return false for empty row identifiers', () => { |
| 113 | const result = rowMatchesIdentifiers({ id: 1 }, {}) |
| 114 | expect(result).toBe(false) |
| 115 | }) |
| 116 | |
| 117 | test('should match row with single identifier', () => { |
| 118 | const result = rowMatchesIdentifiers({ id: 1 }, { id: 1 }) |
| 119 | expect(result).toBe(true) |
| 120 | }) |
| 121 | |
| 122 | test('should match row with multiple identifiers', () => { |
| 123 | const result = rowMatchesIdentifiers( |
| 124 | { id: 1, email: 'test@test.com' }, |
| 125 | { id: 1, email: 'test@test.com' } |
| 126 | ) |
| 127 | expect(result).toBe(true) |
| 128 | }) |
| 129 | |
| 130 | test('should not match row with different values', () => { |
| 131 | const result = rowMatchesIdentifiers({ id: 2 }, { id: 1 }) |
| 132 | expect(result).toBe(false) |
| 133 | }) |
| 134 | |
| 135 | test('should not match row with missing identifier keys', () => { |
| 136 | const result = rowMatchesIdentifiers({ id: 1 }, { id: 1, email: 'test@test.com' }) |
| 137 | expect(result).toBe(false) |
| 138 | }) |
| 139 | |
| 140 | test('should match row with extra keys', () => { |
| 141 | const result = rowMatchesIdentifiers({ id: 1, name: 'John', age: 30 }, { id: 1 }) |
| 142 | expect(result).toBe(true) |
| 143 | }) |
| 144 | |
| 145 | test('should match with null values', () => { |
| 146 | const result = rowMatchesIdentifiers({ id: null }, { id: null }) |
| 147 | expect(result).toBe(true) |
| 148 | }) |
| 149 | |
| 150 | test('should not match with undefined values in row', () => { |
| 151 | const result = rowMatchesIdentifiers({ id: undefined, name: 'test' }, { id: 1 }) |
| 152 | expect(result).toBe(false) |
| 153 | }) |
| 154 | }) |
| 155 | |
| 156 | describe('formatGridDataWithOperationValues', () => { |
| 157 | const makeRow = (idx: number, data: Record<string, unknown> = {}): SupaRow => ({ |
| 158 | idx, |
| 159 | ...data, |
| 160 | }) |
| 161 | |
| 162 | const makeEditOp = ( |
| 163 | overrides: Partial<QueuedOperation & { payload: any }> = {} |
| 164 | ): QueuedOperation => ({ |
| 165 | id: 'op-1', |
| 166 | tableId: 1, |
| 167 | timestamp: Date.now(), |
| 168 | type: QueuedOperationType.EDIT_CELL_CONTENT, |
| 169 | payload: { |
| 170 | rowIdentifiers: { id: 1 }, |
| 171 | columnName: 'name', |
| 172 | oldValue: 'old', |
| 173 | newValue: 'new', |
| 174 | table: {} as any, |
| 175 | }, |
| 176 | ...overrides, |
| 177 | }) |
| 178 | |
| 179 | const makeDeleteOp = ( |
| 180 | rowIdentifiers: Record<string, unknown>, |
| 181 | originalRow: SupaRow |
| 182 | ): QueuedOperation => ({ |
| 183 | id: 'op-del', |
| 184 | tableId: 1, |
| 185 | timestamp: Date.now(), |
| 186 | type: QueuedOperationType.DELETE_ROW, |
| 187 | payload: { rowIdentifiers, originalRow, table: {} as any }, |
| 188 | }) |
| 189 | |
| 190 | const makeAddOp = (tempId: string, rowData: Record<string, unknown> = {}): QueuedOperation => ({ |
| 191 | id: 'op-add', |
| 192 | tableId: 1, |
| 193 | timestamp: Date.now(), |
| 194 | type: QueuedOperationType.ADD_ROW, |
| 195 | payload: { |
| 196 | tempId, |
| 197 | rowData: { idx: Number(tempId), __tempId: tempId, ...rowData }, |
| 198 | table: {} as any, |
| 199 | }, |
| 200 | }) |
| 201 | |
| 202 | test('should return rows unchanged when there are no operations', () => { |
| 203 | const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] |
| 204 | const result = formatGridDataWithOperationValues({ operations: [], rows }) |
| 205 | expect(result).toEqual(rows) |
| 206 | }) |
| 207 | |
| 208 | test('should apply EDIT_CELL_CONTENT to matching row', () => { |
| 209 | const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] |
| 210 | const op = makeEditOp({ |
| 211 | payload: { |
| 212 | rowIdentifiers: { id: 1 }, |
| 213 | columnName: 'name', |
| 214 | oldValue: 'Alice', |
| 215 | newValue: 'Updated', |
| 216 | table: {} as any, |
| 217 | }, |
| 218 | }) |
| 219 | |
| 220 | const result = formatGridDataWithOperationValues({ operations: [op], rows }) |
| 221 | expect(result[0]).toEqual({ idx: 0, id: 1, name: 'Updated' }) |
| 222 | expect(result[1]).toEqual(rows[1]) |
| 223 | }) |
| 224 | |
| 225 | test('should not modify rows when EDIT_CELL_CONTENT does not match any row', () => { |
| 226 | const rows = [makeRow(0, { id: 1, name: 'Alice' })] |
| 227 | const op = makeEditOp({ |
| 228 | payload: { |
| 229 | rowIdentifiers: { id: 999 }, |
| 230 | columnName: 'name', |
| 231 | oldValue: 'old', |
| 232 | newValue: 'new', |
| 233 | table: {} as any, |
| 234 | }, |
| 235 | }) |
| 236 | |
| 237 | const result = formatGridDataWithOperationValues({ operations: [op], rows }) |
| 238 | expect(result).toEqual(rows) |
| 239 | }) |
| 240 | |
| 241 | test('should apply multiple EDIT_CELL_CONTENT operations to different rows', () => { |
| 242 | const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] |
| 243 | const op1 = makeEditOp({ |
| 244 | id: 'op-1', |
| 245 | payload: { |
| 246 | rowIdentifiers: { id: 1 }, |
| 247 | columnName: 'name', |
| 248 | oldValue: 'Alice', |
| 249 | newValue: 'Updated Alice', |
| 250 | table: {} as any, |
| 251 | }, |
| 252 | }) |
| 253 | const op2 = makeEditOp({ |
| 254 | id: 'op-2', |
| 255 | payload: { |
| 256 | rowIdentifiers: { id: 2 }, |
| 257 | columnName: 'name', |
| 258 | oldValue: 'Bob', |
| 259 | newValue: 'Updated Bob', |
| 260 | table: {} as any, |
| 261 | }, |
| 262 | }) |
| 263 | |
| 264 | const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows }) |
| 265 | expect(result[0].name).toBe('Updated Alice') |
| 266 | expect(result[1].name).toBe('Updated Bob') |
| 267 | }) |
| 268 | |
| 269 | test('multiple operations targeting the same row preserve all column changes', () => { |
| 270 | const rows = [makeRow(0, { id: 1, name: 'Alice', email: 'alice@test.com' })] |
| 271 | const op1 = makeEditOp({ |
| 272 | id: 'op-1', |
| 273 | payload: { |
| 274 | rowIdentifiers: { id: 1 }, |
| 275 | columnName: 'name', |
| 276 | oldValue: 'Alice', |
| 277 | newValue: 'Updated', |
| 278 | table: {} as any, |
| 279 | }, |
| 280 | }) |
| 281 | const op2 = makeEditOp({ |
| 282 | id: 'op-2', |
| 283 | payload: { |
| 284 | rowIdentifiers: { id: 1 }, |
| 285 | columnName: 'email', |
| 286 | oldValue: 'alice@test.com', |
| 287 | newValue: 'updated@test.com', |
| 288 | table: {} as any, |
| 289 | }, |
| 290 | }) |
| 291 | |
| 292 | const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows }) |
| 293 | // Both column edits should be preserved |
| 294 | expect(result[0].name).toBe('Updated') |
| 295 | expect(result[0].email).toBe('updated@test.com') |
| 296 | }) |
| 297 | |
| 298 | test('should mark matching row as deleted for DELETE_ROW operation', () => { |
| 299 | const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] |
| 300 | const op = makeDeleteOp({ id: 1 }, rows[0]) |
| 301 | |
| 302 | const result = formatGridDataWithOperationValues({ operations: [op], rows }) |
| 303 | expect(result[0].__isDeleted).toBe(true) |
| 304 | expect(result[1].__isDeleted).toBeUndefined() |
| 305 | }) |
| 306 | |
| 307 | test('should not modify rows when DELETE_ROW does not match any row', () => { |
| 308 | const rows = [makeRow(0, { id: 1, name: 'Alice' })] |
| 309 | const op = makeDeleteOp({ id: 999 }, makeRow(0, { id: 999 })) |
| 310 | |
| 311 | const result = formatGridDataWithOperationValues({ operations: [op], rows }) |
| 312 | expect(result[0].__isDeleted).toBeUndefined() |
| 313 | }) |
| 314 | |
| 315 | test('should not mutate the original rows array', () => { |
| 316 | const rows = [makeRow(0, { id: 1, name: 'Alice' })] |
| 317 | const op = makeEditOp({ |
| 318 | payload: { |
| 319 | rowIdentifiers: { id: 1 }, |
| 320 | columnName: 'name', |
| 321 | oldValue: 'Alice', |
| 322 | newValue: 'Updated', |
| 323 | table: {} as any, |
| 324 | }, |
| 325 | }) |
| 326 | |
| 327 | const result = formatGridDataWithOperationValues({ operations: [op], rows }) |
| 328 | expect(rows[0].name).toBe('Alice') |
| 329 | expect(result[0].name).toBe('Updated') |
| 330 | }) |
| 331 | |
| 332 | test('should handle mixed operation types', () => { |
| 333 | const rows = [ |
| 334 | makeRow(0, { id: 1, name: 'Alice' }), |
| 335 | makeRow(1, { id: 2, name: 'Bob' }), |
| 336 | makeRow(2, { id: 3, name: 'Charlie' }), |
| 337 | ] |
| 338 | |
| 339 | const editOp = makeEditOp({ |
| 340 | payload: { |
| 341 | rowIdentifiers: { id: 1 }, |
| 342 | columnName: 'name', |
| 343 | oldValue: 'Alice', |
| 344 | newValue: 'Updated Alice', |
| 345 | table: {} as any, |
| 346 | }, |
| 347 | }) |
| 348 | const deleteOp = makeDeleteOp({ id: 2 }, rows[1]) |
| 349 | |
| 350 | const result = formatGridDataWithOperationValues({ |
| 351 | operations: [editOp, deleteOp], |
| 352 | rows, |
| 353 | }) |
| 354 | |
| 355 | expect(result[0].name).toBe('Updated Alice') |
| 356 | expect(result[1].__isDeleted).toBe(true) |
| 357 | expect(result[2]).toEqual(rows[2]) |
| 358 | }) |
| 359 | |
| 360 | test('should prepend new row for ADD_ROW operation', () => { |
| 361 | const rows = [makeRow(0, { id: 1, name: 'Alice' })] |
| 362 | const op = makeAddOp('-100', { name: 'New Row' }) |
| 363 | |
| 364 | const result = formatGridDataWithOperationValues({ operations: [op], rows }) |
| 365 | expect(result).toHaveLength(2) |
| 366 | expect(result[0]).toMatchObject({ __tempId: '-100', name: 'New Row' }) |
| 367 | expect(result[1]).toEqual(rows[0]) |
| 368 | }) |
| 369 | |
| 370 | test('should update existing pending row for ADD_ROW with same tempId', () => { |
| 371 | const rows: SupaRow[] = [ |
| 372 | { idx: -100, __tempId: '-100', name: 'Original' } as any, |
| 373 | makeRow(1, { id: 1, name: 'Alice' }), |
| 374 | ] |
| 375 | const op = makeAddOp('-100', { name: 'Updated' }) |
| 376 | |
| 377 | const result = formatGridDataWithOperationValues({ operations: [op], rows }) |
| 378 | expect(result).toHaveLength(2) |
| 379 | expect(result[0]).toMatchObject({ __tempId: '-100', name: 'Updated' }) |
| 380 | }) |
| 381 | |
| 382 | test('should handle multiple ADD_ROW operations', () => { |
| 383 | const rows = [makeRow(0, { id: 1, name: 'Alice' })] |
| 384 | const op1 = makeAddOp('-100', { name: 'Row 1' }) |
| 385 | const op2 = makeAddOp('-200', { name: 'Row 2' }) |
| 386 | |
| 387 | const result = formatGridDataWithOperationValues({ operations: [op1, op2], rows }) |
| 388 | expect(result).toHaveLength(3) |
| 389 | expect(result[0]).toMatchObject({ __tempId: '-200', name: 'Row 2' }) |
| 390 | expect(result[1]).toMatchObject({ __tempId: '-100', name: 'Row 1' }) |
| 391 | }) |
| 392 | |
| 393 | test('should correctly delete a row after adding a new row (ADD then DELETE)', () => { |
| 394 | const rows = [makeRow(0, { id: 1, name: 'Alice' }), makeRow(1, { id: 2, name: 'Bob' })] |
| 395 | const addOp = makeAddOp('-100', { name: 'New Row' }) |
| 396 | const deleteOp = makeDeleteOp({ id: 1 }, rows[0]) |
| 397 | |
| 398 | const result = formatGridDataWithOperationValues({ |
| 399 | operations: [addOp, deleteOp], |
| 400 | rows, |
| 401 | }) |
| 402 | |
| 403 | expect(result).toHaveLength(3) |
| 404 | // New row should be preserved at position 0 |
| 405 | expect(result[0]).toMatchObject({ __tempId: '-100', name: 'New Row' }) |
| 406 | expect(result[0].__isDeleted).toBeUndefined() |
| 407 | // Deleted row should be marked |
| 408 | expect(result[1].__isDeleted).toBe(true) |
| 409 | expect(result[1].id).toBe(1) |
| 410 | // Other row unaffected |
| 411 | expect(result[2]).toEqual(rows[1]) |
| 412 | }) |
| 413 | |
| 414 | test('should correctly edit a row after adding a new row (ADD then EDIT)', () => { |
| 415 | const rows = [makeRow(0, { id: 1, name: 'Alice' })] |
| 416 | const addOp = makeAddOp('-100', { name: 'New Row' }) |
| 417 | const editOp = makeEditOp({ |
| 418 | payload: { |
| 419 | rowIdentifiers: { id: 1 }, |
| 420 | columnName: 'name', |
| 421 | oldValue: 'Alice', |
| 422 | newValue: 'Updated Alice', |
| 423 | table: {} as any, |
| 424 | }, |
| 425 | }) |
| 426 | |
| 427 | const result = formatGridDataWithOperationValues({ |
| 428 | operations: [addOp, editOp], |
| 429 | rows, |
| 430 | }) |
| 431 | |
| 432 | expect(result).toHaveLength(2) |
| 433 | expect(result[0]).toMatchObject({ __tempId: '-100', name: 'New Row' }) |
| 434 | expect(result[1].name).toBe('Updated Alice') |
| 435 | }) |
| 436 | |
| 437 | test('should handle EDIT_CELL_CONTENT with composite primary keys', () => { |
| 438 | const rows = [makeRow(0, { tenant_id: 'a', user_id: 1, name: 'Alice' })] |
| 439 | const op = makeEditOp({ |
| 440 | payload: { |
| 441 | rowIdentifiers: { tenant_id: 'a', user_id: 1 }, |
| 442 | columnName: 'name', |
| 443 | oldValue: 'Alice', |
| 444 | newValue: 'Updated', |
| 445 | table: {} as any, |
| 446 | }, |
| 447 | }) |
| 448 | |
| 449 | const result = formatGridDataWithOperationValues({ operations: [op], rows }) |
| 450 | expect(result[0].name).toBe('Updated') |
| 451 | }) |
| 452 | }) |