@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
@@ -0,0 +1,189 @@
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 * as React from "react"
10
+ import { Button, Input } from "@object-ui/components"
11
+ import { Check, X } from "lucide-react"
12
+ import type { InlineFieldDefinition } from "./types"
13
+
14
+ const cn = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ')
15
+
16
+ export interface InlineQuickAddProps {
17
+ /** Column where the card will be added */
18
+ columnId: string
19
+ /** Field definitions for inline editing */
20
+ fields: InlineFieldDefinition[]
21
+ /** Called with field values when the form is submitted */
22
+ onSubmit: (columnId: string, values: Record<string, any>) => void
23
+ /** Called when the form is cancelled */
24
+ onCancel: () => void
25
+ /** Pre-filled values (e.g. from a card template) */
26
+ defaultValues?: Record<string, any>
27
+ }
28
+
29
+ /**
30
+ * InlineQuickAdd renders form fields directly inside the kanban column
31
+ * without opening a dialog or modal. Submit with Enter or the Save button;
32
+ * cancel with Escape or the Cancel button.
33
+ */
34
+ export const InlineQuickAdd: React.FC<InlineQuickAddProps> = ({
35
+ columnId,
36
+ fields,
37
+ onSubmit,
38
+ onCancel,
39
+ defaultValues,
40
+ }) => {
41
+ const [values, setValues] = React.useState<Record<string, any>>(() => {
42
+ const initial: Record<string, any> = {}
43
+ for (const field of fields) {
44
+ initial[field.name] = defaultValues?.[field.name] ?? field.defaultValue ?? ""
45
+ }
46
+ return initial
47
+ })
48
+
49
+ const firstInputRef = React.useRef<HTMLInputElement | HTMLSelectElement>(null)
50
+
51
+ React.useEffect(() => {
52
+ // Auto-focus first field on mount
53
+ const timer = setTimeout(() => firstInputRef.current?.focus(), 0)
54
+ return () => clearTimeout(timer)
55
+ }, [])
56
+
57
+ const handleChange = React.useCallback((name: string, value: any) => {
58
+ setValues(prev => ({ ...prev, [name]: value }))
59
+ }, [])
60
+
61
+ const handleSubmit = React.useCallback(() => {
62
+ // Require at least one non-empty value
63
+ const hasValue = Object.values(values).some(v =>
64
+ typeof v === "string" ? v.trim().length > 0 : v != null
65
+ )
66
+ if (hasValue) {
67
+ onSubmit(columnId, values)
68
+ }
69
+ }, [columnId, values, onSubmit])
70
+
71
+ const handleKeyDown = React.useCallback(
72
+ (e: React.KeyboardEvent) => {
73
+ if (e.key === "Enter") {
74
+ e.preventDefault()
75
+ handleSubmit()
76
+ } else if (e.key === "Escape") {
77
+ e.preventDefault()
78
+ onCancel()
79
+ }
80
+ },
81
+ [handleSubmit, onCancel],
82
+ )
83
+
84
+ return (
85
+ <div
86
+ className="mt-2 rounded-lg border border-primary/30 bg-card p-3 space-y-2 shadow-sm"
87
+ onKeyDown={handleKeyDown}
88
+ role="form"
89
+ aria-label="Quick add card"
90
+ >
91
+ {fields.map((field, idx) => (
92
+ <div key={field.name} className="space-y-1">
93
+ <label
94
+ htmlFor={`qa-${columnId}-${field.name}`}
95
+ className="text-xs font-mono text-muted-foreground"
96
+ >
97
+ {field.label ?? field.name}
98
+ </label>
99
+ {renderField(field, values[field.name], handleChange, idx === 0 ? firstInputRef : undefined, `qa-${columnId}-${field.name}`)}
100
+ </div>
101
+ ))}
102
+ <div className="flex items-center gap-2 pt-1">
103
+ <Button
104
+ type="button"
105
+ size="sm"
106
+ className="h-7 text-xs gap-1"
107
+ onClick={handleSubmit}
108
+ >
109
+ <Check className="h-3 w-3" />
110
+ Save
111
+ </Button>
112
+ <Button
113
+ type="button"
114
+ variant="ghost"
115
+ size="sm"
116
+ className="h-7 text-xs gap-1"
117
+ onClick={onCancel}
118
+ >
119
+ <X className="h-3 w-3" />
120
+ Cancel
121
+ </Button>
122
+ </div>
123
+ </div>
124
+ )
125
+ }
126
+
127
+ /** Renders a single field based on its type. */
128
+ function renderField(
129
+ field: InlineFieldDefinition,
130
+ value: any,
131
+ onChange: (name: string, value: any) => void,
132
+ ref?: React.Ref<any>,
133
+ id?: string,
134
+ ) {
135
+ const commonClasses = "text-sm h-8"
136
+
137
+ switch (field.type) {
138
+ case "select":
139
+ return (
140
+ <select
141
+ ref={ref as React.Ref<HTMLSelectElement>}
142
+ id={id}
143
+ value={value ?? ""}
144
+ onChange={e => onChange(field.name, e.target.value)}
145
+ className={cn(
146
+ commonClasses,
147
+ "w-full rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background",
148
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
149
+ )}
150
+ >
151
+ <option value="">{field.placeholder ?? `Select ${field.label ?? field.name}...`}</option>
152
+ {(field.options ?? []).map(opt => (
153
+ <option key={opt.value} value={opt.value}>
154
+ {opt.label}
155
+ </option>
156
+ ))}
157
+ </select>
158
+ )
159
+
160
+ case "number":
161
+ return (
162
+ <Input
163
+ ref={ref as React.Ref<HTMLInputElement>}
164
+ id={id}
165
+ type="number"
166
+ value={value ?? ""}
167
+ onChange={e => onChange(field.name, e.target.value === "" ? "" : Number(e.target.value))}
168
+ placeholder={field.placeholder ?? `Enter ${field.label ?? field.name}...`}
169
+ className={commonClasses}
170
+ />
171
+ )
172
+
173
+ case "text":
174
+ default:
175
+ return (
176
+ <Input
177
+ ref={ref as React.Ref<HTMLInputElement>}
178
+ id={id}
179
+ type="text"
180
+ value={value ?? ""}
181
+ onChange={e => onChange(field.name, e.target.value)}
182
+ placeholder={field.placeholder ?? `Enter ${field.label ?? field.name}...`}
183
+ className={commonClasses}
184
+ />
185
+ )
186
+ }
187
+ }
188
+
189
+ export default InlineQuickAdd
@@ -25,8 +25,8 @@ 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, Button } from "@object-ui/components"
29
- import { ChevronDown, ChevronRight, AlertTriangle } from "lucide-react"
28
+ import { Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Input } from "@object-ui/components"
29
+ import { ChevronDown, ChevronRight, AlertTriangle, Plus } from "lucide-react"
30
30
 
