@object-ui/plugin-kanban 2.0.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.
- package/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +20 -0
- package/dist/{KanbanEnhanced-BMreTWOT.js → KanbanEnhanced-BPIKjTDv.js} +7 -7
- package/dist/KanbanImpl-BfOKAnJS.js +194 -0
- package/dist/{index-a4_RI-v7.js → index-CWGTi2xn.js} +241 -220
- package/dist/index.js +1 -1
- package/dist/index.umd.cjs +4 -4
- package/dist/{sortable.esm-ZHwgFQIO.js → sortable.esm-CNNHgHk5.js} +1 -0
- package/dist/src/KanbanImpl.d.ts +2 -1
- package/dist/src/KanbanImpl.d.ts.map +1 -1
- package/dist/src/ObjectKanban.EdgeCases.stories.d.ts +26 -0
- package/dist/src/ObjectKanban.EdgeCases.stories.d.ts.map +1 -0
- package/dist/src/ObjectKanban.d.ts +2 -0
- package/dist/src/ObjectKanban.d.ts.map +1 -1
- package/dist/src/ObjectKanban.stories.d.ts +24 -0
- package/dist/src/ObjectKanban.stories.d.ts.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/KanbanImpl.tsx +82 -20
- package/src/ObjectKanban.EdgeCases.stories.tsx +168 -0
- package/src/ObjectKanban.stories.tsx +152 -0
- package/src/ObjectKanban.tsx +43 -2
- package/src/__tests__/KanbanEnhanced.test.tsx +1 -0
- package/src/__tests__/accessibility.test.tsx +296 -0
- package/src/__tests__/dnd-undo-integration.test.tsx +525 -0
- package/src/__tests__/performance-benchmark.test.tsx +306 -0
- package/src/__tests__/view-states.test.tsx +403 -0
- package/src/index.tsx +2 -0
- package/dist/KanbanImpl--kTNN_B8.js +0 -144
|
@@ -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
|
+
});
|