@object-ui/plugin-gantt 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.
@@ -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
- // 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
- }
94
+ function getGanttConfig(schema: ObjectGridSchema | any): GanttConfig | null {
95
+ let config: GanttConfig | null = null;
97
96
 
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;
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 dataConfig = getDataConfig(schema);
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
- setData(result?.data || []);
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="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>
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,38 @@
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
- return <ObjectGantt schema={schema} dataSource={null as any} />;
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
- category: 'plugin',
30
+ category: 'view',
31
+ inputs: [
32
+ { name: 'objectName', type: 'string', label: 'Object Name', required: true },
33
+ { name: 'gantt', type: 'object', label: 'Gantt Config', description: 'startDateField, endDateField, titleField, progressField, percentageField, colorField, dependenciesField' },
34
+ ],
35
+ });
36
+
37
+ ComponentRegistry.register('gantt', ObjectGanttRenderer, {
38
+ namespace: 'view',
39
+ label: 'Gantt View',
40
+ category: 'view',
25
41
  inputs: [
26
42
  { name: 'objectName', type: 'string', label: 'Object Name', required: true },
27
- { name: 'gantt', type: 'object', label: 'Gantt Config', description: 'startDateField, endDateField, titleField, progressField, dependenciesField' },
43
+ { name: 'gantt', type: 'object', label: 'Gantt Config', description: 'startDateField, endDateField, titleField, progressField, percentageField, colorField, dependenciesField' },
28
44
  ],
29
45
  });
package/vite.config.ts CHANGED
@@ -47,4 +47,7 @@ export default defineConfig({
47
47
  },
48
48
  },
49
49
  },
50
+ test: {
51
+ passWithNoTests: true,
52
+ },
50
53
  });
@@ -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
+ });
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom';