@object-ui/plugin-kanban 0.5.0 → 3.0.0

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.
@@ -0,0 +1,525 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
10
+ import { render, screen, act } from '@testing-library/react';
11
+ import * as React from 'react';
12
+ import { KanbanEnhanced, type KanbanColumn } from '../KanbanEnhanced';
13
+
14
+ // Mock @tanstack/react-virtual
15
+ vi.mock('@tanstack/react-virtual', () => ({
16
+ useVirtualizer: () => ({
17
+ getTotalSize: () => 1000,
18
+ getVirtualItems: () => [],
19
+ measureElement: vi.fn(),
20
+ }),
21
+ }));
22
+
23
+ // Capture onDragEnd so tests can simulate drag-and-drop events
24
+ let capturedOnDragEnd: ((event: any) => void) | null = null;
25
+
26
+ vi.mock('@dnd-kit/core', () => ({
27
+ DndContext: ({ children, onDragEnd }: any) => {
28
+ capturedOnDragEnd = onDragEnd;
29
+ return <div data-testid="dnd-context">{children}</div>;
30
+ },
31
+ DragOverlay: ({ children }: any) => <div data-testid="drag-overlay">{children}</div>,
32
+ PointerSensor: vi.fn(),
33
+ TouchSensor: vi.fn(),
34
+ useSensor: vi.fn(),
35
+ useSensors: () => [],
36
+ closestCorners: vi.fn(),
37
+ }));
38
+
39
+ vi.mock('@dnd-kit/sortable', () => ({
40
+ SortableContext: ({ children }: any) => <div data-testid="sortable-context">{children}</div>,
41
+ useSortable: () => ({
42
+ attributes: {},
43
+ listeners: {},
44
+ setNodeRef: vi.fn(),
45
+ transform: null,
46
+ transition: null,
47
+ isDragging: false,
48
+ }),
49
+ arrayMove: (array: any[], from: number, to: number) => {
50
+ const newArray = [...array];
51
+ newArray.splice(to, 0, newArray.splice(from, 1)[0]);
52
+ return newArray;
53
+ },
54
+ verticalListSortingStrategy: vi.fn(),
55
+ }));
56
+
57
+ vi.mock('@dnd-kit/utilities', () => ({
58
+ CSS: {
59
+ Transform: {
60
+ toString: () => '',
61
+ },
62
+ },
63
+ }));
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Undo/Redo state manager (mock integration layer)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ interface UndoRedoState<T> {
70
+ past: T[];
71
+ present: T;
72
+ future: T[];
73
+ }
74
+
75
+ function createUndoRedoManager<T>(initial: T) {
76
+ const state: UndoRedoState<T> = {
77
+ past: [],
78
+ present: initial,
79
+ future: [],
80
+ };
81
+
82
+ return {
83
+ state,
84
+
85
+ push(newPresent: T) {
86
+ state.past.push(state.present);
87
+ state.present = newPresent;
88
+ state.future = [];
89
+ },
90
+
91
+ undo(): T | null {
92
+ if (state.past.length === 0) return null;
93
+ const previous = state.past.pop()!;
94
+ state.future.push(state.present);
95
+ state.present = previous;
96
+ return previous;
97
+ },
98
+
99
+ redo(): T | null {
100
+ if (state.future.length === 0) return null;
101
+ const next = state.future.pop()!;
102
+ state.past.push(state.present);
103
+ state.present = next;
104
+ return next;
105
+ },
106
+
107
+ get current() {
108
+ return state.present;
109
+ },
110
+
111
+ get canUndo() {
112
+ return state.past.length > 0;
113
+ },
114
+
115
+ get canRedo() {
116
+ return state.future.length > 0;
117
+ },
118
+ };
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Helpers
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function makeColumns(): KanbanColumn[] {
126
+ return [
127
+ {
128
+ id: 'todo',
129
+ title: 'To Do',
130
+ cards: [
131
+ { id: 'card-1', title: 'Task 1' },
132
+ { id: 'card-2', title: 'Task 2' },
133
+ { id: 'card-3', title: 'Task 3' },
134
+ ],
135
+ },
136
+ {
137
+ id: 'in-progress',
138
+ title: 'In Progress',
139
+ cards: [
140
+ { id: 'card-4', title: 'Task 4' },
141
+ ],
142
+ },
143
+ {
144
+ id: 'done',
145
+ title: 'Done',
146
+ cards: [],
147
+ },
148
+ ];
149
+ }
150
+
151
+ /** Deep-clone columns so mutations in the manager don't alias component state. */
152
+ function cloneColumns(cols: KanbanColumn[]): KanbanColumn[] {
153
+ return cols.map(c => ({ ...c, cards: c.cards.map(card => ({ ...card })) }));
154
+ }
155
+
156
+ /** Apply a cross-column card move to a columns snapshot and return a new snapshot. */
157
+ function applyMove(
158
+ columns: KanbanColumn[],
159
+ cardId: string,
160
+ fromColumnId: string,
161
+ toColumnId: string,
162
+ newIndex: number,
163
+ ): KanbanColumn[] {
164
+ const next = cloneColumns(columns);
165
+ const fromCol = next.find(c => c.id === fromColumnId)!;
166
+ const toCol = next.find(c => c.id === toColumnId)!;
167
+ const cardIdx = fromCol.cards.findIndex(c => c.id === cardId);
168
+ const [card] = fromCol.cards.splice(cardIdx, 1);
169
+ toCol.cards.splice(newIndex, 0, card);
170
+ return next;
171
+ }
172
+
173
+ /** Apply a same-column reorder to a columns snapshot. */
174
+ function applyReorder(
175
+ columns: KanbanColumn[],
176
+ columnId: string,
177
+ cardId: string,
178
+ toCardId: string,
179
+ ): KanbanColumn[] {
180
+ const next = cloneColumns(columns);
181
+ const col = next.find(c => c.id === columnId)!;
182
+ const fromIdx = col.cards.findIndex(c => c.id === cardId);
183
+ const toIdx = col.cards.findIndex(c => c.id === toCardId);
184
+ const [card] = col.cards.splice(fromIdx, 1);
185
+ col.cards.splice(toIdx, 0, card);
186
+ return next;
187
+ }
188
+
189
+ /** Find which column a card belongs to. */
190
+ function findColumnOfCard(columns: KanbanColumn[], cardId: string): string | undefined {
191
+ return columns.find(col => col.cards.some(c => c.id === cardId))?.id;
192
+ }
193
+
194
+ /** Wrapper component that wires KanbanEnhanced to the undo/redo manager. */
195
+ function KanbanWithUndo({
196
+ manager,
197
+ onColumnsChange,
198
+ }: {
199
+ manager: ReturnType<typeof createUndoRedoManager<KanbanColumn[]>>;
200
+ onColumnsChange: (cols: KanbanColumn[]) => void;
201
+ }) {
202
+ const [columns, setColumns] = React.useState<KanbanColumn[]>(manager.current);
203
+
204
+ const handleCardMove = React.useCallback(
205
+ (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => {
206
+ const updated = applyMove(manager.current, cardId, fromColumnId, toColumnId, newIndex);
207
+ manager.push(updated);
208
+ setColumns(updated);
209
+ onColumnsChange(updated);
210
+ },
211
+ [manager, onColumnsChange],
212
+ );
213
+
214
+ // Expose a way for tests to drive undo/redo through re-render
215
+ React.useEffect(() => {
216
+ setColumns(manager.current);
217
+ }, [manager.current]); // eslint-disable-line react-hooks/exhaustive-deps
218
+
219
+ return <KanbanEnhanced columns={columns} onCardMove={handleCardMove} />;
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Tests
224
+ // ---------------------------------------------------------------------------
225
+
226
+ describe('DnD + Undo/Redo Integration', () => {
227
+ let manager: ReturnType<typeof createUndoRedoManager<KanbanColumn[]>>;
228
+ let onColumnsChange: ReturnType<typeof vi.fn>;
229
+
230
+ beforeEach(() => {
231
+ capturedOnDragEnd = null;
232
+ manager = createUndoRedoManager<KanbanColumn[]>(makeColumns());
233
+ onColumnsChange = vi.fn();
234
+ });
235
+
236
+ // -----------------------------------------------------------------------
237
+ // 1. Moving a card between columns updates the board state
238
+ // -----------------------------------------------------------------------
239
+
240
+ it('should update board state when a card is moved between columns', () => {
241
+ render(<KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />);
242
+
243
+ // Simulate dragging card-1 from "todo" onto "in-progress" column
244
+ act(() => {
245
+ capturedOnDragEnd?.({
246
+ active: { id: 'card-1' },
247
+ over: { id: 'in-progress' },
248
+ });
249
+ });
250
+
251
+ expect(onColumnsChange).toHaveBeenCalledTimes(1);
252
+
253
+ const updatedColumns = manager.current;
254
+ expect(findColumnOfCard(updatedColumns, 'card-1')).toBe('in-progress');
255
+
256
+ // Original column lost the card
257
+ const todoCards = updatedColumns.find(c => c.id === 'todo')!.cards;
258
+ expect(todoCards.some(c => c.id === 'card-1')).toBe(false);
259
+
260
+ // Target column gained it
261
+ const ipCards = updatedColumns.find(c => c.id === 'in-progress')!.cards;
262
+ expect(ipCards.some(c => c.id === 'card-1')).toBe(true);
263
+ });
264
+
265
+ // -----------------------------------------------------------------------
266
+ // 2. Undo after a drag-drop reverts the card to its original column
267
+ // -----------------------------------------------------------------------
268
+
269
+ it('should revert the card to its original column on undo', () => {
270
+ const { rerender } = render(
271
+ <KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />,
272
+ );
273
+
274
+ // Move card-1 → in-progress
275
+ act(() => {
276
+ capturedOnDragEnd?.({
277
+ active: { id: 'card-1' },
278
+ over: { id: 'in-progress' },
279
+ });
280
+ });
281
+
282
+ expect(findColumnOfCard(manager.current, 'card-1')).toBe('in-progress');
283
+
284
+ // Undo
285
+ act(() => {
286
+ manager.undo();
287
+ rerender(<KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />);
288
+ });
289
+
290
+ expect(findColumnOfCard(manager.current, 'card-1')).toBe('todo');
291
+ expect(manager.current.find(c => c.id === 'todo')!.cards.map(c => c.id)).toContain('card-1');
292
+ });
293
+
294
+ // -----------------------------------------------------------------------
295
+ // 3. Redo after undo re-applies the move
296
+ // -----------------------------------------------------------------------
297
+
298
+ it('should re-apply the move on redo after undo', () => {
299
+ const { rerender } = render(
300
+ <KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />,
301
+ );
302
+
303
+ // Move card-2 → done
304
+ act(() => {
305
+ capturedOnDragEnd?.({
306
+ active: { id: 'card-2' },
307
+ over: { id: 'done' },
308
+ });
309
+ });
310
+
311
+ expect(findColumnOfCard(manager.current, 'card-2')).toBe('done');
312
+
313
+ // Undo
314
+ act(() => {
315
+ manager.undo();
316
+ rerender(<KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />);
317
+ });
318
+
319
+ expect(findColumnOfCard(manager.current, 'card-2')).toBe('todo');
320
+
321
+ // Redo
322
+ act(() => {
323
+ manager.redo();
324
+ rerender(<KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />);
325
+ });
326
+
327
+ expect(findColumnOfCard(manager.current, 'card-2')).toBe('done');
328
+ });
329
+
330
+ // -----------------------------------------------------------------------
331
+ // 4. Multiple sequential moves can be undone in reverse order
332
+ // -----------------------------------------------------------------------
333
+
334
+ it('should undo multiple sequential moves in reverse order', () => {
335
+ const { rerender } = render(
336
+ <KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />,
337
+ );
338
+
339
+ // Move 1: card-1 → in-progress
340
+ act(() => {
341
+ capturedOnDragEnd?.({
342
+ active: { id: 'card-1' },
343
+ over: { id: 'in-progress' },
344
+ });
345
+ });
346
+
347
+ // Move 2: card-2 → done
348
+ act(() => {
349
+ capturedOnDragEnd?.({
350
+ active: { id: 'card-2' },
351
+ over: { id: 'done' },
352
+ });
353
+ });
354
+
355
+ // Move 3: card-4 → done
356
+ act(() => {
357
+ capturedOnDragEnd?.({
358
+ active: { id: 'card-4' },
359
+ over: { id: 'done' },
360
+ });
361
+ });
362
+
363
+ expect(findColumnOfCard(manager.current, 'card-1')).toBe('in-progress');
364
+ expect(findColumnOfCard(manager.current, 'card-2')).toBe('done');
365
+ expect(findColumnOfCard(manager.current, 'card-4')).toBe('done');
366
+
367
+ // Undo move 3 → card-4 back to in-progress
368
+ act(() => {
369
+ manager.undo();
370
+ rerender(<KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />);
371
+ });
372
+
373
+ expect(findColumnOfCard(manager.current, 'card-4')).toBe('in-progress');
374
+ expect(findColumnOfCard(manager.current, 'card-2')).toBe('done');
375
+
376
+ // Undo move 2 → card-2 back to todo
377
+ act(() => {
378
+ manager.undo();
379
+ rerender(<KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />);
380
+ });
381
+
382
+ expect(findColumnOfCard(manager.current, 'card-2')).toBe('todo');
383
+ expect(findColumnOfCard(manager.current, 'card-1')).toBe('in-progress');
384
+
385
+ // Undo move 1 → card-1 back to todo
386
+ act(() => {
387
+ manager.undo();
388
+ rerender(<KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />);
389
+ });
390
+
391
+ expect(findColumnOfCard(manager.current, 'card-1')).toBe('todo');
392
+ expect(manager.canUndo).toBe(false);
393
+ });
394
+
395
+ // -----------------------------------------------------------------------
396
+ // 5. Undo stack is cleared when a new action is performed after undo
397
+ // -----------------------------------------------------------------------
398
+
399
+ it('should clear the redo stack when a new move is performed after undo', () => {
400
+ const { rerender } = render(
401
+ <KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />,
402
+ );
403
+
404
+ // Move card-1 → in-progress
405
+ act(() => {
406
+ capturedOnDragEnd?.({
407
+ active: { id: 'card-1' },
408
+ over: { id: 'in-progress' },
409
+ });
410
+ });
411
+
412
+ // Move card-2 → done
413
+ act(() => {
414
+ capturedOnDragEnd?.({
415
+ active: { id: 'card-2' },
416
+ over: { id: 'done' },
417
+ });
418
+ });
419
+
420
+ // Undo last move (card-2 back to todo)
421
+ act(() => {
422
+ manager.undo();
423
+ rerender(<KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />);
424
+ });
425
+
426
+ expect(manager.canRedo).toBe(true);
427
+
428
+ // Perform a NEW move — this should discard the redo stack
429
+ act(() => {
430
+ capturedOnDragEnd?.({
431
+ active: { id: 'card-3' },
432
+ over: { id: 'done' },
433
+ });
434
+ });
435
+
436
+ expect(manager.canRedo).toBe(false);
437
+
438
+ // The original redo (card-2 → done) is gone
439
+ const redoResult = manager.redo();
440
+ expect(redoResult).toBeNull();
441
+
442
+ // But the new move is present
443
+ expect(findColumnOfCard(manager.current, 'card-3')).toBe('done');
444
+ });
445
+
446
+ // -----------------------------------------------------------------------
447
+ // 6. Moving a card within the same column (reordering) can be undone
448
+ // -----------------------------------------------------------------------
449
+
450
+ it('should undo a same-column card reorder', () => {
451
+ // For same-column reorder, KanbanEnhanced handles it internally without
452
+ // calling onCardMove. We test the undo/redo manager directly with the
453
+ // reorder helper to validate the integration pattern.
454
+
455
+ const initial = makeColumns();
456
+ const reorderManager = createUndoRedoManager<KanbanColumn[]>(initial);
457
+
458
+ // Original order in 'todo': card-1, card-2, card-3
459
+ const todoBefore = reorderManager.current.find(c => c.id === 'todo')!;
460
+ expect(todoBefore.cards.map(c => c.id)).toEqual(['card-1', 'card-2', 'card-3']);
461
+
462
+ // Reorder: move card-3 before card-1
463
+ const reordered = applyReorder(reorderManager.current, 'todo', 'card-3', 'card-1');
464
+ reorderManager.push(reordered);
465
+
466
+ const todoAfter = reorderManager.current.find(c => c.id === 'todo')!;
467
+ expect(todoAfter.cards.map(c => c.id)).toEqual(['card-3', 'card-1', 'card-2']);
468
+
469
+ // Undo → back to original order
470
+ reorderManager.undo();
471
+
472
+ const todoReverted = reorderManager.current.find(c => c.id === 'todo')!;
473
+ expect(todoReverted.cards.map(c => c.id)).toEqual(['card-1', 'card-2', 'card-3']);
474
+ });
475
+
476
+ // -----------------------------------------------------------------------
477
+ // Additional edge-case coverage
478
+ // -----------------------------------------------------------------------
479
+
480
+ it('should handle undo when there is nothing to undo', () => {
481
+ expect(manager.canUndo).toBe(false);
482
+ const result = manager.undo();
483
+ expect(result).toBeNull();
484
+ // State unchanged
485
+ expect(findColumnOfCard(manager.current, 'card-1')).toBe('todo');
486
+ });
487
+
488
+ it('should handle redo when there is nothing to redo', () => {
489
+ expect(manager.canRedo).toBe(false);
490
+ const result = manager.redo();
491
+ expect(result).toBeNull();
492
+ });
493
+
494
+ it('should render correctly after multiple undo/redo cycles', () => {
495
+ const { rerender } = render(
496
+ <KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />,
497
+ );
498
+
499
+ // Move card-1 → done
500
+ act(() => {
501
+ capturedOnDragEnd?.({
502
+ active: { id: 'card-1' },
503
+ over: { id: 'done' },
504
+ });
505
+ });
506
+
507
+ // Cycle undo ↔ redo several times
508
+ for (let i = 0; i < 3; i++) {
509
+ act(() => {
510
+ manager.undo();
511
+ rerender(<KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />);
512
+ });
513
+ expect(findColumnOfCard(manager.current, 'card-1')).toBe('todo');
514
+
515
+ act(() => {
516
+ manager.redo();
517
+ rerender(<KanbanWithUndo manager={manager} onColumnsChange={onColumnsChange} />);
518
+ });
519
+ expect(findColumnOfCard(manager.current, 'card-1')).toBe('done');
520
+ }
521
+
522
+ // Board still renders correctly
523
+ expect(screen.getByText('Task 1')).toBeInTheDocument();
524
+ });
525
+ });