@object-ui/plugin-kanban 3.3.0 → 3.3.2
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/CHANGELOG.md +19 -0
- package/README.md +24 -0
- package/dist/{KanbanEnhanced-TdUe0kQH.js → KanbanEnhanced-Do9ZB1Mh.js} +35 -32
- package/dist/{KanbanImpl-BtlPa7GE.js → KanbanImpl-BdocXM5T.js} +1 -1
- package/dist/{chevron-down-B6UH8BbF.js → chevron-down-C0JUlGjk.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/index.umd.cjs +2 -2
- package/dist/{plus-BTqoaaEC.js → plus-CHsXVJSY.js} +1 -1
- package/package.json +34 -11
- package/.turbo/turbo-build.log +0 -32
- package/src/CardTemplates.tsx +0 -123
- package/src/InlineQuickAdd.tsx +0 -189
- package/src/KanbanEnhanced.tsx +0 -525
- package/src/KanbanImpl.tsx +0 -597
- package/src/ObjectKanban.EdgeCases.stories.tsx +0 -168
- package/src/ObjectKanban.msw.test.tsx +0 -95
- package/src/ObjectKanban.stories.tsx +0 -152
- package/src/ObjectKanban.tsx +0 -276
- package/src/__tests__/KanbanEnhanced.test.tsx +0 -260
- package/src/__tests__/KanbanGrouping.test.tsx +0 -164
- package/src/__tests__/KanbanSwimlanes.test.tsx +0 -194
- package/src/__tests__/ObjectKanbanTitle.test.tsx +0 -93
- package/src/__tests__/SwimlanePersistence.test.tsx +0 -159
- package/src/__tests__/accessibility.test.tsx +0 -296
- package/src/__tests__/dnd-undo-integration.test.tsx +0 -525
- package/src/__tests__/performance-benchmark.test.tsx +0 -306
- package/src/__tests__/phase13-features.test.tsx +0 -387
- package/src/__tests__/view-states.test.tsx +0 -403
- package/src/index.test.ts +0 -112
- package/src/index.tsx +0 -327
- package/src/registration.test.tsx +0 -26
- package/src/types.ts +0 -185
- package/src/useColumnWidths.ts +0 -125
- package/src/useCrossSwimlaneMove.ts +0 -116
- package/src/useQuickAddReorder.ts +0 -107
- package/tsconfig.json +0 -19
- package/vite.config.ts +0 -62
- package/vitest.config.ts +0 -12
- package/vitest.setup.ts +0 -1
|
@@ -1,306 +0,0 @@
|
|
|
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 200 cards spread across 5 columns under 1,000ms', () => {
|
|
201
|
-
const columns = generateColumns(5, 200);
|
|
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 500 cards spread across 5 columns under 2,000ms', () => {
|
|
212
|
-
const columns = generateColumns(5, 500);
|
|
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 10+ columns without degradation', () => {
|
|
223
|
-
const columns = generateColumns(12, 120);
|
|
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 10+ columns quickly', () => {
|
|
234
|
-
const columns = generateColumns(12, 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 10+ column board', () => {
|
|
264
|
-
const columns = generateColumns(12, 24);
|
|
265
|
-
render(<KanbanBoard columns={columns} />);
|
|
266
|
-
|
|
267
|
-
for (let i = 0; i < 12; 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: 100 }, (_, 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 500 cards across 10 columns under 2,000ms', () => {
|
|
297
|
-
const columns = generateColumns(10, 500);
|
|
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
|
-
});
|
|
@@ -1,387 +0,0 @@
|
|
|
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, waitFor, fireEvent } from '@testing-library/react';
|
|
11
|
-
import { renderHook, act } from '@testing-library/react';
|
|
12
|
-
import { InlineQuickAdd } from '../InlineQuickAdd';
|
|
13
|
-
import { CardTemplates } from '../CardTemplates';
|
|
14
|
-
import { useColumnWidths } from '../useColumnWidths';
|
|
15
|
-
import { useCrossSwimlaneMove } from '../useCrossSwimlaneMove';
|
|
16
|
-
import { useQuickAddReorder } from '../useQuickAddReorder';
|
|
17
|
-
import type { InlineFieldDefinition, CardTemplate, KanbanCard, KanbanColumn } from '../types';
|
|
18
|
-
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
// localStorage mock
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
const localStorageMock = (() => {
|
|
23
|
-
let store: Record<string, string> = {};
|
|
24
|
-
return {
|
|
25
|
-
getItem: vi.fn((key: string) => store[key] ?? null),
|
|
26
|
-
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
|
|
27
|
-
removeItem: vi.fn((key: string) => { delete store[key]; }),
|
|
28
|
-
clear: vi.fn(() => { store = {}; }),
|
|
29
|
-
};
|
|
30
|
-
})();
|
|
31
|
-
|
|
32
|
-
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Helpers
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
const textField: InlineFieldDefinition = { name: 'title', label: 'Title', type: 'text' };
|
|
38
|
-
const numberField: InlineFieldDefinition = { name: 'points', label: 'Points', type: 'number' };
|
|
39
|
-
const selectField: InlineFieldDefinition = {
|
|
40
|
-
name: 'priority',
|
|
41
|
-
label: 'Priority',
|
|
42
|
-
type: 'select',
|
|
43
|
-
options: [
|
|
44
|
-
{ label: 'Low', value: 'low' },
|
|
45
|
-
{ label: 'High', value: 'high' },
|
|
46
|
-
],
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const sampleTemplates: CardTemplate[] = [
|
|
50
|
-
{ id: 't1', name: 'Bug Report', values: { title: 'Bug: ', priority: 'high' } },
|
|
51
|
-
{ id: 't2', name: 'Feature', values: { title: 'Feature: ' } },
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
const makeCards = (ids: string[]): KanbanCard[] =>
|
|
55
|
-
ids.map(id => ({ id, title: `Card ${id}` }));
|
|
56
|
-
|
|
57
|
-
const makeColumns = (ids: string[]): KanbanColumn[] =>
|
|
58
|
-
ids.map(id => ({ id, title: `Col ${id}`, cards: [] }));
|
|
59
|
-
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// InlineQuickAdd
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
describe('InlineQuickAdd', () => {
|
|
64
|
-
const onSubmit = vi.fn();
|
|
65
|
-
const onCancel = vi.fn();
|
|
66
|
-
|
|
67
|
-
beforeEach(() => {
|
|
68
|
-
vi.clearAllMocks();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('renders form fields based on field definitions', () => {
|
|
72
|
-
render(
|
|
73
|
-
<InlineQuickAdd
|
|
74
|
-
columnId="col1"
|
|
75
|
-
fields={[textField, numberField, selectField]}
|
|
76
|
-
onSubmit={onSubmit}
|
|
77
|
-
onCancel={onCancel}
|
|
78
|
-
/>,
|
|
79
|
-
);
|
|
80
|
-
expect(screen.getByLabelText('Title')).toBeDefined();
|
|
81
|
-
expect(screen.getByLabelText('Points')).toBeDefined();
|
|
82
|
-
expect(screen.getByLabelText('Priority')).toBeDefined();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('auto-focuses first field', async () => {
|
|
86
|
-
render(
|
|
87
|
-
<InlineQuickAdd
|
|
88
|
-
columnId="col1"
|
|
89
|
-
fields={[textField]}
|
|
90
|
-
onSubmit={onSubmit}
|
|
91
|
-
onCancel={onCancel}
|
|
92
|
-
/>,
|
|
93
|
-
);
|
|
94
|
-
await waitFor(() => {
|
|
95
|
-
expect(document.activeElement).toBe(screen.getByLabelText('Title'));
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('submits on Enter', async () => {
|
|
100
|
-
render(
|
|
101
|
-
<InlineQuickAdd
|
|
102
|
-
columnId="col1"
|
|
103
|
-
fields={[textField]}
|
|
104
|
-
onSubmit={onSubmit}
|
|
105
|
-
onCancel={onCancel}
|
|
106
|
-
/>,
|
|
107
|
-
);
|
|
108
|
-
const input = screen.getByLabelText('Title');
|
|
109
|
-
fireEvent.change(input, { target: { value: 'Hello' } });
|
|
110
|
-
fireEvent.keyDown(input, { key: 'Enter' });
|
|
111
|
-
expect(onSubmit).toHaveBeenCalledWith('col1', { title: 'Hello' });
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('cancels on Escape', () => {
|
|
115
|
-
render(
|
|
116
|
-
<InlineQuickAdd
|
|
117
|
-
columnId="col1"
|
|
118
|
-
fields={[textField]}
|
|
119
|
-
onSubmit={onSubmit}
|
|
120
|
-
onCancel={onCancel}
|
|
121
|
-
/>,
|
|
122
|
-
);
|
|
123
|
-
fireEvent.keyDown(screen.getByLabelText('Title'), { key: 'Escape' });
|
|
124
|
-
expect(onCancel).toHaveBeenCalled();
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('applies default values (from template)', () => {
|
|
128
|
-
render(
|
|
129
|
-
<InlineQuickAdd
|
|
130
|
-
columnId="col1"
|
|
131
|
-
fields={[textField, numberField]}
|
|
132
|
-
onSubmit={onSubmit}
|
|
133
|
-
onCancel={onCancel}
|
|
134
|
-
defaultValues={{ title: 'Bug: ', points: 5 }}
|
|
135
|
-
/>,
|
|
136
|
-
);
|
|
137
|
-
expect((screen.getByLabelText('Title') as HTMLInputElement).value).toBe('Bug: ');
|
|
138
|
-
expect((screen.getByLabelText('Points') as HTMLInputElement).value).toBe('5');
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('calls onSubmit with field values via Save button', () => {
|
|
142
|
-
render(
|
|
143
|
-
<InlineQuickAdd
|
|
144
|
-
columnId="col1"
|
|
145
|
-
fields={[textField]}
|
|
146
|
-
onSubmit={onSubmit}
|
|
147
|
-
onCancel={onCancel}
|
|
148
|
-
defaultValues={{ title: 'task' }}
|
|
149
|
-
/>,
|
|
150
|
-
);
|
|
151
|
-
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
|
152
|
-
expect(onSubmit).toHaveBeenCalledWith('col1', { title: 'task' });
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('calls onCancel via Cancel button', () => {
|
|
156
|
-
render(
|
|
157
|
-
<InlineQuickAdd
|
|
158
|
-
columnId="col1"
|
|
159
|
-
fields={[textField]}
|
|
160
|
-
onSubmit={onSubmit}
|
|
161
|
-
onCancel={onCancel}
|
|
162
|
-
/>,
|
|
163
|
-
);
|
|
164
|
-
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
|
165
|
-
expect(onCancel).toHaveBeenCalled();
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
// CardTemplates
|
|
171
|
-
// ---------------------------------------------------------------------------
|
|
172
|
-
describe('CardTemplates', () => {
|
|
173
|
-
const onSelect = vi.fn();
|
|
174
|
-
|
|
175
|
-
beforeEach(() => {
|
|
176
|
-
vi.clearAllMocks();
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('renders template dropdown trigger', () => {
|
|
180
|
-
render(<CardTemplates templates={sampleTemplates} onSelect={onSelect} columnId="col1" />);
|
|
181
|
-
expect(screen.getByRole('button', { name: /add card to col1/i })).toBeDefined();
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('shows template options in dropdown', () => {
|
|
185
|
-
render(<CardTemplates templates={sampleTemplates} onSelect={onSelect} columnId="col1" />);
|
|
186
|
-
fireEvent.click(screen.getByRole('button', { name: /add card to col1/i }));
|
|
187
|
-
expect(screen.getByRole('listbox', { name: /card templates/i })).toBeDefined();
|
|
188
|
-
expect(screen.getByText('Bug Report')).toBeDefined();
|
|
189
|
-
expect(screen.getByText('Feature')).toBeDefined();
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('calls onSelect with template when clicked', () => {
|
|
193
|
-
render(<CardTemplates templates={sampleTemplates} onSelect={onSelect} columnId="col1" />);
|
|
194
|
-
fireEvent.click(screen.getByRole('button', { name: /add card to col1/i }));
|
|
195
|
-
fireEvent.click(screen.getByText('Bug Report'));
|
|
196
|
-
expect(onSelect).toHaveBeenCalledWith(sampleTemplates[0]);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it('shows Custom option and calls onSelect(null)', () => {
|
|
200
|
-
render(<CardTemplates templates={sampleTemplates} onSelect={onSelect} columnId="col1" />);
|
|
201
|
-
fireEvent.click(screen.getByRole('button', { name: /add card to col1/i }));
|
|
202
|
-
expect(screen.getByText('Custom')).toBeDefined();
|
|
203
|
-
fireEvent.click(screen.getByText('Custom'));
|
|
204
|
-
expect(onSelect).toHaveBeenCalledWith(null);
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
// ---------------------------------------------------------------------------
|
|
209
|
-
// useColumnWidths
|
|
210
|
-
// ---------------------------------------------------------------------------
|
|
211
|
-
describe('useColumnWidths', () => {
|
|
212
|
-
beforeEach(() => {
|
|
213
|
-
vi.clearAllMocks();
|
|
214
|
-
localStorageMock.clear();
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it('returns default width for all columns', () => {
|
|
218
|
-
const columns = makeColumns(['a', 'b']);
|
|
219
|
-
const { result } = renderHook(() => useColumnWidths({ columns, defaultWidth: 300 }));
|
|
220
|
-
expect(result.current.getColumnWidth('a')).toBe(300);
|
|
221
|
-
expect(result.current.getColumnWidth('b')).toBe(300);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('applies per-column overrides', () => {
|
|
225
|
-
const columns = makeColumns(['a', 'b']);
|
|
226
|
-
const { result } = renderHook(() => useColumnWidths({ columns, defaultWidth: 300 }));
|
|
227
|
-
act(() => { result.current.setColumnWidth('a', 400); });
|
|
228
|
-
expect(result.current.getColumnWidth('a')).toBe(400);
|
|
229
|
-
expect(result.current.getColumnWidth('b')).toBe(300);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('clamps to minWidth and maxWidth', () => {
|
|
233
|
-
const columns = makeColumns(['a']);
|
|
234
|
-
const { result } = renderHook(() =>
|
|
235
|
-
useColumnWidths({ columns, defaultWidth: 300, minWidth: 200, maxWidth: 500 }),
|
|
236
|
-
);
|
|
237
|
-
act(() => { result.current.setColumnWidth('a', 100); });
|
|
238
|
-
expect(result.current.getColumnWidth('a')).toBe(200);
|
|
239
|
-
act(() => { result.current.setColumnWidth('a', 900); });
|
|
240
|
-
expect(result.current.getColumnWidth('a')).toBe(500);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('persists to localStorage', () => {
|
|
244
|
-
const columns = makeColumns(['a']);
|
|
245
|
-
const { result } = renderHook(() =>
|
|
246
|
-
useColumnWidths({ columns, storageKey: 'board1' }),
|
|
247
|
-
);
|
|
248
|
-
act(() => { result.current.setColumnWidth('a', 350); });
|
|
249
|
-
expect(localStorageMock.setItem).toHaveBeenCalled();
|
|
250
|
-
const stored = JSON.parse(
|
|
251
|
-
localStorageMock.setItem.mock.calls.at(-1)![1] as string,
|
|
252
|
-
);
|
|
253
|
-
expect(stored.a).toBe(350);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('resetWidths restores defaults', () => {
|
|
257
|
-
const columns = makeColumns(['a']);
|
|
258
|
-
const { result } = renderHook(() =>
|
|
259
|
-
useColumnWidths({ columns, defaultWidth: 320, storageKey: 'board2' }),
|
|
260
|
-
);
|
|
261
|
-
act(() => { result.current.setColumnWidth('a', 400); });
|
|
262
|
-
expect(result.current.getColumnWidth('a')).toBe(400);
|
|
263
|
-
act(() => { result.current.resetWidths(); });
|
|
264
|
-
expect(result.current.getColumnWidth('a')).toBe(320);
|
|
265
|
-
expect(localStorageMock.removeItem).toHaveBeenCalled();
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// ---------------------------------------------------------------------------
|
|
270
|
-
// useCrossSwimlaneMove
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
describe('useCrossSwimlaneMove', () => {
|
|
273
|
-
const swimlanes = [
|
|
274
|
-
{ id: 'team-a', title: 'Team A' },
|
|
275
|
-
{ id: 'team-b', title: 'Team B', acceptFrom: ['team-a'] },
|
|
276
|
-
{ id: 'team-c', title: 'Team C' },
|
|
277
|
-
];
|
|
278
|
-
|
|
279
|
-
beforeEach(() => {
|
|
280
|
-
vi.clearAllMocks();
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('returns initial state (not dragging)', () => {
|
|
284
|
-
const { result } = renderHook(() =>
|
|
285
|
-
useCrossSwimlaneMove({ swimlanes }),
|
|
286
|
-
);
|
|
287
|
-
expect(result.current.isDraggingAcrossSwimlanes).toBe(false);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it('handleCrossSwimlaneMove calls onCardMove', () => {
|
|
291
|
-
const onCardMove = vi.fn();
|
|
292
|
-
const { result } = renderHook(() =>
|
|
293
|
-
useCrossSwimlaneMove({ swimlanes, onCardMove }),
|
|
294
|
-
);
|
|
295
|
-
let allowed: boolean;
|
|
296
|
-
act(() => {
|
|
297
|
-
allowed = result.current.handleCrossSwimlaneMove('card1', 'team-a', 'team-c', 'col1');
|
|
298
|
-
});
|
|
299
|
-
expect(allowed!).toBe(true);
|
|
300
|
-
expect(onCardMove).toHaveBeenCalledWith({
|
|
301
|
-
cardId: 'card1',
|
|
302
|
-
fromSwimlane: 'team-a',
|
|
303
|
-
toSwimlane: 'team-c',
|
|
304
|
-
columnId: 'col1',
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it('respects acceptFrom constraints', () => {
|
|
309
|
-
const onCardMove = vi.fn();
|
|
310
|
-
const { result } = renderHook(() =>
|
|
311
|
-
useCrossSwimlaneMove({ swimlanes, onCardMove }),
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
// team-b only accepts from team-a
|
|
315
|
-
let allowed: boolean;
|
|
316
|
-
act(() => {
|
|
317
|
-
allowed = result.current.handleCrossSwimlaneMove('card1', 'team-c', 'team-b', 'col1');
|
|
318
|
-
});
|
|
319
|
-
expect(allowed!).toBe(false);
|
|
320
|
-
expect(onCardMove).not.toHaveBeenCalled();
|
|
321
|
-
|
|
322
|
-
// team-a → team-b is allowed
|
|
323
|
-
act(() => {
|
|
324
|
-
allowed = result.current.handleCrossSwimlaneMove('card1', 'team-a', 'team-b', 'col1');
|
|
325
|
-
});
|
|
326
|
-
expect(allowed!).toBe(true);
|
|
327
|
-
expect(onCardMove).toHaveBeenCalled();
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it('isDraggingAcrossSwimlanes state tracks movement', () => {
|
|
331
|
-
const { result } = renderHook(() =>
|
|
332
|
-
useCrossSwimlaneMove({ swimlanes }),
|
|
333
|
-
);
|
|
334
|
-
expect(result.current.isDraggingAcrossSwimlanes).toBe(false);
|
|
335
|
-
act(() => { result.current.startCrossSwimlaneDrag('team-a'); });
|
|
336
|
-
expect(result.current.isDraggingAcrossSwimlanes).toBe(true);
|
|
337
|
-
act(() => { result.current.endCrossSwimlaneDrag(); });
|
|
338
|
-
expect(result.current.isDraggingAcrossSwimlanes).toBe(false);
|
|
339
|
-
});
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
// ---------------------------------------------------------------------------
|
|
343
|
-
// useQuickAddReorder
|
|
344
|
-
// ---------------------------------------------------------------------------
|
|
345
|
-
describe('useQuickAddReorder', () => {
|
|
346
|
-
beforeEach(() => {
|
|
347
|
-
vi.clearAllMocks();
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
it('initializes with provided cards', () => {
|
|
351
|
-
const cards = makeCards(['1', '2', '3']);
|
|
352
|
-
const { result } = renderHook(() => useQuickAddReorder({ cards }));
|
|
353
|
-
expect(result.current.reorderedCards.map(c => c.id)).toEqual(['1', '2', '3']);
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
it('reorders cards correctly', () => {
|
|
357
|
-
const cards = makeCards(['1', '2', '3']);
|
|
358
|
-
const { result } = renderHook(() => useQuickAddReorder({ cards }));
|
|
359
|
-
// Must be in drag state so the sync guard doesn't reset
|
|
360
|
-
act(() => { result.current.startDrag(); });
|
|
361
|
-
act(() => { result.current.onReorder(0, 2); });
|
|
362
|
-
expect(result.current.reorderedCards.map(c => c.id)).toEqual(['2', '3', '1']);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it('returns isDragging state', () => {
|
|
366
|
-
const cards = makeCards(['1']);
|
|
367
|
-
const { result } = renderHook(() => useQuickAddReorder({ cards }));
|
|
368
|
-
expect(result.current.isDragging).toBe(false);
|
|
369
|
-
act(() => { result.current.startDrag(); });
|
|
370
|
-
expect(result.current.isDragging).toBe(true);
|
|
371
|
-
act(() => { result.current.endDrag(); });
|
|
372
|
-
expect(result.current.isDragging).toBe(false);
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it('syncs with external card changes', () => {
|
|
376
|
-
const initial = makeCards(['1', '2']);
|
|
377
|
-
const { result, rerender } = renderHook(
|
|
378
|
-
({ cards }) => useQuickAddReorder({ cards }),
|
|
379
|
-
{ initialProps: { cards: initial } },
|
|
380
|
-
);
|
|
381
|
-
expect(result.current.reorderedCards.map(c => c.id)).toEqual(['1', '2']);
|
|
382
|
-
|
|
383
|
-
const updated = makeCards(['1', '2', '3']);
|
|
384
|
-
rerender({ cards: updated });
|
|
385
|
-
expect(result.current.reorderedCards.map(c => c.id)).toEqual(['1', '2', '3']);
|
|
386
|
-
});
|
|
387
|
-
});
|