@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.
- package/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +34 -0
- package/dist/{KanbanEnhanced-BqDEu7Z6.js → KanbanEnhanced-BPIKjTDv.js} +7 -7
- package/dist/KanbanImpl-BfOKAnJS.js +194 -0
- package/dist/{index-CrR06na7.js → index-CWGTi2xn.js} +253 -215
- 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.test.ts +8 -8
- package/src/index.tsx +29 -4
- package/dist/KanbanImpl-B8nu2BvG.js +0 -144
|
@@ -0,0 +1,306 @@
|
|
|
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
|
+
* Performance benchmark tests for KanbanBoard (KanbanImpl).
|
|
9
|
+
* Part of P2.4 Performance at Scale roadmap.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
13
|
+
import { render } from '@testing-library/react';
|
|
14
|
+
import '@testing-library/jest-dom';
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import type { KanbanColumn, KanbanCard, KanbanBoardProps } from '../KanbanImpl';
|
|
17
|
+
|
|
18
|
+
// Mock @dnd-kit/core
|
|
19
|
+
vi.mock('@dnd-kit/core', () => ({
|
|
20
|
+
DndContext: ({ children }: any) => <div data-testid="dnd-context">{children}</div>,
|
|
21
|
+
DragOverlay: ({ children }: any) => <div data-testid="drag-overlay">{children}</div>,
|
|
22
|
+
PointerSensor: vi.fn(),
|
|
23
|
+
TouchSensor: vi.fn(),
|
|
24
|
+
useSensor: vi.fn(),
|
|
25
|
+
useSensors: () => [],
|
|
26
|
+
closestCorners: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Mock @dnd-kit/sortable
|
|
30
|
+
vi.mock('@dnd-kit/sortable', () => ({
|
|
31
|
+
SortableContext: ({ children }: any) => <div data-testid="sortable-context">{children}</div>,
|
|
32
|
+
useSortable: () => ({
|
|
33
|
+
attributes: {},
|
|
34
|
+
listeners: {},
|
|
35
|
+
setNodeRef: vi.fn(),
|
|
36
|
+
transform: null,
|
|
37
|
+
transition: null,
|
|
38
|
+
isDragging: false,
|
|
39
|
+
}),
|
|
40
|
+
arrayMove: (array: any[], from: number, to: number) => {
|
|
41
|
+
const newArray = [...array];
|
|
42
|
+
newArray.splice(to, 0, newArray.splice(from, 1)[0]);
|
|
43
|
+
return newArray;
|
|
44
|
+
},
|
|
45
|
+
verticalListSortingStrategy: vi.fn(),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// Mock @dnd-kit/utilities
|
|
49
|
+
vi.mock('@dnd-kit/utilities', () => ({
|
|
50
|
+
CSS: {
|
|
51
|
+
Transform: {
|
|
52
|
+
toString: () => '',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// Mock @object-ui/components
|
|
58
|
+
vi.mock('@object-ui/components', () => ({
|
|
59
|
+
Badge: ({ children, ...props }: any) => <span data-testid="badge" {...props}>{children}</span>,
|
|
60
|
+
Card: ({ children, ...props }: any) => <div data-testid="card" {...props}>{children}</div>,
|
|
61
|
+
CardHeader: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
62
|
+
CardTitle: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
63
|
+
CardDescription: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
64
|
+
CardContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
65
|
+
ScrollArea: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// Mock @object-ui/react
|
|
69
|
+
vi.mock('@object-ui/react', () => ({
|
|
70
|
+
useHasDndProvider: () => false,
|
|
71
|
+
useDnd: () => ({
|
|
72
|
+
startDrag: vi.fn(),
|
|
73
|
+
endDrag: vi.fn(),
|
|
74
|
+
}),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
// --- Data generators ---
|
|
78
|
+
|
|
79
|
+
function generateCards(count: number): KanbanCard[] {
|
|
80
|
+
const cards: KanbanCard[] = [];
|
|
81
|
+
for (let i = 0; i < count; i++) {
|
|
82
|
+
cards.push({
|
|
83
|
+
id: `card-${i}`,
|
|
84
|
+
title: `Task ${i}`,
|
|
85
|
+
description: `Description for task ${i}`,
|
|
86
|
+
badges: i % 3 === 0
|
|
87
|
+
? [{ label: 'High', variant: 'destructive' as const }]
|
|
88
|
+
: undefined,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return cards;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function generateColumns(
|
|
95
|
+
columnCount: number,
|
|
96
|
+
totalCards: number,
|
|
97
|
+
): KanbanColumn[] {
|
|
98
|
+
const cardsPerColumn = Math.ceil(totalCards / columnCount);
|
|
99
|
+
const columns: KanbanColumn[] = [];
|
|
100
|
+
let cardIndex = 0;
|
|
101
|
+
for (let c = 0; c < columnCount; c++) {
|
|
102
|
+
const columnCards: KanbanCard[] = [];
|
|
103
|
+
const count = Math.min(cardsPerColumn, totalCards - cardIndex);
|
|
104
|
+
for (let i = 0; i < count; i++) {
|
|
105
|
+
columnCards.push({
|
|
106
|
+
id: `col${c}-card-${cardIndex}`,
|
|
107
|
+
title: `Task ${cardIndex}`,
|
|
108
|
+
description: `Description for task ${cardIndex}`,
|
|
109
|
+
badges: cardIndex % 3 === 0
|
|
110
|
+
? [{ label: 'High', variant: 'destructive' as const }]
|
|
111
|
+
: undefined,
|
|
112
|
+
});
|
|
113
|
+
cardIndex++;
|
|
114
|
+
}
|
|
115
|
+
columns.push({
|
|
116
|
+
id: `col-${c}`,
|
|
117
|
+
title: `Column ${c}`,
|
|
118
|
+
cards: columnCards,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return columns;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let KanbanBoard: React.ComponentType<KanbanBoardProps>;
|
|
125
|
+
|
|
126
|
+
async function setupMocksAndImport() {
|
|
127
|
+
vi.resetModules();
|
|
128
|
+
|
|
129
|
+
vi.doMock('@dnd-kit/core', () => ({
|
|
130
|
+
DndContext: ({ children }: any) => React.createElement('div', { 'data-testid': 'dnd-context' }, children),
|
|
131
|
+
DragOverlay: ({ children }: any) => React.createElement('div', { 'data-testid': 'drag-overlay' }, children),
|
|
132
|
+
PointerSensor: vi.fn(),
|
|
133
|
+
TouchSensor: vi.fn(),
|
|
134
|
+
useSensor: vi.fn(),
|
|
135
|
+
useSensors: () => [],
|
|
136
|
+
closestCorners: vi.fn(),
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
vi.doMock('@dnd-kit/sortable', () => ({
|
|
140
|
+
SortableContext: ({ children }: any) => React.createElement('div', { 'data-testid': 'sortable-context' }, children),
|
|
141
|
+
useSortable: () => ({
|
|
142
|
+
attributes: {},
|
|
143
|
+
listeners: {},
|
|
144
|
+
setNodeRef: vi.fn(),
|
|
145
|
+
transform: null,
|
|
146
|
+
transition: null,
|
|
147
|
+
isDragging: false,
|
|
148
|
+
}),
|
|
149
|
+
arrayMove: (array: any[], from: number, to: number) => {
|
|
150
|
+
const newArray = [...array];
|
|
151
|
+
newArray.splice(to, 0, newArray.splice(from, 1)[0]);
|
|
152
|
+
return newArray;
|
|
153
|
+
},
|
|
154
|
+
verticalListSortingStrategy: vi.fn(),
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
vi.doMock('@dnd-kit/utilities', () => ({
|
|
158
|
+
CSS: { Transform: { toString: () => '' } },
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
vi.doMock('@object-ui/components', () => ({
|
|
162
|
+
Badge: ({ children, ...props }: any) => React.createElement('span', { 'data-testid': 'badge', ...props }, children),
|
|
163
|
+
Card: ({ children, ...props }: any) => React.createElement('div', { 'data-testid': 'card', ...props }, children),
|
|
164
|
+
CardHeader: ({ children, ...props }: any) => React.createElement('div', props, children),
|
|
165
|
+
CardTitle: ({ children, ...props }: any) => React.createElement('div', props, children),
|
|
166
|
+
CardDescription: ({ children, ...props }: any) => React.createElement('div', props, children),
|
|
167
|
+
CardContent: ({ children, ...props }: any) => React.createElement('div', props, children),
|
|
168
|
+
ScrollArea: ({ children, ...props }: any) => React.createElement('div', props, children),
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
vi.doMock('@object-ui/react', () => ({
|
|
172
|
+
useHasDndProvider: () => false,
|
|
173
|
+
useDnd: () => ({ startDrag: vi.fn(), endDrag: vi.fn() }),
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
const mod = await import('../KanbanImpl');
|
|
177
|
+
KanbanBoard = mod.default;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// =========================================================================
|
|
181
|
+
// Performance Benchmarks
|
|
182
|
+
// =========================================================================
|
|
183
|
+
|
|
184
|
+
describe('KanbanBoard (KanbanImpl): performance benchmarks', () => {
|
|
185
|
+
beforeEach(async () => {
|
|
186
|
+
await setupMocksAndImport();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('renders 100 cards spread across 5 columns under 500ms', () => {
|
|
190
|
+
const columns = generateColumns(5, 100);
|
|
191
|
+
|
|
192
|
+
const start = performance.now();
|
|
193
|
+
const { container } = render(<KanbanBoard columns={columns} />);
|
|
194
|
+
const elapsed = performance.now() - start;
|
|
195
|
+
|
|
196
|
+
expect(container).toBeTruthy();
|
|
197
|
+
expect(elapsed).toBeLessThan(500);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('renders 500 cards spread across 5 columns under 1,000ms', () => {
|
|
201
|
+
const columns = generateColumns(5, 500);
|
|
202
|
+
|
|
203
|
+
const start = performance.now();
|
|
204
|
+
const { container } = render(<KanbanBoard columns={columns} />);
|
|
205
|
+
const elapsed = performance.now() - start;
|
|
206
|
+
|
|
207
|
+
expect(container).toBeTruthy();
|
|
208
|
+
expect(elapsed).toBeLessThan(1_000);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('renders 1,000 cards spread across 5 columns under 2,000ms', () => {
|
|
212
|
+
const columns = generateColumns(5, 1_000);
|
|
213
|
+
|
|
214
|
+
const start = performance.now();
|
|
215
|
+
const { container } = render(<KanbanBoard columns={columns} />);
|
|
216
|
+
const elapsed = performance.now() - start;
|
|
217
|
+
|
|
218
|
+
expect(container).toBeTruthy();
|
|
219
|
+
expect(elapsed).toBeLessThan(2_000);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('renders with 20+ columns without degradation', () => {
|
|
223
|
+
const columns = generateColumns(25, 250);
|
|
224
|
+
|
|
225
|
+
const start = performance.now();
|
|
226
|
+
const { container } = render(<KanbanBoard columns={columns} />);
|
|
227
|
+
const elapsed = performance.now() - start;
|
|
228
|
+
|
|
229
|
+
expect(container).toBeTruthy();
|
|
230
|
+
expect(elapsed).toBeLessThan(2_000);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('renders empty board with 20+ columns quickly', () => {
|
|
234
|
+
const columns = generateColumns(25, 0);
|
|
235
|
+
|
|
236
|
+
const start = performance.now();
|
|
237
|
+
const { container } = render(<KanbanBoard columns={columns} />);
|
|
238
|
+
const elapsed = performance.now() - start;
|
|
239
|
+
|
|
240
|
+
expect(container).toBeTruthy();
|
|
241
|
+
expect(elapsed).toBeLessThan(500);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('data generation for 1,000 cards is fast (< 100ms)', () => {
|
|
245
|
+
const start = performance.now();
|
|
246
|
+
const cards = generateCards(1_000);
|
|
247
|
+
const elapsed = performance.now() - start;
|
|
248
|
+
|
|
249
|
+
expect(cards).toHaveLength(1_000);
|
|
250
|
+
expect(elapsed).toBeLessThan(100);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// =========================================================================
|
|
255
|
+
// Scaling characteristics
|
|
256
|
+
// =========================================================================
|
|
257
|
+
|
|
258
|
+
describe('KanbanBoard (KanbanImpl): scaling characteristics', () => {
|
|
259
|
+
beforeEach(async () => {
|
|
260
|
+
await setupMocksAndImport();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('renders all column titles for 20+ column board', () => {
|
|
264
|
+
const columns = generateColumns(25, 50);
|
|
265
|
+
render(<KanbanBoard columns={columns} />);
|
|
266
|
+
|
|
267
|
+
for (let i = 0; i < 25; i++) {
|
|
268
|
+
expect(document.body.textContent).toContain(`Column ${i}`);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('renders cards with badges without significant overhead', () => {
|
|
273
|
+
const columns: KanbanColumn[] = [
|
|
274
|
+
{
|
|
275
|
+
id: 'badges-col',
|
|
276
|
+
title: 'With Badges',
|
|
277
|
+
cards: Array.from({ length: 500 }, (_, i) => ({
|
|
278
|
+
id: `badge-card-${i}`,
|
|
279
|
+
title: `Task ${i}`,
|
|
280
|
+
badges: [
|
|
281
|
+
{ label: 'Priority', variant: 'destructive' as const },
|
|
282
|
+
{ label: 'Sprint 1', variant: 'secondary' as const },
|
|
283
|
+
],
|
|
284
|
+
})),
|
|
285
|
+
},
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
const start = performance.now();
|
|
289
|
+
const { container } = render(<KanbanBoard columns={columns} />);
|
|
290
|
+
const elapsed = performance.now() - start;
|
|
291
|
+
|
|
292
|
+
expect(container).toBeTruthy();
|
|
293
|
+
expect(elapsed).toBeLessThan(2_000);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('renders 1,000 cards across 10 columns under 2,000ms', () => {
|
|
297
|
+
const columns = generateColumns(10, 1_000);
|
|
298
|
+
|
|
299
|
+
const start = performance.now();
|
|
300
|
+
const { container } = render(<KanbanBoard columns={columns} />);
|
|
301
|
+
const elapsed = performance.now() - start;
|
|
302
|
+
|
|
303
|
+
expect(container).toBeTruthy();
|
|
304
|
+
expect(elapsed).toBeLessThan(2_000);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,403 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* P3.3 Plugin View Robustness - Kanban View States
|
|
11
|
+
*
|
|
12
|
+
* Tests empty, populated, and edge-case states for KanbanEnhanced component.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
16
|
+
import { render, screen } from '@testing-library/react';
|
|
17
|
+
import '@testing-library/jest-dom';
|
|
18
|
+
import React from 'react';
|
|
19
|
+
import { KanbanEnhanced, type KanbanColumn } from '../KanbanEnhanced';
|
|
20
|
+
import KanbanBoard from '../KanbanImpl';
|
|
21
|
+
|
|
22
|
+
// Mock @tanstack/react-virtual
|
|
23
|
+
vi.mock('@tanstack/react-virtual', () => ({
|
|
24
|
+
useVirtualizer: () => ({
|
|
25
|
+
getTotalSize: () => 1000,
|
|
26
|
+
getVirtualItems: () => [],
|
|
27
|
+
measureElement: vi.fn(),
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// Mock @dnd-kit/core
|
|
32
|
+
vi.mock('@dnd-kit/core', () => ({
|
|
33
|
+
DndContext: ({ children }: any) => <div data-testid="dnd-context">{children}</div>,
|
|
34
|
+
DragOverlay: ({ children }: any) => <div data-testid="drag-overlay">{children}</div>,
|
|
35
|
+
PointerSensor: vi.fn(),
|
|
36
|
+
TouchSensor: vi.fn(),
|
|
37
|
+
useSensor: vi.fn(),
|
|
38
|
+
useSensors: () => [],
|
|
39
|
+
closestCorners: vi.fn(),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('@dnd-kit/sortable', () => ({
|
|
43
|
+
SortableContext: ({ children }: any) => <div data-testid="sortable-context">{children}</div>,
|
|
44
|
+
useSortable: () => ({
|
|
45
|
+
attributes: {},
|
|
46
|
+
listeners: {},
|
|
47
|
+
setNodeRef: vi.fn(),
|
|
48
|
+
transform: null,
|
|
49
|
+
transition: null,
|
|
50
|
+
isDragging: false,
|
|
51
|
+
}),
|
|
52
|
+
arrayMove: (array: any[], from: number, to: number) => {
|
|
53
|
+
const newArray = [...array];
|
|
54
|
+
newArray.splice(to, 0, newArray.splice(from, 1)[0]);
|
|
55
|
+
return newArray;
|
|
56
|
+
},
|
|
57
|
+
verticalListSortingStrategy: vi.fn(),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock('@dnd-kit/utilities', () => ({
|
|
61
|
+
CSS: {
|
|
62
|
+
Transform: {
|
|
63
|
+
toString: () => '',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
vi.mock('@object-ui/react', () => ({
|
|
69
|
+
useHasDndProvider: () => false,
|
|
70
|
+
useDnd: vi.fn(),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
describe('P3.3 Kanban View States', () => {
|
|
74
|
+
// ---------------------------------------------------------------
|
|
75
|
+
// Empty state
|
|
76
|
+
// ---------------------------------------------------------------
|
|
77
|
+
describe('empty state', () => {
|
|
78
|
+
it('renders with empty columns array', () => {
|
|
79
|
+
const { container } = render(
|
|
80
|
+
<KanbanEnhanced columns={[]} />
|
|
81
|
+
);
|
|
82
|
+
expect(container.firstElementChild).toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('renders columns with no cards', () => {
|
|
86
|
+
const emptyColumns: KanbanColumn[] = [
|
|
87
|
+
{ id: 'todo', title: 'To Do', cards: [] },
|
|
88
|
+
{ id: 'done', title: 'Done', cards: [] },
|
|
89
|
+
];
|
|
90
|
+
render(<KanbanEnhanced columns={emptyColumns} />);
|
|
91
|
+
expect(screen.getByText('To Do')).toBeInTheDocument();
|
|
92
|
+
expect(screen.getByText('Done')).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('shows 0 count for empty columns', () => {
|
|
96
|
+
const emptyColumns: KanbanColumn[] = [
|
|
97
|
+
{ id: 'todo', title: 'To Do', cards: [] },
|
|
98
|
+
];
|
|
99
|
+
render(<KanbanEnhanced columns={emptyColumns} />);
|
|
100
|
+
expect(screen.getByText('0')).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------
|
|
105
|
+
// Populated state
|
|
106
|
+
// ---------------------------------------------------------------
|
|
107
|
+
describe('populated state', () => {
|
|
108
|
+
const populatedColumns: KanbanColumn[] = [
|
|
109
|
+
{
|
|
110
|
+
id: 'todo',
|
|
111
|
+
title: 'To Do',
|
|
112
|
+
cards: [
|
|
113
|
+
{ id: 'c1', title: 'Task 1', description: 'First task' },
|
|
114
|
+
{ id: 'c2', title: 'Task 2' },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'done',
|
|
119
|
+
title: 'Done',
|
|
120
|
+
cards: [
|
|
121
|
+
{ id: 'c3', title: 'Task 3', description: 'Completed' },
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
it('renders column titles', () => {
|
|
127
|
+
render(<KanbanEnhanced columns={populatedColumns} />);
|
|
128
|
+
expect(screen.getByText('To Do')).toBeInTheDocument();
|
|
129
|
+
expect(screen.getByText('Done')).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('renders card titles', () => {
|
|
133
|
+
render(<KanbanEnhanced columns={populatedColumns} />);
|
|
134
|
+
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
|
135
|
+
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
|
136
|
+
expect(screen.getByText('Task 3')).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('renders card descriptions', () => {
|
|
140
|
+
render(<KanbanEnhanced columns={populatedColumns} />);
|
|
141
|
+
expect(screen.getByText('First task')).toBeInTheDocument();
|
|
142
|
+
expect(screen.getByText('Completed')).toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('renders card counts', () => {
|
|
146
|
+
render(<KanbanEnhanced columns={populatedColumns} />);
|
|
147
|
+
expect(screen.getByText('2')).toBeInTheDocument(); // To Do has 2
|
|
148
|
+
expect(screen.getByText('1')).toBeInTheDocument(); // Done has 1
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------
|
|
153
|
+
// Columns with limits
|
|
154
|
+
// ---------------------------------------------------------------
|
|
155
|
+
describe('columns with limits', () => {
|
|
156
|
+
it('shows limit indicator', () => {
|
|
157
|
+
const columns: KanbanColumn[] = [
|
|
158
|
+
{
|
|
159
|
+
id: 'wip',
|
|
160
|
+
title: 'In Progress',
|
|
161
|
+
limit: 3,
|
|
162
|
+
cards: [
|
|
163
|
+
{ id: 'c1', title: 'Task 1' },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
render(<KanbanEnhanced columns={columns} />);
|
|
168
|
+
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
|
169
|
+
expect(screen.getByText(/1\s*\/\s*3/)).toBeInTheDocument();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('shows warning at 80% capacity', () => {
|
|
173
|
+
const columns: KanbanColumn[] = [
|
|
174
|
+
{
|
|
175
|
+
id: 'wip',
|
|
176
|
+
title: 'WIP',
|
|
177
|
+
limit: 5,
|
|
178
|
+
cards: Array.from({ length: 4 }, (_, i) => ({
|
|
179
|
+
id: `c${i}`,
|
|
180
|
+
title: `Task ${i}`,
|
|
181
|
+
})),
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
const { container } = render(<KanbanEnhanced columns={columns} />);
|
|
185
|
+
// At 80% (4/5), should show warning styling
|
|
186
|
+
expect(container.querySelector('[class*="text-yellow"]')).toBeInTheDocument();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('shows error when over limit', () => {
|
|
190
|
+
const columns: KanbanColumn[] = [
|
|
191
|
+
{
|
|
192
|
+
id: 'wip',
|
|
193
|
+
title: 'WIP',
|
|
194
|
+
limit: 2,
|
|
195
|
+
cards: Array.from({ length: 3 }, (_, i) => ({
|
|
196
|
+
id: `c${i}`,
|
|
197
|
+
title: `Task ${i}`,
|
|
198
|
+
})),
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
const { container } = render(<KanbanEnhanced columns={columns} />);
|
|
202
|
+
// Over limit uses text-destructive class
|
|
203
|
+
expect(container.querySelector('[class*="text-destructive"]')).toBeInTheDocument();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------
|
|
208
|
+
// Edge cases
|
|
209
|
+
// ---------------------------------------------------------------
|
|
210
|
+
describe('edge cases', () => {
|
|
211
|
+
it('handles single column', () => {
|
|
212
|
+
const columns: KanbanColumn[] = [
|
|
213
|
+
{ id: 'only', title: 'Only Column', cards: [{ id: 'c1', title: 'Alone' }] },
|
|
214
|
+
];
|
|
215
|
+
render(<KanbanEnhanced columns={columns} />);
|
|
216
|
+
expect(screen.getByText('Only Column')).toBeInTheDocument();
|
|
217
|
+
expect(screen.getByText('Alone')).toBeInTheDocument();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('handles many columns', () => {
|
|
221
|
+
const columns: KanbanColumn[] = Array.from({ length: 10 }, (_, i) => ({
|
|
222
|
+
id: `col-${i}`,
|
|
223
|
+
title: `Column ${i}`,
|
|
224
|
+
cards: [],
|
|
225
|
+
}));
|
|
226
|
+
render(<KanbanEnhanced columns={columns} />);
|
|
227
|
+
expect(screen.getByText('Column 0')).toBeInTheDocument();
|
|
228
|
+
expect(screen.getByText('Column 9')).toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('handles cards with badges', () => {
|
|
232
|
+
const columns: KanbanColumn[] = [
|
|
233
|
+
{
|
|
234
|
+
id: 'todo',
|
|
235
|
+
title: 'To Do',
|
|
236
|
+
cards: [
|
|
237
|
+
{
|
|
238
|
+
id: 'c1',
|
|
239
|
+
title: 'Badged Task',
|
|
240
|
+
badges: [
|
|
241
|
+
{ label: 'Urgent', variant: 'destructive' },
|
|
242
|
+
{ label: 'Feature', variant: 'default' },
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
render(<KanbanEnhanced columns={columns} />);
|
|
249
|
+
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
|
250
|
+
expect(screen.getByText('Feature')).toBeInTheDocument();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('handles card with empty title', () => {
|
|
254
|
+
const columns: KanbanColumn[] = [
|
|
255
|
+
{ id: 'col', title: 'Col', cards: [{ id: 'c1', title: '' }] },
|
|
256
|
+
];
|
|
257
|
+
const { container } = render(<KanbanEnhanced columns={columns} />);
|
|
258
|
+
expect(container).toBeInTheDocument();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('accepts className prop', () => {
|
|
262
|
+
const { container } = render(
|
|
263
|
+
<KanbanEnhanced columns={[]} className="my-kanban" />
|
|
264
|
+
);
|
|
265
|
+
expect(container.innerHTML).toContain('my-kanban');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------
|
|
271
|
+
// KanbanBoard (from KanbanImpl) View States
|
|
272
|
+
// ---------------------------------------------------------------
|
|
273
|
+
describe('P3.3 KanbanBoard (KanbanImpl) View States', () => {
|
|
274
|
+
describe('empty state', () => {
|
|
275
|
+
it('renders with empty columns array', () => {
|
|
276
|
+
const { container } = render(<KanbanBoard columns={[]} />);
|
|
277
|
+
expect(container.querySelector('[role="region"]')).toBeInTheDocument();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('renders columns with no cards', () => {
|
|
281
|
+
const columns: KanbanColumn[] = [
|
|
282
|
+
{ id: 'backlog', title: 'Backlog', cards: [] },
|
|
283
|
+
{ id: 'active', title: 'Active', cards: [] },
|
|
284
|
+
];
|
|
285
|
+
render(<KanbanBoard columns={columns} />);
|
|
286
|
+
expect(screen.getByText('Backlog')).toBeInTheDocument();
|
|
287
|
+
expect(screen.getByText('Active')).toBeInTheDocument();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('shows card count of 0 for empty columns', () => {
|
|
291
|
+
const columns: KanbanColumn[] = [
|
|
292
|
+
{ id: 'empty', title: 'Empty Col', cards: [] },
|
|
293
|
+
];
|
|
294
|
+
render(<KanbanBoard columns={columns} />);
|
|
295
|
+
expect(screen.getByText('0')).toBeInTheDocument();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('single column with many cards', () => {
|
|
300
|
+
it('renders column with 50 cards without crashing', () => {
|
|
301
|
+
const manyCards = Array.from({ length: 50 }, (_, i) => ({
|
|
302
|
+
id: `card-${i}`,
|
|
303
|
+
title: `Card ${i}`,
|
|
304
|
+
}));
|
|
305
|
+
const columns: KanbanColumn[] = [
|
|
306
|
+
{ id: 'big', title: 'Big Column', cards: manyCards },
|
|
307
|
+
];
|
|
308
|
+
render(<KanbanBoard columns={columns} />);
|
|
309
|
+
expect(screen.getByText('Big Column')).toBeInTheDocument();
|
|
310
|
+
expect(screen.getByText('Card 0')).toBeInTheDocument();
|
|
311
|
+
expect(screen.getByText('Card 49')).toBeInTheDocument();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('shows correct count for many cards', () => {
|
|
315
|
+
const manyCards = Array.from({ length: 25 }, (_, i) => ({
|
|
316
|
+
id: `card-${i}`,
|
|
317
|
+
title: `Task ${i}`,
|
|
318
|
+
}));
|
|
319
|
+
const columns: KanbanColumn[] = [
|
|
320
|
+
{ id: 'col', title: 'Tasks', cards: manyCards },
|
|
321
|
+
];
|
|
322
|
+
render(<KanbanBoard columns={columns} />);
|
|
323
|
+
expect(screen.getByText('25')).toBeInTheDocument();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('WIP limit exceeded', () => {
|
|
328
|
+
it('shows Full badge when cards reach limit', () => {
|
|
329
|
+
const columns: KanbanColumn[] = [
|
|
330
|
+
{
|
|
331
|
+
id: 'wip',
|
|
332
|
+
title: 'In Progress',
|
|
333
|
+
limit: 3,
|
|
334
|
+
cards: Array.from({ length: 3 }, (_, i) => ({
|
|
335
|
+
id: `c${i}`,
|
|
336
|
+
title: `WIP Task ${i}`,
|
|
337
|
+
})),
|
|
338
|
+
},
|
|
339
|
+
];
|
|
340
|
+
render(<KanbanBoard columns={columns} />);
|
|
341
|
+
expect(screen.getByText('Full')).toBeInTheDocument();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('shows Full badge when cards exceed limit', () => {
|
|
345
|
+
const columns: KanbanColumn[] = [
|
|
346
|
+
{
|
|
347
|
+
id: 'wip',
|
|
348
|
+
title: 'In Progress',
|
|
349
|
+
limit: 2,
|
|
350
|
+
cards: Array.from({ length: 5 }, (_, i) => ({
|
|
351
|
+
id: `c${i}`,
|
|
352
|
+
title: `Over Task ${i}`,
|
|
353
|
+
})),
|
|
354
|
+
},
|
|
355
|
+
];
|
|
356
|
+
render(<KanbanBoard columns={columns} />);
|
|
357
|
+
expect(screen.getByText('Full')).toBeInTheDocument();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('does not show Full badge when under limit', () => {
|
|
361
|
+
const columns: KanbanColumn[] = [
|
|
362
|
+
{
|
|
363
|
+
id: 'wip',
|
|
364
|
+
title: 'In Progress',
|
|
365
|
+
limit: 10,
|
|
366
|
+
cards: [{ id: 'c1', title: 'Solo Task' }],
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
render(<KanbanBoard columns={columns} />);
|
|
370
|
+
expect(screen.queryByText('Full')).not.toBeInTheDocument();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('shows count with limit format', () => {
|
|
374
|
+
const columns: KanbanColumn[] = [
|
|
375
|
+
{
|
|
376
|
+
id: 'wip',
|
|
377
|
+
title: 'WIP',
|
|
378
|
+
limit: 5,
|
|
379
|
+
cards: Array.from({ length: 3 }, (_, i) => ({
|
|
380
|
+
id: `c${i}`,
|
|
381
|
+
title: `Task ${i}`,
|
|
382
|
+
})),
|
|
383
|
+
},
|
|
384
|
+
];
|
|
385
|
+
render(<KanbanBoard columns={columns} />);
|
|
386
|
+
expect(screen.getByText(/3\s*\/\s*5/)).toBeInTheDocument();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('className and structure', () => {
|
|
391
|
+
it('applies className prop', () => {
|
|
392
|
+
const { container } = render(
|
|
393
|
+
<KanbanBoard columns={[]} className="custom-board" />
|
|
394
|
+
);
|
|
395
|
+
expect(container.innerHTML).toContain('custom-board');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('renders kanban board with region role', () => {
|
|
399
|
+
render(<KanbanBoard columns={[]} />);
|
|
400
|
+
expect(screen.getByRole('region', { name: 'Kanban board' })).toBeInTheDocument();
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
});
|