@object-ui/plugin-kanban 0.3.0 → 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.
- package/.turbo/turbo-build.log +20 -0
- package/CHANGELOG.md +11 -0
- package/dist/KanbanEnhanced-BqDEu7Z6.js +894 -0
- package/dist/KanbanImpl-B8nu2BvG.js +144 -0
- package/dist/index-CrR06na7.js +562 -0
- package/dist/index.d.ts +2 -22
- package/dist/index.js +6 -3
- package/dist/index.umd.cjs +6 -11
- package/dist/{KanbanImpl-mGLdSHcd.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/{KanbanImpl.d.ts → src/KanbanImpl.d.ts} +7 -0
- package/dist/src/KanbanImpl.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 +31 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/{types.d.ts → src/types.d.ts} +20 -0
- package/dist/src/types.d.ts.map +1 -0
- package/package.json +22 -9
- package/src/KanbanEnhanced.tsx +394 -0
- package/src/KanbanImpl.tsx +8 -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 -1
- package/src/index.tsx +93 -2
- package/src/registration.test.tsx +26 -0
- package/src/types.ts +33 -0
- package/tsconfig.json +12 -4
- package/vite.config.ts +23 -0
- package/vitest.config.ts +12 -0
- package/vitest.setup.ts +1 -0
- package/dist/KanbanImpl.d.ts.map +0 -1
- package/dist/index-i_5clVsp.js +0 -746
- package/dist/index.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
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 React, { useEffect, useState, useMemo } from 'react';
|
|
10
|
+
import type { DataSource } from '@object-ui/types';
|
|
11
|
+
import { useDataScope } from '@object-ui/react';
|
|
12
|
+
import { KanbanRenderer } from './index';
|
|
13
|
+
import { KanbanSchema } from './types';
|
|
14
|
+
|
|
15
|
+
export interface ObjectKanbanProps {
|
|
16
|
+
schema: KanbanSchema;
|
|
17
|
+
dataSource?: DataSource;
|
|
18
|
+
className?: string; // Allow override
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
|
|
22
|
+
schema,
|
|
23
|
+
dataSource,
|
|
24
|
+
className,
|
|
25
|
+
...props
|
|
26
|
+
}) => {
|
|
27
|
+
const [fetchedData, setFetchedData] = useState<any[]>([]);
|
|
28
|
+
const [objectDef, setObjectDef] = useState<any>(null);
|
|
29
|
+
// loading state
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
const [error, setError] = useState<Error | null>(null);
|
|
32
|
+
|
|
33
|
+
// Resolve bound data if 'bind' property exists
|
|
34
|
+
const boundData = useDataScope(schema.bind);
|
|
35
|
+
|
|
36
|
+
// Fetch object definition for metadata (labels, options)
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let isMounted = true;
|
|
39
|
+
const fetchMeta = async () => {
|
|
40
|
+
if (!dataSource || !schema.objectName) return;
|
|
41
|
+
try {
|
|
42
|
+
const def = await dataSource.getObjectSchema(schema.objectName);
|
|
43
|
+
if (isMounted) setObjectDef(def);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.warn("Failed to fetch object def", e);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
fetchMeta();
|
|
49
|
+
return () => { isMounted = false; };
|
|
50
|
+
}, [schema.objectName, dataSource]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let isMounted = true;
|
|
54
|
+
const fetchData = async () => {
|
|
55
|
+
if (!dataSource || !schema.objectName) return;
|
|
56
|
+
if (isMounted) setLoading(true);
|
|
57
|
+
try {
|
|
58
|
+
const results = await dataSource.find(schema.objectName, {
|
|
59
|
+
options: { $top: 100 },
|
|
60
|
+
$filter: schema.filter
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Handle { value: [] } OData shape or { data: [] } shape or direct array
|
|
64
|
+
let data: any[] = [];
|
|
65
|
+
if (Array.isArray(results)) {
|
|
66
|
+
data = results;
|
|
67
|
+
} else if (results && typeof results === 'object') {
|
|
68
|
+
if (Array.isArray((results as any).value)) {
|
|
69
|
+
data = (results as any).value;
|
|
70
|
+
} else if (Array.isArray((results as any).data)) {
|
|
71
|
+
data = (results as any).data;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`[ObjectKanban] Extracted data (length: ${data.length})`);
|
|
76
|
+
|
|
77
|
+
if (isMounted) {
|
|
78
|
+
setFetchedData(data);
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
console.error('[ObjectKanban] Fetch error:', e);
|
|
82
|
+
if (isMounted) setError(e as Error);
|
|
83
|
+
} finally {
|
|
84
|
+
if (isMounted) setLoading(false);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Trigger fetch if we have an objectName AND verify no inline/bound data overrides it
|
|
89
|
+
// And NO props.data passed from ListView
|
|
90
|
+
if (schema.objectName && !boundData && !schema.data && !(props as any).data) {
|
|
91
|
+
fetchData();
|
|
92
|
+
}
|
|
93
|
+
return () => { isMounted = false; };
|
|
94
|
+
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, (props as any).data]);
|
|
95
|
+
|
|
96
|
+
// Determine which data to use: props.data -> bound -> inline -> fetched
|
|
97
|
+
const rawData = (props as any).data || boundData || schema.data || fetchedData;
|
|
98
|
+
|
|
99
|
+
// Enhance data with title mapping and ensure IDs
|
|
100
|
+
const effectiveData = useMemo(() => {
|
|
101
|
+
if (!Array.isArray(rawData)) return [];
|
|
102
|
+
|
|
103
|
+
// Support cardTitle property from schema (passed by ObjectView)
|
|
104
|
+
// Fallback to legacy titleField for backwards compatibility
|
|
105
|
+
let titleField = schema.cardTitle || (schema as any).titleField;
|
|
106
|
+
|
|
107
|
+
// Fallback: Try to infer from object definition
|
|
108
|
+
if (!titleField && objectDef) {
|
|
109
|
+
// 1. Check for titleFormat like "{subject}" first (Higher priority for Cards)
|
|
110
|
+
if (objectDef.titleFormat) {
|
|
111
|
+
const match = /\{(.+?)\}/.exec(objectDef.titleFormat);
|
|
112
|
+
if (match) titleField = match[1];
|
|
113
|
+
}
|
|
114
|
+
// 2. Check for standard NAME_FIELD_KEY
|
|
115
|
+
if (!titleField && objectDef.NAME_FIELD_KEY) {
|
|
116
|
+
titleField = objectDef.NAME_FIELD_KEY;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Default to 'name'
|
|
121
|
+
const finalTitleField = titleField || 'name';
|
|
122
|
+
|
|
123
|
+
return rawData.map(item => ({
|
|
124
|
+
...item,
|
|
125
|
+
// Ensure id exists
|
|
126
|
+
id: item.id || item._id,
|
|
127
|
+
// Map title
|
|
128
|
+
title: item[finalTitleField] || item.title || 'Untitled',
|
|
129
|
+
}));
|
|
130
|
+
}, [rawData, schema, objectDef]);
|
|
131
|
+
|
|
132
|
+
// Generate columns if missing but groupBy is present
|
|
133
|
+
const effectiveColumns = useMemo(() => {
|
|
134
|
+
// If columns exist, returns them (normalized)
|
|
135
|
+
if (schema.columns && schema.columns.length > 0) {
|
|
136
|
+
// If columns is array of strings, normalize to objects
|
|
137
|
+
if (typeof schema.columns[0] === 'string') {
|
|
138
|
+
// If grouping is active, assume string columns are meant for data display, not lanes
|
|
139
|
+
if (!schema.groupBy) {
|
|
140
|
+
return (schema.columns as unknown as string[]).map(val => ({
|
|
141
|
+
id: val,
|
|
142
|
+
title: val
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
return schema.columns;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Try to get options from metadata
|
|
151
|
+
if (schema.groupBy && objectDef?.fields?.[schema.groupBy]?.options) {
|
|
152
|
+
return objectDef.fields[schema.groupBy].options.map((opt: any) => ({
|
|
153
|
+
id: opt.value,
|
|
154
|
+
title: opt.label
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// If no columns, but we have groupBy and data, generate from data
|
|
159
|
+
if (schema.groupBy && effectiveData.length > 0) {
|
|
160
|
+
const groups = new Set(effectiveData.map(item => item[schema.groupBy!]));
|
|
161
|
+
return Array.from(groups).map(g => ({
|
|
162
|
+
id: String(g),
|
|
163
|
+
title: String(g)
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return [];
|
|
168
|
+
}, [schema.columns, schema.groupBy, effectiveData, objectDef]);
|
|
169
|
+
|
|
170
|
+
// Clone schema to inject data and className
|
|
171
|
+
const effectiveSchema = {
|
|
172
|
+
...schema,
|
|
173
|
+
data: effectiveData,
|
|
174
|
+
columns: effectiveColumns,
|
|
175
|
+
className: className || schema.className
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (error) {
|
|
179
|
+
return (
|
|
180
|
+
<div className="p-4 border border-destructive/50 rounded bg-destructive/10 text-destructive">
|
|
181
|
+
Error loading kanban data: {error.message}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Pass through to the renderer
|
|
187
|
+
return <KanbanRenderer schema={effectiveSchema} />;
|
|
188
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
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 { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
11
|
+
import { KanbanEnhanced, type KanbanColumn } from '../KanbanEnhanced';
|
|
12
|
+
|
|
13
|
+
// Mock @tanstack/react-virtual
|
|
14
|
+
vi.mock('@tanstack/react-virtual', () => ({
|
|
15
|
+
useVirtualizer: () => ({
|
|
16
|
+
getTotalSize: () => 1000,
|
|
17
|
+
getVirtualItems: () => [],
|
|
18
|
+
measureElement: vi.fn(),
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Mock @dnd-kit/core and utilities
|
|
23
|
+
vi.mock('@dnd-kit/core', () => ({
|
|
24
|
+
DndContext: ({ children }: any) => <div data-testid="dnd-context">{children}</div>,
|
|
25
|
+
DragOverlay: ({ children }: any) => <div data-testid="drag-overlay">{children}</div>,
|
|
26
|
+
PointerSensor: vi.fn(),
|
|
27
|
+
useSensor: vi.fn(),
|
|
28
|
+
useSensors: () => [],
|
|
29
|
+
closestCorners: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock('@dnd-kit/sortable', () => ({
|
|
33
|
+
SortableContext: ({ children }: any) => <div data-testid="sortable-context">{children}</div>,
|
|
34
|
+
useSortable: () => ({
|
|
35
|
+
attributes: {},
|
|
36
|
+
listeners: {},
|
|
37
|
+
setNodeRef: vi.fn(),
|
|
38
|
+
transform: null,
|
|
39
|
+
transition: null,
|
|
40
|
+
isDragging: false,
|
|
41
|
+
}),
|
|
42
|
+
arrayMove: (array: any[], from: number, to: number) => {
|
|
43
|
+
const newArray = [...array];
|
|
44
|
+
newArray.splice(to, 0, newArray.splice(from, 1)[0]);
|
|
45
|
+
return newArray;
|
|
46
|
+
},
|
|
47
|
+
verticalListSortingStrategy: vi.fn(),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('@dnd-kit/utilities', () => ({
|
|
51
|
+
CSS: {
|
|
52
|
+
Transform: {
|
|
53
|
+
toString: () => '',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
describe('KanbanEnhanced', () => {
|
|
59
|
+
const mockColumns: KanbanColumn[] = [
|
|
60
|
+
{
|
|
61
|
+
id: 'todo',
|
|
62
|
+
title: 'To Do',
|
|
63
|
+
cards: [
|
|
64
|
+
{
|
|
65
|
+
id: 'card-1',
|
|
66
|
+
title: 'Task 1',
|
|
67
|
+
description: 'Description 1',
|
|
68
|
+
badges: [{ label: 'High', variant: 'destructive' }],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'card-2',
|
|
72
|
+
title: 'Task 2',
|
|
73
|
+
description: 'Description 2',
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'in-progress',
|
|
79
|
+
title: 'In Progress',
|
|
80
|
+
limit: 3,
|
|
81
|
+
cards: [
|
|
82
|
+
{
|
|
83
|
+
id: 'card-3',
|
|
84
|
+
title: 'Task 3',
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'done',
|
|
90
|
+
title: 'Done',
|
|
91
|
+
cards: [],
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
it('should render without crashing', () => {
|
|
96
|
+
const { container } = render(<KanbanEnhanced columns={mockColumns} />);
|
|
97
|
+
expect(container).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should render all columns', () => {
|
|
101
|
+
render(<KanbanEnhanced columns={mockColumns} />);
|
|
102
|
+
|
|
103
|
+
expect(screen.getByText('To Do')).toBeInTheDocument();
|
|
104
|
+
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
|
105
|
+
expect(screen.getByText('Done')).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should render all cards', () => {
|
|
109
|
+
render(<KanbanEnhanced columns={mockColumns} />);
|
|
110
|
+
|
|
111
|
+
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
|
112
|
+
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
|
113
|
+
expect(screen.getByText('Task 3')).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should display card count for each column', () => {
|
|
117
|
+
render(<KanbanEnhanced columns={mockColumns} />);
|
|
118
|
+
|
|
119
|
+
// Columns should show card counts in the format "count" or "count / limit"
|
|
120
|
+
expect(screen.getByText('To Do')).toBeInTheDocument();
|
|
121
|
+
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
|
122
|
+
expect(screen.getByText('1 / 3')).toBeInTheDocument(); // In Progress has 1 card with limit 3
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should display column limit warning when at 80% capacity', () => {
|
|
126
|
+
const columnsNearLimit: KanbanColumn[] = [
|
|
127
|
+
{
|
|
128
|
+
id: 'limited',
|
|
129
|
+
title: 'Limited Column',
|
|
130
|
+
limit: 5,
|
|
131
|
+
cards: Array(4).fill(null).map((_, i) => ({
|
|
132
|
+
id: `card-${i}`,
|
|
133
|
+
title: `Task ${i}`,
|
|
134
|
+
})),
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const { container } = render(<KanbanEnhanced columns={columnsNearLimit} />);
|
|
139
|
+
|
|
140
|
+
// Should show warning indicator (80% of 5 = 4) - AlertTriangle icon with yellow color
|
|
141
|
+
expect(container.querySelector('.text-yellow-500')).toBeTruthy();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should collapse/expand columns when toggle button is clicked', () => {
|
|
145
|
+
render(<KanbanEnhanced columns={mockColumns} enableCollapse={true} />);
|
|
146
|
+
|
|
147
|
+
// Find collapse toggle button (chevron icons)
|
|
148
|
+
const toggleButtons = screen.getAllByRole('button');
|
|
149
|
+
const collapseButton = toggleButtons.find(btn =>
|
|
150
|
+
btn.querySelector('svg') !== null
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (collapseButton) {
|
|
154
|
+
fireEvent.click(collapseButton);
|
|
155
|
+
// After clicking, the column state would change
|
|
156
|
+
// In a real test with proper DOM, we would verify:
|
|
157
|
+
// - Column content is hidden
|
|
158
|
+
// - Icon changes from ChevronDown to ChevronRight
|
|
159
|
+
expect(collapseButton).toBeTruthy();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should call onCardMove when a card is moved', () => {
|
|
164
|
+
const onCardMove = vi.fn();
|
|
165
|
+
render(<KanbanEnhanced columns={mockColumns} onCardMove={onCardMove} />);
|
|
166
|
+
|
|
167
|
+
// In our mocked environment with mocked dnd-kit,
|
|
168
|
+
// we can't easily simulate the full drag and drop interaction.
|
|
169
|
+
// In a real integration test, this would verify:
|
|
170
|
+
// - Dragging a card from one column to another
|
|
171
|
+
// - onCardMove is called with correct parameters (cardId, fromColumn, toColumn)
|
|
172
|
+
expect(onCardMove).toBeDefined();
|
|
173
|
+
|
|
174
|
+
// Example of what the callback would receive:
|
|
175
|
+
// expect(onCardMove).toHaveBeenCalledWith('card-1', 'todo', 'in-progress');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should call onColumnToggle when a column is collapsed', () => {
|
|
179
|
+
const onColumnToggle = vi.fn();
|
|
180
|
+
render(
|
|
181
|
+
<KanbanEnhanced
|
|
182
|
+
columns={mockColumns}
|
|
183
|
+
enableCollapse={true}
|
|
184
|
+
onColumnToggle={onColumnToggle}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(onColumnToggle).toBeDefined();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should render card badges', () => {
|
|
192
|
+
render(<KanbanEnhanced columns={mockColumns} />);
|
|
193
|
+
|
|
194
|
+
expect(screen.getByText('High')).toBeInTheDocument();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should render card descriptions', () => {
|
|
198
|
+
render(<KanbanEnhanced columns={mockColumns} />);
|
|
199
|
+
|
|
200
|
+
expect(screen.getByText('Description 1')).toBeInTheDocument();
|
|
201
|
+
expect(screen.getByText('Description 2')).toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should apply custom className', () => {
|
|
205
|
+
const { container } = render(
|
|
206
|
+
<KanbanEnhanced columns={mockColumns} className="custom-class" />
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const kanbanContainer = container.querySelector('.custom-class');
|
|
210
|
+
expect(kanbanContainer).toBeTruthy();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should render empty columns', () => {
|
|
214
|
+
render(<KanbanEnhanced columns={mockColumns} />);
|
|
215
|
+
|
|
216
|
+
// "Done" column has 0 cards
|
|
217
|
+
expect(screen.getByText('Done')).toBeInTheDocument();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should enable virtual scrolling when specified', () => {
|
|
221
|
+
render(<KanbanEnhanced columns={mockColumns} enableVirtualScrolling={true} />);
|
|
222
|
+
|
|
223
|
+
// Virtual scrolling should be active
|
|
224
|
+
const { container } = render(<KanbanEnhanced columns={mockColumns} enableVirtualScrolling={true} />);
|
|
225
|
+
expect(container).toBeTruthy();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should render drag overlay when dragging', () => {
|
|
229
|
+
const { container } = render(<KanbanEnhanced columns={mockColumns} />);
|
|
230
|
+
|
|
231
|
+
const dragOverlay = container.querySelector('[data-testid="drag-overlay"]');
|
|
232
|
+
expect(dragOverlay).toBeTruthy();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should show error state when column is over limit', () => {
|
|
236
|
+
const columnsOverLimit: KanbanColumn[] = [
|
|
237
|
+
{
|
|
238
|
+
id: 'limited',
|
|
239
|
+
title: 'Over Limit',
|
|
240
|
+
limit: 3,
|
|
241
|
+
cards: Array(4).fill(null).map((_, i) => ({
|
|
242
|
+
id: `card-${i}`,
|
|
243
|
+
title: `Task ${i}`,
|
|
244
|
+
})),
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
render(<KanbanEnhanced columns={columnsOverLimit} />);
|
|
249
|
+
|
|
250
|
+
// Should show error indicator (over 100% of limit)
|
|
251
|
+
const { container } = render(<KanbanEnhanced columns={columnsOverLimit} />);
|
|
252
|
+
expect(container.querySelector('[class*="destructive"]')).toBeTruthy();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should handle empty columns array', () => {
|
|
256
|
+
const { container } = render(<KanbanEnhanced columns={[]} />);
|
|
257
|
+
expect(container).toBeTruthy();
|
|
258
|
+
});
|
|
259
|
+
});
|
package/src/index.test.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
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
|
+
|
|
1
9
|
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
10
|
import { ComponentRegistry } from '@object-ui/core';
|
|
3
11
|
|
|
@@ -5,7 +13,7 @@ describe('Plugin Kanban', () => {
|
|
|
5
13
|
// Import all renderers to register them
|
|
6
14
|
beforeAll(async () => {
|
|
7
15
|
await import('./index');
|
|
8
|
-
});
|
|
16
|
+
}, 15000); // Increase timeout to 15 seconds for async import
|
|
9
17
|
|
|
10
18
|
describe('kanban component', () => {
|
|
11
19
|
it('should be registered in ComponentRegistry', () => {
|
package/src/index.tsx
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
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
|
+
|
|
1
9
|
import React, { Suspense } from 'react';
|
|
2
10
|
import { ComponentRegistry } from '@object-ui/core';
|
|
11
|
+
import { useSchemaContext } from '@object-ui/react';
|
|
3
12
|
import { Skeleton } from '@object-ui/components';
|
|
13
|
+
import { ObjectKanban } from './ObjectKanban';
|
|
4
14
|
|
|
5
15
|
// Export types for external use
|
|
6
16
|
export type { KanbanSchema, KanbanCard, KanbanColumn } from './types';
|
|
17
|
+
export { ObjectKanban };
|
|
18
|
+
export type { ObjectKanbanProps } from './ObjectKanban';
|
|
7
19
|
|
|
8
|
-
// 🚀 Lazy load the implementation
|
|
9
|
-
// This ensures @dnd-kit is only loaded when the component is actually rendered
|
|
20
|
+
// 🚀 Lazy load the implementation files
|
|
10
21
|
const LazyKanban = React.lazy(() => import('./KanbanImpl'));
|
|
22
|
+
const LazyKanbanEnhanced = React.lazy(() => import('./KanbanEnhanced'));
|
|
11
23
|
|
|
12
24
|
export interface KanbanRendererProps {
|
|
13
25
|
schema: {
|
|
@@ -70,6 +82,7 @@ ComponentRegistry.register(
|
|
|
70
82
|
'kanban',
|
|
71
83
|
KanbanRenderer,
|
|
72
84
|
{
|
|
85
|
+
namespace: 'plugin-kanban',
|
|
73
86
|
label: 'Kanban Board',
|
|
74
87
|
icon: 'LayoutDashboard',
|
|
75
88
|
category: 'plugin',
|
|
@@ -165,4 +178,82 @@ ComponentRegistry.register(
|
|
|
165
178
|
// Standard Export Protocol - for manual integration
|
|
166
179
|
export const kanbanComponents = {
|
|
167
180
|
'kanban': KanbanRenderer,
|
|
181
|
+
'kanban-enhanced': LazyKanbanEnhanced,
|
|
182
|
+
'object-kanban': ObjectKanban,
|
|
168
183
|
};
|
|
184
|
+
|
|
185
|
+
// Register enhanced Kanban
|
|
186
|
+
ComponentRegistry.register(
|
|
187
|
+
'kanban-enhanced',
|
|
188
|
+
({ schema }: { schema: any }) => {
|
|
189
|
+
const processedColumns = React.useMemo(() => {
|
|
190
|
+
const { columns = [], data, groupBy } = schema;
|
|
191
|
+
if (data && groupBy && Array.isArray(data)) {
|
|
192
|
+
const groups = data.reduce((acc, item) => {
|
|
193
|
+
const key = item[groupBy];
|
|
194
|
+
if (!acc[key]) acc[key] = [];
|
|
195
|
+
acc[key].push(item);
|
|
196
|
+
return acc;
|
|
197
|
+
}, {} as Record<string, any[]>);
|
|
198
|
+
return columns.map((col: any) => ({
|
|
199
|
+
...col,
|
|
200
|
+
cards: [...(col.cards || []), ...(groups[col.id] || [])]
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
return columns;
|
|
204
|
+
}, [schema]);
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<Suspense fallback={<Skeleton className="w-full h-[600px]" />}>
|
|
208
|
+
<LazyKanbanEnhanced
|
|
209
|
+
columns={processedColumns}
|
|
210
|
+
onCardMove={schema.onCardMove}
|
|
211
|
+
onColumnToggle={schema.onColumnToggle}
|
|
212
|
+
enableVirtualScrolling={schema.enableVirtualScrolling}
|
|
213
|
+
virtualScrollThreshold={schema.virtualScrollThreshold}
|
|
214
|
+
className={schema.className}
|
|
215
|
+
/>
|
|
216
|
+
</Suspense>
|
|
217
|
+
);
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
namespace: 'plugin-kanban',
|
|
221
|
+
label: 'Kanban Board (Enhanced)',
|
|
222
|
+
icon: 'LayoutGrid',
|
|
223
|
+
category: 'plugin',
|
|
224
|
+
inputs: [
|
|
225
|
+
{ name: 'columns', type: 'array', label: 'Columns', required: true },
|
|
226
|
+
{ name: 'enableVirtualScrolling', type: 'boolean', label: 'Virtual Scrolling', defaultValue: false },
|
|
227
|
+
{ name: 'virtualScrollThreshold', type: 'number', label: 'Virtual Scroll Threshold', defaultValue: 50 },
|
|
228
|
+
{ name: 'onCardMove', type: 'code', label: 'On Card Move', advanced: true },
|
|
229
|
+
{ name: 'onColumnToggle', type: 'code', label: 'On Column Toggle', advanced: true },
|
|
230
|
+
{ name: 'className', type: 'string', label: 'CSS Class' }
|
|
231
|
+
],
|
|
232
|
+
defaultProps: {
|
|
233
|
+
columns: [],
|
|
234
|
+
enableVirtualScrolling: false,
|
|
235
|
+
virtualScrollThreshold: 50,
|
|
236
|
+
className: 'w-full'
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// Register object-kanban for ListView integration
|
|
242
|
+
export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => {
|
|
243
|
+
const { dataSource } = useSchemaContext() || {};
|
|
244
|
+
return <ObjectKanban schema={schema} dataSource={dataSource} {...props} />;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
ComponentRegistry.register(
|
|
248
|
+
'object-kanban',
|
|
249
|
+
ObjectKanbanRenderer,
|
|
250
|
+
{
|
|
251
|
+
namespace: 'plugin-kanban',
|
|
252
|
+
label: 'Object Kanban',
|
|
253
|
+
category: 'plugin',
|
|
254
|
+
inputs: [
|
|
255
|
+
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
256
|
+
{ name: 'columns', type: 'array', label: 'Columns' }
|
|
257
|
+
]
|
|
258
|
+
}
|
|
259
|
+
);
|