@object-ui/plugin-kanban 3.0.3 → 3.1.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 +9 -9
- package/dist/{KanbanEnhanced-BPIKjTDv.js → KanbanEnhanced-CXDSLlGR.js} +338 -324
- package/dist/KanbanImpl-4dgoNPtI.js +350 -0
- package/dist/index-CyNcIIS1.js +1077 -0
- package/dist/index.js +9 -4
- package/dist/index.umd.cjs +4 -4
- package/dist/src/CardTemplates.d.ts +25 -0
- package/dist/src/CardTemplates.d.ts.map +1 -0
- package/dist/src/InlineQuickAdd.d.ts +29 -0
- package/dist/src/InlineQuickAdd.d.ts.map +1 -0
- package/dist/src/KanbanEnhanced.d.ts +12 -1
- package/dist/src/KanbanEnhanced.d.ts.map +1 -1
- package/dist/src/KanbanImpl.d.ts +15 -1
- package/dist/src/KanbanImpl.d.ts.map +1 -1
- package/dist/src/ObjectKanban.d.ts.map +1 -1
- package/dist/src/index.d.ts +22 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/types.d.ts +97 -1
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/useColumnWidths.d.ts +30 -0
- package/dist/src/useColumnWidths.d.ts.map +1 -0
- package/dist/src/useCrossSwimlaneMove.d.ts +46 -0
- package/dist/src/useCrossSwimlaneMove.d.ts.map +1 -0
- package/dist/src/useQuickAddReorder.d.ts +28 -0
- package/dist/src/useQuickAddReorder.d.ts.map +1 -0
- package/package.json +9 -9
- package/src/CardTemplates.tsx +123 -0
- package/src/InlineQuickAdd.tsx +189 -0
- package/src/KanbanEnhanced.tsx +140 -9
- package/src/KanbanImpl.tsx +266 -23
- package/src/ObjectKanban.tsx +39 -24
- package/src/__tests__/KanbanGrouping.test.tsx +164 -0
- package/src/__tests__/KanbanSwimlanes.test.tsx +194 -0
- package/src/__tests__/ObjectKanbanTitle.test.tsx +93 -0
- package/src/__tests__/SwimlanePersistence.test.tsx +159 -0
- package/src/__tests__/performance-benchmark.test.tsx +14 -14
- package/src/__tests__/phase13-features.test.tsx +387 -0
- package/src/index.tsx +49 -6
- package/src/types.ts +106 -1
- package/src/useColumnWidths.ts +125 -0
- package/src/useCrossSwimlaneMove.ts +116 -0
- package/src/useQuickAddReorder.ts +107 -0
- package/dist/KanbanImpl-BfOKAnJS.js +0 -194
- package/dist/index-CWGTi2xn.js +0 -600
|
@@ -0,0 +1,194 @@
|
|
|
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, fireEvent } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
|
|
13
|
+
// Mock @dnd-kit/core and utilities
|
|
14
|
+
vi.mock('@dnd-kit/core', () => ({
|
|
15
|
+
DndContext: ({ children }: any) => <div data-testid="dnd-context">{children}</div>,
|
|
16
|
+
DragOverlay: ({ children }: any) => <div data-testid="drag-overlay">{children}</div>,
|
|
17
|
+
PointerSensor: vi.fn(),
|
|
18
|
+
TouchSensor: vi.fn(),
|
|
19
|
+
useSensor: vi.fn(),
|
|
20
|
+
useSensors: () => [],
|
|
21
|
+
closestCorners: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('@dnd-kit/sortable', () => ({
|
|
25
|
+
SortableContext: ({ children }: any) => <div data-testid="sortable-context">{children}</div>,
|
|
26
|
+
useSortable: () => ({
|
|
27
|
+
attributes: {},
|
|
28
|
+
listeners: {},
|
|
29
|
+
setNodeRef: vi.fn(),
|
|
30
|
+
transform: null,
|
|
31
|
+
transition: null,
|
|
32
|
+
isDragging: false,
|
|
33
|
+
}),
|
|
34
|
+
arrayMove: (array: any[], from: number, to: number) => {
|
|
35
|
+
const newArray = [...array];
|
|
36
|
+
newArray.splice(to, 0, newArray.splice(from, 1)[0]);
|
|
37
|
+
return newArray;
|
|
38
|
+
},
|
|
39
|
+
verticalListSortingStrategy: vi.fn(),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('@dnd-kit/utilities', () => ({
|
|
43
|
+
CSS: {
|
|
44
|
+
Transform: {
|
|
45
|
+
toString: () => '',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('@object-ui/components', () => ({
|
|
51
|
+
Badge: ({ children, ...props }: any) => <span {...props}>{children}</span>,
|
|
52
|
+
Card: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
53
|
+
CardHeader: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
54
|
+
CardTitle: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
55
|
+
CardDescription: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
56
|
+
CardContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
57
|
+
ScrollArea: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
58
|
+
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
|
59
|
+
Input: (props: any) => <input {...props} />,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
vi.mock('@object-ui/react', () => ({
|
|
63
|
+
useHasDndProvider: () => false,
|
|
64
|
+
useDnd: () => ({
|
|
65
|
+
startDrag: vi.fn(),
|
|
66
|
+
endDrag: vi.fn(),
|
|
67
|
+
}),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock('lucide-react', () => ({
|
|
71
|
+
Plus: () => <span>+</span>,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
import KanbanBoard from '../KanbanImpl';
|
|
75
|
+
|
|
76
|
+
const mockColumns = [
|
|
77
|
+
{
|
|
78
|
+
id: 'todo',
|
|
79
|
+
title: 'To Do',
|
|
80
|
+
cards: [
|
|
81
|
+
{ id: 'c1', title: 'Task 1', priority: 'High', category: 'Frontend' },
|
|
82
|
+
{ id: 'c2', title: 'Task 2', priority: 'Low', category: 'Backend' },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'done',
|
|
87
|
+
title: 'Done',
|
|
88
|
+
cards: [
|
|
89
|
+
{ id: 'c3', title: 'Task 3', priority: 'High', category: 'Frontend' },
|
|
90
|
+
{ id: 'c4', title: 'Task 4', priority: 'Medium', category: 'Backend' },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
describe('KanbanSwimlanes', () => {
|
|
96
|
+
describe('without swimlaneField', () => {
|
|
97
|
+
it('renders the standard flat layout with no swimlane headers', () => {
|
|
98
|
+
render(<KanbanBoard columns={mockColumns} />);
|
|
99
|
+
|
|
100
|
+
// Flat layout renders a region labelled "Kanban board"
|
|
101
|
+
expect(screen.getByRole('region', { name: 'Kanban board' })).toBeInTheDocument();
|
|
102
|
+
|
|
103
|
+
// Column titles are visible
|
|
104
|
+
expect(screen.getByText('To Do')).toBeInTheDocument();
|
|
105
|
+
expect(screen.getByText('Done')).toBeInTheDocument();
|
|
106
|
+
|
|
107
|
+
// All cards are visible
|
|
108
|
+
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
|
109
|
+
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
|
110
|
+
expect(screen.getByText('Task 3')).toBeInTheDocument();
|
|
111
|
+
expect(screen.getByText('Task 4')).toBeInTheDocument();
|
|
112
|
+
|
|
113
|
+
// No swimlane region
|
|
114
|
+
expect(screen.queryByRole('region', { name: 'Kanban board with swimlanes' })).not.toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('with swimlaneField', () => {
|
|
119
|
+
it('renders swimlane row headers', () => {
|
|
120
|
+
render(<KanbanBoard columns={mockColumns} swimlaneField="category" />);
|
|
121
|
+
|
|
122
|
+
// Swimlane layout renders a region labelled "Kanban board with swimlanes"
|
|
123
|
+
expect(screen.getByRole('region', { name: 'Kanban board with swimlanes' })).toBeInTheDocument();
|
|
124
|
+
|
|
125
|
+
// Swimlane headers for each unique category value (sorted)
|
|
126
|
+
expect(screen.getByText('Backend')).toBeInTheDocument();
|
|
127
|
+
expect(screen.getByText('Frontend')).toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('renders collapsible swimlane rows with aria-expanded', () => {
|
|
131
|
+
render(<KanbanBoard columns={mockColumns} swimlaneField="category" />);
|
|
132
|
+
|
|
133
|
+
// Each swimlane header is a button with aria-expanded
|
|
134
|
+
const swimlaneButtons = screen.getAllByRole('button', { expanded: true });
|
|
135
|
+
expect(swimlaneButtons.length).toBe(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('collapses a swimlane and hides its cards', () => {
|
|
139
|
+
render(<KanbanBoard columns={mockColumns} swimlaneField="category" />);
|
|
140
|
+
|
|
141
|
+
// Frontend cards are visible initially
|
|
142
|
+
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
|
143
|
+
expect(screen.getByText('Task 3')).toBeInTheDocument();
|
|
144
|
+
|
|
145
|
+
// Click the "Frontend" swimlane toggle to collapse it
|
|
146
|
+
const frontendBtn = screen.getByRole('button', { name: /Frontend/i });
|
|
147
|
+
fireEvent.click(frontendBtn);
|
|
148
|
+
|
|
149
|
+
// After collapse, Frontend cards should not be visible
|
|
150
|
+
// (Task 1 and Task 3 belong to Frontend)
|
|
151
|
+
expect(screen.queryByText('Task 1')).not.toBeInTheDocument();
|
|
152
|
+
expect(screen.queryByText('Task 3')).not.toBeInTheDocument();
|
|
153
|
+
|
|
154
|
+
// Backend cards should still be visible
|
|
155
|
+
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
|
156
|
+
expect(screen.getByText('Task 4')).toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('expands a collapsed swimlane and shows its cards again', () => {
|
|
160
|
+
render(<KanbanBoard columns={mockColumns} swimlaneField="category" />);
|
|
161
|
+
|
|
162
|
+
const backendBtn = screen.getByRole('button', { name: /Backend/i });
|
|
163
|
+
|
|
164
|
+
// Collapse Backend
|
|
165
|
+
fireEvent.click(backendBtn);
|
|
166
|
+
expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
|
|
167
|
+
expect(screen.queryByText('Task 4')).not.toBeInTheDocument();
|
|
168
|
+
|
|
169
|
+
// Expand Backend
|
|
170
|
+
fireEvent.click(backendBtn);
|
|
171
|
+
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
|
172
|
+
expect(screen.getByText('Task 4')).toBeInTheDocument();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('shows correct card counts in swimlane headers', () => {
|
|
176
|
+
render(<KanbanBoard columns={mockColumns} swimlaneField="category" />);
|
|
177
|
+
|
|
178
|
+
// Each swimlane header button contains the lane name and card count
|
|
179
|
+
const backendBtn = screen.getByRole('button', { name: /Backend/i });
|
|
180
|
+
expect(backendBtn.textContent).toContain('(2)');
|
|
181
|
+
|
|
182
|
+
const frontendBtn = screen.getByRole('button', { name: /Frontend/i });
|
|
183
|
+
expect(frontendBtn.textContent).toContain('(2)');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('shows column headers above swimlane rows', () => {
|
|
187
|
+
render(<KanbanBoard columns={mockColumns} swimlaneField="category" />);
|
|
188
|
+
|
|
189
|
+
// Column titles still appear in the column headers
|
|
190
|
+
expect(screen.getByText('To Do')).toBeInTheDocument();
|
|
191
|
+
expect(screen.getByText('Done')).toBeInTheDocument();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
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 } from 'vitest';
|
|
10
|
+
import { renderHook } from '@testing-library/react';
|
|
11
|
+
import { useMemo } from 'react';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Tests for the title resolution fallback chain in ObjectKanban.
|
|
15
|
+
*
|
|
16
|
+
* The effectiveData logic tries fields in this order:
|
|
17
|
+
* 1. Explicit cardTitle / titleField from schema
|
|
18
|
+
* 2. objectDef.titleFormat (e.g. "{subject}")
|
|
19
|
+
* 3. objectDef.NAME_FIELD_KEY
|
|
20
|
+
* 4. Fallback chain: name → title → subject → label → display_name
|
|
21
|
+
* 5. 'Untitled'
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Extract the title resolution logic from ObjectKanban to test it in isolation
|
|
25
|
+
const TITLE_FALLBACK_FIELDS = ['name', 'title', 'subject', 'label', 'display_name'];
|
|
26
|
+
|
|
27
|
+
function resolveTitle(item: Record<string, any>, titleField?: string): string {
|
|
28
|
+
let resolvedTitle = titleField ? item[titleField] : undefined;
|
|
29
|
+
|
|
30
|
+
if (!resolvedTitle) {
|
|
31
|
+
for (const field of TITLE_FALLBACK_FIELDS) {
|
|
32
|
+
if (item[field]) {
|
|
33
|
+
resolvedTitle = item[field];
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return resolvedTitle || 'Untitled';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('ObjectKanban title resolution', () => {
|
|
43
|
+
it('uses explicit titleField when value exists', () => {
|
|
44
|
+
const item = { id: '1', custom_title: 'My Custom Title', name: 'Fallback Name' };
|
|
45
|
+
expect(resolveTitle(item, 'custom_title')).toBe('My Custom Title');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('falls back to common fields when titleField value is empty', () => {
|
|
49
|
+
const item = { id: '1', custom_title: '', name: 'Name Field' };
|
|
50
|
+
expect(resolveTitle(item, 'custom_title')).toBe('Name Field');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('resolves name field first in fallback chain', () => {
|
|
54
|
+
const item = { id: '1', name: 'Name Value', title: 'Title Value', subject: 'Subject Value' };
|
|
55
|
+
expect(resolveTitle(item)).toBe('Name Value');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('resolves title field second in fallback chain', () => {
|
|
59
|
+
const item = { id: '1', title: 'Title Value', subject: 'Subject Value' };
|
|
60
|
+
expect(resolveTitle(item)).toBe('Title Value');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('resolves subject field third in fallback chain', () => {
|
|
64
|
+
const item = { id: '1', subject: 'Subject Value', label: 'Label Value' };
|
|
65
|
+
expect(resolveTitle(item)).toBe('Subject Value');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('resolves label field fourth in fallback chain', () => {
|
|
69
|
+
const item = { id: '1', label: 'Label Value', display_name: 'Display Name' };
|
|
70
|
+
expect(resolveTitle(item)).toBe('Label Value');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('resolves display_name field fifth in fallback chain', () => {
|
|
74
|
+
const item = { id: '1', display_name: 'Display Name' };
|
|
75
|
+
expect(resolveTitle(item)).toBe('Display Name');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('falls back to Untitled when no common fields exist', () => {
|
|
79
|
+
const item = { id: '1', status: 'open', priority: 'high' };
|
|
80
|
+
expect(resolveTitle(item)).toBe('Untitled');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('skips falsy field values in fallback chain', () => {
|
|
84
|
+
const item = { id: '1', name: '', title: null, subject: 'Bug Report' };
|
|
85
|
+
expect(resolveTitle(item)).toBe('Bug Report');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('handles todo_task objects with subject field', () => {
|
|
89
|
+
// This is the exact scenario from the bug report
|
|
90
|
+
const todoTask = { id: '1', status: 'in_progress', subject: 'Fix login bug', priority: 'high' };
|
|
91
|
+
expect(resolveTitle(todoTask)).toBe('Fix login bug');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
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, fireEvent } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
|
|
13
|
+
// Mock @dnd-kit/core and utilities
|
|
14
|
+
vi.mock('@dnd-kit/core', () => ({
|
|
15
|
+
DndContext: ({ children }: any) => <div data-testid="dnd-context">{children}</div>,
|
|
16
|
+
DragOverlay: ({ children }: any) => <div data-testid="drag-overlay">{children}</div>,
|
|
17
|
+
PointerSensor: vi.fn(),
|
|
18
|
+
TouchSensor: vi.fn(),
|
|
19
|
+
useSensor: vi.fn(),
|
|
20
|
+
useSensors: () => [],
|
|
21
|
+
closestCorners: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('@dnd-kit/sortable', () => ({
|
|
25
|
+
SortableContext: ({ children }: any) => <div data-testid="sortable-context">{children}</div>,
|
|
26
|
+
useSortable: () => ({
|
|
27
|
+
attributes: {},
|
|
28
|
+
listeners: {},
|
|
29
|
+
setNodeRef: vi.fn(),
|
|
30
|
+
transform: null,
|
|
31
|
+
transition: null,
|
|
32
|
+
isDragging: false,
|
|
33
|
+
}),
|
|
34
|
+
arrayMove: (array: any[], from: number, to: number) => {
|
|
35
|
+
const newArray = [...array];
|
|
36
|
+
newArray.splice(to, 0, newArray.splice(from, 1)[0]);
|
|
37
|
+
return newArray;
|
|
38
|
+
},
|
|
39
|
+
verticalListSortingStrategy: vi.fn(),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('@dnd-kit/utilities', () => ({
|
|
43
|
+
CSS: {
|
|
44
|
+
Transform: {
|
|
45
|
+
toString: () => '',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('@object-ui/components', () => ({
|
|
51
|
+
Badge: ({ children, ...props }: any) => <span {...props}>{children}</span>,
|
|
52
|
+
Card: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
53
|
+
CardHeader: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
54
|
+
CardTitle: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
55
|
+
CardDescription: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
56
|
+
CardContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
57
|
+
ScrollArea: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
58
|
+
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
|
59
|
+
Input: (props: any) => <input {...props} />,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
vi.mock('@object-ui/react', () => ({
|
|
63
|
+
useHasDndProvider: () => false,
|
|
64
|
+
useDnd: () => ({
|
|
65
|
+
startDrag: vi.fn(),
|
|
66
|
+
endDrag: vi.fn(),
|
|
67
|
+
}),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock('lucide-react', () => ({
|
|
71
|
+
Plus: () => <span>+</span>,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
import KanbanBoard from '../KanbanImpl';
|
|
75
|
+
|
|
76
|
+
// Mock localStorage
|
|
77
|
+
const localStorageMock = (() => {
|
|
78
|
+
let store: Record<string, string> = {};
|
|
79
|
+
return {
|
|
80
|
+
getItem: vi.fn((key: string) => store[key] ?? null),
|
|
81
|
+
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
|
|
82
|
+
clear: () => { store = {}; },
|
|
83
|
+
removeItem: vi.fn((key: string) => { delete store[key]; }),
|
|
84
|
+
};
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
|
88
|
+
|
|
89
|
+
const mockColumns = [
|
|
90
|
+
{
|
|
91
|
+
id: 'todo',
|
|
92
|
+
title: 'To Do',
|
|
93
|
+
cards: [
|
|
94
|
+
{ id: 'c1', title: 'Task 1', category: 'Frontend' },
|
|
95
|
+
{ id: 'c2', title: 'Task 2', category: 'Backend' },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'done',
|
|
100
|
+
title: 'Done',
|
|
101
|
+
cards: [
|
|
102
|
+
{ id: 'c3', title: 'Task 3', category: 'Frontend' },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
describe('KanbanBoard swimlane persistence', () => {
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
localStorageMock.clear();
|
|
110
|
+
localStorageMock.getItem.mockClear();
|
|
111
|
+
localStorageMock.setItem.mockClear();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('reads collapsed lanes from localStorage on mount when swimlaneField is set', () => {
|
|
115
|
+
localStorageMock.setItem(
|
|
116
|
+
'objectui:kanban-collapsed:category',
|
|
117
|
+
JSON.stringify(['Frontend']),
|
|
118
|
+
);
|
|
119
|
+
localStorageMock.getItem.mockClear();
|
|
120
|
+
|
|
121
|
+
render(<KanbanBoard columns={mockColumns} swimlaneField="category" />);
|
|
122
|
+
|
|
123
|
+
expect(localStorageMock.getItem).toHaveBeenCalledWith(
|
|
124
|
+
'objectui:kanban-collapsed:category',
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('writes collapsed state to localStorage when a lane is toggled', () => {
|
|
129
|
+
render(<KanbanBoard columns={mockColumns} swimlaneField="category" />);
|
|
130
|
+
|
|
131
|
+
// Find a swimlane collapse button and click it
|
|
132
|
+
const collapseButtons = screen.getAllByRole('button').filter(
|
|
133
|
+
btn => btn.getAttribute('aria-label')?.includes('collapse') ||
|
|
134
|
+
btn.getAttribute('aria-label')?.includes('Toggle') ||
|
|
135
|
+
btn.textContent?.includes('▸') ||
|
|
136
|
+
btn.textContent?.includes('▾'),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (collapseButtons.length > 0) {
|
|
140
|
+
fireEvent.click(collapseButtons[0]);
|
|
141
|
+
expect(localStorageMock.setItem).toHaveBeenCalled();
|
|
142
|
+
const lastCall = localStorageMock.setItem.mock.calls.at(-1);
|
|
143
|
+
expect(lastCall?.[0]).toBe('objectui:kanban-collapsed:category');
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does not access localStorage when swimlaneField is not set', () => {
|
|
148
|
+
localStorageMock.getItem.mockClear();
|
|
149
|
+
localStorageMock.setItem.mockClear();
|
|
150
|
+
|
|
151
|
+
render(<KanbanBoard columns={mockColumns} />);
|
|
152
|
+
|
|
153
|
+
// No localStorage reads for collapsed state key
|
|
154
|
+
const collapsedCalls = localStorageMock.getItem.mock.calls.filter(
|
|
155
|
+
([key]: [string]) => key.startsWith('objectui:kanban-collapsed:'),
|
|
156
|
+
);
|
|
157
|
+
expect(collapsedCalls).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -197,8 +197,8 @@ describe('KanbanBoard (KanbanImpl): performance benchmarks', () => {
|
|
|
197
197
|
expect(elapsed).toBeLessThan(500);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
-
it('renders
|
|
201
|
-
const columns = generateColumns(5,
|
|
200
|
+
it('renders 200 cards spread across 5 columns under 1,000ms', () => {
|
|
201
|
+
const columns = generateColumns(5, 200);
|
|
202
202
|
|
|
203
203
|
const start = performance.now();
|
|
204
204
|
const { container } = render(<KanbanBoard columns={columns} />);
|
|
@@ -208,8 +208,8 @@ describe('KanbanBoard (KanbanImpl): performance benchmarks', () => {
|
|
|
208
208
|
expect(elapsed).toBeLessThan(1_000);
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
-
it('renders
|
|
212
|
-
const columns = generateColumns(5,
|
|
211
|
+
it('renders 500 cards spread across 5 columns under 2,000ms', () => {
|
|
212
|
+
const columns = generateColumns(5, 500);
|
|
213
213
|
|
|
214
214
|
const start = performance.now();
|
|
215
215
|
const { container } = render(<KanbanBoard columns={columns} />);
|
|
@@ -219,8 +219,8 @@ describe('KanbanBoard (KanbanImpl): performance benchmarks', () => {
|
|
|
219
219
|
expect(elapsed).toBeLessThan(2_000);
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
-
it('renders with
|
|
223
|
-
const columns = generateColumns(
|
|
222
|
+
it('renders with 10+ columns without degradation', () => {
|
|
223
|
+
const columns = generateColumns(12, 120);
|
|
224
224
|
|
|
225
225
|
const start = performance.now();
|
|
226
226
|
const { container } = render(<KanbanBoard columns={columns} />);
|
|
@@ -230,8 +230,8 @@ describe('KanbanBoard (KanbanImpl): performance benchmarks', () => {
|
|
|
230
230
|
expect(elapsed).toBeLessThan(2_000);
|
|
231
231
|
});
|
|
232
232
|
|
|
233
|
-
it('renders empty board with
|
|
234
|
-
const columns = generateColumns(
|
|
233
|
+
it('renders empty board with 10+ columns quickly', () => {
|
|
234
|
+
const columns = generateColumns(12, 0);
|
|
235
235
|
|
|
236
236
|
const start = performance.now();
|
|
237
237
|
const { container } = render(<KanbanBoard columns={columns} />);
|
|
@@ -260,11 +260,11 @@ describe('KanbanBoard (KanbanImpl): scaling characteristics', () => {
|
|
|
260
260
|
await setupMocksAndImport();
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
it('renders all column titles for
|
|
264
|
-
const columns = generateColumns(
|
|
263
|
+
it('renders all column titles for 10+ column board', () => {
|
|
264
|
+
const columns = generateColumns(12, 24);
|
|
265
265
|
render(<KanbanBoard columns={columns} />);
|
|
266
266
|
|
|
267
|
-
for (let i = 0; i <
|
|
267
|
+
for (let i = 0; i < 12; i++) {
|
|
268
268
|
expect(document.body.textContent).toContain(`Column ${i}`);
|
|
269
269
|
}
|
|
270
270
|
});
|
|
@@ -274,7 +274,7 @@ describe('KanbanBoard (KanbanImpl): scaling characteristics', () => {
|
|
|
274
274
|
{
|
|
275
275
|
id: 'badges-col',
|
|
276
276
|
title: 'With Badges',
|
|
277
|
-
cards: Array.from({ length:
|
|
277
|
+
cards: Array.from({ length: 100 }, (_, i) => ({
|
|
278
278
|
id: `badge-card-${i}`,
|
|
279
279
|
title: `Task ${i}`,
|
|
280
280
|
badges: [
|
|
@@ -293,8 +293,8 @@ describe('KanbanBoard (KanbanImpl): scaling characteristics', () => {
|
|
|
293
293
|
expect(elapsed).toBeLessThan(2_000);
|
|
294
294
|
});
|
|
295
295
|
|
|
296
|
-
it('renders
|
|
297
|
-
const columns = generateColumns(10,
|
|
296
|
+
it('renders 500 cards across 10 columns under 2,000ms', () => {
|
|
297
|
+
const columns = generateColumns(10, 500);
|
|
298
298
|
|
|
299
299
|
const start = performance.now();
|
|
300
300
|
const { container } = render(<KanbanBoard columns={columns} />);
|