@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.
Files changed (44) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/dist/{KanbanEnhanced-BPIKjTDv.js → KanbanEnhanced-CXDSLlGR.js} +338 -324
  3. package/dist/KanbanImpl-4dgoNPtI.js +350 -0
  4. package/dist/index-CyNcIIS1.js +1077 -0
  5. package/dist/index.js +9 -4
  6. package/dist/index.umd.cjs +4 -4
  7. package/dist/src/CardTemplates.d.ts +25 -0
  8. package/dist/src/CardTemplates.d.ts.map +1 -0
  9. package/dist/src/InlineQuickAdd.d.ts +29 -0
  10. package/dist/src/InlineQuickAdd.d.ts.map +1 -0
  11. package/dist/src/KanbanEnhanced.d.ts +12 -1
  12. package/dist/src/KanbanEnhanced.d.ts.map +1 -1
  13. package/dist/src/KanbanImpl.d.ts +15 -1
  14. package/dist/src/KanbanImpl.d.ts.map +1 -1
  15. package/dist/src/ObjectKanban.d.ts.map +1 -1
  16. package/dist/src/index.d.ts +22 -1
  17. package/dist/src/index.d.ts.map +1 -1
  18. package/dist/src/types.d.ts +97 -1
  19. package/dist/src/types.d.ts.map +1 -1
  20. package/dist/src/useColumnWidths.d.ts +30 -0
  21. package/dist/src/useColumnWidths.d.ts.map +1 -0
  22. package/dist/src/useCrossSwimlaneMove.d.ts +46 -0
  23. package/dist/src/useCrossSwimlaneMove.d.ts.map +1 -0
  24. package/dist/src/useQuickAddReorder.d.ts +28 -0
  25. package/dist/src/useQuickAddReorder.d.ts.map +1 -0
  26. package/package.json +9 -9
  27. package/src/CardTemplates.tsx +123 -0
  28. package/src/InlineQuickAdd.tsx +189 -0
  29. package/src/KanbanEnhanced.tsx +140 -9
  30. package/src/KanbanImpl.tsx +266 -23
  31. package/src/ObjectKanban.tsx +39 -24
  32. package/src/__tests__/KanbanGrouping.test.tsx +164 -0
  33. package/src/__tests__/KanbanSwimlanes.test.tsx +194 -0
  34. package/src/__tests__/ObjectKanbanTitle.test.tsx +93 -0
  35. package/src/__tests__/SwimlanePersistence.test.tsx +159 -0
  36. package/src/__tests__/performance-benchmark.test.tsx +14 -14
  37. package/src/__tests__/phase13-features.test.tsx +387 -0
  38. package/src/index.tsx +49 -6
  39. package/src/types.ts +106 -1
  40. package/src/useColumnWidths.ts +125 -0
  41. package/src/useCrossSwimlaneMove.ts +116 -0
  42. package/src/useQuickAddReorder.ts +107 -0
  43. package/dist/KanbanImpl-BfOKAnJS.js +0 -194
  44. package/dist/index-CWGTi2xn.js +0 -600
@@ -25,17 +25,21 @@ import {
25
25
  verticalListSortingStrategy,
26
26
  } from "@dnd-kit/sortable"
27
27
  import { CSS } from "@dnd-kit/utilities"
28
- import { Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, ScrollArea } from "@object-ui/components"
28
+ import { Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, ScrollArea, Button, Input } from "@object-ui/components"
29
29
  import { useHasDndProvider, useDnd } from "@object-ui/react"
30
+ import { Plus } from "lucide-react"
30
31
 
31
32
  // Utility function to merge class names (inline to avoid external dependency)
32
33
  const cn = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ')
33
34
 
35
+ const UNCATEGORIZED_LANE = 'Uncategorized'
36
+
34
37
  export interface KanbanCard {
35
38
  id: string
36
39
  title: string
37
40
  description?: string
38
41
  badges?: Array<{ label: string; variant?: "default" | "secondary" | "destructive" | "outline" }>
42
+ coverImage?: string
39
43
  [key: string]: any
40
44
  }
