@object-ui/plugin-gantt 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,408 @@
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
+ /**
10
+ * ObjectGantt Component
11
+ *
12
+ * A specialized Gantt chart component that works with ObjectQL data sources.
13
+ * Displays tasks with date ranges, progress, and dependencies.
14
+ * Implements the gantt view type from @objectstack/spec view.zod ListView schema.
15
+ *
16
+ * Features:
17
+ * - Gantt chart timeline visualization
18
+ * - Task progress tracking (0-100%)
19
+ * - Task dependencies visualization
20
+ * - Date range display
21
+ * - Auto-scrolling timeline
22
+ * - Works with object/api/value data providers
23
+ */
24
+
25
+ import React, { useEffect, useState, useMemo } from 'react';
26
+ import type { ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@object-ui/types';
27
+
28
+ export interface ObjectGanttProps {
29
+ schema: ObjectGridSchema;
30
+ dataSource?: DataSource;
31
+ className?: string;
32
+ onTaskClick?: (record: any) => void;
33
+ onEdit?: (record: any) => void;
34
+ onDelete?: (record: any) => void;
35
+ }
36
+
37
+ /**
38
+ * Helper to get data configuration from schema
39
+ */
40
+ function getDataConfig(schema: ObjectGridSchema): ViewData | null {
41
+ if (schema.data) {
42
+ return schema.data;
43
+ }
44
+
45
+ if (schema.staticData) {
46
+ return {
47
+ provider: 'value',
48
+ items: schema.staticData,
49
+ };
50
+ }
51
+
52
+ if (schema.objectName) {
53
+ return {
54
+ provider: 'object',
55
+ object: schema.objectName,
56
+ };
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Helper to convert sort config to QueryParams format
64
+ */
65
+ function convertSortToQueryParams(sort: string | any[] | undefined): Record<string, 'asc' | 'desc'> | undefined {
66
+ if (!sort) return undefined;
67
+
68
+ // If it's a string like "name desc"
69
+ if (typeof sort === 'string') {
70
+ const parts = sort.split(' ');
71
+ const field = parts[0];
72
+ const order = (parts[1]?.toLowerCase() === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc';
73
+ return { [field]: order };
74
+ }
75
+
76
+ // If it's an array of SortConfig objects
77
+ if (Array.isArray(sort)) {
78
+ return sort.reduce((acc, item) => {
79
+ if (item.field && item.order) {
80
+ acc[item.field] = item.order;
81
+ }
82
+ return acc;
83
+ }, {} as Record<string, 'asc' | 'desc'>);
84
+ }
85
+
86
+ return undefined;
87
+ }
88
+
89
+ /**
90
+ * Helper to get gantt configuration from schema
91
+ */
92
+ function getGanttConfig(schema: ObjectGridSchema): GanttConfig | null {
93
+ // Check if schema has gantt configuration
94
+ if (schema.filter && typeof schema.filter === 'object' && 'gantt' in schema.filter) {
95
+ return (schema.filter as any).gantt as GanttConfig;
96
+ }
97
+
98
+ // For backward compatibility, check if schema has gantt config at root
99
+ if ((schema as any).gantt) {
100
+ return (schema as any).gantt as GanttConfig;
101
+ }
102
+
103
+ return null;
104
+ }
105
+
106
+ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
107
+ schema,
108
+ dataSource,
109
+ className,
110
+ onTaskClick,
111
+ }) => {
112
+ const [data, setData] = useState<any[]>([]);
113
+ const [loading, setLoading] = useState(true);
114
+ const [error, setError] = useState<Error | null>(null);
115
+ const [objectSchema, setObjectSchema] = useState<any>(null);
116
+
117
+ const dataConfig = getDataConfig(schema);
118
+ const ganttConfig = getGanttConfig(schema);
119
+ const hasInlineData = dataConfig?.provider === 'value';
120
+
121
+ // Fetch data based on provider
122
+ useEffect(() => {
123
+ const fetchData = async () => {
124
+ try {
125
+ setLoading(true);
126
+
127
+ if (hasInlineData && dataConfig?.provider === 'value') {
128
+ setData(dataConfig.items as any[]);
129
+ setLoading(false);
130
+ return;
131
+ }
132
+
133
+ if (!dataSource) {
134
+ throw new Error('DataSource required for object/api providers');
135
+ }
136
+
137
+ if (dataConfig?.provider === 'object') {
138
+ const objectName = dataConfig.object;
139
+ const result = await dataSource.find(objectName, {
140
+ $filter: schema.filter,
141
+ $orderby: convertSortToQueryParams(schema.sort),
142
+ });
143
+ setData(result?.data || []);
144
+ } else if (dataConfig?.provider === 'api') {
145
+ console.warn('API provider not yet implemented for ObjectGantt');
146
+ setData([]);
147
+ }
148
+
149
+ setLoading(false);
150
+ } catch (err) {
151
+ setError(err as Error);
152
+ setLoading(false);
153
+ }
154
+ };
155
+
156
+ fetchData();
157
+ }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort]);
158
+
159
+ // Fetch object schema for field metadata
160
+ useEffect(() => {
161
+ const fetchObjectSchema = async () => {
162
+ try {
163
+ if (!dataSource) return;
164
+
165
+ const objectName = dataConfig?.provider === 'object'
166
+ ? dataConfig.object
167
+ : schema.objectName;
168
+
169
+ if (!objectName) return;
170
+
171
+ const schemaData = await dataSource.getObjectSchema(objectName);
172
+ setObjectSchema(schemaData);
173
+ } catch (err) {
174
+ console.error('Failed to fetch object schema:', err);
175
+ }
176
+ };
177
+
178
+ if (!hasInlineData && dataSource) {
179
+ fetchObjectSchema();
180
+ }
181
+ }, [schema.objectName, dataSource, hasInlineData, dataConfig]);
182
+
183
+ // Transform data to gantt tasks
184
+ const tasks = useMemo(() => {
185
+ if (!ganttConfig || !data.length) {
186
+ return [];
187
+ }
188
+
189
+ const { startDateField, endDateField, titleField, progressField, dependenciesField } = ganttConfig;
190
+
191
+ return data.map((record, index) => {
192
+ const startDate = record[startDateField];
193
+ const endDate = record[endDateField];
194
+ const title = record[titleField] || 'Untitled Task';
195
+ const progress = progressField ? record[progressField] : 0;
196
+ const dependencies = dependenciesField ? record[dependenciesField] : [];
197
+
198
+ return {
199
+ id: record.id || record._id || `task-${index}`,
200
+ title,
201
+ start: startDate ? new Date(startDate) : new Date(),
202
+ end: endDate ? new Date(endDate) : new Date(),
203
+ progress: Math.min(100, Math.max(0, progress || 0)), // Clamp between 0-100
204
+ dependencies: Array.isArray(dependencies) ? dependencies : [],
205
+ data: record,
206
+ };
207
+ }).filter(task => !isNaN(task.start.getTime()) && !isNaN(task.end.getTime()));
208
+ }, [data, ganttConfig]);
209
+
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
+ if (loading) {
258
+ return (
259
+ <div className={className}>
260
+ <div className="flex items-center justify-center h-96">
261
+ <div className="text-muted-foreground">Loading Gantt chart...</div>
262
+ </div>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ if (error) {
268
+ return (
269
+ <div className={className}>
270
+ <div className="flex items-center justify-center h-96">
271
+ <div className="text-destructive">Error: {error.message}</div>
272
+ </div>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ if (!ganttConfig) {
278
+ return (
279
+ <div className={className}>
280
+ <div className="flex items-center justify-center h-96">
281
+ <div className="text-muted-foreground">
282
+ Gantt configuration required. Please specify startDateField, endDateField, and titleField.
283
+ </div>
284
+ </div>
285
+ </div>
286
+ );
287
+ }
288
+
289
+ const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
290
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
291
+
292
+ return (
293
+ <div className={className}>
294
+ <div className="border rounded-lg bg-background overflow-hidden">
295
+ <div className="flex">
296
+ {/* Task List */}
297
+ <div className="w-64 border-r flex-shrink-0">
298
+ <div className="border-b p-3 font-semibold bg-muted">Tasks</div>
299
+ <div>
300
+ {tasks.map(task => (
301
+ <div
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>
405
+ </div>
406
+ </div>
407
+ );
408
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,29 @@
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 from 'react';
10
+ import { ComponentRegistry } from '@object-ui/core';
11
+ import { ObjectGantt } from './ObjectGantt';
12
+ import type { ObjectGanttProps } from './ObjectGantt';
13
+
14
+ export { ObjectGantt };
15
+ export type { ObjectGanttProps };
16
+
17
+ // Register component
18
+ const ObjectGanttRenderer: React.FC<{ schema: any }> = ({ schema }) => {
19
+ return <ObjectGantt schema={schema} dataSource={null as any} />;
20
+ };
21
+
22
+ ComponentRegistry.register('object-gantt', ObjectGanttRenderer, {
23
+ label: 'Object Gantt',
24
+ category: 'plugin',
25
+ inputs: [
26
+ { name: 'objectName', type: 'string', label: 'Object Name', required: true },
27
+ { name: 'gantt', type: 'object', label: 'Gantt Config', description: 'startDateField, endDateField, titleField, progressField, dependenciesField' },
28
+ ],
29
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "jsx": "react-jsx",
6
+ "baseUrl": ".",
7
+ "paths": {
8
+ "@/*": ["src/*"]
9
+ },
10
+ "noEmit": false,
11
+ "declaration": true,
12
+ "composite": true,
13
+ "declarationMap": true,
14
+ "skipLibCheck": true
15
+ },
16
+ "include": ["src"],
17
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
18
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,50 @@
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 { defineConfig } from 'vite';
10
+ import react from '@vitejs/plugin-react';
11
+ import dts from 'vite-plugin-dts';
12
+ import { resolve } from 'path';
13
+
14
+ export default defineConfig({
15
+ plugins: [
16
+ react(),
17
+ dts({
18
+ insertTypesEntry: true,
19
+ include: ['src'],
20
+ exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'],
21
+ skipDiagnostics: true,
22
+ }),
23
+ ],
24
+ resolve: {
25
+ alias: {
26
+ '@': resolve(__dirname, './src'),
27
+ },
28
+ },
29
+ build: {
30
+ lib: {
31
+ entry: resolve(__dirname, 'src/index.tsx'),
32
+ name: 'ObjectUIPluginGantt',
33
+ fileName: 'index',
34
+ },
35
+ rollupOptions: {
36
+ external: ['react', 'react-dom', '@object-ui/components', '@object-ui/core', '@object-ui/react', '@object-ui/types', 'lucide-react'],
37
+ output: {
38
+ globals: {
39
+ react: 'React',
40
+ 'react-dom': 'ReactDOM',
41
+ '@object-ui/components': 'ObjectUIComponents',
42
+ '@object-ui/core': 'ObjectUICore',
43
+ '@object-ui/react': 'ObjectUIReact',
44
+ '@object-ui/types': 'ObjectUITypes',
45
+ 'lucide-react': 'LucideReact',
46
+ },
47
+ },
48
+ },
49
+ },
50
+ });