@object-ui/plugin-gantt 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.
- package/.turbo/turbo-build.log +16 -0
- package/dist/index.js +13538 -406
- package/dist/index.umd.cjs +41 -2
- package/dist/src/GanttView.d.ts +30 -0
- package/dist/src/GanttView.d.ts.map +1 -0
- package/dist/src/ObjectGantt.d.ts.map +1 -1
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +9 -8
- package/src/GanttView.tsx +333 -0
- package/src/ObjectGantt.test.tsx +88 -0
- package/src/ObjectGantt.tsx +65 -172
- package/src/index.test.tsx +25 -0
- package/src/index.tsx +9 -3
- package/vite.config.ts +3 -0
- package/vitest.config.ts +13 -0
- package/vitest.setup.ts +1 -0
package/src/ObjectGantt.tsx
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
|
|
25
25
|
import React, { useEffect, useState, useMemo } from 'react';
|
|
26
26
|
import type { ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types';
|
|
27
|
+
import { GanttConfigSchema } from '@objectstack/spec/ui';
|
|
28
|
+
import { GanttView, type GanttTask } from './GanttView';
|
|
27
29
|
|
|
28
30
|
export interface ObjectGanttProps {
|
|
29
31
|
schema: ObjectGridSchema;
|
|
@@ -89,15 +91,33 @@ function convertSortToQueryParams(sort: string | any[] | undefined): Record<stri
|
|
|
89
91
|
/**
|
|
90
92
|
* Helper to get gantt configuration from schema
|
|
91
93
|
*/
|
|
92
|
-
function getGanttConfig(schema: ObjectGridSchema): GanttConfig | null {
|
|
93
|
-
|
|
94
|
-
if (schema.filter && typeof schema.filter === 'object' && 'gantt' in schema.filter) {
|
|
95
|
-
return (schema.filter as any).gantt as GanttConfig;
|
|
96
|
-
}
|
|
94
|
+
function getGanttConfig(schema: ObjectGridSchema | any): GanttConfig | null {
|
|
95
|
+
let config: GanttConfig | null = null;
|
|
97
96
|
|
|
98
|
-
//
|
|
99
|
-
if (
|
|
100
|
-
|
|
97
|
+
// 1. Check top-level properties (ObjectGanttSchema style)
|
|
98
|
+
if (schema.startDateField && schema.endDateField) {
|
|
99
|
+
config = {
|
|
100
|
+
startDateField: schema.startDateField,
|
|
101
|
+
endDateField: schema.endDateField,
|
|
102
|
+
titleField: schema.titleField || 'name',
|
|
103
|
+
progressField: schema.progressField,
|
|
104
|
+
dependenciesField: schema.dependenciesField || schema.dependencyField,
|
|
105
|
+
colorField: schema.colorField
|
|
106
|
+
};
|
|
107
|
+
return config;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 2. Check schema.gantt (ObjectGridSchema style)
|
|
111
|
+
if (schema.gantt) {
|
|
112
|
+
config = schema.gantt as GanttConfig;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (config) {
|
|
116
|
+
const result = GanttConfigSchema.safeParse(config);
|
|
117
|
+
if (!result.success) {
|
|
118
|
+
console.warn(`[ObjectGantt] Invalid gantt configuration:`, result.error.format());
|
|
119
|
+
}
|
|
120
|
+
return config;
|
|
101
121
|
}
|
|
102
122
|
|
|
103
123
|
return null;
|
|
@@ -108,13 +128,19 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
|
|
|
108
128
|
dataSource,
|
|
109
129
|
className,
|
|
110
130
|
onTaskClick,
|
|
131
|
+
...rest
|
|
111
132
|
}) => {
|
|
112
133
|
const [data, setData] = useState<any[]>([]);
|
|
113
134
|
const [loading, setLoading] = useState(true);
|
|
114
135
|
const [error, setError] = useState<Error | null>(null);
|
|
115
136
|
const [objectSchema, setObjectSchema] = useState<any>(null);
|
|
116
137
|
|
|
117
|
-
const
|
|
138
|
+
const rawDataConfig = getDataConfig(schema);
|
|
139
|
+
// Memoize dataConfig using deep comparison to prevent infinite loops
|
|
140
|
+
const dataConfig = useMemo(() => {
|
|
141
|
+
return rawDataConfig;
|
|
142
|
+
}, [JSON.stringify(rawDataConfig)]);
|
|
143
|
+
|
|
118
144
|
const ganttConfig = getGanttConfig(schema);
|
|
119
145
|
const hasInlineData = dataConfig?.provider === 'value';
|
|
120
146
|
|
|
@@ -123,6 +149,13 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
|
|
|
123
149
|
const fetchData = async () => {
|
|
124
150
|
try {
|
|
125
151
|
setLoading(true);
|
|
152
|
+
// 1. Check for data prop (Unified ListView)
|
|
153
|
+
if ((rest as any).data && Array.isArray((rest as any).data)) {
|
|
154
|
+
setData((rest as any).data);
|
|
155
|
+
setLoading(false);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
126
159
|
|
|
127
160
|
if (hasInlineData && dataConfig?.provider === 'value') {
|
|
128
161
|
setData(dataConfig.items as any[]);
|
|
@@ -140,7 +173,18 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
|
|
|
140
173
|
$filter: schema.filter,
|
|
141
174
|
$orderby: convertSortToQueryParams(schema.sort),
|
|
142
175
|
});
|
|
143
|
-
|
|
176
|
+
|
|
177
|
+
let items: any[] = [];
|
|
178
|
+
if (Array.isArray(result)) {
|
|
179
|
+
items = result;
|
|
180
|
+
} else if (result && typeof result === 'object') {
|
|
181
|
+
if (Array.isArray((result as any).data)) {
|
|
182
|
+
items = (result as any).data;
|
|
183
|
+
} else if (Array.isArray((result as any).value)) {
|
|
184
|
+
items = (result as any).value;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
setData(items);
|
|
144
188
|
} else if (dataConfig?.provider === 'api') {
|
|
145
189
|
console.warn('API provider not yet implemented for ObjectGantt');
|
|
146
190
|
setData([]);
|
|
@@ -186,7 +230,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
|
|
|
186
230
|
return [];
|
|
187
231
|
}
|
|
188
232
|
|
|
189
|
-
const { startDateField, endDateField, titleField, progressField, dependenciesField } = ganttConfig;
|
|
233
|
+
const { startDateField, endDateField, titleField, progressField, dependenciesField, colorField } = ganttConfig;
|
|
190
234
|
|
|
191
235
|
return data.map((record, index) => {
|
|
192
236
|
const startDate = record[startDateField];
|
|
@@ -194,6 +238,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
|
|
|
194
238
|
const title = record[titleField] || 'Untitled Task';
|
|
195
239
|
const progress = progressField ? record[progressField] : 0;
|
|
196
240
|
const dependencies = dependenciesField ? record[dependenciesField] : [];
|
|
241
|
+
const color = colorField ? record[colorField] : undefined;
|
|
197
242
|
|
|
198
243
|
return {
|
|
199
244
|
id: record.id || record._id || `task-${index}`,
|
|
@@ -202,58 +247,12 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
|
|
|
202
247
|
end: endDate ? new Date(endDate) : new Date(),
|
|
203
248
|
progress: Math.min(100, Math.max(0, progress || 0)), // Clamp between 0-100
|
|
204
249
|
dependencies: Array.isArray(dependencies) ? dependencies : [],
|
|
250
|
+
color,
|
|
205
251
|
data: record,
|
|
206
252
|
};
|
|
207
253
|
}).filter(task => !isNaN(task.start.getTime()) && !isNaN(task.end.getTime()));
|
|
208
254
|
}, [data, ganttConfig]);
|
|
209
255
|
|
|
210
|
-
// Calculate timeline range
|
|
211
|
-
const timelineRange = useMemo(() => {
|
|
212
|
-
if (!tasks.length) {
|
|
213
|
-
const now = new Date();
|
|
214
|
-
return {
|
|
215
|
-
start: new Date(now.getFullYear(), now.getMonth(), 1),
|
|
216
|
-
end: new Date(now.getFullYear(), now.getMonth() + 3, 0),
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const allDates = tasks.flatMap(task => [task.start, task.end]);
|
|
221
|
-
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())));
|
|
222
|
-
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())));
|
|
223
|
-
|
|
224
|
-
// Add some padding
|
|
225
|
-
minDate.setDate(minDate.getDate() - 7);
|
|
226
|
-
maxDate.setDate(maxDate.getDate() + 7);
|
|
227
|
-
|
|
228
|
-
return { start: minDate, end: maxDate };
|
|
229
|
-
}, [tasks]);
|
|
230
|
-
|
|
231
|
-
// Generate month headers
|
|
232
|
-
const months = useMemo(() => {
|
|
233
|
-
const result = [];
|
|
234
|
-
const current = new Date(timelineRange.start);
|
|
235
|
-
current.setDate(1);
|
|
236
|
-
|
|
237
|
-
while (current <= timelineRange.end) {
|
|
238
|
-
result.push(new Date(current));
|
|
239
|
-
current.setMonth(current.getMonth() + 1);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return result;
|
|
243
|
-
}, [timelineRange]);
|
|
244
|
-
|
|
245
|
-
// Calculate task bar position and width
|
|
246
|
-
const getTaskPosition = (task: any) => {
|
|
247
|
-
const totalDays = (timelineRange.end.getTime() - timelineRange.start.getTime()) / (1000 * 60 * 60 * 24);
|
|
248
|
-
const taskStart = (task.start.getTime() - timelineRange.start.getTime()) / (1000 * 60 * 60 * 24);
|
|
249
|
-
const taskDuration = (task.end.getTime() - task.start.getTime()) / (1000 * 60 * 60 * 24);
|
|
250
|
-
|
|
251
|
-
return {
|
|
252
|
-
left: `${(taskStart / totalDays) * 100}%`,
|
|
253
|
-
width: `${(taskDuration / totalDays) * 100}%`,
|
|
254
|
-
};
|
|
255
|
-
};
|
|
256
|
-
|
|
257
256
|
if (loading) {
|
|
258
257
|
return (
|
|
259
258
|
<div className={className}>
|
|
@@ -286,122 +285,16 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
|
|
|
286
285
|
);
|
|
287
286
|
}
|
|
288
287
|
|
|
289
|
-
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
290
|
-
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
291
|
-
|
|
292
288
|
return (
|
|
293
289
|
<div className={className}>
|
|
294
|
-
<div className="
|
|
295
|
-
<
|
|
296
|
-
{
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
key={task.id}
|
|
303
|
-
className="border-b p-3 hover:bg-muted/50 cursor-pointer"
|
|
304
|
-
onClick={() => onTaskClick?.(task.data)}
|
|
305
|
-
>
|
|
306
|
-
<div className="font-medium text-sm truncate">{task.title}</div>
|
|
307
|
-
<div className="text-xs text-muted-foreground mt-1">
|
|
308
|
-
{task.start.toLocaleDateString()} - {task.end.toLocaleDateString()}
|
|
309
|
-
</div>
|
|
310
|
-
{task.progress > 0 && (
|
|
311
|
-
<div className="mt-2">
|
|
312
|
-
<div className="flex justify-between text-xs mb-1">
|
|
313
|
-
<span>Progress</span>
|
|
314
|
-
<span>{task.progress}%</span>
|
|
315
|
-
</div>
|
|
316
|
-
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
|
317
|
-
<div
|
|
318
|
-
className="h-full bg-primary"
|
|
319
|
-
style={{ width: `${task.progress}%` }}
|
|
320
|
-
/>
|
|
321
|
-
</div>
|
|
322
|
-
</div>
|
|
323
|
-
)}
|
|
324
|
-
</div>
|
|
325
|
-
))}
|
|
326
|
-
</div>
|
|
327
|
-
</div>
|
|
328
|
-
|
|
329
|
-
{/* Timeline */}
|
|
330
|
-
<div className="flex-1 overflow-x-auto">
|
|
331
|
-
{/* Month Headers */}
|
|
332
|
-
<div className="border-b bg-muted sticky top-0 z-10">
|
|
333
|
-
<div className="flex">
|
|
334
|
-
{months.map((month, index) => {
|
|
335
|
-
const daysInMonth = new Date(
|
|
336
|
-
month.getFullYear(),
|
|
337
|
-
month.getMonth() + 1,
|
|
338
|
-
0
|
|
339
|
-
).getDate();
|
|
340
|
-
|
|
341
|
-
return (
|
|
342
|
-
<div
|
|
343
|
-
key={index}
|
|
344
|
-
className="border-r p-3 text-center font-semibold text-sm"
|
|
345
|
-
style={{ minWidth: `${daysInMonth * 20}px` }}
|
|
346
|
-
>
|
|
347
|
-
{monthNames[month.getMonth()]} {month.getFullYear()}
|
|
348
|
-
</div>
|
|
349
|
-
);
|
|
350
|
-
})}
|
|
351
|
-
</div>
|
|
352
|
-
</div>
|
|
353
|
-
|
|
354
|
-
{/* Task Bars */}
|
|
355
|
-
<div className="relative">
|
|
356
|
-
{tasks.map((task) => {
|
|
357
|
-
const position = getTaskPosition(task);
|
|
358
|
-
|
|
359
|
-
return (
|
|
360
|
-
<div
|
|
361
|
-
key={task.id}
|
|
362
|
-
className="border-b relative"
|
|
363
|
-
style={{ height: '60px' }}
|
|
364
|
-
>
|
|
365
|
-
{/* Month grid lines */}
|
|
366
|
-
<div className="absolute inset-0 flex pointer-events-none">
|
|
367
|
-
{months.map((month, idx) => {
|
|
368
|
-
const daysInMonth = new Date(
|
|
369
|
-
month.getFullYear(),
|
|
370
|
-
month.getMonth() + 1,
|
|
371
|
-
0
|
|
372
|
-
).getDate();
|
|
373
|
-
|
|
374
|
-
return (
|
|
375
|
-
<div
|
|
376
|
-
key={idx}
|
|
377
|
-
className="border-r"
|
|
378
|
-
style={{ minWidth: `${daysInMonth * 20}px` }}
|
|
379
|
-
/>
|
|
380
|
-
);
|
|
381
|
-
})}
|
|
382
|
-
</div>
|
|
383
|
-
|
|
384
|
-
{/* Task Bar */}
|
|
385
|
-
<div
|
|
386
|
-
className="absolute top-1/2 -translate-y-1/2 h-8 bg-primary/80 rounded cursor-pointer hover:bg-primary transition-colors flex items-center px-2 text-white text-xs font-medium overflow-hidden"
|
|
387
|
-
style={position}
|
|
388
|
-
onClick={() => onTaskClick?.(task.data)}
|
|
389
|
-
>
|
|
390
|
-
{/* Progress overlay */}
|
|
391
|
-
{task.progress > 0 && (
|
|
392
|
-
<div
|
|
393
|
-
className="absolute inset-0 bg-primary-foreground/20"
|
|
394
|
-
style={{ width: `${task.progress}%` }}
|
|
395
|
-
/>
|
|
396
|
-
)}
|
|
397
|
-
<span className="relative z-10 truncate">{task.title}</span>
|
|
398
|
-
</div>
|
|
399
|
-
</div>
|
|
400
|
-
);
|
|
401
|
-
})}
|
|
402
|
-
</div>
|
|
403
|
-
</div>
|
|
404
|
-
</div>
|
|
290
|
+
<div className="h-[calc(100vh-200px)] min-h-[600px]">
|
|
291
|
+
<GanttView
|
|
292
|
+
tasks={tasks}
|
|
293
|
+
onTaskClick={(task) => onTaskClick?.(task.data)}
|
|
294
|
+
onAddClick={() => {
|
|
295
|
+
// Placeholder for add action
|
|
296
|
+
}}
|
|
297
|
+
/>
|
|
405
298
|
</div>
|
|
406
299
|
</div>
|
|
407
300
|
);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { ObjectGanttRenderer } from './index';
|
|
5
|
+
|
|
6
|
+
// Mock dependencies
|
|
7
|
+
vi.mock('@object-ui/react', () => ({
|
|
8
|
+
useSchemaContext: vi.fn(() => ({ dataSource: { type: 'mock-datasource' } })),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('./ObjectGantt', () => ({
|
|
12
|
+
ObjectGantt: ({ dataSource }: any) => (
|
|
13
|
+
<div data-testid="gantt-mock">
|
|
14
|
+
{dataSource ? `DataSource: ${dataSource.type}` : 'No DataSource'}
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
describe('Plugin Gantt Registration', () => {
|
|
20
|
+
it('renderer passes dataSource from context', () => {
|
|
21
|
+
// Note: We test the renderer directly to avoid singleton issues with ComponentRegistry in tests
|
|
22
|
+
render(<ObjectGanttRenderer schema={{}} />);
|
|
23
|
+
expect(screen.getByTestId('gantt-mock')).toHaveTextContent('DataSource: mock-datasource');
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/index.tsx
CHANGED
|
@@ -8,22 +8,28 @@
|
|
|
8
8
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { ComponentRegistry } from '@object-ui/core';
|
|
11
|
+
import { useSchemaContext } from '@object-ui/react';
|
|
11
12
|
import { ObjectGantt } from './ObjectGantt';
|
|
12
13
|
import type { ObjectGanttProps } from './ObjectGantt';
|
|
13
14
|
|
|
14
15
|
export { ObjectGantt };
|
|
15
16
|
export type { ObjectGanttProps };
|
|
16
17
|
|
|
18
|
+
export { GanttView } from './GanttView';
|
|
19
|
+
export type { GanttViewProps, GanttTask, GanttViewMode } from './GanttView';
|
|
20
|
+
|
|
17
21
|
// Register component
|
|
18
|
-
const ObjectGanttRenderer: React.FC<{ schema: any }> = ({ schema }) => {
|
|
19
|
-
|
|
22
|
+
export const ObjectGanttRenderer: React.FC<{ schema: any }> = ({ schema }) => {
|
|
23
|
+
const { dataSource } = useSchemaContext();
|
|
24
|
+
return <ObjectGantt schema={schema} dataSource={dataSource} />;
|
|
20
25
|
};
|
|
21
26
|
|
|
22
27
|
ComponentRegistry.register('object-gantt', ObjectGanttRenderer, {
|
|
28
|
+
namespace: 'plugin-gantt',
|
|
23
29
|
label: 'Object Gantt',
|
|
24
30
|
category: 'plugin',
|
|
25
31
|
inputs: [
|
|
26
32
|
{ name: 'objectName', type: 'string', label: 'Object Name', required: true },
|
|
27
|
-
{ name: 'gantt', type: 'object', label: 'Gantt Config', description: 'startDateField, endDateField, titleField, progressField, dependenciesField' },
|
|
33
|
+
{ name: 'gantt', type: 'object', label: 'Gantt Config', description: 'startDateField, endDateField, titleField, progressField, percentageField, colorField, dependenciesField' },
|
|
28
34
|
],
|
|
29
35
|
});
|
package/vite.config.ts
CHANGED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/// <reference types="vitest" />
|
|
2
|
+
import { defineConfig } from 'vite';
|
|
3
|
+
import react from '@vitejs/plugin-react';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [react()],
|
|
8
|
+
test: {
|
|
9
|
+
environment: 'happy-dom',
|
|
10
|
+
globals: true,
|
|
11
|
+
setupFiles: ['./vitest.setup.ts'],
|
|
12
|
+
},
|
|
13
|
+
});
|
package/vitest.setup.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|