Skip to content

Commit 84e029e

Browse files
CopilotCopilot
andcommitted
test: add Phase 13 Kanban & Views Enhancement L2/L3 tests
Add comprehensive tests for InlineQuickAdd, CardTemplates, useColumnWidths, useCrossSwimlaneMove, and useQuickAddReorder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 50476ca commit 84e029e

1 file changed

Lines changed: 387 additions & 0 deletions

File tree

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
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+
});

0 commit comments

Comments
 (0)