@object-ui/plugin-kanban 3.3.0 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +24 -0
- package/dist/{KanbanEnhanced-TdUe0kQH.js → KanbanEnhanced-Do9ZB1Mh.js} +35 -32
- package/dist/{KanbanImpl-BtlPa7GE.js → KanbanImpl-BdocXM5T.js} +1 -1
- package/dist/{chevron-down-B6UH8BbF.js → chevron-down-C0JUlGjk.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/index.umd.cjs +2 -2
- package/dist/{plus-BTqoaaEC.js → plus-CHsXVJSY.js} +1 -1
- package/package.json +34 -11
- package/.turbo/turbo-build.log +0 -32
- package/src/CardTemplates.tsx +0 -123
- package/src/InlineQuickAdd.tsx +0 -189
- package/src/KanbanEnhanced.tsx +0 -525
- package/src/KanbanImpl.tsx +0 -597
- package/src/ObjectKanban.EdgeCases.stories.tsx +0 -168
- package/src/ObjectKanban.msw.test.tsx +0 -95
- package/src/ObjectKanban.stories.tsx +0 -152
- package/src/ObjectKanban.tsx +0 -276
- package/src/__tests__/KanbanEnhanced.test.tsx +0 -260
- package/src/__tests__/KanbanGrouping.test.tsx +0 -164
- package/src/__tests__/KanbanSwimlanes.test.tsx +0 -194
- package/src/__tests__/ObjectKanbanTitle.test.tsx +0 -93
- package/src/__tests__/SwimlanePersistence.test.tsx +0 -159
- package/src/__tests__/accessibility.test.tsx +0 -296
- package/src/__tests__/dnd-undo-integration.test.tsx +0 -525
- package/src/__tests__/performance-benchmark.test.tsx +0 -306
- package/src/__tests__/phase13-features.test.tsx +0 -387
- package/src/__tests__/view-states.test.tsx +0 -403
- package/src/index.test.ts +0 -112
- package/src/index.tsx +0 -327
- package/src/registration.test.tsx +0 -26
- package/src/types.ts +0 -185
- package/src/useColumnWidths.ts +0 -125
- package/src/useCrossSwimlaneMove.ts +0 -116
- package/src/useQuickAddReorder.ts +0 -107
- package/tsconfig.json +0 -19
- package/vite.config.ts +0 -62
- package/vitest.config.ts +0 -12
- package/vitest.setup.ts +0 -1
|
@@ -1,168 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,95 +0,0 @@
|
|
|
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 / discovery
|
|
34
|
-
http.get(`${BASE_URL}/api/v1`, () => {
|
|
35
|
-
return HttpResponse.json({ status: 'ok', version: '1.0.0' });
|
|
36
|
-
}),
|
|
37
|
-
|
|
38
|
-
http.get(`${BASE_URL}/api/v1/discovery`, () => {
|
|
39
|
-
return HttpResponse.json({ status: 'ok', version: '1.0.0' });
|
|
40
|
-
}),
|
|
41
|
-
|
|
42
|
-
// Data Query: GET /api/v1/data/tasks
|
|
43
|
-
http.get(`${BASE_URL}/api/v1/data/tasks`, () => {
|
|
44
|
-
return HttpResponse.json(mockTasks);
|
|
45
|
-
})
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
const server = setupServer(...handlers);
|
|
49
|
-
|
|
50
|
-
// --- Test Suite ---
|
|
51
|
-
|
|
52
|
-
describe('ObjectKanban with MSW', () => {
|
|
53
|
-
if (!process.env.OBJECTSTACK_API_URL) {
|
|
54
|
-
beforeAll(() => server.listen());
|
|
55
|
-
afterEach(() => server.resetHandlers());
|
|
56
|
-
afterAll(() => server.close());
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const dataSource = new ObjectStackAdapter({
|
|
60
|
-
baseUrl: BASE_URL,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('fetches tasks and renders them in columns based on groupBy', async () => {
|
|
64
|
-
render(
|
|
65
|
-
<ObjectKanban
|
|
66
|
-
schema={{
|
|
67
|
-
type: 'kanban',
|
|
68
|
-
objectName: 'tasks',
|
|
69
|
-
groupBy: 'status',
|
|
70
|
-
columns: [
|
|
71
|
-
{ id: 'todo', title: 'To Do', cards: [] },
|
|
72
|
-
{ id: 'done', title: 'Done', cards: [] }
|
|
73
|
-
]
|
|
74
|
-
}}
|
|
75
|
-
dataSource={dataSource}
|
|
76
|
-
/>
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
// Initial state might show Skeleton, wait for data
|
|
80
|
-
await waitFor(() => {
|
|
81
|
-
expect(screen.getByText('Task 1')).toBeInTheDocument();
|
|
82
|
-
}, { timeout: 10000 });
|
|
83
|
-
|
|
84
|
-
// Check classification
|
|
85
|
-
// Task 1 (todo) and Task 3 (todo) should be in To Do column.
|
|
86
|
-
// Task 2 (done) should be in Done column.
|
|
87
|
-
|
|
88
|
-
// We can verify "Task 1" is present.
|
|
89
|
-
expect(screen.getByText('Task 2')).toBeInTheDocument();
|
|
90
|
-
expect(screen.getByText('Task 3')).toBeInTheDocument();
|
|
91
|
-
|
|
92
|
-
// Check descriptions
|
|
93
|
-
expect(screen.getByText('Description 1')).toBeInTheDocument();
|
|
94
|
-
});
|
|
95
|
-
});
|
|
@@ -1,152 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import React, { useEffect, useState, useMemo } from 'react';
|
|
10
|
-
import type { DataSource } from '@object-ui/types';
|
|
11
|
-
import { useDataScope, useNavigationOverlay } from '@object-ui/react';
|
|
12
|
-
import { NavigationOverlay } from '@object-ui/components';
|
|
13
|
-
import { extractRecords, buildExpandFields } from '@object-ui/core';
|
|
14
|
-
import { KanbanRenderer } from './index';
|
|
15
|
-
import { KanbanSchema } from './types';
|
|
16
|
-
|
|
17
|
-
export interface ObjectKanbanProps {
|
|
18
|
-
schema: KanbanSchema;
|
|
19
|
-
dataSource?: DataSource;
|
|
20
|
-
className?: string; // Allow override
|
|
21
|
-
/** Pre-fetched records passed by a parent (e.g. ListView). When provided, skips internal data fetching. */
|
|
22
|
-
data?: any[];
|
|
23
|
-
/** Loading state propagated from a parent. Respected only when `data` is also provided. */
|
|
24
|
-
loading?: boolean;
|
|
25
|
-
onRowClick?: (record: any) => void;
|
|
26
|
-
onCardClick?: (record: any) => void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
|
|
30
|
-
schema,
|
|
31
|
-
dataSource,
|
|
32
|
-
className,
|
|
33
|
-
data: externalData,
|
|
34
|
-
loading: externalLoading,
|
|
35
|
-
onRowClick,
|
|
36
|
-
onCardClick,
|
|
37
|
-
...props
|
|
38
|
-
}) => {
|
|
39
|
-
// When a parent (e.g. ListView) pre-fetches data and passes it via the `data` prop,
|
|
40
|
-
// we must not trigger a second fetch. Detect external data by checking if externalData
|
|
41
|
-
// is an array (undefined when not provided by parent).
|
|
42
|
-
const hasExternalData = Array.isArray(externalData);
|
|
43
|
-
|
|
44
|
-
const [fetchedData, setFetchedData] = useState<any[]>([]);
|
|
45
|
-
const [objectDef, setObjectDef] = useState<any>(null);
|
|
46
|
-
// loading state
|
|
47
|
-
const [loading, setLoading] = useState(hasExternalData ? (externalLoading ?? false) : false);
|
|
48
|
-
const [error, setError] = useState<Error | null>(null);
|
|
49
|
-
const [refreshKey, setRefreshKey] = useState(0);
|
|
50
|
-
|
|
51
|
-
// Resolve bound data if 'bind' property exists
|
|
52
|
-
const boundData = useDataScope(schema.bind);
|
|
53
|
-
|
|
54
|
-
// P2: Auto-subscribe to DataSource mutation events (standalone mode only).
|
|
55
|
-
// When rendered as a child of ListView, data is managed externally and this is skipped.
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
if (hasExternalData) return; // Parent handles refresh
|
|
58
|
-
if (!dataSource?.onMutation || !schema.objectName) return;
|
|
59
|
-
const unsub = dataSource.onMutation((event: any) => {
|
|
60
|
-
if (event.resource === schema.objectName) {
|
|
61
|
-
setRefreshKey(k => k + 1);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
return unsub;
|
|
65
|
-
}, [dataSource, schema.objectName, hasExternalData]);
|
|
66
|
-
|
|
67
|
-
// Sync external data changes from parent (e.g. ListView re-fetches after filter change)
|
|
68
|
-
useEffect(() => {
|
|
69
|
-
if (hasExternalData && externalLoading !== undefined) {
|
|
70
|
-
setLoading(externalLoading);
|
|
71
|
-
}
|
|
72
|
-
}, [externalLoading, hasExternalData]);
|
|
73
|
-
|
|
74
|
-
// Fetch object definition for metadata (labels, options)
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
let isMounted = true;
|
|
77
|
-
const fetchMeta = async () => {
|
|
78
|
-
if (!dataSource || !schema.objectName) return;
|
|
79
|
-
try {
|
|
80
|
-
const def = await dataSource.getObjectSchema(schema.objectName);
|
|
81
|
-
if (isMounted) setObjectDef(def);
|
|
82
|
-
} catch (e) {
|
|
83
|
-
console.warn("Failed to fetch object def", e);
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
fetchMeta();
|
|
87
|
-
return () => { isMounted = false; };
|
|
88
|
-
}, [schema.objectName, dataSource]);
|
|
89
|
-
|
|
90
|
-
useEffect(() => {
|
|
91
|
-
// Skip internal fetch when data is managed by a parent component
|
|
92
|
-
if (hasExternalData) return;
|
|
93
|
-
|
|
94
|
-
let isMounted = true;
|
|
95
|
-
const fetchData = async () => {
|
|
96
|
-
if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) return;
|
|
97
|
-
if (isMounted) setLoading(true);
|
|
98
|
-
try {
|
|
99
|
-
// Auto-inject $expand for lookup/master_detail fields
|
|
100
|
-
const expand = buildExpandFields(objectDef?.fields);
|
|
101
|
-
const results = await dataSource.find(schema.objectName, {
|
|
102
|
-
options: { $top: 100 },
|
|
103
|
-
$filter: schema.filter,
|
|
104
|
-
...(expand.length > 0 ? { $expand: expand } : {}),
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// Handle { value: [] } OData shape or { data: [] } shape or direct array
|
|
108
|
-
const data = extractRecords(results);
|
|
109
|
-
|
|
110
|
-
if (isMounted) {
|
|
111
|
-
setFetchedData(data);
|
|
112
|
-
}
|
|
113
|
-
} catch (e) {
|
|
114
|
-
console.error('[ObjectKanban] Fetch error:', e);
|
|
115
|
-
if (isMounted) setError(e as Error);
|
|
116
|
-
} finally {
|
|
117
|
-
if (isMounted) setLoading(false);
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
// Trigger fetch if we have an objectName AND verify no inline/bound data overrides it
|
|
122
|
-
if (schema.objectName && !boundData && !schema.data) {
|
|
123
|
-
fetchData();
|
|
124
|
-
}
|
|
125
|
-
return () => { isMounted = false; };
|
|
126
|
-
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, hasExternalData, objectDef, refreshKey]);
|
|
127
|
-
|
|
128
|
-
// Determine which data to use: external -> bound -> inline -> fetched
|
|
129
|
-
const rawData = (hasExternalData ? externalData : undefined) || boundData || schema.data || fetchedData;
|
|
130
|
-
|
|
131
|
-
// Enhance data with title mapping and ensure IDs
|
|
132
|
-
const effectiveData = useMemo(() => {
|
|
133
|
-
if (!Array.isArray(rawData)) return [];
|
|
134
|
-
|
|
135
|
-
// Support cardTitle property from schema (passed by ObjectView)
|
|
136
|
-
// Fallback to legacy titleField for backwards compatibility
|
|
137
|
-
let titleField = schema.cardTitle || (schema as any).titleField;
|
|
138
|
-
|
|
139
|
-
// Fallback: Try to infer from object definition
|
|
140
|
-
if (!titleField && objectDef) {
|
|
141
|
-
// 1. Check for titleFormat like "{subject}" first (Higher priority for Cards)
|
|
142
|
-
if (objectDef.titleFormat) {
|
|
143
|
-
const match = /\{(.+?)\}/.exec(objectDef.titleFormat);
|
|
144
|
-
if (match) titleField = match[1];
|
|
145
|
-
}
|
|
146
|
-
// 2. Check for standard NAME_FIELD_KEY
|
|
147
|
-
if (!titleField && objectDef.NAME_FIELD_KEY) {
|
|
148
|
-
titleField = objectDef.NAME_FIELD_KEY;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Common title field names to try as fallback
|
|
153
|
-
const TITLE_FALLBACK_FIELDS = ['name', 'title', 'subject', 'label', 'display_name'];
|
|
154
|
-
|
|
155
|
-
return rawData.map(item => {
|
|
156
|
-
// If a specific title field was configured, try it first
|
|
157
|
-
let resolvedTitle = titleField ? item[titleField] : undefined;
|
|
158
|
-
|
|
159
|
-
// Fallback: try common field names
|
|
160
|
-
if (!resolvedTitle) {
|
|
161
|
-
for (const field of TITLE_FALLBACK_FIELDS) {
|
|
162
|
-
if (item[field]) {
|
|
163
|
-
resolvedTitle = item[field];
|
|
164
|
-
break;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
...item,
|
|
171
|
-
// Ensure id exists
|
|
172
|
-
id: item.id || item._id,
|
|
173
|
-
// Map title
|
|
174
|
-
title: resolvedTitle || 'Untitled',
|
|
175
|
-
};
|
|
176
|
-
});
|
|
177
|
-
}, [rawData, schema, objectDef]);
|
|
178
|
-
|
|
179
|
-
// Generate columns if missing but groupBy is present
|
|
180
|
-
const effectiveColumns = useMemo(() => {
|
|
181
|
-
// If columns exist, returns them (normalized)
|
|
182
|
-
if (schema.columns && schema.columns.length > 0) {
|
|
183
|
-
// If columns is array of strings, normalize to objects
|
|
184
|
-
if (typeof schema.columns[0] === 'string') {
|
|
185
|
-
// If grouping is active, assume string columns are meant for data display, not lanes
|
|
186
|
-
if (!schema.groupBy) {
|
|
187
|
-
return (schema.columns as unknown as string[]).map(val => ({
|
|
188
|
-
id: val,
|
|
189
|
-
title: val
|
|
190
|
-
}));
|
|
191
|
-
}
|
|
192
|
-
} else {
|
|
193
|
-
return schema.columns;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Try to get options from metadata
|
|
198
|
-
if (schema.groupBy && objectDef?.fields?.[schema.groupBy]?.options) {
|
|
199
|
-
return objectDef.fields[schema.groupBy].options.map((opt: any) => ({
|
|
200
|
-
id: opt.value,
|
|
201
|
-
title: opt.label
|
|
202
|
-
}));
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// If no columns, but we have groupBy and data, generate from data
|
|
206
|
-
if (schema.groupBy && effectiveData.length > 0) {
|
|
207
|
-
const groups = new Set(effectiveData.map(item => item[schema.groupBy!]));
|
|
208
|
-
return Array.from(groups).map(g => ({
|
|
209
|
-
id: String(g),
|
|
210
|
-
title: String(g)
|
|
211
|
-
}));
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return [];
|
|
215
|
-
}, [schema.columns, schema.groupBy, effectiveData, objectDef]);
|
|
216
|
-
|
|
217
|
-
// Clone schema to inject data and className
|
|
218
|
-
// Use grouping.fields[0].field as swimlaneField fallback when no explicit swimlaneField
|
|
219
|
-
const effectiveSwimlaneField = schema.swimlaneField
|
|
220
|
-
|| (schema.grouping?.fields?.[0]?.field);
|
|
221
|
-
|
|
222
|
-
const effectiveSchema = {
|
|
223
|
-
...schema,
|
|
224
|
-
data: effectiveData,
|
|
225
|
-
columns: effectiveColumns,
|
|
226
|
-
className: className || schema.className,
|
|
227
|
-
...(effectiveSwimlaneField ? { swimlaneField: effectiveSwimlaneField } : {}),
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
const navigation = useNavigationOverlay({
|
|
231
|
-
navigation: (schema as any).navigation,
|
|
232
|
-
objectName: schema.objectName,
|
|
233
|
-
onRowClick: onRowClick ?? onCardClick,
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
if (error) {
|
|
237
|
-
return (
|
|
238
|
-
<div className="p-4 border border-destructive/50 rounded bg-destructive/10 text-destructive">
|
|
239
|
-
Error loading kanban data: {error.message}
|
|
240
|
-
</div>
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Pass through to the renderer
|
|
245
|
-
const detailTitle = schema.objectName
|
|
246
|
-
? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1).replace(/_/g, ' ')} Detail`
|
|
247
|
-
: 'Card Details';
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<>
|
|
251
|
-
<KanbanRenderer schema={{
|
|
252
|
-
...effectiveSchema,
|
|
253
|
-
onCardClick: (card: any) => {
|
|
254
|
-
navigation.handleClick(card);
|
|
255
|
-
onCardClick?.(card);
|
|
256
|
-
},
|
|
257
|
-
}} />
|
|
258
|
-
{navigation.isOverlay && (
|
|
259
|
-
<NavigationOverlay {...navigation} title={detailTitle}>
|
|
260
|
-
{(record) => (
|
|
261
|
-
<div className="space-y-3">
|
|
262
|
-
{Object.entries(record).map(([key, value]) => (
|
|
263
|
-
<div key={key} className="flex flex-col">
|
|
264
|
-
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
265
|
-
{key.replace(/_/g, ' ')}
|
|
266
|
-
</span>
|
|
267
|
-
<span className="text-sm">{String(value ?? '—')}</span>
|
|
268
|
-
</div>
|
|
269
|
-
))}
|
|
270
|
-
</div>
|
|
271
|
-
)}
|
|
272
|
-
</NavigationOverlay>
|
|
273
|
-
)}
|
|
274
|
-
</>
|
|
275
|
-
);
|
|
276
|
-
}
|