@object-ui/plugin-kanban 0.3.1 → 0.5.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.
@@ -0,0 +1,36 @@
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
+ export interface KanbanCard {
9
+ id: string;
10
+ title: string;
11
+ description?: string;
12
+ badges?: Array<{
13
+ label: string;
14
+ variant?: "default" | "secondary" | "destructive" | "outline";
15
+ }>;
16
+ [key: string]: any;
17
+ }
18
+ export interface KanbanColumn {
19
+ id: string;
20
+ title: string;
21
+ cards: KanbanCard[];
22
+ limit?: number;
23
+ className?: string;
24
+ collapsed?: boolean;
25
+ }
26
+ export interface KanbanEnhancedProps {
27
+ columns: KanbanColumn[];
28
+ onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void;
29
+ onColumnToggle?: (columnId: string, collapsed: boolean) => void;
30
+ enableVirtualScrolling?: boolean;
31
+ virtualScrollThreshold?: number;
32
+ className?: string;
33
+ }
34
+ export declare function KanbanEnhanced({ columns, onCardMove, onColumnToggle, enableVirtualScrolling, virtualScrollThreshold, className, }: KanbanEnhancedProps): import("react/jsx-runtime").JSX.Element;
35
+ export default KanbanEnhanced;
36
+ //# sourceMappingURL=KanbanEnhanced.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"KanbanEnhanced.d.ts","sourceRoot":"","sources":["../../src/KanbanEnhanced.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA0BH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,aAAa,GAAG,SAAS,CAAA;KAAE,CAAC,CAAA;IAChG,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,UAAU,EAAE,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,YAAY,EAAE,CAAA;IACvB,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAA;IACjG,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IAC/D,sBAAsB,CAAC,EAAE,OAAO,CAAA;IAChC,sBAAsB,CAAC,EAAE,MAAM,CAAA;IAC/B,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAsLD,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,UAAU,EACV,cAAc,EACd,sBAA8B,EAC9B,sBAA2B,EAC3B,SAAS,GACV,EAAE,mBAAmB,2CAkJrB;AAED,eAAe,cAAc,CAAC"}
@@ -0,0 +1,10 @@
1
+ import { default as React } from 'react';
2
+ import { DataSource } from '../../types/src';
3
+ import { KanbanSchema } from './types';
4
+ export interface ObjectKanbanProps {
5
+ schema: KanbanSchema;
6
+ dataSource?: DataSource;
7
+ className?: string;
8
+ }
9
+ export declare const ObjectKanban: React.FC<ObjectKanbanProps>;
10
+ //# sourceMappingURL=ObjectKanban.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ObjectKanban.d.ts","sourceRoot":"","sources":["../../src/ObjectKanban.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAuC,MAAM,OAAO,CAAC;AAC5D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAGnD,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvC,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,YAAY,CAAC;IACrB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CAuKpD,CAAA"}
@@ -1,5 +1,8 @@
1
1
  import { default as React } from 'react';
2
+ import { ObjectKanban } from './ObjectKanban';
2
3
  export type { KanbanSchema, KanbanCard, KanbanColumn } from './types';
