@startsimpli/ui 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/__mocks__/next/link.js +11 -0
  3. package/src/components/account/__tests__/account.test.tsx +315 -0
  4. package/src/components/command-palette/CommandGroup.tsx +23 -0
  5. package/src/components/command-palette/CommandPalette.tsx +183 -200
  6. package/src/components/command-palette/CommandResultItem.tsx +59 -0
  7. package/src/components/command-palette/__tests__/CommandGroup.test.tsx +81 -0
  8. package/src/components/command-palette/__tests__/CommandResultItem.test.tsx +166 -0
  9. package/src/components/command-palette/__tests__/command-palette-context.test.tsx +166 -0
  10. package/src/components/command-palette/__tests__/useCommandPaletteSearch.test.ts +271 -0
  11. package/src/components/command-palette/index.ts +6 -0
  12. package/src/components/command-palette/useCommandPaletteSearch.ts +114 -0
  13. package/src/components/compose/__tests__/compose.test.tsx +656 -0
  14. package/src/components/dashboard/PipelineFunnel.tsx +126 -0
  15. package/src/components/dashboard/TopCampaigns.tsx +132 -0
  16. package/src/components/dashboard/__tests__/dashboard.test.tsx +785 -0
  17. package/src/components/dashboard/index.ts +6 -0
  18. package/src/components/dialog/ConfirmDialog.tsx +72 -0
  19. package/src/components/dialog/__tests__/ConfirmDialog.test.tsx +126 -0
  20. package/src/components/dialog/index.ts +3 -0
  21. package/src/components/email-dialogs/__tests__/email-dialogs.test.tsx +982 -0
  22. package/src/components/email-editor/BlockRenderer.tsx +120 -0
  23. package/src/components/email-editor/__tests__/BlockRenderer.test.tsx +332 -0
  24. package/src/components/email-editor/__tests__/block-renderers.test.ts +624 -0
  25. package/src/components/email-editor/__tests__/email-html-renderer.test.ts +376 -0
  26. package/src/components/email-editor/blocks/__tests__/blocks.test.tsx +818 -0
  27. package/src/components/email-editor/editor-sidebar.tsx +6 -731
  28. package/src/components/email-editor/email-editor.tsx +78 -467
  29. package/src/components/email-editor/hooks/__tests__/useDragDrop.test.ts +355 -0
  30. package/src/components/email-editor/hooks/__tests__/useEmailEditorState.test.ts +551 -0
  31. package/src/components/email-editor/hooks/useDragDrop.ts +181 -0
  32. package/src/components/email-editor/hooks/useEmailEditorState.ts +426 -0
  33. package/src/components/email-editor/index.ts +1 -0
  34. package/src/components/email-editor/panels/BlockPropertyPanel.tsx +637 -0
  35. package/src/components/email-editor/panels/GlobalStylesPanel.tsx +108 -0
  36. package/src/components/email-editor/panels/SectionSettingsPanel.tsx +80 -0
  37. package/src/components/email-editor/panels/__tests__/BlockPropertyPanel.test.tsx +707 -0
  38. package/src/components/email-editor/panels/__tests__/GlobalStylesPanel.test.tsx +226 -0
  39. package/src/components/email-editor/panels/index.ts +3 -0
  40. package/src/components/enrichment/__tests__/enrichment.test.tsx +184 -0
  41. package/src/components/gantt/GanttBoardView.tsx +71 -0
  42. package/src/components/gantt/GanttChart.tsx +134 -881
  43. package/src/components/gantt/GanttFilterBar.tsx +100 -0
  44. package/src/components/gantt/GanttListView.tsx +63 -0
  45. package/src/components/gantt/GanttTimelineView.tsx +215 -0
  46. package/src/components/gantt/__tests__/GanttBoardView.test.tsx +305 -0
  47. package/src/components/gantt/__tests__/GanttFilterBar.test.tsx +544 -0
  48. package/src/components/gantt/__tests__/GanttListView.test.tsx +337 -0
  49. package/src/components/gantt/__tests__/GanttTimelineView.test.tsx +375 -0
  50. package/src/components/gantt/__tests__/gantt-utils.test.ts +341 -0
  51. package/src/components/gantt/__tests__/useGanttState.test.ts +535 -0
  52. package/src/components/gantt/hooks/useGanttState.ts +644 -0
  53. package/src/components/gantt/index.ts +10 -0
  54. package/src/components/integrations/__tests__/integrations.test.tsx +191 -0
  55. package/src/components/kanban/__tests__/kanban.test.tsx +157 -0
  56. package/src/components/lists/__tests__/lists.test.tsx +263 -0
  57. package/src/components/loading/__tests__/loading.test.tsx +114 -0
  58. package/src/components/navigation/__tests__/navigation.test.tsx +194 -0
  59. package/src/components/pipeline/__tests__/pipeline.test.tsx +169 -0
  60. package/src/components/settings/__tests__/settings.test.tsx +181 -0
  61. package/src/components/wizard/__tests__/wizard.test.tsx +97 -0
