@object-ui/plugin-kanban 3.0.3 → 3.1.1

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.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/CHANGELOG.md +10 -0
  3. package/dist/{KanbanEnhanced-BPIKjTDv.js → KanbanEnhanced-CXDSLlGR.js} +338 -324
  4. package/dist/KanbanImpl-4dgoNPtI.js +350 -0
  5. package/dist/index-CyNcIIS1.js +1077 -0
  6. package/dist/index.js +9 -4
  7. package/dist/index.umd.cjs +4 -4
  8. package/dist/src/CardTemplates.d.ts +25 -0
  9. package/dist/src/CardTemplates.d.ts.map +1 -0
  10. package/dist/src/InlineQuickAdd.d.ts +29 -0
  11. package/dist/src/InlineQuickAdd.d.ts.map +1 -0
  12. package/dist/src/KanbanEnhanced.d.ts +12 -1
  13. package/dist/src/KanbanEnhanced.d.ts.map +1 -1
  14. package/dist/src/KanbanImpl.d.ts +15 -1
  15. package/dist/src/KanbanImpl.d.ts.map +1 -1
  16. package/dist/src/ObjectKanban.d.ts.map +1 -1
  17. package/dist/src/index.d.ts +22 -1
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/types.d.ts +97 -1
  20. package/dist/src/types.d.ts.map +1 -1
  21. package/dist/src/useColumnWidths.d.ts +30 -0
  22. package/dist/src/useColumnWidths.d.ts.map +1 -0
  23. package/dist/src/useCrossSwimlaneMove.d.ts +46 -0
  24. package/dist/src/useCrossSwimlaneMove.d.ts.map +1 -0
  25. package/dist/src/useQuickAddReorder.d.ts +28 -0
  26. package/dist/src/useQuickAddReorder.d.ts.map +1 -0
  27. package/package.json +9 -9
  28. package/src/CardTemplates.tsx +123 -0
  29. package/src/InlineQuickAdd.tsx +189 -0
  30. package/src/KanbanEnhanced.tsx +140 -9
  31. package/src/KanbanImpl.tsx +266 -23
  32. package/src/ObjectKanban.tsx +39 -24
  33. package/src/__tests__/KanbanGrouping.test.tsx +164 -0
  34. package/src/__tests__/KanbanSwimlanes.test.tsx +194 -0
  35. package/src/__tests__/ObjectKanbanTitle.test.tsx +93 -0
  36. package/src/__tests__/SwimlanePersistence.test.tsx +159 -0
  37. package/src/__tests__/performance-benchmark.test.tsx +14 -14
  38. package/src/__tests__/phase13-features.test.tsx +387 -0
  39. package/src/index.tsx +49 -6
  40. package/src/types.ts +106 -1
  41. package/src/useColumnWidths.ts +125 -0
  42. package/src/useCrossSwimlaneMove.ts +116 -0
  43. package/src/useQuickAddReorder.ts +107 -0
  44. package/dist/KanbanImpl-BfOKAnJS.js +0 -194
  45. 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 (assuming they have 'cards' inside)
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
  }