@object-ui/plugin-kanban 0.3.1 → 2.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 +20 -0
- package/CHANGELOG.md +14 -0
- package/dist/KanbanEnhanced-BMreTWOT.js +894 -0
- package/dist/KanbanImpl--kTNN_B8.js +144 -0
- package/dist/index-a4_RI-v7.js +579 -0
- package/dist/index.js +6 -3
- package/dist/index.umd.cjs +6 -3
- package/dist/{KanbanImpl-CUWM-JC-.js → sortable.esm-ZHwgFQIO.js} +1053 -1183
- package/dist/src/KanbanEnhanced.d.ts +36 -0
- package/dist/src/KanbanEnhanced.d.ts.map +1 -0
- package/dist/src/ObjectKanban.d.ts +10 -0
- package/dist/src/ObjectKanban.d.ts.map +1 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/types.d.ts +21 -1
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +11 -8
- package/src/KanbanEnhanced.tsx +394 -0
- package/src/ObjectKanban.msw.test.tsx +91 -0
- package/src/ObjectKanban.tsx +188 -0
- package/src/__tests__/KanbanEnhanced.test.tsx +259 -0
- package/src/index.test.ts +9 -9
- package/src/index.tsx +111 -5
- package/src/registration.test.tsx +26 -0
- package/src/types.ts +25 -0
- package/vite.config.ts +13 -0
- package/vitest.config.ts +12 -0
- package/vitest.setup.ts +1 -0
- package/dist/index-BV3FWhCb.js +0 -395
|
@@ -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"}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
package/dist/src/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAmB,MAAM,OAAO,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,CA+CxD,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"}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseSchema } from '
|
|
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.
|
package/dist/src/types.d.ts.map
CHANGED
|
@@ -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
|
+
"version": "2.0.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
|
-
"@
|
|
31
|
-
"
|
|
32
|
-
"@object-ui/
|
|
33
|
-
"@object-ui/
|
|
30
|
+
"@tanstack/react-virtual": "^3.10.8",
|
|
31
|
+
"lucide-react": "^0.563.0",
|
|
32
|
+
"@object-ui/components": "2.0.0",
|
|
33
|
+
"@object-ui/core": "2.0.0",
|
|
34
|
+
"@object-ui/react": "2.0.0",
|
|
35
|
+
"@object-ui/types": "2.0.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.
|
|
42
|
+
"@types/react": "^19.2.13",
|
|
41
43
|
"@types/react-dom": "^19.2.3",
|
|
42
|
-
"@vitejs/plugin-react": "^
|
|
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": "2.0.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
|
+
});
|