41
45
 
@@ -47,14 +51,67 @@ export interface KanbanColumn {
47
51
  className?: string
48
52
  }
49
53
 
54
+ export interface ConditionalFormattingRule {
55
+ field: string
56
+ operator: 'equals' | 'not_equals' | 'contains' | 'in'
57
+ value: string | string[]
58
+ backgroundColor?: string
59
+ borderColor?: string
60
+ }
61
+
50
62
  export interface KanbanBoardProps {
51
63
  columns: KanbanColumn[]
52
64
  onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void
53
65
  onCardClick?: (card: KanbanCard) => void
54
66
  className?: string
67
+ quickAdd?: boolean
68
+ onQuickAdd?: (columnId: string, title: string) => void
69
+ coverImageField?: string
70
+ conditionalFormatting?: ConditionalFormattingRule[]
71
+ /** Field name for swimlane rows (2D grouping) */
72
+ swimlaneField?: string
55
73
  }
56
74
 
57
- function SortableCard({ card, onCardClick }: { card: KanbanCard; onCardClick?: (card: KanbanCard) => void }) {
75
+ /**
76
+ * Evaluate conditional formatting rules for a card.
77
+ * Returns CSS style overrides for backgroundColor and borderColor.
78
+ */
79
+ function getCardStyles(card: KanbanCard, rules?: ConditionalFormattingRule[]): React.CSSProperties {
80
+ if (!rules || rules.length === 0) return {}
81
+
82
+ for (const rule of rules) {
83
+ const fieldValue = card[rule.field]
84
+ if (fieldValue === undefined || fieldValue === null) continue
85
+
86
+ let matches = false
87
+ const strValue = String(fieldValue)
88
+
89
+ switch (rule.operator) {
90
+ case 'equals':
91
+ matches = strValue === String(rule.value)
92
+ break
93
+ case 'not_equals':
94
+ matches = strValue !== String(rule.value)
95
+ break
96
+ case 'contains':
97
+ matches = strValue.toLowerCase().includes(String(rule.value).toLowerCase())
98
+ break
99
+ case 'in':
100
+ matches = Array.isArray(rule.value) && rule.value.includes(strValue)
101
+ break
102
+ }
103
+
104
+ if (matches) {
105
+ return {
106
+ ...(rule.backgroundColor ? { backgroundColor: rule.backgroundColor } : {}),
107
+ ...(rule.borderColor ? { borderColor: rule.borderColor } : {}),
108
+ }
109
+ }
110
+ }
111
+ return {}
112
+ }
113
+
114
+ function SortableCard({ card, onCardClick, conditionalFormatting }: { card: KanbanCard; onCardClick?: (card: KanbanCard) => void; conditionalFormatting?: ConditionalFormattingRule[] }) {
58
115
  const {
59
116
  attributes,
60
117
  listeners,
@@ -70,11 +127,23 @@ function SortableCard({ card, onCardClick }: { card: KanbanCard; onCardClick?: (
70
127
  opacity: isDragging ? 0.5 : undefined,
71
128
  }
72
129
 
130
+ const cardStyles = getCardStyles(card, conditionalFormatting)
131
+
73
132
  return (
74
133
  <div ref={setNodeRef} style={style} {...attributes} {...listeners} role="listitem" aria-label={card.title}
75
134
  onClick={() => onCardClick?.(card)}
76
135
  >
77
- <Card className="mb-2 cursor-grab active:cursor-grabbing border-border bg-card/60 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 group touch-manipulation">
136
+ <Card className="mb-2 cursor-grab active:cursor-grabbing border-border border-l-4 border-l-primary/40 bg-card/60 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 group touch-manipulation" style={cardStyles}>
137
+ {card.coverImage && (
138
+ <div className="w-full h-32 overflow-hidden rounded-t-lg">
139
+ <img
140
+ src={card.coverImage}
141
+ alt=""
142
+ className="w-full h-full object-cover"
143
+ loading="lazy"
144
+ />
145
+ </div>
146
+ )}
78
147
  <CardHeader className="p-2 sm:p-4">
79
148
  <CardTitle className="text-xs sm:text-sm font-medium font-mono tracking-tight text-foreground group-hover:text-primary transition-colors">{card.title}</CardTitle>
80
149
  {card.description && (
@@ -99,14 +168,77 @@ function SortableCard({ card, onCardClick }: { card: KanbanCard; onCardClick?: (
99
168
  )
100
169
  }
101
170
 
102
- function KanbanColumn({
171
+ function QuickAddForm({ columnId, onAdd }: { columnId: string; onAdd: (columnId: string, title: string) => void }) {
172
+ const [isAdding, setIsAdding] = React.useState(false)
173
+ const [title, setTitle] = React.useState('')
174
+ const inputRef = React.useRef<HTMLInputElement>(null)
175
+
176
+ const handleSubmit = () => {
177
+ const trimmed = title.trim()
178
+ if (trimmed) {
179
+ onAdd(columnId, trimmed)
180
+ setTitle('')
181
+ }
182
+ setIsAdding(false)
183
+ }
184
+
185
+ const handleKeyDown = (e: React.KeyboardEvent) => {
186
+ if (e.key === 'Enter') {
187
+ e.preventDefault()
188
+ handleSubmit()
189
+ } else if (e.key === 'Escape') {
190
+ setTitle('')
191
+ setIsAdding(false)
192
+ }
193
+ }
194
+
195
+ if (!isAdding) {
196
+ return (
197
+ <Button
198
+ variant="ghost"
199
+ size="sm"
200
+ className="w-full mt-2 text-muted-foreground hover:text-foreground"
201
+ onClick={() => {
202
+ setIsAdding(true)
203
+ setTimeout(() => inputRef.current?.focus(), 0)
204
+ }}
205
+ >
206
+ <Plus className="h-4 w-4 mr-1" />
207
+ Add Card
208
+ </Button>
209
+ )
210
+ }
211
+
212
+ return (
213
+ <div className="mt-2 space-y-2">
214
+ <Input
215
+ ref={inputRef}
216
+ value={title}
217
+ onChange={(e) => setTitle(e.target.value)}
218
+ onKeyDown={handleKeyDown}
219
+ onBlur={handleSubmit}
220
+ placeholder="Enter card title..."
221
+ className="text-sm"
222
+ autoFocus
223
+ />
224
+ </div>
225
+ )
226
+ }
227
+
228
+ function KanbanColumnView({
103
229
  column,
104
230
  cards,
105
231
  onCardClick,
232
+ quickAdd,
233
+ onQuickAdd,
234
+ conditionalFormatting,
106
235
  }: {
107
236
  column: KanbanColumn
108
237
  cards: KanbanCard[]
109
238
  onCardClick?: (card: KanbanCard) => void
239
+ quickAdd?: boolean
240
+ onQuickAdd?: (columnId: string, title: string) => void
241
+ conditionalFormatting?: ConditionalFormattingRule[]
110
242
  }) {
111
243
  const safeCards = cards || [];
112
244
  const { setNodeRef } = useSortable({
@@ -128,14 +260,14 @@ function KanbanColumn({
128
260
  column.className
129
261
  )}
130
262
  >
131
- <div className="p-3 sm:p-4 border-b border-border/50 bg-muted/20">
263
+ <div className="p-3 sm:p-4 border-b border-border/50 bg-muted/30 rounded-t-lg">
132
264
  <div className="flex items-center justify-between">
133
265
  <h3 id={`kanban-col-${column.id}`} className="font-mono text-xs sm:text-sm font-semibold tracking-wider text-primary/90 uppercase truncate">{column.title}</h3>
134
266
  <div className="flex items-center gap-2">
135
- <span className="font-mono text-xs text-muted-foreground" aria-label={`${safeCards.length} cards${column.limit ? ` of ${column.limit} maximum` : ''}`}>
267
+ <Badge variant="secondary" className="text-xs font-mono tabular-nums">
136
268
  {safeCards.length}
137
269
  {column.limit && ` / ${column.limit}`}
138
- </span>
270
+ </Badge>
139
271
  {isLimitExceeded && (
140
272
  <Badge variant="destructive" className="text-xs">
141
273
  Full
@@ -150,11 +282,19 @@ function KanbanColumn({
150
282
  strategy={verticalListSortingStrategy}
151
283
  >
152
284
  <div className="space-y-2" role="list" aria-label={`${column.title} cards`}>
285
+ {safeCards.length === 0 && (
286
+ <div className="flex flex-col items-center justify-center py-8 text-muted-foreground/50">
287
+ <span className="text-xs font-mono">No cards</span>
288
+ </div>
289
+ )}
153
290
  {safeCards.map((card) => (
154
- <SortableCard key={card.id} card={card} onCardClick={onCardClick} />
291
+ <SortableCard key={card.id} card={card} onCardClick={onCardClick} conditionalFormatting={conditionalFormatting} />
155
292
  ))}
156
293
  </div>
157
294
  </SortableContext>
295
+ {quickAdd && onQuickAdd && (
296
+ <QuickAddForm columnId={column.id} onAdd={onQuickAdd} />
297
+ )}
158
298
  </ScrollArea>
159
299
  </div>
160
300
  )
@@ -166,22 +306,36 @@ function DndBridge({ children }: { children: (dnd: ReturnType<typeof useDnd>) =>
166
306
  return <>{children(dnd)}</>
167
307
  }
168
308
 
169
- export default function KanbanBoard({ columns, onCardMove, onCardClick, className }: KanbanBoardProps) {
309
+ export default function KanbanBoard({ columns, onCardMove, onCardClick, className, quickAdd, onQuickAdd, coverImageField, conditionalFormatting, swimlaneField }: KanbanBoardProps) {
170
310
  const hasDnd = useHasDndProvider()
171
311
 
172
312
  if (hasDnd) {
173
313
  return (
174
314
  <DndBridge>
175
- {(dnd) => <KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={dnd} />}
315
+ {(dnd) => <KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={dnd} quickAdd={quickAdd} onQuickAdd={onQuickAdd} coverImageField={coverImageField} conditionalFormatting={conditionalFormatting} swimlaneField={swimlaneField} />}
176
316
  </DndBridge>
177
317
  )
178
318
  }
179
319
 
180
- return <KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={null} />
320
+ return <KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={null} quickAdd={quickAdd} onQuickAdd={onQuickAdd} coverImageField={coverImageField} conditionalFormatting={conditionalFormatting} swimlaneField={swimlaneField} />
181
321
  }
182
322
 
183
- function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd }: KanbanBoardProps & { dnd: ReturnType<typeof useDnd> | null }) {
323
+ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, quickAdd, onQuickAdd, coverImageField: _coverImageField, conditionalFormatting, swimlaneField }: KanbanBoardProps & { dnd: ReturnType<typeof useDnd> | null }) {
184
324
  const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null)
325
+
326
+ // Persist collapsed swimlane state per swimlaneField
327
+ const storageKey = swimlaneField ? `objectui:kanban-collapsed:${swimlaneField}` : null
328
+ const [collapsedLanes, setCollapsedLanes] = React.useState<Set<string>>(() => {
329
+ if (!storageKey) return new Set()
330
+ try {
331
+ const stored = localStorage.getItem(storageKey)
332
+ if (stored) {
333
+ const parsed = JSON.parse(stored)
334
+ if (Array.isArray(parsed)) return new Set(parsed.filter((v): v is string => typeof v === 'string'))
335
+ }
336
+ } catch { /* ignore corrupt data */ }
337
+ return new Set()
338
+ })
185
339
 
186
340
  // Ensure we always have valid columns with cards array
187
341
  const safeColumns = React.useMemo(() => {
@@ -197,6 +351,30 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd }:
197
351
  setBoardColumns(safeColumns)
198
352
  }, [safeColumns])
199
353
 
354
+ // Compute swimlane rows when swimlaneField is provided
355
+ const swimlanes = React.useMemo(() => {
356
+ if (!swimlaneField) return null
357
+ const allCards = boardColumns.flatMap(col => col.cards)
358
+ const laneValues = new Set<string>()
359
+ allCards.forEach(card => {
360
+ const val = card[swimlaneField]
361
+ laneValues.add(val != null ? String(val) : UNCATEGORIZED_LANE)
362
+ })
363
+ return Array.from(laneValues).sort()
364
+ }, [boardColumns, swimlaneField])
365
+
366
+ const toggleLane = React.useCallback((lane: string) => {
367
+ setCollapsedLanes(prev => {
368
+ const next = new Set(prev)
369
+ if (next.has(lane)) next.delete(lane)
370
+ else next.add(lane)
371
+ if (storageKey) {
372
+ try { localStorage.setItem(storageKey, JSON.stringify([...next])) } catch { /* quota exceeded */ }
373
+ }
374
+ return next
375
+ })
376
+ }, [storageKey])
377
+
200
378
  const sensors = useSensors(
201
379
  useSensor(PointerSensor, {
202
380
  activationConstraint: {
@@ -334,19 +512,84 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd }:
334
512
  <span>{boardColumns.length} columns</span>
335
513
  <span>← Swipe to navigate →</span>
336
514
  </div>
337
- <div className={cn("flex gap-3 sm:gap-4 overflow-x-auto snap-x snap-mandatory p-2 sm:p-4 [-webkit-overflow-scrolling:touch]", className)} role="region" aria-label="Kanban board">
338
- {boardColumns.map((column) => (
339
- <KanbanColumn
340
- key={column.id}
341
- column={column}
342
- cards={column.cards}
343
- onCardClick={onCardClick}
344
- />
345
- ))}
346
- </div>
515
+
516
+ {swimlanes ? (
517
+ /* Swimlane (2D) layout */
518
+ <div className={cn("flex flex-col gap-1 p-2 sm:p-4 min-w-0 overflow-hidden", className)} role="region" aria-label="Kanban board with swimlanes">
519
+ {/* Column headers */}
520
+ <div className="flex gap-3 sm:gap-4 pl-36 sm:pl-44 overflow-x-auto">
521
+ {boardColumns.map(col => (
522
+ <div key={col.id} className="w-[85vw] sm:w-80 flex-shrink-0 text-center">
523
+ <span className="font-mono text-xs sm:text-sm font-semibold tracking-wider text-primary/90 uppercase">{col.title}</span>
524
+ <span className="ml-2 font-mono text-xs text-muted-foreground">({col.cards.length})</span>
525
+ </div>
526
+ ))}
527
+ </div>
528
+
529
+ {/* Swimlane rows */}
530
+ {swimlanes.map(lane => {
531
+ const isCollapsed = collapsedLanes.has(lane)
532
+ const laneCardCount = boardColumns.reduce((sum, col) =>
533
+ sum + col.cards.filter(c => (c[swimlaneField!] != null ? String(c[swimlaneField!]) : UNCATEGORIZED_LANE) === lane).length, 0)
534
+
535
+ return (
536
+ <div key={lane} className="border rounded-lg bg-muted/10">
537
+ {/* Lane header */}
538
+ <button
539
+ className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-muted/30 transition-colors"
540
+ onClick={() => toggleLane(lane)}
541
+ aria-expanded={!isCollapsed}
542
+ >
543
+ <span className={cn("transition-transform text-xs", isCollapsed ? "" : "rotate-90")}>▶</span>
544
+ <span className="font-mono text-xs font-semibold text-muted-foreground uppercase tracking-wider">{lane}</span>
545
+ <span className="font-mono text-xs text-muted-foreground">({laneCardCount})</span>
546
+ </button>
547
+
548
+ {/* Lane content */}
549
+ {!isCollapsed && (
550
+ <div className="flex gap-3 sm:gap-4 overflow-x-auto px-2 pb-3 pl-36 sm:pl-44">
551
+ {boardColumns.map(col => {
552
+ const laneCards = col.cards.filter(c =>
553
+ (c[swimlaneField!] != null ? String(c[swimlaneField!]) : UNCATEGORIZED_LANE) === lane
554
+ )
555
+ return (
556
+ <div key={col.id} className="w-[85vw] sm:w-80 flex-shrink-0 min-h-[60px] rounded-md bg-card/20 p-2">
557
+ <SortableContext items={laneCards.map(c => c.id)} strategy={verticalListSortingStrategy}>
558
+ <div className="space-y-2" role="list" aria-label={`${col.title} - ${lane} cards`}>
559
+ {laneCards.map(card => (
560
+ <SortableCard key={card.id} card={card} onCardClick={onCardClick} conditionalFormatting={conditionalFormatting} />
561
+ ))}
562
+ </div>
563
+ </SortableContext>
564
+ </div>
565
+ )
566
+ })}
567
+ </div>
568
+ )}
569
+ </div>
570
+ )
571
+ })}
572
+ </div>
573
+ ) : (
574
+ /* Standard flat layout */
575
+ <div className={cn("flex gap-3 sm:gap-4 overflow-x-auto snap-x snap-mandatory p-2 sm:p-4 bg-muted/10 rounded-lg [-webkit-overflow-scrolling:touch] min-w-0", className)} role="region" aria-label="Kanban board">
576
+ {boardColumns.map((column) => (
577
+ <KanbanColumnView
578
+ key={column.id}
579
+ column={column}
580
+ cards={column.cards}
581
+ onCardClick={onCardClick}
582
+ quickAdd={quickAdd}
583
+ onQuickAdd={onQuickAdd}
584
+ conditionalFormatting={conditionalFormatting}
585
+ />
586
+ ))}
587
+ </div>
588
+ )}
589
+
347
590
  <DragOverlay>
348
591
  <div aria-live="assertive" aria-label={activeCard ? `Dragging ${activeCard.title}` : undefined}>
349
- {activeCard ? <SortableCard card={activeCard} /> : null}
592
+ {activeCard ? <SortableCard card={activeCard} conditionalFormatting={conditionalFormatting} /> : null}
350
593
  </div>
351
594
  </DragOverlay>
352
595
  </DndContext>
@@ -10,6 +10,7 @@ import React, { useEffect, useState, useMemo } from 'react';
10
10
  import type { DataSource } from '@object-ui/types';
11
11
  import { useDataScope, useNavigationOverlay } from '@object-ui/react';
12
12
  import { NavigationOverlay } from '@object-ui/components';
13
+ import { extractRecords, buildExpandFields } from '@object-ui/core';
13
14
  import { KanbanRenderer } from './index';
14
15
  import { KanbanSchema } from './types';
15
16
 
@@ -57,25 +58,19 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
57
58
  useEffect(() => {
58
59
  let isMounted = true;
59
60
  const fetchData = async () => {
60
- if (!dataSource || !schema.objectName) return;
61
+ if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) return;
61
62
  if (isMounted) setLoading(true);
62
63
  try {
64
+ // Auto-inject $expand for lookup/master_detail fields
65
+ const expand = buildExpandFields(objectDef?.fields);
63
66
  const results = await dataSource.find(schema.objectName, {
64
67
  options: { $top: 100 },
65
- $filter: schema.filter
68
+ $filter: schema.filter,
69
+ ...(expand.length > 0 ? { $expand: expand } : {}),
66
70
  });
67
71
 
68
72
  // Handle { value: [] } OData shape or { data: [] } shape or direct array
69
- let data: any[] = [];
70
- if (Array.isArray(results)) {
71
- data = results;
72
- } else if (results && typeof results === 'object') {
73
- if (Array.isArray((results as any).value)) {
74
- data = (results as any).value;
75
- } else if (Array.isArray((results as any).data)) {
76
- data = (results as any).data;
77
- }
78
- }
73
+ const data = extractRecords(results);
79
74
 
80
75
  console.log(`[ObjectKanban] Extracted data (length: ${data.length})`);
81
76
 
@@ -96,7 +91,7 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
96
91
  fetchData();
97
92
  }
98
93
  return () => { isMounted = false; };
99
- }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, (props as any).data]);
94
+ }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, (props as any).data, objectDef]);
100
95
 
101
96
  // Determine which data to use: props.data -> bound -> inline -> fetched
102
97
  const rawData = (props as any).data || boundData || schema.data || fetchedData;
@@ -122,16 +117,31 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
122
117
  }
123
118
  }
124
119
 
125
- // Default to 'name'
126
- const finalTitleField = titleField || 'name';
127
-
128
- return rawData.map(item => ({
129
- ...item,
130
- // Ensure id exists
131
- id: item.id || item._id,
132
- // Map title
133
- title: item[finalTitleField] || item.title || 'Untitled',
134
- }));
120
+ // Common title field names to try as fallback
121
+ const TITLE_FALLBACK_FIELDS = ['name', 'title', 'subject', 'label', 'display_name'];
122
+
123
+ return rawData.map(item => {
124
+ // If a specific title field was configured, try it first
125
+ let resolvedTitle = titleField ? item[titleField] : undefined;
126
+
127
+ // Fallback: try common field names
128
+ if (!resolvedTitle) {
129
+ for (const field of TITLE_FALLBACK_FIELDS) {
130
+ if (item[field]) {
131
+ resolvedTitle = item[field];
132
+ break;
133
+ }
134
+ }
135
+ }
136
+
137
+ return {
138
+ ...item,
139
+ // Ensure id exists
140
+ id: item.id || item._id,
141
+ // Map title
142
+ title: resolvedTitle || 'Untitled',
143
+ };
144
+ });
135
145
  }, [rawData, schema, objectDef]);
136
146
 
137
147
  // Generate columns if missing but groupBy is present
@@ -173,11 +183,16 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
173
183
  }, [schema.columns, schema.groupBy, effectiveData, objectDef]);
174
184
 
175
185
  // Clone schema to inject data and className
186
+ // Use grouping.fields[0].field as swimlaneField fallback when no explicit swimlaneField
187
+ const effectiveSwimlaneField = schema.swimlaneField
188
+ || (schema.grouping?.fields?.[0]?.field);
189
+
176
190
  const effectiveSchema = {
177
191
  ...schema,
178
192
  data: effectiveData,
179
193
  columns: effectiveColumns,
180
- className: className || schema.className
194
+ className: className || schema.className,
195
+ ...(effectiveSwimlaneField ? { swimlaneField: effectiveSwimlaneField } : {}),
181
196
  };
182
197
 
183
198
  const navigation = useNavigationOverlay({
@@ -0,0 +1,164 @@
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 { render, screen, act } from '@testing-library/react';
11
+ import React, { Suspense } from 'react';
12
+
13
+ // Mock dnd-kit
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
+ Skeleton: ({ className }: any) => <div data-testid="skeleton" className={className} />,
61
+ NavigationOverlay: ({ children, selectedRecord }: any) => (
62
+ selectedRecord ? <div data-testid="navigation-overlay">{children(selectedRecord)}</div> : null
63
+ ),
64
+ }));
65
+
66
+ vi.mock('@object-ui/react', () => ({
67
+ useHasDndProvider: () => false,
68
+ useDnd: () => ({
69
+ startDrag: vi.fn(),
70
+ endDrag: vi.fn(),
71
+ }),
72
+ useDataScope: () => undefined,
73
+ useNavigationOverlay: () => ({
74
+ isOverlay: false,
75
+ handleClick: vi.fn(),
76
+ selectedRecord: null,
77
+ isOpen: false,
78
+ close: vi.fn(),
79
+ setIsOpen: vi.fn(),
80
+ mode: 'page' as const,
81
+ }),
82
+ }));
83
+
84
+ vi.mock('lucide-react', () => ({
85
+ Plus: () => <span>+</span>,
86
+ }));
87
+
88
+ // Import KanbanBoard (the impl) directly to avoid lazy-loading issues in tests
89
+ import KanbanBoard from '../KanbanImpl';
90
+
91
+ const mockColumns = [
92
+ {
93
+ id: 'todo',
94
+ title: 'To Do',
95
+ cards: [
96
+ { id: 'c1', title: 'Task 1', priority: 'High', team: 'Frontend' },
97
+ { id: 'c2', title: 'Task 2', priority: 'Low', team: 'Backend' },
98
+ ],
99
+ },
100
+ {
101
+ id: 'done',
102
+ title: 'Done',
103
+ cards: [
104
+ { id: 'c3', title: 'Task 3', priority: 'High', team: 'Frontend' },
105
+ { id: 'c4', title: 'Task 4', priority: 'Medium', team: 'Backend' },
106
+ ],
107
+ },
108
+ ];
109
+
110
+ describe('ObjectKanban grouping config → swimlaneField mapping', () => {
111
+ it('uses grouping field as swimlane when passed to KanbanImpl', () => {
112
+ // This simulates what ObjectKanban does: map grouping.fields[0].field to swimlaneField
113
+ render(<KanbanBoard columns={mockColumns} swimlaneField="team" />);
114
+
115
+ // Swimlane layout should render
116
+ expect(screen.getByRole('region', { name: 'Kanban board with swimlanes' })).toBeInTheDocument();
117
+
118
+ // Swimlane headers for each team
119
+ expect(screen.getByText('Backend')).toBeInTheDocument();
120
+ expect(screen.getByText('Frontend')).toBeInTheDocument();
121
+ });
122
+
123
+ it('renders flat kanban when no swimlane/grouping is provided', () => {
124
+ render(<KanbanBoard columns={mockColumns} />);
125
+
126
+ // Flat layout renders "Kanban board"
127
+ expect(screen.getByRole('region', { name: 'Kanban board' })).toBeInTheDocument();
128
+ expect(screen.queryByRole('region', { name: 'Kanban board with swimlanes' })).not.toBeInTheDocument();
129
+ });
130
+
131
+ describe('ObjectKanban swimlaneField resolution logic', () => {
132
+ // Test the resolution logic independently (same as ObjectKanban.tsx effectiveSwimlaneField)
133
+ function resolveEffectiveSwimlaneField(
134
+ swimlaneField?: string,
135
+ grouping?: { fields: Array<{ field: string; order: string; collapsed: boolean }> },
136
+ ): string | undefined {
137
+ return swimlaneField || grouping?.fields?.[0]?.field;
138
+ }
139
+
140
+ it('prefers explicit swimlaneField over grouping', () => {
141
+ const result = resolveEffectiveSwimlaneField('priority', {
142
+ fields: [{ field: 'team', order: 'asc', collapsed: false }],
143
+ });
144
+ expect(result).toBe('priority');
145
+ });
146
+
147
+ it('falls back to grouping.fields[0].field when no swimlaneField', () => {
148
+ const result = resolveEffectiveSwimlaneField(undefined, {
149
+ fields: [{ field: 'team', order: 'asc', collapsed: false }],
150
+ });
151
+ expect(result).toBe('team');
152
+ });
153
+
154
+ it('returns undefined when neither swimlaneField nor grouping is set', () => {
155
+ const result = resolveEffectiveSwimlaneField(undefined, undefined);
156
+ expect(result).toBeUndefined();
157
+ });
158
+
159
+ it('returns undefined when grouping has empty fields array', () => {
160
+ const result = resolveEffectiveSwimlaneField(undefined, { fields: [] });
161
+ expect(result).toBeUndefined();
162
+ });
163
+ });
164
+ });