@object-ui/plugin-kanban 3.1.5 → 3.3.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.
- package/CHANGELOG.md +28 -0
- package/README.md +24 -0
- package/dist/{KanbanEnhanced-CvxO2soF.js → KanbanEnhanced-Do9ZB1Mh.js} +36 -33
- package/dist/{KanbanImpl-ii52_k8g.js → KanbanImpl-BdocXM5T.js} +2 -2
- package/dist/{chevron-down-DpXJN6OX.js → chevron-down-C0JUlGjk.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +35 -26
- package/dist/index.umd.cjs +4 -4
- package/dist/packages/plugin-kanban/src/CardTemplates.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/InlineQuickAdd.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/KanbanEnhanced.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/KanbanImpl.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/ObjectKanban.EdgeCases.stories.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/ObjectKanban.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/ObjectKanban.stories.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/index.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/types.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/useColumnWidths.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/useCrossSwimlaneMove.d.ts.map +1 -0
- package/dist/packages/plugin-kanban/src/useQuickAddReorder.d.ts.map +1 -0
- package/dist/{plus-CAtTu4zt.js → plus-CHsXVJSY.js} +39 -36
- package/dist/{sortable.esm-DzUCoMzQ.js → sortable.esm-LJG1TjKd.js} +4 -4
- package/package.json +35 -12
- package/.turbo/turbo-build.log +0 -32
- package/dist/src/CardTemplates.d.ts.map +0 -1
- package/dist/src/InlineQuickAdd.d.ts.map +0 -1
- package/dist/src/KanbanEnhanced.d.ts.map +0 -1
- package/dist/src/KanbanImpl.d.ts.map +0 -1
- package/dist/src/ObjectKanban.EdgeCases.stories.d.ts.map +0 -1
- package/dist/src/ObjectKanban.d.ts.map +0 -1
- package/dist/src/ObjectKanban.stories.d.ts.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/useColumnWidths.d.ts.map +0 -1
- package/dist/src/useCrossSwimlaneMove.d.ts.map +0 -1
- package/dist/src/useQuickAddReorder.d.ts.map +0 -1
- package/src/CardTemplates.tsx +0 -123
- package/src/InlineQuickAdd.tsx +0 -189
- package/src/KanbanEnhanced.tsx +0 -525
- package/src/KanbanImpl.tsx +0 -597
- package/src/ObjectKanban.EdgeCases.stories.tsx +0 -168
- package/src/ObjectKanban.msw.test.tsx +0 -91
- package/src/ObjectKanban.stories.tsx +0 -152
- package/src/ObjectKanban.tsx +0 -262
- package/src/__tests__/KanbanEnhanced.test.tsx +0 -260
- package/src/__tests__/KanbanGrouping.test.tsx +0 -164
- package/src/__tests__/KanbanSwimlanes.test.tsx +0 -194
- package/src/__tests__/ObjectKanbanTitle.test.tsx +0 -93
- package/src/__tests__/SwimlanePersistence.test.tsx +0 -159
- package/src/__tests__/accessibility.test.tsx +0 -296
- package/src/__tests__/dnd-undo-integration.test.tsx +0 -525
- package/src/__tests__/performance-benchmark.test.tsx +0 -306
- package/src/__tests__/phase13-features.test.tsx +0 -387
- package/src/__tests__/view-states.test.tsx +0 -403
- package/src/index.test.ts +0 -112
- package/src/index.tsx +0 -327
- package/src/registration.test.tsx +0 -26
- package/src/types.ts +0 -185
- package/src/useColumnWidths.ts +0 -125
- package/src/useCrossSwimlaneMove.ts +0 -116
- package/src/useQuickAddReorder.ts +0 -107
- package/tsconfig.json +0 -19
- package/vite.config.ts +0 -61
- package/vitest.config.ts +0 -12
- package/vitest.setup.ts +0 -1
- /package/dist/{src → packages/plugin-kanban/src}/CardTemplates.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/InlineQuickAdd.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/KanbanEnhanced.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/KanbanImpl.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/ObjectKanban.EdgeCases.stories.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/ObjectKanban.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/ObjectKanban.stories.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/index.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/types.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/useColumnWidths.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/useCrossSwimlaneMove.d.ts +0 -0
- /package/dist/{src → packages/plugin-kanban/src}/useQuickAddReorder.d.ts +0 -0
package/src/InlineQuickAdd.tsx
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
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
|
package/src/KanbanEnhanced.tsx
DELETED
|
@@ -1,525 +0,0 @@
|
|
|
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 { useVirtualizer } from "@tanstack/react-virtual"
|
|
11
|
-
import {
|
|
12
|
-
DndContext,
|
|
13
|
-
DragEndEvent,
|
|
14
|
-
DragOverlay,
|
|
15
|
-
DragStartEvent,
|
|
16
|
-
PointerSensor,
|
|
17
|
-
useSensor,
|
|
18
|
-
useSensors,
|
|
19
|
-
closestCorners,
|
|
20
|
-
} from "@dnd-kit/core"
|
|
21
|
-
import {
|
|
22
|
-
SortableContext,
|
|
23
|
-
arrayMove,
|
|
24
|
-
useSortable,
|
|
25
|
-
verticalListSortingStrategy,
|
|
26
|
-
} from "@dnd-kit/sortable"
|
|
27
|
-
import { CSS } from "@dnd-kit/utilities"
|
|
28
|
-
import { Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Input } from "@object-ui/components"
|
|
29
|
-
import { ChevronDown, ChevronRight, AlertTriangle, Plus } from "lucide-react"
|
|
30
|
-
|
|
31
|
-
const cn = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ')
|
|
32
|
-
|
|
33
|
-
export interface KanbanCard {
|
|
34
|
-
id: string
|
|
35
|
-
title: string
|
|
36
|
-
description?: string
|
|
37
|
-
badges?: Array<{ label: string; variant?: "default" | "secondary" | "destructive" | "outline" }>
|
|
38
|
-
coverImage?: string
|
|
39
|
-
[key: string]: any
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface KanbanColumn {
|
|
43
|
-
id: string
|
|
44
|
-
title: string
|
|
45
|
-
cards: KanbanCard[]
|
|
46
|
-
limit?: number
|
|
47
|
-
className?: string
|
|
48
|
-
collapsed?: boolean
|
|
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
|
-
|
|
59
|
-
export interface KanbanEnhancedProps {
|
|
60
|
-
columns: KanbanColumn[]
|
|
61
|
-
onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void
|
|
62
|
-
onColumnToggle?: (columnId: string, collapsed: boolean) => void
|
|
63
|
-
enableVirtualScrolling?: boolean
|
|
64
|
-
virtualScrollThreshold?: number
|
|
65
|
-
className?: string
|
|
66
|
-
quickAdd?: boolean
|
|
67
|
-
onQuickAdd?: (columnId: string, title: string) => void
|
|
68
|
-
conditionalFormatting?: ConditionalFormattingRule[]
|
|
69
|
-
}
|
|
70
|
-
|
|
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[] }) {
|
|
107
|
-
const {
|
|
108
|
-
attributes,
|
|
109
|
-
listeners,
|
|
110
|
-
setNodeRef,
|
|
111
|
-
transform,
|
|
112
|
-
transition,
|
|
113
|
-
isDragging,
|
|
114
|
-
} = useSortable({ id: card.id })
|
|
115
|
-
|
|
116
|
-
const style = {
|
|
117
|
-
transform: CSS.Transform.toString(transform),
|
|
118
|
-
transition,
|
|
119
|
-
opacity: isDragging ? 0.5 : undefined,
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const cardStyles = getCardStyles(card, conditionalFormatting)
|
|
123
|
-
|
|
124
|
-
return (
|
|
125
|
-
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
|
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
|
-
)}
|
|
137
|
-
<CardHeader className="p-4">
|
|
138
|
-
<CardTitle className="text-sm font-medium font-mono tracking-tight text-foreground group-hover:text-primary transition-colors">{card.title}</CardTitle>
|
|
139
|
-
{card.description && (
|
|
140
|
-
<CardDescription className="text-xs text-muted-foreground font-mono">
|
|
141
|
-
{card.description}
|
|
142
|
-
</CardDescription>
|
|
143
|
-
)}
|
|
144
|
-
</CardHeader>
|
|
145
|
-
{card.badges && card.badges.length > 0 && (
|
|
146
|
-
<CardContent className="p-4 pt-0">
|
|
147
|
-
<div className="flex flex-wrap gap-1">
|
|
148
|
-
{card.badges.map((badge, index) => (
|
|
149
|
-
<Badge key={index} variant={badge.variant || "default"} className="text-xs">
|
|
150
|
-
{badge.label}
|
|
151
|
-
</Badge>
|
|
152
|
-
))}
|
|
153
|
-
</div>
|
|
154
|
-
</CardContent>
|
|
155
|
-
)}
|
|
156
|
-
</Card>
|
|
157
|
-
</div>
|
|
158
|
-
)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function VirtualizedCardList({ cards, parentRef, conditionalFormatting }: { cards: KanbanCard[]; parentRef: React.RefObject<HTMLDivElement | null>; conditionalFormatting?: ConditionalFormattingRule[] }) {
|
|
162
|
-
const rowVirtualizer = useVirtualizer({
|
|
163
|
-
count: cards.length,
|
|
164
|
-
getScrollElement: () => parentRef.current,
|
|
165
|
-
estimateSize: () => 120,
|
|
166
|
-
overscan: 5,
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
return (
|
|
170
|
-
<div
|
|
171
|
-
style={{
|
|
172
|
-
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
173
|
-
width: '100%',
|
|
174
|
-
position: 'relative',
|
|
175
|
-
}}
|
|
176
|
-
>
|
|
177
|
-
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
|
|
178
|
-
const card = cards[virtualItem.index]
|
|
179
|
-
return (
|
|
180
|
-
<div
|
|
181
|
-
key={card.id}
|
|
182
|
-
style={{
|
|
183
|
-
position: 'absolute',
|
|
184
|
-
top: 0,
|
|
185
|
-
left: 0,
|
|
186
|
-
width: '100%',
|
|
187
|
-
transform: `translateY(${virtualItem.start}px)`,
|
|
188
|
-
}}
|
|
189
|
-
>
|
|
190
|
-
<SortableCard card={card} conditionalFormatting={conditionalFormatting} />
|
|
191
|
-
</div>
|
|
192
|
-
)
|
|
193
|
-
})}
|
|
194
|
-
</div>
|
|
195
|
-
)
|
|
196
|
-
}
|
|
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
|
-
|
|
255
|
-
function KanbanColumnEnhanced({
|
|
256
|
-
column,
|
|
257
|
-
cards,
|
|
258
|
-
onToggle,
|
|
259
|
-
enableVirtual,
|
|
260
|
-
quickAdd,
|
|
261
|
-
onQuickAdd,
|
|
262
|
-
conditionalFormatting,
|
|
263
|
-
}: {
|
|
264
|
-
column: KanbanColumn
|
|
265
|
-
cards: KanbanCard[]
|
|
266
|
-
onToggle: (collapsed: boolean) => void
|
|
267
|
-
enableVirtual: boolean
|
|
268
|
-
quickAdd?: boolean
|
|
269
|
-
onQuickAdd?: (columnId: string, title: string) => void
|
|
270
|
-
conditionalFormatting?: ConditionalFormattingRule[]
|
|
271
|
-
}) {
|
|
272
|
-
const safeCards = cards || []
|
|
273
|
-
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
274
|
-
const { setNodeRef } = useSortable({
|
|
275
|
-
id: column.id,
|
|
276
|
-
data: {
|
|
277
|
-
type: "column",
|
|
278
|
-
},
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
const isLimitExceeded = column.limit && safeCards.length >= column.limit
|
|
282
|
-
const isNearLimit = column.limit && safeCards.length >= column.limit * 0.8
|
|
283
|
-
|
|
284
|
-
return (
|
|
285
|
-
<div
|
|
286
|
-
ref={setNodeRef}
|
|
287
|
-
className={cn(
|
|
288
|
-
"flex flex-col flex-shrink-0 rounded-lg border border-border bg-card/20 backdrop-blur-sm shadow-xl transition-all",
|
|
289
|
-
column.collapsed ? "w-16" : "w-80",
|
|
290
|
-
column.className
|
|
291
|
-
)}
|
|
292
|
-
>
|
|
293
|
-
<div className="p-4 border-b border-border/50 bg-muted/20 flex items-center justify-between">
|
|
294
|
-
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
295
|
-
<Button
|
|
296
|
-
variant="ghost"
|
|
297
|
-
size="sm"
|
|
298
|
-
className="h-6 w-6 p-0"
|
|
299
|
-
onClick={() => onToggle(!column.collapsed)}
|
|
300
|
-
>
|
|
301
|
-
{column.collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
302
|
-
</Button>
|
|
303
|
-
{!column.collapsed && (
|
|
304
|
-
<>
|
|
305
|
-
<h3 className="font-mono text-sm font-semibold tracking-wider text-primary/90 uppercase truncate">
|
|
306
|
-
{column.title}
|
|
307
|
-
</h3>
|
|
308
|
-
<div className="flex items-center gap-2">
|
|
309
|
-
<span className={cn(
|
|
310
|
-
"font-mono text-xs",
|
|
311
|
-
isLimitExceeded ? "text-destructive" : isNearLimit ? "text-yellow-500" : "text-muted-foreground"
|
|
312
|
-
)}>
|
|
313
|
-
{safeCards.length}
|
|
314
|
-
{column.limit && ` / ${column.limit}`}
|
|
315
|
-
</span>
|
|
316
|
-
{isLimitExceeded && (
|
|
317
|
-
<Badge variant="destructive" className="text-xs">
|
|
318
|
-
Full
|
|
319
|
-
</Badge>
|
|
320
|
-
)}
|
|
321
|
-
{isNearLimit && !isLimitExceeded && (
|
|
322
|
-
<AlertTriangle className="h-3 w-3 text-yellow-500" />
|
|
323
|
-
)}
|
|
324
|
-
</div>
|
|
325
|
-
</>
|
|
326
|
-
)}
|
|
327
|
-
</div>
|
|
328
|
-
{column.collapsed && (
|
|
329
|
-
<div className="flex flex-col items-center gap-1">
|
|
330
|
-
<span className="font-mono text-xs font-bold text-primary/90 [writing-mode:vertical-rl] rotate-180">
|
|
331
|
-
{column.title}
|
|
332
|
-
</span>
|
|
333
|
-
<Badge variant="secondary" className="text-xs">
|
|
334
|
-
{safeCards.length}
|
|
335
|
-
</Badge>
|
|
336
|
-
</div>
|
|
337
|
-
)}
|
|
338
|
-
</div>
|
|
339
|
-
{!column.collapsed && (
|
|
340
|
-
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto" style={{ maxHeight: '600px' }}>
|
|
341
|
-
<SortableContext
|
|
342
|
-
items={safeCards.map((c) => c.id)}
|
|
343
|
-
strategy={verticalListSortingStrategy}
|
|
344
|
-
>
|
|
345
|
-
{enableVirtual ? (
|
|
346
|
-
<VirtualizedCardList cards={safeCards} parentRef={scrollRef} conditionalFormatting={conditionalFormatting} />
|
|
347
|
-
) : (
|
|
348
|
-
<div className="space-y-2">
|
|
349
|
-
{safeCards.map((card) => (
|
|
350
|
-
<SortableCard key={card.id} card={card} conditionalFormatting={conditionalFormatting} />
|
|
351
|
-
))}
|
|
352
|
-
</div>
|
|
353
|
-
)}
|
|
354
|
-
</SortableContext>
|
|
355
|
-
{quickAdd && onQuickAdd && (
|
|
356
|
-
<QuickAddForm columnId={column.id} onAdd={onQuickAdd} />
|
|
357
|
-
)}
|
|
358
|
-
</div>
|
|
359
|
-
)}
|
|
360
|
-
</div>
|
|
361
|
-
)
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
export function KanbanEnhanced({
|
|
365
|
-
columns,
|
|
366
|
-
onCardMove,
|
|
367
|
-
onColumnToggle,
|
|
368
|
-
enableVirtualScrolling = false,
|
|
369
|
-
virtualScrollThreshold = 50,
|
|
370
|
-
className,
|
|
371
|
-
quickAdd,
|
|
372
|
-
onQuickAdd,
|
|
373
|
-
conditionalFormatting,
|
|
374
|
-
}: KanbanEnhancedProps) {
|
|
375
|
-
const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null)
|
|
376
|
-
|
|
377
|
-
const safeColumns = React.useMemo(() => {
|
|
378
|
-
return (columns || []).map(col => ({
|
|
379
|
-
...col,
|
|
380
|
-
cards: col.cards || []
|
|
381
|
-
}));
|
|
382
|
-
}, [columns]);
|
|
383
|
-
|
|
384
|
-
const [boardColumns, setBoardColumns] = React.useState<KanbanColumn[]>(safeColumns)
|
|
385
|
-
|
|
386
|
-
React.useEffect(() => {
|
|
387
|
-
setBoardColumns(safeColumns)
|
|
388
|
-
}, [safeColumns])
|
|
389
|
-
|
|
390
|
-
const sensors = useSensors(
|
|
391
|
-
useSensor(PointerSensor, {
|
|
392
|
-
activationConstraint: {
|
|
393
|
-
distance: 8,
|
|
394
|
-
},
|
|
395
|
-
})
|
|
396
|
-
)
|
|
397
|
-
|
|
398
|
-
const handleDragStart = (event: DragStartEvent) => {
|
|
399
|
-
const { active } = event
|
|
400
|
-
const card = findCard(active.id as string)
|
|
401
|
-
setActiveCard(card)
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
const handleDragEnd = (event: DragEndEvent) => {
|
|
405
|
-
const { active, over } = event
|
|
406
|
-
setActiveCard(null)
|
|
407
|
-
|
|
408
|
-
if (!over) return
|
|
409
|
-
|
|
410
|
-
const activeId = active.id as string
|
|
411
|
-
const overId = over.id as string
|
|
412
|
-
|
|
413
|
-
if (activeId === overId) return
|
|
414
|
-
|
|
415
|
-
const activeColumn = findColumnByCardId(activeId)
|
|
416
|
-
const overColumn = findColumnByCardId(overId) || findColumnById(overId)
|
|
417
|
-
|
|
418
|
-
if (!activeColumn || !overColumn) return
|
|
419
|
-
|
|
420
|
-
if (activeColumn.id === overColumn.id) {
|
|
421
|
-
const cards = [...activeColumn.cards]
|
|
422
|
-
const oldIndex = cards.findIndex((c) => c.id === activeId)
|
|
423
|
-
const newIndex = cards.findIndex((c) => c.id === overId)
|
|
424
|
-
|
|
425
|
-
const newCards = arrayMove(cards, oldIndex, newIndex)
|
|
426
|
-
setBoardColumns((prev) =>
|
|
427
|
-
prev.map((col) =>
|
|
428
|
-
col.id === activeColumn.id ? { ...col, cards: newCards } : col
|
|
429
|
-
)
|
|
430
|
-
)
|
|
431
|
-
} else {
|
|
432
|
-
const activeCards = [...activeColumn.cards]
|
|
433
|
-
const overCards = [...overColumn.cards]
|
|
434
|
-
const activeIndex = activeCards.findIndex((c) => c.id === activeId)
|
|
435
|
-
|
|
436
|
-
const isDroppingOnColumn = overId === overColumn.id
|
|
437
|
-
const overIndex = isDroppingOnColumn
|
|
438
|
-
? overCards.length
|
|
439
|
-
: overCards.findIndex((c) => c.id === overId)
|
|
440
|
-
|
|
441
|
-
const [movedCard] = activeCards.splice(activeIndex, 1)
|
|
442
|
-
overCards.splice(overIndex, 0, movedCard)
|
|
443
|
-
|
|
444
|
-
setBoardColumns((prev) =>
|
|
445
|
-
prev.map((col) => {
|
|
446
|
-
if (col.id === activeColumn.id) {
|
|
447
|
-
return { ...col, cards: activeCards }
|
|
448
|
-
}
|
|
449
|
-
if (col.id === overColumn.id) {
|
|
450
|
-
return { ...col, cards: overCards }
|
|
451
|
-
}
|
|
452
|
-
return col
|
|
453
|
-
})
|
|
454
|
-
)
|
|
455
|
-
|
|
456
|
-
if (onCardMove) {
|
|
457
|
-
onCardMove(activeId, activeColumn.id, overColumn.id, overIndex)
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const findCard = React.useCallback(
|
|
463
|
-
(cardId: string): KanbanCard | null => {
|
|
464
|
-
for (const column of boardColumns) {
|
|
465
|
-
const card = column.cards.find((c) => c.id === cardId)
|
|
466
|
-
if (card) return card
|
|
467
|
-
}
|
|
468
|
-
return null
|
|
469
|
-
},
|
|
470
|
-
[boardColumns]
|
|
471
|
-
)
|
|
472
|
-
|
|
473
|
-
const findColumnByCardId = React.useCallback(
|
|
474
|
-
(cardId: string): KanbanColumn | null => {
|
|
475
|
-
return boardColumns.find((col) => col.cards.some((c) => c.id === cardId)) || null
|
|
476
|
-
},
|
|
477
|
-
[boardColumns]
|
|
478
|
-
)
|
|
479
|
-
|
|
480
|
-
const findColumnById = React.useCallback(
|
|
481
|
-
(columnId: string): KanbanColumn | null => {
|
|
482
|
-
return boardColumns.find((col) => col.id === columnId) || null
|
|
483
|
-
},
|
|
484
|
-
[boardColumns]
|
|
485
|
-
)
|
|
486
|
-
|
|
487
|
-
const handleColumnToggle = React.useCallback((columnId: string, collapsed: boolean) => {
|
|
488
|
-
setBoardColumns(prev =>
|
|
489
|
-
prev.map(col => col.id === columnId ? { ...col, collapsed } : col)
|
|
490
|
-
)
|
|
491
|
-
onColumnToggle?.(columnId, collapsed)
|
|
492
|
-
}, [onColumnToggle])
|
|
493
|
-
|
|
494
|
-
return (
|
|
495
|
-
<DndContext
|
|
496
|
-
sensors={sensors}
|
|
497
|
-
collisionDetection={closestCorners}
|
|
498
|
-
onDragStart={handleDragStart}
|
|
499
|
-
onDragEnd={handleDragEnd}
|
|
500
|
-
>
|
|
501
|
-
<div className={cn("flex gap-4 overflow-x-auto p-4", className)}>
|
|
502
|
-
{boardColumns.map((column) => {
|
|
503
|
-
const shouldUseVirtual = enableVirtualScrolling && column.cards.length > virtualScrollThreshold
|
|
504
|
-
return (
|
|
505
|
-
<KanbanColumnEnhanced
|
|
506
|
-
key={column.id}
|
|
507
|
-
column={column}
|
|
508
|
-
cards={column.cards}
|
|
509
|
-
onToggle={(collapsed) => handleColumnToggle(column.id, collapsed)}
|
|
510
|
-
enableVirtual={shouldUseVirtual}
|
|
511
|
-
quickAdd={quickAdd}
|
|
512
|
-
onQuickAdd={onQuickAdd}
|
|
513
|
-
conditionalFormatting={conditionalFormatting}
|
|
514
|
-
/>
|
|
515
|
-
)
|
|
516
|
-
})}
|
|
517
|
-
</div>
|
|
518
|
-
<DragOverlay>
|
|
519
|
-
{activeCard ? <SortableCard card={activeCard} conditionalFormatting={conditionalFormatting} /> : null}
|
|
520
|
-
</DragOverlay>
|
|
521
|
-
</DndContext>
|
|
522
|
-
)
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
export default KanbanEnhanced;
|