@object-ui/plugin-kanban 3.0.3 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +9 -9
- package/dist/{KanbanEnhanced-BPIKjTDv.js → KanbanEnhanced-CXDSLlGR.js} +338 -324
- package/dist/KanbanImpl-4dgoNPtI.js +350 -0
- package/dist/index-CyNcIIS1.js +1077 -0
- package/dist/index.js +9 -4
- package/dist/index.umd.cjs +4 -4
- package/dist/src/CardTemplates.d.ts +25 -0
- package/dist/src/CardTemplates.d.ts.map +1 -0
- package/dist/src/InlineQuickAdd.d.ts +29 -0
- package/dist/src/InlineQuickAdd.d.ts.map +1 -0
- package/dist/src/KanbanEnhanced.d.ts +12 -1
- package/dist/src/KanbanEnhanced.d.ts.map +1 -1
- package/dist/src/KanbanImpl.d.ts +15 -1
- package/dist/src/KanbanImpl.d.ts.map +1 -1
- package/dist/src/ObjectKanban.d.ts.map +1 -1
- package/dist/src/index.d.ts +22 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/types.d.ts +97 -1
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/useColumnWidths.d.ts +30 -0
- package/dist/src/useColumnWidths.d.ts.map +1 -0
- package/dist/src/useCrossSwimlaneMove.d.ts +46 -0
- package/dist/src/useCrossSwimlaneMove.d.ts.map +1 -0
- package/dist/src/useQuickAddReorder.d.ts +28 -0
- package/dist/src/useQuickAddReorder.d.ts.map +1 -0
- package/package.json +9 -9
- package/src/CardTemplates.tsx +123 -0
- package/src/InlineQuickAdd.tsx +189 -0
- package/src/KanbanEnhanced.tsx +140 -9
- package/src/KanbanImpl.tsx +266 -23
- package/src/ObjectKanban.tsx +39 -24
- package/src/__tests__/KanbanGrouping.test.tsx +164 -0
- package/src/__tests__/KanbanSwimlanes.test.tsx +194 -0
- package/src/__tests__/ObjectKanbanTitle.test.tsx +93 -0
- package/src/__tests__/SwimlanePersistence.test.tsx +159 -0
- package/src/__tests__/performance-benchmark.test.tsx +14 -14
- package/src/__tests__/phase13-features.test.tsx +387 -0
- package/src/index.tsx +49 -6
- package/src/types.ts +106 -1
- package/src/useColumnWidths.ts +125 -0
- package/src/useCrossSwimlaneMove.ts +116 -0
- package/src/useQuickAddReorder.ts +107 -0
- package/dist/KanbanImpl-BfOKAnJS.js +0 -194
- package/dist/index-CWGTi2xn.js +0 -600
package/src/KanbanImpl.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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/
|
|
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
|
-
<
|
|
267
|
+
<Badge variant="secondary" className="text-xs font-mono tabular-nums">
|
|
136
268
|
{safeCards.length}
|
|
137
269
|
{column.limit && ` / ${column.limit}`}
|
|
138
|
-
</
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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>
|
package/src/ObjectKanban.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
return rawData.map(item =>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
|
|
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
|
+
});
|