31
31
  const cn = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ')
32
32
 
@@ -35,6 +35,7 @@ export interface KanbanCard {
35
35
  title: string
36
36
  description?: string
37
37
  badges?: Array<{ label: string; variant?: "default" | "secondary" | "destructive" | "outline" }>
38
+ coverImage?: string
38
39
  [key: string]: any
39
40
  }
40
41
 
@@ -47,6 +48,14 @@ export interface KanbanColumn {
47
48
  collapsed?: boolean
48
49
  }
49
50
 
51
+ export interface ConditionalFormattingRule {
52
+ field: string
53
+ operator: 'equals' | 'not_equals' | 'contains' | 'in'
54
+ value: string | string[]
55
+ backgroundColor?: string
56
+ borderColor?: string
57
+ }
58
+
50
59
  export interface KanbanEnhancedProps {
51
60
  columns: KanbanColumn[]
52
61
  onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void
@@ -54,9 +63,47 @@ export interface KanbanEnhancedProps {
54
63
  enableVirtualScrolling?: boolean
55
64
  virtualScrollThreshold?: number
56
65
  className?: string
66
+ quickAdd?: boolean
67
+ onQuickAdd?: (columnId: string, title: string) => void
68
+ conditionalFormatting?: ConditionalFormattingRule[]
57
69
  }
58
70
 
59
- function SortableCard({ card }: { card: KanbanCard }) {
71
+ function getCardStyles(card: KanbanCard, rules?: ConditionalFormattingRule[]): React.CSSProperties {
72
+ if (!rules || rules.length === 0) return {}
73
+
74
+ for (const rule of rules) {
75
+ const fieldValue = card[rule.field]
76
+ if (fieldValue === undefined || fieldValue === null) continue
77
+
78
+ let matches = false
79
+ const strValue = String(fieldValue)
80
+
81
+ switch (rule.operator) {
82
+ case 'equals':
83
+ matches = strValue === String(rule.value)
84
+ break
85
+ case 'not_equals':
86
+ matches = strValue !== String(rule.value)
87
+ break
88
+ case 'contains':
89
+ matches = strValue.toLowerCase().includes(String(rule.value).toLowerCase())
90
+ break
91
+ case 'in':
92
+ matches = Array.isArray(rule.value) && rule.value.includes(strValue)
93
+ break
94
+ }
95
+
96
+ if (matches) {
97
+ return {
98
+ ...(rule.backgroundColor ? { backgroundColor: rule.backgroundColor } : {}),
99
+ ...(rule.borderColor ? { borderColor: rule.borderColor } : {}),
100
+ }
101
+ }
102
+ }
103
+ return {}
104
+ }
105
+
106
+ function SortableCard({ card, conditionalFormatting }: { card: KanbanCard; conditionalFormatting?: ConditionalFormattingRule[] }) {
60
107
  const {
61
108
  attributes,
62
109
  listeners,
@@ -72,9 +119,21 @@ function SortableCard({ card }: { card: KanbanCard }) {
72
119
  opacity: isDragging ? 0.5 : undefined,
73
120
  }
74
121
 
122
+ const cardStyles = getCardStyles(card, conditionalFormatting)
123
+
75
124
  return (
76
125
  <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
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">
126
+ <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" style={cardStyles}>
127
+ {card.coverImage && (
128
+ <div className="w-full h-32 overflow-hidden rounded-t-lg">
129
+ <img
130
+ src={card.coverImage}
131
+ alt=""
132
+ className="w-full h-full object-cover"
133
+ loading="lazy"
134
+ />
135
+ </div>
136
+ )}
78
137
  <CardHeader className="p-4">
79
138
  <CardTitle className="text-sm font-medium font-mono tracking-tight text-foreground group-hover:text-primary transition-colors">{card.title}</CardTitle>
80
139
  {card.description && (
@@ -99,7 +158,7 @@ function SortableCard({ card }: { card: KanbanCard }) {
99
158
  )
100
159
  }
101
160
 
102
- function VirtualizedCardList({ cards, parentRef }: { cards: KanbanCard[]; parentRef: React.RefObject<HTMLDivElement | null> }) {
161
+ function VirtualizedCardList({ cards, parentRef, conditionalFormatting }: { cards: KanbanCard[]; parentRef: React.RefObject<HTMLDivElement | null>; conditionalFormatting?: ConditionalFormattingRule[] }) {
103
162
  const rowVirtualizer = useVirtualizer({
104
163
  count: cards.length,
105
164
  getScrollElement: () => parentRef.current,
@@ -128,7 +187,7 @@ function VirtualizedCardList({ cards, parentRef }: { cards: KanbanCard[]; parent
128
187
  transform: `translateY(${virtualItem.start}px)`,
129
188
  }}
130
189
  >
131
- <SortableCard card={card} />
190
+ <SortableCard card={card} conditionalFormatting={conditionalFormatting} />
132
191
  </div>
133
192
  )
134
193
  })}
@@ -136,16 +195,79 @@ function VirtualizedCardList({ cards, parentRef }: { cards: KanbanCard[]; parent
136
195
  )
137
196
  }
138
197
 
198
+ function QuickAddForm({ columnId, onAdd }: { columnId: string; onAdd: (columnId: string, title: string) => void }) {
199
+ const [isAdding, setIsAdding] = React.useState(false)
200
+ const [title, setTitle] = React.useState('')
201
+ const inputRef = React.useRef<HTMLInputElement>(null)
202
+
203
+ const handleSubmit = () => {
204
+ const trimmed = title.trim()
205
+ if (trimmed) {
206
+ onAdd(columnId, trimmed)
207
+ setTitle('')
208
+ }
209
+ setIsAdding(false)
210
+ }
211
+
212
+ const handleKeyDown = (e: React.KeyboardEvent) => {
213
+ if (e.key === 'Enter') {
214
+ e.preventDefault()
215
+ handleSubmit()
216
+ } else if (e.key === 'Escape') {
217
+ setTitle('')
218
+ setIsAdding(false)
219
+ }
220
+ }
221
+
222
+ if (!isAdding) {
223
+ return (
224
+ <Button
225
+ variant="ghost"
226
+ size="sm"
227
+ className="w-full mt-2 text-muted-foreground hover:text-foreground"
228
+ onClick={() => {
229
+ setIsAdding(true)
230
+ setTimeout(() => inputRef.current?.focus(), 0)
231
+ }}
232
+ >
233
+ <Plus className="h-4 w-4 mr-1" />
234
+ Add Card
235
+ </Button>
236
+ )
237
+ }
238
+
239
+ return (
240
+ <div className="mt-2 space-y-2">
241
+ <Input
242
+ ref={inputRef}
243
+ value={title}
244
+ onChange={(e) => setTitle(e.target.value)}
245
+ onKeyDown={handleKeyDown}
246
+ onBlur={handleSubmit}
247
+ placeholder="Enter card title..."
248
+ className="text-sm"
249
+ autoFocus
250
+ />
251
+ </div>
252
+ )
253
+ }
254
+
139
255
  function KanbanColumnEnhanced({
140
256
  column,
141
257
  cards,
142
258
  onToggle,
143
259
  enableVirtual,
260
+ quickAdd,
261
+ onQuickAdd,
262
+ conditionalFormatting,
144
263
  }: {
145
264
  column: KanbanColumn
146
265
  cards: KanbanCard[]
147
266
  onToggle: (collapsed: boolean) => void
148
267
  enableVirtual: boolean
268
+ quickAdd?: boolean
269
+ onQuickAdd?: (columnId: string, title: string) => void
270
+ conditionalFormatting?: ConditionalFormattingRule[]
149
271
  }) {
150
272
  const safeCards = cards || []
151
273
  const scrollRef = React.useRef<HTMLDivElement>(null)
@@ -221,15 +343,18 @@ function KanbanColumnEnhanced({
221
343
  strategy={verticalListSortingStrategy}
222
344
  >
223
345
  {enableVirtual ? (
224
- <VirtualizedCardList cards={safeCards} parentRef={scrollRef} />
346
+ <VirtualizedCardList cards={safeCards} parentRef={scrollRef} conditionalFormatting={conditionalFormatting} />
225
347
  ) : (
226
348
  <div className="space-y-2">
227
349
  {safeCards.map((card) => (
228
- <SortableCard key={card.id} card={card} />
350
+ <SortableCard key={card.id} card={card} conditionalFormatting={conditionalFormatting} />
229
351
  ))}
230
352
  </div>
231
353
  )}
232
354
  </SortableContext>
355
+ {quickAdd && onQuickAdd && (
356
+ <QuickAddForm columnId={column.id} onAdd={onQuickAdd} />
357
+ )}
233
358
  </div>
234
359
  )}
235
360
  </div>
@@ -243,6 +368,9 @@ export function KanbanEnhanced({
243
368
  enableVirtualScrolling = false,
244
369
  virtualScrollThreshold = 50,
245
370
  className,
371
+ quickAdd,
372
+ onQuickAdd,
373
+ conditionalFormatting,
246
374
  }: KanbanEnhancedProps) {
247
375
  const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null)
248
376
 
@@ -380,12 +508,15 @@ export function KanbanEnhanced({
380
508
  cards={column.cards}
381
509
  onToggle={(collapsed) => handleColumnToggle(column.id, collapsed)}
382
510
  enableVirtual={shouldUseVirtual}
511
+ quickAdd={quickAdd}
512
+ onQuickAdd={onQuickAdd}
513
+ conditionalFormatting={conditionalFormatting}
383
514
  />
384
515
  )
385
516
  })}
386
517
  </div>
387
518
  <DragOverlay>
388
- {activeCard ? <SortableCard card={activeCard} /> : null}
519
+ {activeCard ? <SortableCard card={activeCard} conditionalFormatting={conditionalFormatting} /> : null}
389
520
  </DragOverlay>
390
521
  </DndContext>
391
522
  )