@object-ui/plugin-kanban 3.0.2 → 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/CHANGELOG.md +9 -0
- 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,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
|
+
});
|
package/src/index.tsx
CHANGED
|
@@ -13,10 +13,22 @@ import { Skeleton } from '@object-ui/components';
|
|
|
13
13
|
import { ObjectKanban } from './ObjectKanban';
|
|
14
14
|
|
|
15
15
|
// Export types for external use
|
|
16
|
-
export type { KanbanSchema, KanbanCard, KanbanColumn } from './types';
|
|
16
|
+
export type { KanbanSchema, KanbanCard, KanbanColumn, CardTemplate, ColumnWidthConfig, InlineFieldDefinition } from './types';
|
|
17
17
|
export { ObjectKanban };
|
|
18
18
|
export type { ObjectKanbanProps } from './ObjectKanban';
|
|
19
19
|
|
|
20
|
+
// Phase 13 L2/L3: New components and hooks
|
|
21
|
+
export { InlineQuickAdd } from './InlineQuickAdd';
|
|
22
|
+
export type { InlineQuickAddProps } from './InlineQuickAdd';
|
|
23
|
+
export { CardTemplates } from './CardTemplates';
|
|
24
|
+
export type { CardTemplatesProps } from './CardTemplates';
|
|
25
|
+
export { useColumnWidths } from './useColumnWidths';
|
|
26
|
+
export type { UseColumnWidthsOptions, UseColumnWidthsReturn } from './useColumnWidths';
|
|
27
|
+
export { useCrossSwimlaneMove } from './useCrossSwimlaneMove';
|
|
28
|
+
export type { Swimlane, CrossSwimlaneMoveEvent, UseCrossSwimlaneOptions, UseCrossSwimlaneMoveReturn } from './useCrossSwimlaneMove';
|
|
29
|
+
export { useQuickAddReorder } from './useQuickAddReorder';
|
|
30
|
+
export type { UseQuickAddReorderOptions, UseQuickAddReorderReturn } from './useQuickAddReorder';
|
|
31
|
+
|
|
20
32
|
// 🚀 Lazy load the implementation files
|
|
21
33
|
const LazyKanban = React.lazy(() => import('./KanbanImpl'));
|
|
22
34
|
const LazyKanbanEnhanced = React.lazy(() => import('./KanbanEnhanced'));
|
|
@@ -29,8 +41,19 @@ export interface KanbanRendererProps {
|
|
|
29
41
|
columns?: Array<any>;
|
|
30
42
|
data?: Array<any>;
|
|
31
43
|
groupBy?: string;
|
|
44
|
+
swimlaneField?: string;
|
|
32
45
|
onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void;
|
|
33
46
|
onCardClick?: (card: any) => void;
|
|
47
|
+
quickAdd?: boolean;
|
|
48
|
+
onQuickAdd?: (columnId: string, title: string) => void;
|
|
49
|
+
coverImageField?: string;
|
|
50
|
+
conditionalFormatting?: Array<{
|
|
51
|
+
field: string;
|
|
52
|
+
operator: 'equals' | 'not_equals' | 'contains' | 'in';
|
|
53
|
+
value: string | string[];
|
|
54
|
+
backgroundColor?: string;
|
|
55
|
+
borderColor?: string;
|
|
56
|
+
}>;
|
|
34
57
|
};
|
|
35
58
|
}
|
|
36
59
|
|
|
@@ -41,7 +64,16 @@ export interface KanbanRendererProps {
|
|
|
41
64
|
export const KanbanRenderer: React.FC<KanbanRendererProps> = ({ schema }) => {
|
|
42
65
|
// ⚡️ Adapter: Map flat 'data' + 'groupBy' to nested 'cards' structure
|
|
43
66
|
const processedColumns = React.useMemo(() => {
|
|
44
|
-
const { columns = [], data, groupBy } = schema;
|
|
67
|
+
const { columns = [], data, groupBy, coverImageField } = schema;
|
|
68
|
+
|
|
69
|
+
// Helper to map cover image field onto cards
|
|
70
|
+
const mapCoverImage = (item: any) => {
|
|
71
|
+
if (!coverImageField) return item;
|
|
72
|
+
const imgValue = item[coverImageField];
|
|
73
|
+
if (!imgValue) return item;
|
|
74
|
+
const coverImage = typeof imgValue === 'string' ? imgValue : imgValue?.url;
|
|
75
|
+
return coverImage ? { ...item, coverImage } : item;
|
|
76
|
+
};
|
|
45
77
|
|
|
46
78
|
// If we have flat data and a grouping key, distribute items into columns
|
|
47
79
|
if (data && groupBy && Array.isArray(data)) {
|
|
@@ -58,7 +90,7 @@ export const KanbanRenderer: React.FC<KanbanRendererProps> = ({ schema }) => {
|
|
|
58
90
|
const rawKey = String(item[groupBy] ?? '');
|
|
59
91
|
const key = labelToColumnId[rawKey.toLowerCase()] ?? rawKey;
|
|
60
92
|
if (!acc[key]) acc[key] = [];
|
|
61
|
-
acc[key].push(item);
|
|
93
|
+
acc[key].push(mapCoverImage(item));
|
|
62
94
|
return acc;
|
|
63
95
|
}, {} as Record<string, any[]>);
|
|
64
96
|
|
|
@@ -66,14 +98,17 @@ export const KanbanRenderer: React.FC<KanbanRendererProps> = ({ schema }) => {
|
|
|
66
98
|
return columns.map((col: any) => ({
|
|
67
99
|
...col,
|
|
68
100
|
cards: [
|
|
69
|
-
...(col.cards || []), // Preserve static cards
|
|
101
|
+
...(col.cards || []).map(mapCoverImage), // Preserve static cards
|
|
70
102
|
...(groups[col.id] || []) // Add dynamic cards
|
|
71
103
|
]
|
|
72
104
|
}));
|
|
73
105
|
}
|
|
74
106
|
|
|
75
|
-
// Default: Return columns as-is
|
|
76
|
-
return columns
|
|
107
|
+
// Default: Return columns as-is, mapping cover images
|
|
108
|
+
return columns.map((col: any) => ({
|
|
109
|
+
...col,
|
|
110
|
+
cards: (col.cards || []).map(mapCoverImage),
|
|
111
|
+
}));
|
|
77
112
|
}, [schema]);
|
|
78
113
|
|
|
79
114
|
return (
|
|
@@ -83,6 +118,11 @@ export const KanbanRenderer: React.FC<KanbanRendererProps> = ({ schema }) => {
|
|
|
83
118
|
onCardMove={schema.onCardMove}
|
|
84
119
|
onCardClick={schema.onCardClick}
|
|
85
120
|
className={schema.className}
|
|
121
|
+
quickAdd={schema.quickAdd}
|
|
122
|
+
onQuickAdd={schema.onQuickAdd}
|
|
123
|
+
coverImageField={schema.coverImageField}
|
|
124
|
+
conditionalFormatting={schema.conditionalFormatting}
|
|
125
|
+
swimlaneField={schema.swimlaneField}
|
|
86
126
|
/>
|
|
87
127
|
</Suspense>
|
|
88
128
|
);
|
|
@@ -223,6 +263,9 @@ ComponentRegistry.register(
|
|
|
223
263
|
enableVirtualScrolling={schema.enableVirtualScrolling}
|
|
224
264
|
virtualScrollThreshold={schema.virtualScrollThreshold}
|
|
225
265
|
className={schema.className}
|
|
266
|
+
quickAdd={schema.quickAdd}
|
|
267
|
+
onQuickAdd={schema.onQuickAdd}
|
|
268
|
+
conditionalFormatting={schema.conditionalFormatting}
|
|
226
269
|
/>
|
|
227
270
|
</Suspense>
|
|
228
271
|
);
|
package/src/types.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { BaseSchema } from '@object-ui/types';
|
|
9
|
+
import type { BaseSchema, GroupingConfig } from '@object-ui/types';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Kanban card interface.
|
|
@@ -47,6 +47,12 @@ export interface KanbanSchema extends BaseSchema {
|
|
|
47
47
|
*/
|
|
48
48
|
groupBy?: string;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Field for swimlane rows (2D grouping). When set, cards are grouped
|
|
52
|
+
* vertically by `groupBy` (columns) and horizontally by `swimlaneField` (rows).
|
|
53
|
+
*/
|
|
54
|
+
swimlaneField?: string;
|
|
55
|
+
|
|
50
56
|
/**
|
|
51
57
|
* Field to use as the card title.
|
|
52
58
|
*/
|
|
@@ -77,4 +83,103 @@ export interface KanbanSchema extends BaseSchema {
|
|
|
77
83
|
* Optional CSS class name to apply custom styling.
|
|
78
84
|
*/
|
|
79
85
|
className?: string;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Enable Quick Add button at the bottom of each column.
|
|
89
|
+
* When true, a "+" button appears allowing inline card creation.
|
|
90
|
+
* @default false
|
|
91
|
+
*/
|
|
92
|
+
quickAdd?: boolean;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Callback when a new card is created via Quick Add.
|
|
96
|
+
*/
|
|
97
|
+
onQuickAdd?: (columnId: string, title: string) => void;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Field name to use as cover image on cards.
|
|
101
|
+
* The field value should be a URL string or file object with a `url` property.
|
|
102
|
+
*/
|
|
103
|
+
coverImageField?: string;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Allow columns to be collapsed/expanded.
|
|
107
|
+
* @default false
|
|
108
|
+
*/
|
|
109
|
+
allowCollapse?: boolean;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Conditional formatting rules for card coloring.
|
|
113
|
+
*/
|
|
114
|
+
conditionalFormatting?: Array<{
|
|
115
|
+
field: string;
|
|
116
|
+
operator: 'equals' | 'not_equals' | 'contains' | 'in';
|
|
117
|
+
value: string | string[];
|
|
118
|
+
backgroundColor?: string;
|
|
119
|
+
borderColor?: string;
|
|
120
|
+
}>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Predefined card templates for quick-add.
|
|
124
|
+
* Each template pre-fills the quick-add form with default values.
|
|
125
|
+
*/
|
|
126
|
+
cardTemplates?: CardTemplate[];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Custom column width configuration.
|
|
130
|
+
* Supports per-column overrides with min/max constraints.
|
|
131
|
+
*/
|
|
132
|
+
columnWidths?: ColumnWidthConfig;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Grouping configuration from ListView.
|
|
136
|
+
* When set, the first grouping field is used as swimlaneField fallback.
|
|
137
|
+
*/
|
|
138
|
+
grouping?: GroupingConfig;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A predefined card template with pre-filled field values.
|
|
143
|
+
*/
|
|
144
|
+
export interface CardTemplate {
|
|
145
|
+
/** Unique template identifier */
|
|
146
|
+
id: string;
|
|
147
|
+
/** Human-readable template name */
|
|
148
|
+
name: string;
|
|
149
|
+
/** Optional Lucide icon name */
|
|
150
|
+
icon?: string;
|
|
151
|
+
/** Pre-filled field values */
|
|
152
|
+
values: Record<string, any>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Configuration for custom column widths.
|
|
157
|
+
*/
|
|
158
|
+
export interface ColumnWidthConfig {
|
|
159
|
+
/** Default column width in pixels */
|
|
160
|
+
defaultWidth?: number;
|
|
161
|
+
/** Minimum column width in pixels */
|
|
162
|
+
minWidth?: number;
|
|
163
|
+
/** Maximum column width in pixels */
|
|
164
|
+
maxWidth?: number;
|
|
165
|
+
/** Per-column width overrides keyed by column ID */
|
|
166
|
+
overrides?: Record<string, number>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Field definition for inline quick-add forms.
|
|
171
|
+
*/
|
|
172
|
+
export interface InlineFieldDefinition {
|
|
173
|
+
/** Field name (key in the resulting values object) */
|
|
174
|
+
name: string;
|
|
175
|
+
/** Display label */
|
|
176
|
+
label?: string;
|
|
177
|
+
/** Field type */
|
|
178
|
+
type: 'text' | 'number' | 'select';
|
|
179
|
+
/** Placeholder text */
|
|
180
|
+
placeholder?: string;
|
|
181
|
+
/** Default value */
|
|
182
|
+
defaultValue?: any;
|
|
183
|
+
/** Options for select fields */
|
|
184
|
+
options?: Array<{ label: string; value: string }>;
|
|
80
185
|
}
|