Skip to content

Commit c9a5cad

Browse files
Copilothotlong
andcommitted
feat(plugin-kanban): Phase 13 L1 - Quick Add, cover image, column collapse, conditional coloring
- Add Quick Add button at bottom of each Kanban column (KanbanImpl + KanbanEnhanced) - Add cover image field support on cards (coverImageField schema prop) - Column collapse/expand already existed in KanbanEnhanced, now exposed via allowCollapse - Add card conditional coloring based on field values (conditionalFormatting rules) - Update KanbanSchema types (local + ObjectKanbanSchema + Zod validation) - All 88 kanban tests pass Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 93295f8 commit c9a5cad

7 files changed

Lines changed: 409 additions & 25 deletions

File tree

packages/plugin-kanban/src/KanbanEnhanced.tsx

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ import {
2525
verticalListSortingStrategy,
2626
} from "@dnd-kit/sortable"
2727
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"
3030

3131
const cn = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ')
3232

@@ -35,6 +35,7 @@ export interface KanbanCard {
3535
title: string
3636
description?: string
3737
badges?: Array<{ label: string; variant?: "default" | "secondary" | "destructive" | "outline" }>
38+
coverImage?: string
3839
[key: string]: any
3940
}
4041

@@ -47,16 +48,62 @@ export interface KanbanColumn {
4748
collapsed?: boolean
4849
}
4950

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+
5059
export interface KanbanEnhancedProps {
5160
columns: KanbanColumn[]
5261
onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void
5362
onColumnToggle?: (columnId: string, collapsed: boolean) => void
5463
enableVirtualScrolling?: boolean
5564
virtualScrollThreshold?: number
5665
className?: string
66+
quickAdd?: boolean
67+
onQuickAdd?: (columnId: string, title: string) => void
68+
conditionalFormatting?: ConditionalFormattingRule[]
5769
}
5870

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[] }) {
60107
const {
61108
attributes,
62109
listeners,
@@ -72,9 +119,21 @@ function SortableCard({ card }: { card: KanbanCard }) {
72119
opacity: isDragging ? 0.5 : undefined,
73120
}
74121

122+
const cardStyles = getCardStyles(card, conditionalFormatting)
123+
75124
return (
76125
<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+
)}
78137
<CardHeader className="p-4">
79138
<CardTitle className="text-sm font-medium font-mono tracking-tight text-foreground group-hover:text-primary transition-colors">{card.title}</CardTitle>
80139
{card.description && (
@@ -99,7 +158,7 @@ function SortableCard({ card }: { card: KanbanCard }) {
99158
)
100159
}
101160

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[] }) {
103162
const rowVirtualizer = useVirtualizer({
104163
count: cards.length,
105164
getScrollElement: () => parentRef.current,
@@ -128,24 +187,87 @@ function VirtualizedCardList({ cards, parentRef }: { cards: KanbanCard[]; parent
128187
transform: `translateY(${virtualItem.start}px)`,
129188
}}
130189
>
131-
<SortableCard card={card} />
190+
<SortableCard card={card} conditionalFormatting={conditionalFormatting} />
132191
</div>
133192
)
134193
})}
135194
</div>
136195
)
137196
}
138197

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+
139255
function KanbanColumnEnhanced({
140256
column,
141257
cards,
142258
onToggle,
143259
enableVirtual,
260+
quickAdd,
261+
onQuickAdd,
262+
conditionalFormatting,
144263
}: {
145264
column: KanbanColumn
146265
cards: KanbanCard[]
147266
onToggle: (collapsed: boolean) => void
148267
enableVirtual: boolean
268+
quickAdd?: boolean
269+
onQuickAdd?: (columnId: string, title: string) => void
270+
conditionalFormatting?: ConditionalFormattingRule[]
149271
}) {
150272
const safeCards = cards || []
151273
const scrollRef = React.useRef<HTMLDivElement>(null)
@@ -221,15 +343,18 @@ function KanbanColumnEnhanced({
221343
strategy={verticalListSortingStrategy}
222344
>
223345
{enableVirtual ? (
224-
<VirtualizedCardList cards={safeCards} parentRef={scrollRef} />
346+
<VirtualizedCardList cards={safeCards} parentRef={scrollRef} conditionalFormatting={conditionalFormatting} />
225347
) : (
226348
<div className="space-y-2">
227349
{safeCards.map((card) => (
228-
<SortableCard key={card.id} card={card} />
350+
<SortableCard key={card.id} card={card} conditionalFormatting={conditionalFormatting} />
229351
))}
230352
</div>
231353
)}
232354
</SortableContext>
355+
{quickAdd && onQuickAdd && (
356+
<QuickAddForm columnId={column.id} onAdd={onQuickAdd} />
357+
)}
233358
</div>
234359
)}
235360
</div>
@@ -243,6 +368,9 @@ export function KanbanEnhanced({
243368
enableVirtualScrolling = false,
244369
virtualScrollThreshold = 50,
245370
className,
371+
quickAdd,
372+
onQuickAdd,
373+
conditionalFormatting,
246374
}: KanbanEnhancedProps) {
247375
const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null)
248376

@@ -380,12 +508,15 @@ export function KanbanEnhanced({
380508
cards={column.cards}
381509
onToggle={(collapsed) => handleColumnToggle(column.id, collapsed)}
382510
enableVirtual={shouldUseVirtual}
511+
quickAdd={quickAdd}
512+
onQuickAdd={onQuickAdd}
513+
conditionalFormatting={conditionalFormatting}
383514
/>
384515
)
385516
})}
386517
</div>
387518
<DragOverlay>
388-
{activeCard ? <SortableCard card={activeCard} /> : null}
519+
{activeCard ? <SortableCard card={activeCard} conditionalFormatting={conditionalFormatting} /> : null}
389520
</DragOverlay>
390521
</DndContext>
391522
)

0 commit comments

Comments
 (0)