@object-ui/plugin-kanban 0.5.0 → 3.0.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 +8 -8
- package/CHANGELOG.md +34 -0
- package/dist/{KanbanEnhanced-BqDEu7Z6.js → KanbanEnhanced-BPIKjTDv.js} +7 -7
- package/dist/KanbanImpl-BfOKAnJS.js +194 -0
- package/dist/{index-CrR06na7.js → index-CWGTi2xn.js} +253 -215
- package/dist/index.js +1 -1
- package/dist/index.umd.cjs +4 -4
- package/dist/{sortable.esm-ZHwgFQIO.js → sortable.esm-CNNHgHk5.js} +1 -0
- package/dist/src/KanbanImpl.d.ts +2 -1
- package/dist/src/KanbanImpl.d.ts.map +1 -1
- package/dist/src/ObjectKanban.EdgeCases.stories.d.ts +26 -0
- package/dist/src/ObjectKanban.EdgeCases.stories.d.ts.map +1 -0
- package/dist/src/ObjectKanban.d.ts +2 -0
- package/dist/src/ObjectKanban.d.ts.map +1 -1
- package/dist/src/ObjectKanban.stories.d.ts +24 -0
- package/dist/src/ObjectKanban.stories.d.ts.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/KanbanImpl.tsx +82 -20
- package/src/ObjectKanban.EdgeCases.stories.tsx +168 -0
- package/src/ObjectKanban.stories.tsx +152 -0
- package/src/ObjectKanban.tsx +43 -2
- package/src/__tests__/KanbanEnhanced.test.tsx +1 -0
- package/src/__tests__/accessibility.test.tsx +296 -0
- package/src/__tests__/dnd-undo-integration.test.tsx +525 -0
- package/src/__tests__/performance-benchmark.test.tsx +306 -0
- package/src/__tests__/view-states.test.tsx +403 -0
- package/src/index.test.ts +8 -8
- package/src/index.tsx +29 -4
- package/dist/KanbanImpl-B8nu2BvG.js +0 -144
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/plugin-kanban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Kanban board plugin for Object UI, powered by dnd-kit",
|
|
@@ -27,25 +27,25 @@
|
|
|
27
27
|
"@dnd-kit/core": "^6.3.1",
|
|
28
28
|
"@dnd-kit/sortable": "^10.0.0",
|
|
29
29
|
"@dnd-kit/utilities": "^3.2.2",
|
|
30
|
-
"@tanstack/react-virtual": "^3.
|
|
30
|
+
"@tanstack/react-virtual": "^3.13.18",
|
|
31
31
|
"lucide-react": "^0.563.0",
|
|
32
|
-
"@object-ui/components": "0.
|
|
33
|
-
"@object-ui/core": "0.
|
|
34
|
-
"@object-ui/react": "0.
|
|
35
|
-
"@object-ui/types": "0.
|
|
32
|
+
"@object-ui/components": "3.0.0",
|
|
33
|
+
"@object-ui/core": "3.0.0",
|
|
34
|
+
"@object-ui/react": "3.0.0",
|
|
35
|
+
"@object-ui/types": "3.0.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"react": "^18.0.0 || ^19.0.0",
|
|
39
39
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@types/react": "
|
|
43
|
-
"@types/react-dom": "
|
|
44
|
-
"@vitejs/plugin-react": "^5.1.
|
|
42
|
+
"@types/react": "19.2.13",
|
|
43
|
+
"@types/react-dom": "19.2.3",
|
|
44
|
+
"@vitejs/plugin-react": "^5.1.4",
|
|
45
45
|
"typescript": "^5.9.3",
|
|
46
46
|
"vite": "^7.3.1",
|
|
47
47
|
"vite-plugin-dts": "^4.5.4",
|
|
48
|
-
"@object-ui/data-objectstack": "0.
|
|
48
|
+
"@object-ui/data-objectstack": "3.0.0"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"build": "vite build",
|
package/src/KanbanImpl.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
DragOverlay,
|
|
14
14
|
DragStartEvent,
|
|
15
15
|
PointerSensor,
|
|
16
|
+
TouchSensor,
|
|
16
17
|
useSensor,
|
|
17
18
|
useSensors,
|
|
18
19
|
closestCorners,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
} from "@dnd-kit/sortable"
|
|
26
27
|
import { CSS } from "@dnd-kit/utilities"
|
|
27
28
|
import { Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, ScrollArea } from "@object-ui/components"
|
|
29
|
+
import { useHasDndProvider, useDnd } from "@object-ui/react"
|
|
28
30
|
|
|
29
31
|
// Utility function to merge class names (inline to avoid external dependency)
|
|
30
32
|
const cn = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ')
|
|
@@ -48,10 +50,11 @@ export interface KanbanColumn {
|
|
|
48
50
|
export interface KanbanBoardProps {
|
|
49
51
|
columns: KanbanColumn[]
|
|
50
52
|
onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void
|
|
53
|
+
onCardClick?: (card: KanbanCard) => void
|
|
51
54
|
className?: string
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
function SortableCard({ card }: { card: KanbanCard }) {
|
|
57
|
+
function SortableCard({ card, onCardClick }: { card: KanbanCard; onCardClick?: (card: KanbanCard) => void }) {
|
|
55
58
|
const {
|
|
56
59
|
attributes,
|
|
57
60
|
listeners,
|
|
@@ -68,18 +71,20 @@ function SortableCard({ card }: { card: KanbanCard }) {
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
return (
|
|
71
|
-
<div ref={setNodeRef} style={style} {...attributes} {...listeners}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
<div ref={setNodeRef} style={style} {...attributes} {...listeners} role="listitem" aria-label={card.title}
|
|
75
|
+
onClick={() => onCardClick?.(card)}
|
|
76
|
+
>
|
|
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">
|
|
78
|
+
<CardHeader className="p-2 sm:p-4">
|
|
79
|
+
<CardTitle className="text-xs sm:text-sm font-medium font-mono tracking-tight text-foreground group-hover:text-primary transition-colors">{card.title}</CardTitle>
|
|
75
80
|
{card.description && (
|
|
76
|
-
<CardDescription className="text-xs text-muted-foreground font-mono">
|
|
81
|
+
<CardDescription className="text-xs text-muted-foreground font-mono line-clamp-2 sm:line-clamp-none">
|
|
77
82
|
{card.description}
|
|
78
83
|
</CardDescription>
|
|
79
84
|
)}
|
|
80
85
|
</CardHeader>
|
|
81
86
|
{card.badges && card.badges.length > 0 && (
|
|
82
|
-
<CardContent className="p-4 pt-0">
|
|
87
|
+
<CardContent className="p-2 sm:p-4 pt-0">
|
|
83
88
|
<div className="flex flex-wrap gap-1">
|
|
84
89
|
{card.badges.map((badge, index) => (
|
|
85
90
|
<Badge key={index} variant={badge.variant || "default"} className="text-xs">
|
|
@@ -97,9 +102,11 @@ function SortableCard({ card }: { card: KanbanCard }) {
|
|
|
97
102
|
function KanbanColumn({
|
|
98
103
|
column,
|
|
99
104
|
cards,
|
|
105
|
+
onCardClick,
|
|
100
106
|
}: {
|
|
101
107
|
column: KanbanColumn
|
|
102
108
|
cards: KanbanCard[]
|
|
109
|
+
onCardClick?: (card: KanbanCard) => void
|
|
103
110
|
}) {
|
|
104
111
|
const safeCards = cards || [];
|
|
105
112
|
const { setNodeRef } = useSortable({
|
|
@@ -114,16 +121,18 @@ function KanbanColumn({
|
|
|
114
121
|
return (
|
|
115
122
|
<div
|
|
116
123
|
ref={setNodeRef}
|
|
124
|
+
role="group"
|
|
125
|
+
aria-label={column.title}
|
|
117
126
|
className={cn(
|
|
118
|
-
"flex flex-col w-80 flex-shrink-0 rounded-lg border border-border bg-card/20 backdrop-blur-sm shadow-xl",
|
|
127
|
+
"flex flex-col w-[85vw] sm:w-80 flex-shrink-0 rounded-lg border border-border bg-card/20 backdrop-blur-sm shadow-xl snap-start",
|
|
119
128
|
column.className
|
|
120
129
|
)}
|
|
121
130
|
>
|
|
122
|
-
<div className="p-4 border-b border-border/50 bg-muted/20">
|
|
131
|
+
<div className="p-3 sm:p-4 border-b border-border/50 bg-muted/20">
|
|
123
132
|
<div className="flex items-center justify-between">
|
|
124
|
-
<h3 className="font-mono text-sm font-semibold tracking-wider text-primary/90 uppercase">{column.title}</h3>
|
|
133
|
+
<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>
|
|
125
134
|
<div className="flex items-center gap-2">
|
|
126
|
-
<span className="font-mono text-xs text-muted-foreground">
|
|
135
|
+
<span className="font-mono text-xs text-muted-foreground" aria-label={`${safeCards.length} cards${column.limit ? ` of ${column.limit} maximum` : ''}`}>
|
|
127
136
|
{safeCards.length}
|
|
128
137
|
{column.limit && ` / ${column.limit}`}
|
|
129
138
|
</span>
|
|
@@ -140,9 +149,9 @@ function KanbanColumn({
|
|
|
140
149
|
items={safeCards.map((c) => c.id)}
|
|
141
150
|
strategy={verticalListSortingStrategy}
|
|
142
151
|
>
|
|
143
|
-
<div className="space-y-2">
|
|
152
|
+
<div className="space-y-2" role="list" aria-label={`${column.title} cards`}>
|
|
144
153
|
{safeCards.map((card) => (
|
|
145
|
-
<SortableCard key={card.id} card={card} />
|
|
154
|
+
<SortableCard key={card.id} card={card} onCardClick={onCardClick} />
|
|
146
155
|
))}
|
|
147
156
|
</div>
|
|
148
157
|
</SortableContext>
|
|
@@ -151,7 +160,27 @@ function KanbanColumn({
|
|
|
151
160
|
)
|
|
152
161
|
}
|
|
153
162
|
|
|
154
|
-
|
|
163
|
+
/** Bridge wrapper that reads the ObjectUI DnD context and injects it into KanbanBoardInner. */
|
|
164
|
+
function DndBridge({ children }: { children: (dnd: ReturnType<typeof useDnd>) => React.ReactNode }) {
|
|
165
|
+
const dnd = useDnd()
|
|
166
|
+
return <>{children(dnd)}</>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export default function KanbanBoard({ columns, onCardMove, onCardClick, className }: KanbanBoardProps) {
|
|
170
|
+
const hasDnd = useHasDndProvider()
|
|
171
|
+
|
|
172
|
+
if (hasDnd) {
|
|
173
|
+
return (
|
|
174
|
+
<DndBridge>
|
|
175
|
+
{(dnd) => <KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={dnd} />}
|
|
176
|
+
</DndBridge>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return <KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={null} />
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd }: KanbanBoardProps & { dnd: ReturnType<typeof useDnd> | null }) {
|
|
155
184
|
const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null)
|
|
156
185
|
|
|
157
186
|
// Ensure we always have valid columns with cards array
|
|
@@ -171,7 +200,13 @@ export default function KanbanBoard({ columns, onCardMove, className }: KanbanBo
|
|
|
171
200
|
const sensors = useSensors(
|
|
172
201
|
useSensor(PointerSensor, {
|
|
173
202
|
activationConstraint: {
|
|
174
|
-
distance:
|
|
203
|
+
distance: 5,
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
useSensor(TouchSensor, {
|
|
207
|
+
activationConstraint: {
|
|
208
|
+
delay: 200,
|
|
209
|
+
tolerance: 5,
|
|
175
210
|
},
|
|
176
211
|
})
|
|
177
212
|
)
|
|
@@ -180,23 +215,40 @@ export default function KanbanBoard({ columns, onCardMove, className }: KanbanBo
|
|
|
180
215
|
const { active } = event
|
|
181
216
|
const card = findCard(active.id as string)
|
|
182
217
|
setActiveCard(card)
|
|
218
|
+
|
|
219
|
+
// Bridge to ObjectUI spec DnD system
|
|
220
|
+
if (dnd && card) {
|
|
221
|
+
const column = findColumnByCardId(card.id)
|
|
222
|
+
if (column) {
|
|
223
|
+
dnd.startDrag({ id: card.id, type: 'kanban-card', data: card, sourceId: column.id })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
183
226
|
}
|
|
184
227
|
|
|
185
228
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
186
229
|
const { active, over } = event
|
|
187
230
|
setActiveCard(null)
|
|
188
231
|
|
|
189
|
-
if (!over)
|
|
232
|
+
if (!over) {
|
|
233
|
+
if (dnd) dnd.endDrag()
|
|
234
|
+
return
|
|
235
|
+
}
|
|
190
236
|
|
|
191
237
|
const activeId = active.id as string
|
|
192
238
|
const overId = over.id as string
|
|
193
239
|
|
|
194
|
-
if (activeId === overId)
|
|
240
|
+
if (activeId === overId) {
|
|
241
|
+
if (dnd) dnd.endDrag()
|
|
242
|
+
return
|
|
243
|
+
}
|
|
195
244
|
|
|
196
245
|
const activeColumn = findColumnByCardId(activeId)
|
|
197
246
|
const overColumn = findColumnByCardId(overId) || findColumnById(overId)
|
|
198
247
|
|
|
199
|
-
if (!activeColumn || !overColumn)
|
|
248
|
+
if (!activeColumn || !overColumn) {
|
|
249
|
+
if (dnd) dnd.endDrag()
|
|
250
|
+
return
|
|
251
|
+
}
|
|
200
252
|
|
|
201
253
|
if (activeColumn.id === overColumn.id) {
|
|
202
254
|
// Same column reordering
|
|
@@ -241,6 +293,9 @@ export default function KanbanBoard({ columns, onCardMove, className }: KanbanBo
|
|
|
241
293
|
onCardMove(activeId, activeColumn.id, overColumn.id, overIndex)
|
|
242
294
|
}
|
|
243
295
|
}
|
|
296
|
+
|
|
297
|
+
// Bridge to ObjectUI spec DnD system
|
|
298
|
+
if (dnd) dnd.endDrag(overColumn.id)
|
|
244
299
|
}
|
|
245
300
|
|
|
246
301
|
const findCard = React.useCallback(
|
|
@@ -275,17 +330,24 @@ export default function KanbanBoard({ columns, onCardMove, className }: KanbanBo
|
|
|
275
330
|
onDragStart={handleDragStart}
|
|
276
331
|
onDragEnd={handleDragEnd}
|
|
277
332
|
>
|
|
278
|
-
<div className=
|
|
333
|
+
<div className="flex sm:hidden items-center justify-between px-3 pb-2 text-xs text-muted-foreground">
|
|
334
|
+
<span>{boardColumns.length} columns</span>
|
|
335
|
+
<span>← Swipe to navigate →</span>
|
|
336
|
+
</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">
|
|
279
338
|
{boardColumns.map((column) => (
|
|
280
339
|
<KanbanColumn
|
|
281
340
|
key={column.id}
|
|
282
341
|
column={column}
|
|
283
342
|
cards={column.cards}
|
|
343
|
+
onCardClick={onCardClick}
|
|
284
344
|
/>
|
|
285
345
|
))}
|
|
286
346
|
</div>
|
|
287
347
|
<DragOverlay>
|
|
288
|
-
{activeCard ?
|
|
348
|
+
<div aria-live="assertive" aria-label={activeCard ? `Dragging ${activeCard.title}` : undefined}>
|
|
349
|
+
{activeCard ? <SortableCard card={activeCard} /> : null}
|
|
350
|
+
</div>
|
|
289
351
|
</DragOverlay>
|
|
290
352
|
</DndContext>
|
|
291
353
|
)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
3
|
+
import type { BaseSchema } from '@object-ui/types';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Plugins/ObjectKanban/Edge Cases',
|
|
7
|
+
component: SchemaRenderer,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'padded',
|
|
10
|
+
},
|
|
11
|
+
tags: ['autodocs'],
|
|
12
|
+
argTypes: {
|
|
13
|
+
schema: { table: { disable: true } },
|
|
14
|
+
},
|
|
15
|
+
} satisfies Meta<any>;
|
|
16
|
+
|
|
17
|
+
export default meta;
|
|
18
|
+
type Story = StoryObj<typeof meta>;
|
|
19
|
+
|
|
20
|
+
const renderStory = (args: any) => <SchemaRenderer schema={args as unknown as BaseSchema} />;
|
|
21
|
+
|
|
22
|
+
// ── Empty Board ───────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export const EmptyBoard: Story = {
|
|
25
|
+
name: 'Empty Board – No Columns',
|
|
26
|
+
render: renderStory,
|
|
27
|
+
args: {
|
|
28
|
+
type: 'kanban',
|
|
29
|
+
columns: [],
|
|
30
|
+
className: 'w-full',
|
|
31
|
+
} as any,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── Columns With No Cards ─────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export const ColumnsWithNoCards: Story = {
|
|
37
|
+
name: 'Columns With No Cards',
|
|
38
|
+
render: renderStory,
|
|
39
|
+
args: {
|
|
40
|
+
type: 'kanban',
|
|
41
|
+
columns: [
|
|
42
|
+
{ id: 'todo', title: 'To Do', cards: [] },
|
|
43
|
+
{ id: 'in-progress', title: 'In Progress', cards: [] },
|
|
44
|
+
{ id: 'done', title: 'Done', cards: [] },
|
|
45
|
+
],
|
|
46
|
+
className: 'w-full',
|
|
47
|
+
} as any,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ── Column At WIP Limit ───────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export const ColumnAtWipLimit: Story = {
|
|
53
|
+
name: 'Column At WIP Limit',
|
|
54
|
+
render: renderStory,
|
|
55
|
+
args: {
|
|
56
|
+
type: 'kanban',
|
|
57
|
+
columns: [
|
|
58
|
+
{
|
|
59
|
+
id: 'todo',
|
|
60
|
+
title: 'To Do',
|
|
61
|
+
cards: [
|
|
62
|
+
{ id: 'c-1', title: 'Plan sprint', badges: [{ label: 'Planning', variant: 'default' }] },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'wip',
|
|
67
|
+
title: 'In Progress (At Limit)',
|
|
68
|
+
limit: 3,
|
|
69
|
+
cards: [
|
|
70
|
+
{ id: 'c-2', title: 'Build auth module', description: 'JWT implementation', badges: [{ label: 'Feature', variant: 'default' }] },
|
|
71
|
+
{ id: 'c-3', title: 'Write unit tests', description: 'Coverage > 80%', badges: [{ label: 'Testing', variant: 'secondary' }] },
|
|
72
|
+
{ id: 'c-4', title: 'Deploy staging', description: 'Push to staging env', badges: [{ label: 'DevOps', variant: 'secondary' }] },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'done',
|
|
77
|
+
title: 'Done',
|
|
78
|
+
cards: [
|
|
79
|
+
{ id: 'c-5', title: 'Setup repo', badges: [{ label: 'Completed', variant: 'outline' }] },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
className: 'w-full',
|
|
84
|
+
} as any,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ── Cards With Very Long Titles ───────────────────────────────
|
|
88
|
+
|
|
89
|
+
export const CardsWithLongTitles: Story = {
|
|
90
|
+
name: 'Cards With Very Long Titles',
|
|
91
|
+
render: renderStory,
|
|
92
|
+
args: {
|
|
93
|
+
type: 'kanban',
|
|
94
|
+
columns: [
|
|
95
|
+
{
|
|
96
|
+
id: 'backlog',
|
|
97
|
+
title: 'Backlog',
|
|
98
|
+
cards: [
|
|
99
|
+
{
|
|
100
|
+
id: 'long-1',
|
|
101
|
+
title: 'Investigate the root cause of the intermittent timeout errors occurring in the payment processing pipeline during peak traffic hours on weekends',
|
|
102
|
+
description: 'This card has an extremely long title to test text wrapping and overflow behaviour within kanban cards.',
|
|
103
|
+
badges: [
|
|
104
|
+
{ label: 'Bug', variant: 'destructive' },
|
|
105
|
+
{ label: 'P0 – Critical Production Incident', variant: 'destructive' },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'long-2',
|
|
110
|
+
title: 'Refactor the legacy monolithic authentication service into a set of microservices following domain-driven design principles and ensuring backward compatibility',
|
|
111
|
+
badges: [{ label: 'Tech Debt', variant: 'secondary' }],
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'in-progress',
|
|
117
|
+
title: 'In Progress',
|
|
118
|
+
cards: [
|
|
119
|
+
{
|
|
120
|
+
id: 'long-3',
|
|
121
|
+
title: 'A short title for contrast',
|
|
122
|
+
description: 'Normal-length description.',
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
className: 'w-full',
|
|
128
|
+
} as any,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// ── Many Columns (10+) ───────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
export const ManyColumns: Story = {
|
|
134
|
+
name: 'Many Columns (10+)',
|
|
135
|
+
render: renderStory,
|
|
136
|
+
args: {
|
|
137
|
+
type: 'kanban',
|
|
138
|
+
columns: Array.from({ length: 12 }, (_, i) => ({
|
|
139
|
+
id: `col-${i + 1}`,
|
|
140
|
+
title: `Stage ${i + 1}`,
|
|
141
|
+
limit: i === 3 ? 2 : undefined,
|
|
142
|
+
cards: i % 3 === 0
|
|
143
|
+
? [
|
|
144
|
+
{
|
|
145
|
+
id: `mc-${i}-1`,
|
|
146
|
+
title: `Task ${i * 2 + 1}`,
|
|
147
|
+
description: `Description for task in stage ${i + 1}`,
|
|
148
|
+
badges: [{ label: `S${i + 1}`, variant: 'default' as const }],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: `mc-${i}-2`,
|
|
152
|
+
title: `Task ${i * 2 + 2}`,
|
|
153
|
+
badges: [{ label: 'Active', variant: 'secondary' as const }],
|
|
154
|
+
},
|
|
155
|
+
]
|
|
156
|
+
: i % 3 === 1
|
|
157
|
+
? [
|
|
158
|
+
{
|
|
159
|
+
id: `mc-${i}-1`,
|
|
160
|
+
title: `Task ${i * 2 + 1}`,
|
|
161
|
+
badges: [{ label: 'Review', variant: 'outline' as const }],
|
|
162
|
+
},
|
|
163
|
+
]
|
|
164
|
+
: [],
|
|
165
|
+
})),
|
|
166
|
+
className: 'w-full',
|
|
167
|
+
} as any,
|
|
168
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
3
|
+
import type { BaseSchema } from '@object-ui/types';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Plugins/ObjectKanban',
|
|
7
|
+
component: SchemaRenderer,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'padded',
|
|
10
|
+
},
|
|
11
|
+
tags: ['autodocs'],
|
|
12
|
+
argTypes: {
|
|
13
|
+
schema: { table: { disable: true } },
|
|
14
|
+
},
|
|
15
|
+
} satisfies Meta<any>;
|
|
16
|
+
|
|
17
|
+
export default meta;
|
|
18
|
+
type Story = StoryObj<typeof meta>;
|
|
19
|
+
|
|
20
|
+
const renderStory = (args: any) => <SchemaRenderer schema={args as unknown as BaseSchema} />;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
render: renderStory,
|
|
24
|
+
args: {
|
|
25
|
+
type: 'kanban',
|
|
26
|
+
columns: [
|
|
27
|
+
{
|
|
28
|
+
id: 'todo',
|
|
29
|
+
title: 'To Do',
|
|
30
|
+
cards: [
|
|
31
|
+
{
|
|
32
|
+
id: 'card-1',
|
|
33
|
+
title: 'Design homepage',
|
|
34
|
+
description: 'Create wireframes and mockups',
|
|
35
|
+
badges: [{ label: 'Design', variant: 'default' }],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'card-2',
|
|
39
|
+
title: 'Setup CI pipeline',
|
|
40
|
+
description: 'Configure GitHub Actions',
|
|
41
|
+
badges: [{ label: 'DevOps', variant: 'secondary' }],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'in-progress',
|
|
47
|
+
title: 'In Progress',
|
|
48
|
+
limit: 3,
|
|
49
|
+
cards: [
|
|
50
|
+
{
|
|
51
|
+
id: 'card-3',
|
|
52
|
+
title: 'Implement auth',
|
|
53
|
+
description: 'JWT-based authentication flow',
|
|
54
|
+
badges: [
|
|
55
|
+
{ label: 'Feature', variant: 'default' },
|
|
56
|
+
{ label: 'High Priority', variant: 'destructive' },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'done',
|
|
63
|
+
title: 'Done',
|
|
64
|
+
cards: [
|
|
65
|
+
{
|
|
66
|
+
id: 'card-4',
|
|
67
|
+
title: 'Project scaffolding',
|
|
68
|
+
description: 'Initial project setup completed',
|
|
69
|
+
badges: [{ label: 'Completed', variant: 'outline' }],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
className: 'w-full',
|
|
75
|
+
} as any,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const SprintBoard: Story = {
|
|
79
|
+
render: renderStory,
|
|
80
|
+
args: {
|
|
81
|
+
type: 'kanban',
|
|
82
|
+
columns: [
|
|
83
|
+
{
|
|
84
|
+
id: 'backlog',
|
|
85
|
+
title: 'Backlog',
|
|
86
|
+
cards: [
|
|
87
|
+
{ id: 's-1', title: 'Refactor API layer', description: 'Improve error handling', badges: [{ label: 'Tech Debt', variant: 'secondary' }] },
|
|
88
|
+
{ id: 's-2', title: 'Add unit tests', description: 'Increase coverage to 80%', badges: [{ label: 'Testing', variant: 'default' }] },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'in-progress',
|
|
93
|
+
title: 'In Progress',
|
|
94
|
+
limit: 2,
|
|
95
|
+
cards: [
|
|
96
|
+
{ id: 's-3', title: 'User dashboard', description: 'Build analytics dashboard', badges: [{ label: 'Feature', variant: 'default' }, { label: 'P1', variant: 'destructive' }] },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'review',
|
|
101
|
+
title: 'In Review',
|
|
102
|
+
cards: [
|
|
103
|
+
{ id: 's-4', title: 'Search functionality', description: 'Full-text search implementation', badges: [{ label: 'Feature', variant: 'default' }] },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'done',
|
|
108
|
+
title: 'Done',
|
|
109
|
+
cards: [
|
|
110
|
+
{ id: 's-5', title: 'Login page', badges: [{ label: 'Done', variant: 'outline' }] },
|
|
111
|
+
{ id: 's-6', title: 'Database schema', badges: [{ label: 'Done', variant: 'outline' }] },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
className: 'w-full',
|
|
116
|
+
} as any,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const WithColumnLimits: Story = {
|
|
120
|
+
render: renderStory,
|
|
121
|
+
args: {
|
|
122
|
+
type: 'kanban',
|
|
123
|
+
columns: [
|
|
124
|
+
{
|
|
125
|
+
id: 'todo',
|
|
126
|
+
title: 'To Do',
|
|
127
|
+
cards: [
|
|
128
|
+
{ id: 'l-1', title: 'Task A', badges: [{ label: 'P1', variant: 'destructive' }] },
|
|
129
|
+
{ id: 'l-2', title: 'Task B', badges: [{ label: 'P2', variant: 'default' }] },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: 'wip',
|
|
134
|
+
title: 'WIP (Over Limit)',
|
|
135
|
+
limit: 2,
|
|
136
|
+
cards: [
|
|
137
|
+
{ id: 'l-3', title: 'Task C', description: 'Almost done' },
|
|
138
|
+
{ id: 'l-4', title: 'Task D', description: 'In review' },
|
|
139
|
+
{ id: 'l-5', title: 'Task E', description: 'Blocked', badges: [{ label: 'Blocked', variant: 'destructive' }] },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'done',
|
|
144
|
+
title: 'Done',
|
|
145
|
+
cards: [
|
|
146
|
+
{ id: 'l-6', title: 'Task F', badges: [{ label: 'Completed', variant: 'outline' }] },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
className: 'w-full',
|
|
151
|
+
} as any,
|
|
152
|
+
};
|
package/src/ObjectKanban.tsx
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import React, { useEffect, useState, useMemo } from 'react';
|
|
10
10
|
import type { DataSource } from '@object-ui/types';
|
|
11
|
-
import { useDataScope } from '@object-ui/react';
|
|
11
|
+
import { useDataScope, useNavigationOverlay } from '@object-ui/react';
|
|
12
|
+
import { NavigationOverlay } from '@object-ui/components';
|
|
12
13
|
import { KanbanRenderer } from './index';
|
|
13
14
|
import { KanbanSchema } from './types';
|
|
14
15
|
|
|
@@ -16,12 +17,16 @@ export interface ObjectKanbanProps {
|
|
|
16
17
|
schema: KanbanSchema;
|
|
17
18
|
dataSource?: DataSource;
|
|
18
19
|
className?: string; // Allow override
|
|
20
|
+
onRowClick?: (record: any) => void;
|
|
21
|
+
onCardClick?: (record: any) => void;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
|
|
22
25
|
schema,
|
|
23
26
|
dataSource,
|
|
24
27
|
className,
|
|
28
|
+
onRowClick,
|
|
29
|
+
onCardClick,
|
|
25
30
|
...props
|
|
26
31
|
}) => {
|
|
27
32
|
const [fetchedData, setFetchedData] = useState<any[]>([]);
|
|
@@ -175,6 +180,12 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
|
|
|
175
180
|
className: className || schema.className
|
|
176
181
|
};
|
|
177
182
|
|
|
183
|
+
const navigation = useNavigationOverlay({
|
|
184
|
+
navigation: (schema as any).navigation,
|
|
185
|
+
objectName: schema.objectName,
|
|
186
|
+
onRowClick: onRowClick ?? onCardClick,
|
|
187
|
+
});
|
|
188
|
+
|
|
178
189
|
if (error) {
|
|
179
190
|
return (
|
|
180
191
|
<div className="p-4 border border-destructive/50 rounded bg-destructive/10 text-destructive">
|
|
@@ -184,5 +195,35 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
|
|
|
184
195
|
}
|
|
185
196
|
|
|
186
197
|
// Pass through to the renderer
|
|
187
|
-
|
|
198
|
+
const detailTitle = schema.objectName
|
|
199
|
+
? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1).replace(/_/g, ' ')} Detail`
|
|
200
|
+
: 'Card Details';
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<>
|
|
204
|
+
<KanbanRenderer schema={{
|
|
205
|
+
...effectiveSchema,
|
|
206
|
+
onCardClick: (card: any) => {
|
|
207
|
+
navigation.handleClick(card);
|
|
208
|
+
onCardClick?.(card);
|
|
209
|
+
},
|
|
210
|
+
}} />
|
|
211
|
+
{navigation.isOverlay && (
|
|
212
|
+
<NavigationOverlay {...navigation} title={detailTitle}>
|
|
213
|
+
{(record) => (
|
|
214
|
+
<div className="space-y-3">
|
|
215
|
+
{Object.entries(record).map(([key, value]) => (
|
|
216
|
+
<div key={key} className="flex flex-col">
|
|
217
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
218
|
+
{key.replace(/_/g, ' ')}
|
|
219
|
+
</span>
|
|
220
|
+
<span className="text-sm">{String(value ?? '—')}</span>
|
|
221
|
+
</div>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</NavigationOverlay>
|
|
226
|
+
)}
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
188
229
|
}
|
|
@@ -24,6 +24,7 @@ vi.mock('@dnd-kit/core', () => ({
|
|
|
24
24
|
DndContext: ({ children }: any) => <div data-testid="dnd-context">{children}</div>,
|
|
25
25
|
DragOverlay: ({ children }: any) => <div data-testid="drag-overlay">{children}</div>,
|
|
26
26
|
PointerSensor: vi.fn(),
|
|
27
|
+
TouchSensor: vi.fn(),
|
|
27
28
|
useSensor: vi.fn(),
|
|
28
29
|
useSensors: () => [],
|
|
29
30
|
closestCorners: vi.fn(),
|