queueOperationUtils.test.ts 14 KB

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