@@ -0,0 +1,551 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useEmailEditorState } from '../useEmailEditorState'
3
+ import { createSection, createRow, createBlock, Section } from '../../types'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Build a minimal single-section state with one row, one column,
11
+ * and an optional list of blocks already in column 0.
12
+ */
13
+ function makeSections(blockTypes: Array<'text' | 'divider' | 'cta' | 'spacer'> = []): Section[] {
14
+ const section = createSection()
15
+ // createSection already gives one row with layout '1'
16
+ section.rows[0].columns[0] = blockTypes.map((t) => createBlock(t))
17
+ return [section]
18
+ }
19
+
20
+ /**
21
+ * Renders the hook with a self-updating onChange wrapper so that every
22
+ * commitChange call feeds back into the hook as new initialSections.
23
+ * Returns the renderHook result plus a getter for the current sections.
24
+ */
25
+ function setup(initial: Section[] = makeSections()) {
26
+ // We keep a mutable ref outside so we can close over it in onChange
27
+ let currentSections = initial
28
+
29
+ const onChange = jest.fn((next: Section[]) => {
30
+ currentSections = next
31
+ rerender({ initialSections: currentSections, onChange })
32
+ })
33
+
34
+ const { result, rerender } = renderHook(
35
+ ({ initialSections, onChange: cb }: { initialSections: Section[]; onChange: (s: Section[]) => void }) =>
36
+ useEmailEditorState({ initialSections, onChange: cb }),
37
+ { initialProps: { initialSections: initial, onChange } },
38
+ )
39
+
40
+ return { result, onChange, getSections: () => currentSections }
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Block operations
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe('useEmailEditorState — block operations', () => {
48
+ it('addBlock appends a block to a column', () => {
49
+ const { result, onChange } = setup(makeSections())
50
+
51
+ act(() => {
52
+ result.current.addBlock(0, 0, 0, 'text')
53
+ })
54
+
55
+ expect(onChange).toHaveBeenCalledTimes(1)
56
+ const [committed] = onChange.mock.calls[0]
57
+ expect(committed[0].rows[0].columns[0]).toHaveLength(1)
58
+ expect(committed[0].rows[0].columns[0][0].type).toBe('text')
59
+ })
60
+
61
+ it('addBlock inserts after a given block index', () => {
62
+ const initial = makeSections(['text', 'divider'])
63
+ const { result, onChange } = setup(initial)
64
+
65
+ // Insert a spacer after the first block (index 0)
66
+ act(() => {
67
+ result.current.addBlock(0, 0, 0, 'spacer', 0)
68
+ })
69
+
70
+ const [committed] = onChange.mock.calls[0]
71
+ const col = committed[0].rows[0].columns[0]
72
+ expect(col).toHaveLength(3)
73
+ expect(col[0].type).toBe('text')
74
+ expect(col[1].type).toBe('spacer')
75
+ expect(col[2].type).toBe('divider')
76
+ })
77
+
78
+ it('addBlock selects the newly inserted block', () => {
79
+ const { result } = setup(makeSections(['text']))
80
+
81
+ act(() => {
82
+ result.current.addBlock(0, 0, 0, 'cta', 0)
83
+ })
84
+
85
+ expect(result.current.selection).toEqual({
86
+ sectionIndex: 0,
87
+ rowIndex: 0,
88
+ columnIndex: 0,
89
+ blockIndex: 1,
90
+ })
91
+ })
92
+
93
+ it('removeBlock removes the block at the given index', () => {
94
+ const initial = makeSections(['text', 'divider', 'spacer'])
95
+ const { result, onChange } = setup(initial)
96
+
97
+ act(() => {
98
+ result.current.removeBlock(0, 0, 0, 1) // remove divider
99
+ })
100
+
101
+ const [committed] = onChange.mock.calls[0]
102
+ const col = committed[0].rows[0].columns[0]
103
+ expect(col).toHaveLength(2)
104
+ expect(col[0].type).toBe('text')
105
+ expect(col[1].type).toBe('spacer')
106
+ })
107
+
108
+ it('removeBlock clears selection', () => {
109
+ const { result } = setup(makeSections(['text']))
110
+
111
+ act(() => {
112
+ result.current.setSelection({ sectionIndex: 0, rowIndex: 0, columnIndex: 0, blockIndex: 0 })
113
+ })
114
+ act(() => {
115
+ result.current.removeBlock(0, 0, 0, 0)
116
+ })
117
+
118
+ expect(result.current.selection).toBeNull()
119
+ })
120
+
121
+ it('updateBlock merges updates into an existing block', () => {
122
+ const initial = makeSections(['text'])
123
+ const { result, onChange } = setup(initial)
124
+
125
+ act(() => {
126
+ result.current.updateBlock(0, 0, 0, 0, { content: 'Hello world' })
127
+ })
128
+
129
+ const [committed] = onChange.mock.calls[0]
130
+ const block = committed[0].rows[0].columns[0][0]
131
+ expect(block.type).toBe('text')
132
+ // @ts-expect-error content only exists on TextBlock, but we know the type
133
+ expect(block.content).toBe('Hello world')
134
+ })
135
+
136
+ it('duplicateBlock inserts an identical block right after the original', () => {
137
+ const initial = makeSections(['text', 'divider'])
138
+ const { result, onChange } = setup(initial)
139
+
140
+ act(() => {
141
+ result.current.duplicateBlock(0, 0, 0, 0)
142
+ })
143
+
144
+ const [committed] = onChange.mock.calls[0]
145
+ const col = committed[0].rows[0].columns[0]
146
+ expect(col).toHaveLength(3)
147
+ expect(col[1].type).toBe('text')
148
+ // duplicate must have a distinct id
149
+ expect(col[1].id).not.toBe(col[0].id)
150
+ })
151
+
152
+ it('duplicateBlock selects the duplicated block', () => {
153
+ const initial = makeSections(['text'])
154
+ const { result } = setup(initial)
155
+
156
+ act(() => {
157
+ result.current.duplicateBlock(0, 0, 0, 0)
158
+ })
159
+
160
+ expect(result.current.selection).toEqual({
161
+ sectionIndex: 0,
162
+ rowIndex: 0,
163
+ columnIndex: 0,
164
+ blockIndex: 1,
165
+ })
166
+ })
167
+ })
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Section operations
171
+ // ---------------------------------------------------------------------------
172
+
173
+ describe('useEmailEditorState — section operations', () => {
174
+ it('addSection appends a new section after the last one by default', () => {
175
+ const { result, onChange } = setup(makeSections())
176
+
177
+ act(() => {
178
+ result.current.addSection()
179
+ })
180
+
181
+ const [committed] = onChange.mock.calls[0]
182
+ expect(committed).toHaveLength(2)
183
+ })
184
+
185
+ it('addSection inserts after a specific index', () => {
186
+ const initial = [createSection(), createSection(), createSection()]
187
+ const { result, onChange } = setup(initial)
188
+
189
+ act(() => {
190
+ result.current.addSection(0) // after index 0
191
+ })
192
+
193
+ const [committed] = onChange.mock.calls[0]
194
+ expect(committed).toHaveLength(4)
195
+ // New section should be at index 1
196
+ // The original 2nd and 3rd sections shift to 2 and 3
197
+ expect(committed[0].id).toBe(initial[0].id)
198
+ expect(committed[2].id).toBe(initial[1].id)
199
+ })
200
+
201
+ it('removeSection removes the section at a given index', () => {
202
+ const initial = [createSection(), createSection()]
203
+ const firstId = initial[0].id
204
+ const { result, onChange } = setup(initial)
205
+
206
+ act(() => {
207
+ result.current.removeSection(1)
208
+ })
209
+
210
+ const [committed] = onChange.mock.calls[0]
211
+ expect(committed).toHaveLength(1)
212
+ expect(committed[0].id).toBe(firstId)
213
+ })
214
+
215
+ it('removeSection does nothing when only one section remains', () => {
216
+ const { result, onChange } = setup(makeSections())
217
+
218
+ act(() => {
219
+ result.current.removeSection(0)
220
+ })
221
+
222
+ expect(onChange).not.toHaveBeenCalled()
223
+ })
224
+
225
+ it('removeSection clears selection', () => {
226
+ const initial = [createSection(), createSection()]
227
+ const { result } = setup(initial)
228
+
229
+ act(() => {
230
+ result.current.setSelection({ sectionIndex: 1, rowIndex: 0, columnIndex: 0, blockIndex: 0 })
231
+ })
232
+ act(() => {
233
+ result.current.removeSection(1)
234
+ })
235
+
236
+ expect(result.current.selection).toBeNull()
237
+ })
238
+ })
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Row operations
242
+ // ---------------------------------------------------------------------------
243
+
244
+ describe('useEmailEditorState — row operations', () => {
245
+ it('addRow appends a new row to the given section', () => {
246
+ const { result, onChange } = setup(makeSections())
247
+
248
+ act(() => {
249
+ result.current.addRow(0)
250
+ })
251
+
252
+ const [committed] = onChange.mock.calls[0]
253
+ expect(committed[0].rows).toHaveLength(2)
254
+ })
255
+
256
+ it('addRow with a specific layout creates the correct number of columns', () => {
257
+ const { result, onChange } = setup(makeSections())
258
+
259
+ act(() => {
260
+ result.current.addRow(0, '3')
261
+ })
262
+
263
+ const [committed] = onChange.mock.calls[0]
264
+ expect(committed[0].rows[1].layout).toBe('3')
265
+ expect(committed[0].rows[1].columns).toHaveLength(3)
266
+ })
267
+
268
+ it('removeRow removes a row from the section', () => {
269
+ const section = createSection()
270
+ section.rows.push(createRow('1'))
271
+ const { result, onChange } = setup([section])
272
+
273
+ act(() => {
274
+ result.current.removeRow(0, 1)
275
+ })
276
+
277
+ const [committed] = onChange.mock.calls[0]
278
+ expect(committed[0].rows).toHaveLength(1)
279
+ })
280
+
281
+ it('removeRow does nothing when only one row remains in the section', () => {
282
+ const { result, onChange } = setup(makeSections())
283
+
284
+ act(() => {
285
+ result.current.removeRow(0, 0)
286
+ })
287
+
288
+ expect(onChange).not.toHaveBeenCalled()
289
+ })
290
+
291
+ it('changeRowLayout to 2 columns creates two columns', () => {
292
+ const { result, onChange } = setup(makeSections())
293
+
294
+ act(() => {
295
+ result.current.changeRowLayout(0, 0, '2')
296
+ })
297
+
298
+ const [committed] = onChange.mock.calls[0]
299
+ expect(committed[0].rows[0].layout).toBe('2')
300
+ expect(committed[0].rows[0].columns).toHaveLength(2)
301
+ })
302
+
303
+ it('changeRowLayout merges excess blocks when reducing column count', () => {
304
+ // Start with a 3-column row that has blocks in each column
305
+ const section = createSection()
306
+ const row = createRow('3')
307
+ row.columns[0] = [createBlock('text')]
308
+ row.columns[1] = [createBlock('divider')]
309
+ row.columns[2] = [createBlock('spacer')]
310
+ section.rows[0] = row
311
+ const { result, onChange } = setup([section])
312
+
313
+ act(() => {
314
+ result.current.changeRowLayout(0, 0, '1')
315
+ })
316
+
317
+ const [committed] = onChange.mock.calls[0]
318
+ expect(committed[0].rows[0].columns).toHaveLength(1)
319
+ // All three blocks should be merged into the single column
320
+ expect(committed[0].rows[0].columns[0]).toHaveLength(3)
321
+ })
322
+ })
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Selection state
326
+ // ---------------------------------------------------------------------------
327
+
328
+ describe('useEmailEditorState — selection', () => {
329
+ it('setSelection updates the selection', () => {
330
+ const { result } = setup(makeSections())
331
+
332
+ act(() => {
333
+ result.current.setSelection({ sectionIndex: 0, rowIndex: 0, columnIndex: 0, blockIndex: 0 })
334
+ })
335
+
336
+ expect(result.current.selection).toEqual({
337
+ sectionIndex: 0,
338
+ rowIndex: 0,
339
+ columnIndex: 0,
340
+ blockIndex: 0,
341
+ })
342
+ })
343
+
344
+ it('setSelection to null clears selection', () => {
345
+ const { result } = setup(makeSections())
346
+
347
+ act(() => {
348
+ result.current.setSelection({ sectionIndex: 0, rowIndex: 0, columnIndex: 0, blockIndex: 0 })
349
+ })
350
+ act(() => {
351
+ result.current.setSelection(null)
352
+ })
353
+
354
+ expect(result.current.selection).toBeNull()
355
+ })
356
+ })
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // onChange callback
360
+ // ---------------------------------------------------------------------------
361
+
362
+ describe('useEmailEditorState — onChange callback', () => {
363
+ it('onChange is called with updated sections after addBlock', () => {
364
+ const onChange = jest.fn()
365
+ const initial = makeSections()
366
+ const { result } = renderHook(() =>
367
+ useEmailEditorState({ initialSections: initial, onChange }),
368
+ )
369
+
370
+ act(() => {
371
+ result.current.addBlock(0, 0, 0, 'text')
372
+ })
373
+
374
+ expect(onChange).toHaveBeenCalledTimes(1)
375
+ expect(onChange.mock.calls[0][0][0].rows[0].columns[0]).toHaveLength(1)
376
+ })
377
+
378
+ it('onChange is not called when removeSection is a no-op', () => {
379
+ const onChange = jest.fn()
380
+ const { result } = renderHook(() =>
381
+ useEmailEditorState({ initialSections: makeSections(), onChange }),
382
+ )
383
+
384
+ act(() => {
385
+ result.current.removeSection(0) // only one section
386
+ })
387
+
388
+ expect(onChange).not.toHaveBeenCalled()
389
+ })
390
+
391
+ it('onChange fires when commitChange is called directly', () => {
392
+ const onChange = jest.fn()
393
+ const initial = makeSections()
394
+ const { result } = renderHook(() =>
395
+ useEmailEditorState({ initialSections: initial, onChange }),
396
+ )
397
+
398
+ const newSections = [createSection()]
399
+ act(() => {
400
+ result.current.commitChange(newSections)
401
+ })
402
+
403
+ expect(onChange).toHaveBeenCalledWith(newSections)
404
+ })
405
+ })
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Undo / redo
409
+ // ---------------------------------------------------------------------------
410
+
411
+ describe('useEmailEditorState — undo/redo', () => {
412
+ it('canUndo is false initially', () => {
413
+ const { result } = setup(makeSections())
414
+ expect(result.current.canUndo).toBe(false)
415
+ })
416
+
417
+ it('canRedo is false initially', () => {
418
+ const { result } = setup(makeSections())
419
+ expect(result.current.canRedo).toBe(false)
420
+ })
421
+
422
+ it('commitChange records history so canUndo becomes true', () => {
423
+ const { result } = setup(makeSections())
424
+
425
+ act(() => {
426
+ // addBlock triggers commitChange + onChange → rerender via setup() wrapper
427
+ result.current.addBlock(0, 0, 0, 'text')
428
+ })
429
+
430
+ expect(result.current.canUndo).toBe(true)
431
+ })
432
+
433
+ it('undo calls onChange with the previous state', () => {
434
+ const onChange = jest.fn()
435
+ const initial = makeSections(['text'])
436
+ const { result } = renderHook(() =>
437
+ useEmailEditorState({ initialSections: initial, onChange }),
438
+ )
439
+
440
+ const afterChange = makeSections(['text', 'divider'])
441
+
442
+ act(() => {
443
+ result.current.commitChange(afterChange)
444
+ })
445
+ onChange.mockClear()
446
+
447
+ act(() => {
448
+ result.current.undo()
449
+ })
450
+
451
+ expect(onChange).toHaveBeenCalledTimes(1)
452
+ // onChange receives the previous state (no divider)
453
+ const undoneArg = onChange.mock.calls[0][0]
454
+ expect(undoneArg[0].rows[0].columns[0]).toHaveLength(1)
455
+ expect(undoneArg[0].rows[0].columns[0][0].type).toBe('text')
456
+ })
457
+
458
+ it('redo restores the undone state and calls onChange', () => {
459
+ const onChange = jest.fn()
460
+ const initial = makeSections(['text'])
461
+ const { result } = renderHook(() =>
462
+ useEmailEditorState({ initialSections: initial, onChange }),
463
+ )
464
+
465
+ const afterChange = makeSections(['text', 'divider'])
466
+
467
+ act(() => {
468
+ result.current.commitChange(afterChange)
469
+ })
470
+ act(() => {
471
+ result.current.undo()
472
+ })
473
+ onChange.mockClear()
474
+
475
+ act(() => {
476
+ result.current.redo()
477
+ })
478
+
479
+ expect(onChange).toHaveBeenCalledTimes(1)
480
+ const redoneArg = onChange.mock.calls[0][0]
481
+ expect(redoneArg[0].rows[0].columns[0]).toHaveLength(2)
482
+ })
483
+
484
+ it('canRedo becomes true after undo', () => {
485
+ const { result } = setup(makeSections())
486
+
487
+ // addBlock triggers commitChange + onChange → rerender
488
+ act(() => {
489
+ result.current.addBlock(0, 0, 0, 'text')
490
+ })
491
+ act(() => {
492
+ result.current.undo()
493
+ })
494
+
495
+ expect(result.current.canRedo).toBe(true)
496
+ })
497
+
498
+ it('undo with no history calls onChange with unchanged present', () => {
499
+ const onChange = jest.fn()
500
+ const initial = makeSections()
501
+ const { result } = renderHook(() =>
502
+ useEmailEditorState({ initialSections: initial, onChange }),
503
+ )
504
+
505
+ act(() => {
506
+ result.current.undo()
507
+ })
508
+
509
+ // The hook always fires onChange even when there's nothing to undo;
510
+ // the value should be the current present (no blocks).
511
+ expect(onChange).toHaveBeenCalledTimes(1)
512
+ expect(onChange.mock.calls[0][0][0].rows[0].columns[0]).toHaveLength(0)
513
+ })
514
+
515
+ it('redo with no future calls onChange with unchanged present', () => {
516
+ const onChange = jest.fn()
517
+ const initial = makeSections()
518
+ const { result } = renderHook(() =>
519
+ useEmailEditorState({ initialSections: initial, onChange }),
520
+ )
521
+
522
+ act(() => {
523
+ result.current.redo()
524
+ })
525
+
526
+ expect(onChange).toHaveBeenCalledTimes(1)
527
+ expect(onChange.mock.calls[0][0][0].rows[0].columns[0]).toHaveLength(0)
528
+ })
529
+
530
+ it('multiple commits then undo steps back through history in order', () => {
531
+ const onChange = jest.fn()
532
+ const s0 = makeSections([])
533
+ const s1 = makeSections(['text'])
534
+ const s2 = makeSections(['text', 'divider'])
535
+
536
+ const { result } = renderHook(() =>
537
+ useEmailEditorState({ initialSections: s0, onChange }),
538
+ )
539
+
540
+ act(() => { result.current.commitChange(s1) })
541
+ act(() => { result.current.commitChange(s2) })
542
+ onChange.mockClear()
543
+
544
+ act(() => { result.current.undo() })
545
+ expect(onChange.mock.calls[0][0][0].rows[0].columns[0]).toHaveLength(1) // s1
546
+
547
+ onChange.mockClear()
548
+ act(() => { result.current.undo() })
549
+ expect(onChange.mock.calls[0][0][0].rows[0].columns[0]).toHaveLength(0) // s0
550
+ })
551
+ })