4
+ export { ObjectKanban };
5
+ export type { ObjectKanbanProps } from './ObjectKanban';
3
6
  export interface KanbanRendererProps {
4
7
  schema: {
5
8
  type: string;
@@ -18,5 +21,11 @@ export interface KanbanRendererProps {
18
21
  export declare const KanbanRenderer: React.FC<KanbanRendererProps>;
19
22
  export declare const kanbanComponents: {
20
23
  kanban: React.FC<KanbanRendererProps>;
24
+ 'kanban-enhanced': React.LazyExoticComponent<typeof import('./KanbanEnhanced').KanbanEnhanced>;
25
+ 'object-kanban': React.FC<import('./ObjectKanban').ObjectKanbanProps>;
21
26
  };
27
+ export declare const ObjectKanbanRenderer: React.FC<{
28
+ schema: any;
29
+ [key: string]: any;
30
+ }>;
22
31
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAmB,MAAM,OAAO,CAAC;AAKxC,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAMtE,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE;QACN,IAAI,EAAE,MAAM,CAAC;QACb,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,OAAO,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QACrB,IAAI,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QAClB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;KACnG,CAAC;CACH;AAED;;;GAGG;AACH,eAAO,MAAM,cAAc,EAAE,KAAK,CAAC,EAAE,CAAC,mBAAmB,CAsCxD,CAAC;AAoGF,eAAO,MAAM,gBAAgB;;CAE5B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAmB,MAAM,OAAO,CAAC;AAIxC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAG9C,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACtE,OAAO,EAAE,YAAY,EAAE,CAAC;AACxB,YAAY,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAMxD,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE;QACN,IAAI,EAAE,MAAM,CAAC;QACb,EAAE,CAAC,EAAE,MAAM,CAAC;QACZ,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,OAAO,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QACrB,IAAI,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;QAClB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;KACnG,CAAC;CACH;AAED;;;GAGG;AACH,eAAO,MAAM,cAAc,EAAE,KAAK,CAAC,EAAE,CAAC,mBAAmB,CAsCxD,CAAC;AAqGF,eAAO,MAAM,gBAAgB;;;;CAI5B,CAAC;AA2DF,eAAO,MAAM,oBAAoB,EAAE,KAAK,CAAC,EAAE,CAAC;IAAE,MAAM,EAAE,GAAG,CAAC;IAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CAAE,CAG9E,CAAC"}
@@ -1,4 +1,4 @@
1
- import { BaseSchema } from '@object-ui/types';
1
+ import { BaseSchema } from '../../types/src';
2
2
  /**
3
3
  * Kanban card interface.
4
4
  */
@@ -28,6 +28,26 @@ export interface KanbanColumn {
28
28
  */
29
29
  export interface KanbanSchema extends BaseSchema {
30
30
  type: 'kanban';
31
+ /**
32
+ * Object name to fetch data from.
33
+ */
34
+ objectName?: string;
35
+ /**
36
+ * Field to group records by (maps to column IDs).
37
+ */
38
+ groupBy?: string;
39
+ /**
40
+ * Field to use as the card title.
41
+ */
42
+ cardTitle?: string;
43
+ /**
44
+ * Fields to display on the card.
45
+ */
46
+ cardFields?: string[];
47
+ /**
48
+ * Static data or bound data.
49
+ */
50
+ data?: any[];
31
51
  /**
32
52
  * Array of columns to display in the kanban board.
33
53
  * Each column contains an array of cards.
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,aAAa,GAAG,SAAS,CAAA;KAAE,CAAC,CAAC;IACjG,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C,IAAI,EAAE,QAAQ,CAAC;IAEf;;;OAGG;IACH,OAAO,CAAC,EAAE,YAAY,EAAE,CAAC;IAEzB;;OAEG;IACH,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAElG;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,SAAS,GAAG,WAAW,GAAG,aAAa,GAAG,SAAS,CAAA;KAAE,CAAC,CAAC;IACjG,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C,IAAI,EAAE,QAAQ,CAAC;IAEf;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IAEtB;;OAEG;IACH,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC;IAEb;;;OAGG;IACH,OAAO,CAAC,EAAE,YAAY,EAAE,CAAC;IAEzB;;OAEG;IACH,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAElG;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/plugin-kanban",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Kanban board plugin for Object UI, powered by dnd-kit",
@@ -27,22 +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
- "@object-ui/react": "0.3.1",
31
- "@object-ui/types": "0.3.1",
32
- "@object-ui/core": "0.3.1",
33
- "@object-ui/components": "0.3.1"
30
+ "@tanstack/react-virtual": "^3.10.8",
31
+ "lucide-react": "^0.563.0",
32
+ "@object-ui/components": "0.5.0",
33
+ "@object-ui/core": "0.5.0",
34
+ "@object-ui/react": "0.5.0",
35
+ "@object-ui/types": "0.5.0"
34
36
  },
35
37
  "peerDependencies": {
36
38
  "react": "^18.0.0 || ^19.0.0",
37
39
  "react-dom": "^18.0.0 || ^19.0.0"
38
40
  },
39
41
  "devDependencies": {
40
- "@types/react": "^19.2.9",
42
+ "@types/react": "^19.2.10",
41
43
  "@types/react-dom": "^19.2.3",
42
- "@vitejs/plugin-react": "^4.2.1",
44
+ "@vitejs/plugin-react": "^5.1.3",
43
45
  "typescript": "^5.9.3",
44
46
  "vite": "^7.3.1",
45
- "vite-plugin-dts": "^4.5.4"
47
+ "vite-plugin-dts": "^4.5.4",
48
+ "@object-ui/data-objectstack": "0.5.0"
46
49
  },
47
50
  "scripts": {
48
51
  "build": "vite build",
@@ -0,0 +1,394 @@
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 } from "@object-ui/components"
29
+ import { ChevronDown, ChevronRight, AlertTriangle } 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
+ [key: string]: any
39
+ }
40
+
41
+ export interface KanbanColumn {
42
+ id: string
43
+ title: string
44
+ cards: KanbanCard[]
45
+ limit?: number
46
+ className?: string
47
+ collapsed?: boolean
48
+ }
49
+
50
+ export interface KanbanEnhancedProps {
51
+ columns: KanbanColumn[]
52
+ onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void
53
+ onColumnToggle?: (columnId: string, collapsed: boolean) => void
54
+ enableVirtualScrolling?: boolean
55
+ virtualScrollThreshold?: number
56
+ className?: string
57
+ }
58
+
59
+ function SortableCard({ card }: { card: KanbanCard }) {
60
+ const {
61
+ attributes,
62
+ listeners,
63
+ setNodeRef,
64
+ transform,
65
+ transition,
66
+ isDragging,
67
+ } = useSortable({ id: card.id })
68
+
69
+ const style = {
70
+ transform: CSS.Transform.toString(transform),
71
+ transition,
72
+ opacity: isDragging ? 0.5 : undefined,
73
+ }
74
+
75
+ return (
76
+ <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">
78
+ <CardHeader className="p-4">
79
+ <CardTitle className="text-sm font-medium font-mono tracking-tight text-foreground group-hover:text-primary transition-colors">{card.title}</CardTitle>
80
+ {card.description && (
81
+ <CardDescription className="text-xs text-muted-foreground font-mono">
82
+ {card.description}
83
+ </CardDescription>
84
+ )}
85
+ </CardHeader>
86
+ {card.badges && card.badges.length > 0 && (
87
+ <CardContent className="p-4 pt-0">
88
+ <div className="flex flex-wrap gap-1">
89
+ {card.badges.map((badge, index) => (
90
+ <Badge key={index} variant={badge.variant || "default"} className="text-xs">
91
+ {badge.label}
92
+ </Badge>
93
+ ))}
94
+ </div>
95
+ </CardContent>
96
+ )}
97
+ </Card>
98
+ </div>
99
+ )
100
+ }
101
+
102
+ function VirtualizedCardList({ cards, parentRef }: { cards: KanbanCard[]; parentRef: React.RefObject<HTMLDivElement | null> }) {
103
+ const rowVirtualizer = useVirtualizer({
104
+ count: cards.length,
105
+ getScrollElement: () => parentRef.current,
106
+ estimateSize: () => 120,
107
+ overscan: 5,
108
+ })
109
+
110
+ return (
111
+ <div
112
+ style={{
113
+ height: `${rowVirtualizer.getTotalSize()}px`,
114
+ width: '100%',
115
+ position: 'relative',
116
+ }}
117
+ >
118
+ {rowVirtualizer.getVirtualItems().map((virtualItem) => {
119
+ const card = cards[virtualItem.index]
120
+ return (
121
+ <div
122
+ key={card.id}
123
+ style={{
124
+ position: 'absolute',
125
+ top: 0,
126
+ left: 0,
127
+ width: '100%',
128
+ transform: `translateY(${virtualItem.start}px)`,
129
+ }}
130
+ >
131
+ <SortableCard card={card} />
132
+ </div>
133
+ )
134
+ })}
135
+ </div>
136
+ )
137
+ }
138
+
139
+ function KanbanColumnEnhanced({
140
+ column,
141
+ cards,
142
+ onToggle,
143
+ enableVirtual,
144
+ }: {
145
+ column: KanbanColumn
146
+ cards: KanbanCard[]
147
+ onToggle: (collapsed: boolean) => void
148
+ enableVirtual: boolean
149
+ }) {
150
+ const safeCards = cards || []
151
+ const scrollRef = React.useRef<HTMLDivElement>(null)
152
+ const { setNodeRef } = useSortable({
153
+ id: column.id,
154
+ data: {
155
+ type: "column",
156
+ },
157
+ })
158
+
159
+ const isLimitExceeded = column.limit && safeCards.length >= column.limit
160
+ const isNearLimit = column.limit && safeCards.length >= column.limit * 0.8
161
+
162
+ return (
163
+ <div
164
+ ref={setNodeRef}
165
+ className={cn(
166
+ "flex flex-col flex-shrink-0 rounded-lg border border-border bg-card/20 backdrop-blur-sm shadow-xl transition-all",
167
+ column.collapsed ? "w-16" : "w-80",
168
+ column.className
169
+ )}
170
+ >
171
+ <div className="p-4 border-b border-border/50 bg-muted/20 flex items-center justify-between">
172
+ <div className="flex items-center gap-2 flex-1 min-w-0">
173
+ <Button
174
+ variant="ghost"
175
+ size="sm"
176
+ className="h-6 w-6 p-0"
177
+ onClick={() => onToggle(!column.collapsed)}
178
+ >
179
+ {column.collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
180
+ </Button>
181
+ {!column.collapsed && (
182
+ <>
183
+ <h3 className="font-mono text-sm font-semibold tracking-wider text-primary/90 uppercase truncate">
184
+ {column.title}
185
+ </h3>
186
+ <div className="flex items-center gap-2">
187
+ <span className={cn(
188
+ "font-mono text-xs",
189
+ isLimitExceeded ? "text-destructive" : isNearLimit ? "text-yellow-500" : "text-muted-foreground"
190
+ )}>
191
+ {safeCards.length}
192
+ {column.limit && ` / ${column.limit}`}
193
+ </span>
194
+ {isLimitExceeded && (
195
+ <Badge variant="destructive" className="text-xs">
196
+ Full
197
+ </Badge>
198
+ )}
199
+ {isNearLimit && !isLimitExceeded && (
200
+ <AlertTriangle className="h-3 w-3 text-yellow-500" />
201
+ )}
202
+ </div>
203
+ </>
204
+ )}
205
+ </div>
206
+ {column.collapsed && (
207
+ <div className="flex flex-col items-center gap-1">
208
+ <span className="font-mono text-xs font-bold text-primary/90 [writing-mode:vertical-rl] rotate-180">
209
+ {column.title}
210
+ </span>
211
+ <Badge variant="secondary" className="text-xs">
212
+ {safeCards.length}
213
+ </Badge>
214
+ </div>
215
+ )}
216
+ </div>
217
+ {!column.collapsed && (
218
+ <div ref={scrollRef} className="flex-1 p-4 overflow-y-auto" style={{ maxHeight: '600px' }}>
219
+ <SortableContext
220
+ items={safeCards.map((c) => c.id)}
221
+ strategy={verticalListSortingStrategy}
222
+ >
223
+ {enableVirtual ? (
224
+ <VirtualizedCardList cards={safeCards} parentRef={scrollRef} />
225
+ ) : (
226
+ <div className="space-y-2">
227
+ {safeCards.map((card) => (
228
+ <SortableCard key={card.id} card={card} />
229
+ ))}
230
+ </div>
231
+ )}
232
+ </SortableContext>
233
+ </div>
234
+ )}
235
+ </div>
236
+ )
237
+ }
238
+
239
+ export function KanbanEnhanced({
240
+ columns,
241
+ onCardMove,
242
+ onColumnToggle,
243
+ enableVirtualScrolling = false,
244
+ virtualScrollThreshold = 50,
245
+ className,
246
+ }: KanbanEnhancedProps) {
247
+ const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null)
248
+
249
+ const safeColumns = React.useMemo(() => {
250
+ return (columns || []).map(col => ({
251
+ ...col,
252
+ cards: col.cards || []
253
+ }));
254
+ }, [columns]);
255
+
256
+ const [boardColumns, setBoardColumns] = React.useState<KanbanColumn[]>(safeColumns)
257
+
258
+ React.useEffect(() => {
259
+ setBoardColumns(safeColumns)
260
+ }, [safeColumns])
261
+
262
+ const sensors = useSensors(
263
+ useSensor(PointerSensor, {
264
+ activationConstraint: {
265
+ distance: 8,
266
+ },
267
+ })
268
+ )
269
+
270
+ const handleDragStart = (event: DragStartEvent) => {
271
+ const { active } = event
272
+ const card = findCard(active.id as string)
273
+ setActiveCard(card)
274
+ }
275
+
276
+ const handleDragEnd = (event: DragEndEvent) => {
277
+ const { active, over } = event
278
+ setActiveCard(null)
279
+
280
+ if (!over) return
281
+
282
+ const activeId = active.id as string
283
+ const overId = over.id as string
284
+
285
+ if (activeId === overId) return
286
+
287
+ const activeColumn = findColumnByCardId(activeId)
288
+ const overColumn = findColumnByCardId(overId) || findColumnById(overId)
289
+
290
+ if (!activeColumn || !overColumn) return
291
+
292
+ if (activeColumn.id === overColumn.id) {
293
+ const cards = [...activeColumn.cards]
294
+ const oldIndex = cards.findIndex((c) => c.id === activeId)
295
+ const newIndex = cards.findIndex((c) => c.id === overId)
296
+
297
+ const newCards = arrayMove(cards, oldIndex, newIndex)
298
+ setBoardColumns((prev) =>
299
+ prev.map((col) =>
300
+ col.id === activeColumn.id ? { ...col, cards: newCards } : col
301
+ )
302
+ )
303
+ } else {
304
+ const activeCards = [...activeColumn.cards]
305
+ const overCards = [...overColumn.cards]
306
+ const activeIndex = activeCards.findIndex((c) => c.id === activeId)
307
+
308
+ const isDroppingOnColumn = overId === overColumn.id
309
+ const overIndex = isDroppingOnColumn
310
+ ? overCards.length
311
+ : overCards.findIndex((c) => c.id === overId)
312
+
313
+ const [movedCard] = activeCards.splice(activeIndex, 1)
314
+ overCards.splice(overIndex, 0, movedCard)
315
+
316
+ setBoardColumns((prev) =>
317
+ prev.map((col) => {
318
+ if (col.id === activeColumn.id) {
319
+ return { ...col, cards: activeCards }
320
+ }
321
+ if (col.id === overColumn.id) {
322
+ return { ...col, cards: overCards }
323
+ }
324
+ return col
325
+ })
326
+ )
327
+
328
+ if (onCardMove) {
329
+ onCardMove(activeId, activeColumn.id, overColumn.id, overIndex)
330
+ }
331
+ }
332
+ }
333
+
334
+ const findCard = React.useCallback(
335
+ (cardId: string): KanbanCard | null => {
336
+ for (const column of boardColumns) {
337
+ const card = column.cards.find((c) => c.id === cardId)
338
+ if (card) return card
339
+ }
340
+ return null
341
+ },
342
+ [boardColumns]
343
+ )
344
+
345
+ const findColumnByCardId = React.useCallback(
346
+ (cardId: string): KanbanColumn | null => {
347
+ return boardColumns.find((col) => col.cards.some((c) => c.id === cardId)) || null
348
+ },
349
+ [boardColumns]
350
+ )
351
+
352
+ const findColumnById = React.useCallback(
353
+ (columnId: string): KanbanColumn | null => {
354
+ return boardColumns.find((col) => col.id === columnId) || null
355
+ },
356
+ [boardColumns]
357
+ )
358
+
359
+ const handleColumnToggle = React.useCallback((columnId: string, collapsed: boolean) => {
360
+ setBoardColumns(prev =>
361
+ prev.map(col => col.id === columnId ? { ...col, collapsed } : col)
362
+ )
363
+ onColumnToggle?.(columnId, collapsed)
364
+ }, [onColumnToggle])
365
+
366
+ return (
367
+ <DndContext
368
+ sensors={sensors}
369
+ collisionDetection={closestCorners}
370
+ onDragStart={handleDragStart}
371
+ onDragEnd={handleDragEnd}
372
+ >
373
+ <div className={cn("flex gap-4 overflow-x-auto p-4", className)}>
374
+ {boardColumns.map((column) => {
375
+ const shouldUseVirtual = enableVirtualScrolling && column.cards.length > virtualScrollThreshold
376
+ return (
377
+ <KanbanColumnEnhanced
378
+ key={column.id}
379
+ column={column}
380
+ cards={column.cards}
381
+ onToggle={(collapsed) => handleColumnToggle(column.id, collapsed)}
382
+ enableVirtual={shouldUseVirtual}
383
+ />
384
+ )
385
+ })}
386
+ </div>
387
+ <DragOverlay>
388
+ {activeCard ? <SortableCard card={activeCard} /> : null}
389
+ </DragOverlay>
390
+ </DndContext>
391
+ )
392
+ }
393
+
394
+ export default KanbanEnhanced;
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { ObjectKanban } from './ObjectKanban';
5
+ import { ObjectStackAdapter } from '@object-ui/data-objectstack';
6
+ import { setupServer } from 'msw/node';
7
+ import { http, HttpResponse } from 'msw';
8
+ import React from 'react';
9
+
10
+ // Register layout components (if needed by cards)
11
+ // registerLayout();
12
+
13
+ const BASE_URL = 'http://localhost';
14
+
15
+ // --- Mock Data ---
16
+
17
+ const mockTasks = {
18
+ value: [
19
+ { _id: '1', title: 'Task 1', status: 'todo', description: 'Description 1' },
20
+ { _id: '2', title: 'Task 2', status: 'done', description: 'Description 2' },
21
+ { _id: '3', title: 'Task 3', status: 'todo', description: 'Description 3' }
22
+ ]
23
+ };
24
+
25
+ // --- MSW Setup ---
26
+
27
+ const handlers = [
28
+ // OPTIONS handler for CORS preflight checks
29
+ http.options('*', () => {
30
+ return new HttpResponse(null, { status: 200 });
31
+ }),
32
+
33
+ // Health check
34
+ http.get(`${BASE_URL}/api/v1`, () => {
35
+ return HttpResponse.json({ status: 'ok', version: '1.0.0' });
36
+ }),
37
+
38
+ // Data Query: GET /api/v1/data/tasks
39
+ http.get(`${BASE_URL}/api/v1/data/tasks`, () => {
40
+ return HttpResponse.json(mockTasks);
41
+ })
42
+ ];
43
+
44
+ const server = setupServer(...handlers);
45
+
46
+ // --- Test Suite ---
47
+
48
+ describe('ObjectKanban with MSW', () => {
49
+ if (!process.env.OBJECTSTACK_API_URL) {
50
+ beforeAll(() => server.listen());
51
+ afterEach(() => server.resetHandlers());
52
+ afterAll(() => server.close());
53
+ }
54
+
55
+ const dataSource = new ObjectStackAdapter({
56
+ baseUrl: BASE_URL,
57
+ });
58
+
59
+ it('fetches tasks and renders them in columns based on groupBy', async () => {
60
+ render(
61
+ <ObjectKanban
62
+ schema={{
63
+ type: 'kanban',
64
+ objectName: 'tasks',
65
+ groupBy: 'status',
66
+ columns: [
67
+ { id: 'todo', title: 'To Do', cards: [] },
68
+ { id: 'done', title: 'Done', cards: [] }
69
+ ]
70
+ }}
71
+ dataSource={dataSource}
72
+ />
73
+ );
74
+
75
+ // Initial state might show Skeleton, wait for data
76
+ await waitFor(() => {
77
+ expect(screen.getByText('Task 1')).toBeInTheDocument();
78
+ }, { timeout: 10000 });
79
+
80
+ // Check classification
81
+ // Task 1 (todo) and Task 3 (todo) should be in To Do column.
82
+ // Task 2 (done) should be in Done column.
83
+
84
+ // We can verify "Task 1" is present.
85
+ expect(screen.getByText('Task 2')).toBeInTheDocument();
86
+ expect(screen.getByText('Task 3')).toBeInTheDocument();
87
+
88
+ // Check descriptions
89
+ expect(screen.getByText('Description 1')).toBeInTheDocument();
90
+ });
91
+ });