@object-ui/plugin-gantt 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 +16 -0
- package/CHANGELOG.md +13 -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
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
export interface GanttTask {
|
|
9
|
+
id: string | number;
|
|
10
|
+
title: string;
|
|
11
|
+
start: Date;
|
|
12
|
+
end: Date;
|
|
13
|
+
progress: number;
|
|
14
|
+
color?: string;
|
|
15
|
+
data?: any;
|
|
16
|
+
dependencies?: (string | number)[];
|
|
17
|
+
}
|
|
18
|
+
export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter';
|
|
19
|
+
export interface GanttViewProps {
|
|
20
|
+
tasks: GanttTask[];
|
|
21
|
+
viewMode?: GanttViewMode;
|
|
22
|
+
startDate?: Date;
|
|
23
|
+
endDate?: Date;
|
|
24
|
+
onTaskClick?: (task: GanttTask) => void;
|
|
25
|
+
onViewChange?: (view: GanttViewMode) => void;
|
|
26
|
+
onAddClick?: () => void;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function GanttView({ tasks, viewMode, startDate, endDate, onTaskClick, onViewChange, onAddClick, className }: GanttViewProps): import("react/jsx-runtime").JSX.Element;
|
|
30
|
+
//# sourceMappingURL=GanttView.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"GanttView.d.ts","sourceRoot":"","sources":["../../src/GanttView.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA6BH,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,IAAI,CAAA;IACX,GAAG,EAAE,IAAI,CAAA;IACT,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,GAAG,CAAA;IACV,YAAY,CAAC,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAA;CACnC;AAED,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;AAEjE,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,SAAS,EAAE,CAAA;IAClB,QAAQ,CAAC,EAAE,aAAa,CAAA;IACxB,SAAS,CAAC,EAAE,IAAI,CAAA;IAChB,OAAO,CAAC,EAAE,IAAI,CAAA;IACd,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAA;IACvC,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,IAAI,CAAA;IAC5C,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,SAAS,CAAC,EACxB,KAAK,EACL,QAAkB,EAClB,SAAS,EACT,OAAO,EACP,WAAW,EACX,YAAY,EACZ,UAAU,EACV,SAAS,EACV,EAAE,cAAc,2CAwQhB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ObjectGantt.d.ts","sourceRoot":"","sources":["../../src/ObjectGantt.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAuC,MAAM,OAAO,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAyB,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"ObjectGantt.d.ts","sourceRoot":"","sources":["../../src/ObjectGantt.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAuC,MAAM,OAAO,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAyB,MAAM,kBAAkB,CAAC;AAI5F,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,gBAAgB,CAAC;IACzB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IACpC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;IAC/B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,IAAI,CAAC;CAClC;AAyFD,eAAO,MAAM,WAAW,EAAE,KAAK,CAAC,EAAE,CAAC,gBAAgB,CA+KlD,CAAC"}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
1
2
|
import { ObjectGantt, ObjectGanttProps } from './ObjectGantt';
|
|
2
3
|
export { ObjectGantt };
|
|
3
4
|
export type { ObjectGanttProps };
|
|
5
|
+
export { GanttView } from './GanttView';
|
|
6
|
+
export type { GanttViewProps, GanttTask, GanttViewMode } from './GanttView';
|
|
7
|
+
export declare const ObjectGanttRenderer: React.FC<{
|
|
8
|
+
schema: any;
|
|
9
|
+
}>;
|
|
4
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/src/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAG1B,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,OAAO,EAAE,WAAW,EAAE,CAAC;AACvB,YAAY,EAAE,gBAAgB,EAAE,CAAC;AAEjC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAG5E,eAAO,MAAM,mBAAmB,EAAE,KAAK,CAAC,EAAE,CAAC;IAAE,MAAM,EAAE,GAAG,CAAA;CAAE,CAGzD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/plugin-gantt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Gantt chart plugin for Object UI",
|
|
@@ -24,21 +24,22 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"@objectstack/spec": "^0.9.2",
|
|
27
28
|
"lucide-react": "^0.563.0",
|
|
28
|
-
"@object-ui/components": "0.
|
|
29
|
-
"@object-ui/
|
|
30
|
-
"@object-ui/
|
|
31
|
-
"@object-ui/
|
|
32
|
-
"@object-ui/types": "0.
|
|
29
|
+
"@object-ui/components": "0.5.0",
|
|
30
|
+
"@object-ui/fields": "0.5.0",
|
|
31
|
+
"@object-ui/react": "0.5.0",
|
|
32
|
+
"@object-ui/core": "0.5.0",
|
|
33
|
+
"@object-ui/types": "0.5.0"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
36
|
"react": "^18.0.0 || ^19.0.0",
|
|
36
37
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
|
-
"@types/react": "^19.2.
|
|
40
|
+
"@types/react": "^19.2.10",
|
|
40
41
|
"@types/react-dom": "^19.2.3",
|
|
41
|
-
"@vitejs/plugin-react": "^
|
|
42
|
+
"@vitejs/plugin-react": "^5.1.3",
|
|
42
43
|
"typescript": "^5.9.3",
|
|
43
44
|
"vite": "^7.3.1",
|
|
44
45
|
"vite-plugin-dts": "^4.5.4"
|
|
@@ -0,0 +1,333 @@
|
|
|
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
|
+
"use client"
|
|
10
|
+
|
|
11
|
+
import * as React from "react"
|
|
12
|
+
import {
|
|
13
|
+
ChevronLeft,
|
|
14
|
+
ChevronRight,
|
|
15
|
+
ZoomIn,
|
|
16
|
+
ZoomOut,
|
|
17
|
+
Calendar as CalendarIcon,
|
|
18
|
+
MoreHorizontal,
|
|
19
|
+
Plus
|
|
20
|
+
} from "lucide-react"
|
|
21
|
+
import {
|
|
22
|
+
cn,
|
|
23
|
+
Button,
|
|
24
|
+
Select,
|
|
25
|
+
SelectContent,
|
|
26
|
+
SelectItem,
|
|
27
|
+
SelectTrigger,
|
|
28
|
+
SelectValue,
|
|
29
|
+
Separator
|
|
30
|
+
} from "@object-ui/components"
|
|
31
|
+
|
|
32
|
+
const HEADER_HEIGHT = 50;
|
|
33
|
+
const ROW_HEIGHT = 40;
|
|
34
|
+
const COLUMN_WIDTH = 100; // Time column width
|
|
35
|
+
|
|
36
|
+
export interface GanttTask {
|
|
37
|
+
id: string | number
|
|
38
|
+
title: string
|
|
39
|
+
start: Date
|
|
40
|
+
end: Date
|
|
41
|
+
progress: number
|
|
42
|
+
color?: string
|
|
43
|
+
data?: any
|
|
44
|
+
dependencies?: (string | number)[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter';
|
|
48
|
+
|
|
49
|
+
export interface GanttViewProps {
|
|
50
|
+
tasks: GanttTask[]
|
|
51
|
+
viewMode?: GanttViewMode
|
|
52
|
+
startDate?: Date
|
|
53
|
+
endDate?: Date
|
|
54
|
+
onTaskClick?: (task: GanttTask) => void
|
|
55
|
+
onViewChange?: (view: GanttViewMode) => void
|
|
56
|
+
onAddClick?: () => void
|
|
57
|
+
className?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function GanttView({
|
|
61
|
+
tasks,
|
|
62
|
+
viewMode = 'month',
|
|
63
|
+
startDate,
|
|
64
|
+
endDate,
|
|
65
|
+
onTaskClick,
|
|
66
|
+
onViewChange,
|
|
67
|
+
onAddClick,
|
|
68
|
+
className
|
|
69
|
+
}: GanttViewProps) {
|
|
70
|
+
const [currentDate, setCurrentDate] = React.useState(new Date());
|
|
71
|
+
const [columnWidth, setColumnWidth] = React.useState(60);
|
|
72
|
+
|
|
73
|
+
// Calculate timeline range
|
|
74
|
+
const timelineRange = React.useMemo(() => {
|
|
75
|
+
let start = startDate ? new Date(startDate) : new Date();
|
|
76
|
+
let end = endDate ? new Date(endDate) : new Date();
|
|
77
|
+
|
|
78
|
+
if (!startDate && tasks.length > 0) {
|
|
79
|
+
// Find min start date
|
|
80
|
+
start = new Date(Math.min(...tasks.map(t => t.start.getTime())));
|
|
81
|
+
// Add padding
|
|
82
|
+
start.setDate(start.getDate() - 7);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!endDate && tasks.length > 0) {
|
|
86
|
+
// Find max end date
|
|
87
|
+
end = new Date(Math.max(...tasks.map(t => t.end.getTime())));
|
|
88
|
+
// Add padding
|
|
89
|
+
end.setDate(end.getDate() + 14);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Normalize to start of day
|
|
93
|
+
start.setHours(0,0,0,0);
|
|
94
|
+
end.setHours(23,59,59,999);
|
|
95
|
+
|
|
96
|
+
return { start, end };
|
|
97
|
+
}, [startDate, endDate, tasks]);
|
|
98
|
+
|
|
99
|
+
// Generate timeline columns
|
|
100
|
+
const timeColumns = React.useMemo(() => {
|
|
101
|
+
const cols: { date: Date; label: string; isWeekend: boolean }[] = [];
|
|
102
|
+
const current = new Date(timelineRange.start);
|
|
103
|
+
|
|
104
|
+
while (current <= timelineRange.end) {
|
|
105
|
+
cols.push({
|
|
106
|
+
date: new Date(current),
|
|
107
|
+
label: current.getDate().toString(),
|
|
108
|
+
isWeekend: current.getDay() === 0 || current.getDay() === 6
|
|
109
|
+
});
|
|
110
|
+
current.setDate(current.getDate() + 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return cols;
|
|
114
|
+
}, [timelineRange]);
|
|
115
|
+
|
|
116
|
+
const taskListWidth = 300;
|
|
117
|
+
|
|
118
|
+
const headerRef = React.useRef<HTMLDivElement>(null);
|
|
119
|
+
const listRef = React.useRef<HTMLDivElement>(null);
|
|
120
|
+
const timelineRef = React.useRef<HTMLDivElement>(null);
|
|
121
|
+
|
|
122
|
+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
123
|
+
// Sync horizontal scroll to header
|
|
124
|
+
if (headerRef.current) {
|
|
125
|
+
headerRef.current.scrollLeft = e.currentTarget.scrollLeft;
|
|
126
|
+
}
|
|
127
|
+
// Sync vertical scroll to task list
|
|
128
|
+
if (listRef.current) {
|
|
129
|
+
listRef.current.scrollTop = e.currentTarget.scrollTop;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const getTaskStyle = (task: GanttTask) => {
|
|
134
|
+
const totalDuration = timelineRange.end.getTime() - timelineRange.start.getTime();
|
|
135
|
+
const tickWidth = columnWidth; // px per day
|
|
136
|
+
const msPerDay = 1000 * 60 * 60 * 24;
|
|
137
|
+
|
|
138
|
+
const startOffsetMs = task.start.getTime() - timelineRange.start.getTime();
|
|
139
|
+
const durationMs = task.end.getTime() - task.start.getTime();
|
|
140
|
+
|
|
141
|
+
const left = (startOffsetMs / msPerDay) * tickWidth;
|
|
142
|
+
const width = Math.max((durationMs / msPerDay) * tickWidth, tickWidth); // Min 1 day width
|
|
143
|
+
|
|
144
|
+
return { left, width };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className={cn("flex flex-col h-full bg-background border rounded-lg overflow-hidden", className)}>
|
|
149
|
+
{/* Toolbar */}
|
|
150
|
+
<div className="flex items-center justify-between p-2 border-b bg-card">
|
|
151
|
+
<div className="flex items-center gap-2">
|
|
152
|
+
<Button variant="outline" size="sm" onClick={() => onAddClick?.()}>
|
|
153
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
154
|
+
New Task
|
|
155
|
+
</Button>
|
|
156
|
+
<div className="h-4 w-px bg-border mx-2" />
|
|
157
|
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
158
|
+
<ChevronLeft className="h-4 w-4" />
|
|
159
|
+
</Button>
|
|
160
|
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
161
|
+
<ChevronRight className="h-4 w-4" />
|
|
162
|
+
</Button>
|
|
163
|
+
<span className="font-semibold text-sm">
|
|
164
|
+
{timelineRange.start.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })}
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="flex items-center gap-2">
|
|
169
|
+
<Select value={viewMode} onValueChange={(v) => onViewChange?.(v as GanttViewMode)}>
|
|
170
|
+
<SelectTrigger className="w-[120px] h-8">
|
|
171
|
+
<SelectValue />
|
|
172
|
+
</SelectTrigger>
|
|
173
|
+
<SelectContent>
|
|
174
|
+
<SelectItem value="day">Day View</SelectItem>
|
|
175
|
+
<SelectItem value="week">Week View</SelectItem>
|
|
176
|
+
<SelectItem value="month">Month View</SelectItem>
|
|
177
|
+
<SelectItem value="quarter">Quarter View</SelectItem>
|
|
178
|
+
</SelectContent>
|
|
179
|
+
</Select>
|
|
180
|
+
<div className="flex bg-muted rounded-md p-1">
|
|
181
|
+
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setColumnWidth(prev => Math.max(20, prev - 10))}>
|
|
182
|
+
<ZoomOut className="h-3 w-3" />
|
|
183
|
+
</Button>
|
|
184
|
+
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setColumnWidth(prev => Math.min(100, prev + 10))}>
|
|
185
|
+
<ZoomIn className="h-3 w-3" />
|
|
186
|
+
</Button>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{/* Gantt Body */}
|
|
192
|
+
<div className="flex flex-col flex-1 overflow-hidden">
|
|
193
|
+
{/* Headers Row */}
|
|
194
|
+
<div className="flex border-b bg-muted/30 shrink-0 h-[50px]">
|
|
195
|
+
{/* List Header */}
|
|
196
|
+
<div
|
|
197
|
+
className="flex items-center font-medium text-xs text-muted-foreground px-4 border-r bg-card z-20 shadow-sm"
|
|
198
|
+
style={{ width: taskListWidth, minWidth: taskListWidth }}
|
|
199
|
+
>
|
|
200
|
+
<div className="flex-1">Task Name</div>
|
|
201
|
+
<div className="w-20 text-right">Start</div>
|
|
202
|
+
<div className="w-20 text-right">End</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* Timeline Header */}
|
|
206
|
+
<div className="flex-1 overflow-hidden" ref={headerRef}>
|
|
207
|
+
<div className="flex h-full" style={{ width: timeColumns.length * columnWidth }}>
|
|
208
|
+
{timeColumns.map((col, i) => (
|
|
209
|
+
<div
|
|
210
|
+
key={i}
|
|
211
|
+
className={cn(
|
|
212
|
+
"flex flex-col items-center justify-center border-r text-xs text-muted-foreground h-full",
|
|
213
|
+
col.isWeekend && "bg-muted/50"
|
|
214
|
+
)}
|
|
215
|
+
style={{ width: columnWidth, minWidth: columnWidth }}
|
|
216
|
+
>
|
|
217
|
+
<span className="font-medium text-foreground">{col.label}</span>
|
|
218
|
+
<span className="text-[10px] opacity-70">
|
|
219
|
+
{col.date.toLocaleDateString(undefined, { weekday: 'narrow' })}
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Content Row */}
|
|
228
|
+
<div className="flex flex-1 overflow-hidden">
|
|
229
|
+
{/* Left Side: Task List (Grid) */}
|
|
230
|
+
<div
|
|
231
|
+
className="overflow-hidden border-r bg-card z-10 shadow-sm"
|
|
232
|
+
ref={listRef}
|
|
233
|
+
style={{ width: taskListWidth, minWidth: taskListWidth }}
|
|
234
|
+
>
|
|
235
|
+
{tasks.map((task) => (
|
|
236
|
+
<div
|
|
237
|
+
key={task.id}
|
|
238
|
+
className="flex items-center border-b px-4 hover:bg-accent/50 cursor-pointer transition-colors"
|
|
239
|
+
style={{ height: ROW_HEIGHT }}
|
|
240
|
+
onClick={() => onTaskClick?.(task)}
|
|
241
|
+
>
|
|
242
|
+
<div className="flex-1 truncate font-medium text-sm flex items-center gap-2">
|
|
243
|
+
<div
|
|
244
|
+
className="w-2 h-2 rounded-full"
|
|
245
|
+
style={{ backgroundColor: task.color || '#3b82f6' }}
|
|
246
|
+
/>
|
|
247
|
+
{task.title}
|
|
248
|
+
</div>
|
|
249
|
+
<div className="w-20 text-right text-xs text-muted-foreground">
|
|
250
|
+
{task.start.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' })}
|
|
251
|
+
</div>
|
|
252
|
+
<div className="w-20 text-right text-xs text-muted-foreground">
|
|
253
|
+
{task.end.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' })}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* Right Side: Timeline */}
|
|
260
|
+
<div
|
|
261
|
+
className="flex-1 overflow-auto bg-background/50 relative"
|
|
262
|
+
ref={timelineRef}
|
|
263
|
+
onScroll={handleScroll}
|
|
264
|
+
>
|
|
265
|
+
<div style={{ width: timeColumns.length * columnWidth }}>
|
|
266
|
+
{/* Timeline Task Rows */}
|
|
267
|
+
<div className="relative">
|
|
268
|
+
{/* Background Grid */}
|
|
269
|
+
<div className="absolute inset-0 flex pointer-events-none z-0">
|
|
270
|
+
{timeColumns.map((col, i) => (
|
|
271
|
+
<div
|
|
272
|
+
key={i}
|
|
273
|
+
className={cn(
|
|
274
|
+
"border-r h-full",
|
|
275
|
+
col.isWeekend && "bg-muted/20"
|
|
276
|
+
)}
|
|
277
|
+
style={{ width: columnWidth, minWidth: columnWidth }}
|
|
278
|
+
/>
|
|
279
|
+
))}
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
{/* Task Bars */}
|
|
283
|
+
{tasks.map((task) => {
|
|
284
|
+
const style = getTaskStyle(task);
|
|
285
|
+
return (
|
|
286
|
+
<div
|
|
287
|
+
key={task.id}
|
|
288
|
+
className="relative border-b hover:bg-black/5"
|
|
289
|
+
style={{ height: ROW_HEIGHT }}
|
|
290
|
+
>
|
|
291
|
+
<div
|
|
292
|
+
className="absolute top-2 h-[calc(100%-16px)] rounded-sm bg-primary border border-primary-foreground/20 shadow-sm cursor-pointer hover:brightness-110 flex items-center px-2 group"
|
|
293
|
+
style={{
|
|
294
|
+
left: style.left,
|
|
295
|
+
width: style.width,
|
|
296
|
+
backgroundColor: task.color || '#3b82f6'
|
|
297
|
+
}}
|
|
298
|
+
onClick={() => onTaskClick?.(task)}
|
|
299
|
+
>
|
|
300
|
+
{/* Progress Filter */}
|
|
301
|
+
{task.progress > 0 && (
|
|
302
|
+
<div
|
|
303
|
+
className="absolute left-0 top-0 bottom-0 bg-black/20 rounded-l-sm"
|
|
304
|
+
style={{ width: `${task.progress}%` }}
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
{/* Hover Details */}
|
|
309
|
+
<span className="text-[10px] text-white font-medium truncate opacity-0 group-hover:opacity-100 transition-opacity">
|
|
310
|
+
{Math.round(task.progress)}%
|
|
311
|
+
</span>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
)
|
|
315
|
+
})}
|
|
316
|
+
|
|
317
|
+
{/* Current Time Indicator */}
|
|
318
|
+
<div
|
|
319
|
+
className="absolute top-0 bottom-0 w-px bg-red-500 z-20 pointer-events-none"
|
|
320
|
+
style={{
|
|
321
|
+
left: (new Date().getTime() - timelineRange.start.getTime()) / (1000 * 60 * 60 * 24) * columnWidth
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
<div className="w-2 h-2 rounded-full bg-red-500 -ml-[3px]" />
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { ObjectGantt } from './ObjectGantt';
|
|
5
|
+
import { DataSource } from '@object-ui/types';
|
|
6
|
+
|
|
7
|
+
// Mock GanttView to avoid complex rendering in tests
|
|
8
|
+
vi.mock('./GanttView', () => ({
|
|
9
|
+
GanttView: ({ tasks }: any) => (
|
|
10
|
+
<div data-testid="gantt-view">
|
|
11
|
+
{tasks.map((t: any) => (
|
|
12
|
+
<div key={t.id} data-testid="gantt-task">{t.title}</div>
|
|
13
|
+
))}
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const mockData = [
|
|
19
|
+
{ id: '1', name: 'Task 1', start_date: '2024-01-01', end_date: '2024-01-05', progress: 50 },
|
|
20
|
+
{ id: '2', name: 'Task 2', start_date: '2024-01-06', end_date: '2024-01-10', progress: 0 },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const mockDataSource: DataSource = {
|
|
24
|
+
find: vi.fn(),
|
|
25
|
+
findOne: vi.fn(),
|
|
26
|
+
create: vi.fn(),
|
|
27
|
+
update: vi.fn(),
|
|
28
|
+
delete: vi.fn(),
|
|
29
|
+
getObjectSchema: vi.fn().mockResolvedValue({
|
|
30
|
+
fields: {
|
|
31
|
+
name: { type: 'text' },
|
|
32
|
+
start_date: { type: 'date' },
|
|
33
|
+
end_date: { type: 'date' }
|
|
34
|
+
}
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('ObjectGantt', () => {
|
|
39
|
+
it('renders with static value provider', async () => {
|
|
40
|
+
const schema: any = {
|
|
41
|
+
type: 'gantt',
|
|
42
|
+
gantt: {
|
|
43
|
+
titleField: 'name',
|
|
44
|
+
startDateField: 'start_date',
|
|
45
|
+
endDateField: 'end_date',
|
|
46
|
+
},
|
|
47
|
+
data: {
|
|
48
|
+
provider: 'value',
|
|
49
|
+
items: mockData,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
render(<ObjectGantt schema={schema} />);
|
|
54
|
+
|
|
55
|
+
// Check loading first if needed, or wait for tasks
|
|
56
|
+
await waitFor(() => {
|
|
57
|
+
expect(screen.getByTestId('gantt-view')).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(screen.getAllByTestId('gantt-task')).toHaveLength(2);
|
|
61
|
+
expect(screen.getByText('Task 1')).toBeDefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('renders with object provider', async () => {
|
|
65
|
+
(mockDataSource.find as any).mockResolvedValue({ data: mockData });
|
|
66
|
+
|
|
67
|
+
const schema: any = {
|
|
68
|
+
type: 'gantt',
|
|
69
|
+
gantt: {
|
|
70
|
+
titleField: 'name',
|
|
71
|
+
startDateField: 'start_date',
|
|
72
|
+
endDateField: 'end_date',
|
|
73
|
+
},
|
|
74
|
+
data: {
|
|
75
|
+
provider: 'object',
|
|
76
|
+
object: 'tasks',
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
render(<ObjectGantt schema={schema} dataSource={mockDataSource} />);
|
|
81
|
+
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(mockDataSource.find).toHaveBeenCalledWith('tasks', expect.any(Object));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(screen.getAllByTestId('gantt-task')).toHaveLength(2);
|
|
87
|
+
});
|
|
88
|
+
});
|