This commit is contained in:
unknown 2020-07-22 20:50:43 +03:00
commit 063ced7795
40 changed files with 25274 additions and 0 deletions

42
.github/workflows/main.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Begin CI...
uses: actions/checkout@v2
- name: Use Node 12
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Use cached node_modules
uses: actions/cache@v1
with:
path: node_modules
key: nodeModules-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
nodeModules-
- name: Install dependencies
run: yarn install --frozen-lockfile
env:
CI: true
- name: Lint
run: yarn lint
env:
CI: true
- name: Test
run: yarn test --ci --coverage --maxWorkers=2
env:
CI: true
- name: Build
run: yarn build
env:
CI: true

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
*.log
.DS_Store
node_modules
.cache
dist

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"cSpell.words": [
"callout"
]
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 battakycyku@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1
README.md Normal file
View File

@ -0,0 +1 @@
#

3
example/.npmignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
.cache
dist

15
example/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Playground</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="root"></div>
<script src="./index.tsx"></script>
</body>
</html>

259
example/index.tsx Normal file
View File

@ -0,0 +1,259 @@
import 'react-app-polyfill/ie11';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {
Gantt,
Task,
EventOption,
StylingOption,
ViewMode,
DisplayOption,
} from '../src/index';
//Init
const App = () => {
const currentDate = new Date();
const [view, setView] = React.useState<ViewMode>(ViewMode.Day);
let tasks: Task[] = [
{
start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 1),
end: new Date(
currentDate.getFullYear(),
currentDate.getMonth(),
2,
12,
28
),
name: 'Redesign website',
id: 'Task 0',
progress: 45,
isDisabled: true,
},
{
start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 2),
end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 4, 0, 0),
name: 'Write new content',
id: 'Task 1',
progress: 25,
dependencies: ['Task 0'],
},
{
start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 4),
end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 8, 0, 0),
name: 'Apply new styles',
id: 'Task 2',
progress: 10,
dependencies: ['Task 1'],
},
{
start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 8),
end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 9, 0, 0),
name: 'Review',
id: 'Task currentDate.getMonth()',
progress: 2,
dependencies: ['Task 2'],
},
{
start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 8),
end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 10),
name: 'Deploy',
id: 'Task 4',
progress: 70,
dependencies: ['Task 2'],
},
{
start: new Date(currentDate.getFullYear(), currentDate.getMonth(), 15),
end: new Date(currentDate.getFullYear(), currentDate.getMonth(), 26),
name: 'Go Live!',
id: 'Task 6',
progress: currentDate.getMonth(),
dependencies: ['Task 4'],
styles: { progressColor: '#ffbb54', progressSelectedColor: '#ff9e0d' },
},
];
let onTaskChange = (task: Task) => {
console.log('On date change');
};
let onTaskDelete = (task: Task) => {
const conf = confirm('Are you sure?');
if (!conf) throw 'No del';
};
let onProgressChange = (task: Task) => {
console.log('On progress change');
};
let onDblClick = (task: Task) => {
alert('On Double Click event');
};
return (
<div>
<ViewSwitcher onViewChange={viewMode => setView(viewMode)} />
<GanttTableExample
tasks={tasks}
viewMode={view}
onDateChange={onTaskChange}
onTaskDelete={onTaskDelete}
onProgressChange={onProgressChange}
onDoubleClick={onDblClick}
/>
</div>
);
};
//Gantt with Custom table example
type GanttTableExampleProps = { tasks: Task[] } & EventOption & DisplayOption;
export const GanttTableExample: React.SFC<GanttTableExampleProps> = props => {
const gridColumnWidth = 150;
let options: StylingOption = {
fontSize: '14px',
fontFamily:
'Arial, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue',
headerHeight: 50,
rowHeight: 50,
};
if (props.viewMode === ViewMode.Month) {
options.columnWidth = 300;
} else if (props.viewMode === ViewMode.Week) {
options.columnWidth = 250;
}
const [tasks, setTasks] = React.useState(props.tasks);
const onTaskDateChange = async (task: Task) => {
if (props.onDateChange) {
try {
await props.onDateChange(task);
} catch (e) {
throw e;
}
setTasks(tasks.map(t => (t.id === task.id ? task : t)));
}
};
const onTaskProgressChange = async (task: Task) => {
if (props.onProgressChange) {
try {
await props.onProgressChange(task);
} catch (e) {
setTasks(props.tasks.slice());
throw e;
}
setTasks(tasks.map(t => (t.id === task.id ? task : t)));
}
};
const onTaskItemDelete = async (task: Task) => {
if (props.onTaskDelete) {
await props.onTaskDelete(task);
setTasks(tasks.filter(t => t.id !== task.id));
}
};
return (
<div className="Wrapper">
<div
className="GanttTable"
style={{
fontFamily: options.fontFamily,
fontSize: options.fontSize,
}}
>
<div
className="GanttTable-header"
style={{
height: options.headerHeight,
}}
>
<div
className="GanttTable-headerItem"
style={{
minWidth: gridColumnWidth,
}}
>
<span role="img" aria-label="fromDate" className="GanttTable-icon">
📃
</span>
Name
</div>
<div
className="GanttTable-headerItem"
style={{
minWidth: gridColumnWidth,
}}
>
<span role="img" aria-label="fromDate" className="GanttTable-icon">
📅
</span>
From
</div>
<div
className="GanttTable-headerItem"
style={{
minWidth: gridColumnWidth,
}}
>
<span role="img" aria-label="toDate" className="GanttTable-icon">
📅
</span>
To
</div>
</div>
{tasks.map(t => {
return (
<div
className="GanttTable-row"
style={{ height: options.rowHeight }}
>
<div className="GanttTable-cell">{t.name}</div>
<div className="GanttTable-cell">{t.start.toDateString()}</div>
<div className="GanttTable-cell">{t.end.toDateString()}</div>
</div>
);
})}
</div>
<div style={{ overflowX: 'scroll' }}>
<Gantt
{...options}
{...props}
tasks={tasks}
onDateChange={onTaskDateChange}
onTaskDelete={onTaskItemDelete}
onProgressChange={onTaskProgressChange}
/>
</div>
</div>
);
};
type ViewSwitcherProps = {
onViewChange: (viewMode: ViewMode) => void;
};
const ViewSwitcher: React.SFC<ViewSwitcherProps> = ({ onViewChange }) => {
return (
<div className="ViewContainer">
<button
className="Button"
onClick={() => onViewChange(ViewMode.QuarterDay)}
>
Quarter of Day
</button>
<button className="Button" onClick={() => onViewChange(ViewMode.HalfDay)}>
Half of Day
</button>
<button className="Button" onClick={() => onViewChange(ViewMode.Day)}>
Day
</button>
<button className="Button" onClick={() => onViewChange(ViewMode.Week)}>
Week
</button>
<button className="Button" onClick={() => onViewChange(ViewMode.Month)}>
Month
</button>
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));

7052
example/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
example/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "example",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "parcel index.html",
"build": "parcel build index.html"
},
"dependencies": {
"react-app-polyfill": "^1.0.0"
},
"alias": {
"react": "../node_modules/react",
"react-dom": "../node_modules/react-dom/profiling",
"scheduler/tracing": "../node_modules/scheduler/tracing-profiling"
},
"devDependencies": {
"@types/react": "^16.9.11",
"@types/react-dom": "^16.8.4",
"parcel": "^1.12.3",
"typescript": "^3.4.5"
}
}

67
example/style.css Normal file
View File

@ -0,0 +1,67 @@
/*Styles for Example Table*/
.Wrapper {
display: flex;
justify-content: space-around;
background: #ffff;
padding: 0;
margin: 0;
list-style: none;
}
.GanttTable {
display: table;
}
.GanttTable-header {
display: table-row;
list-style: none;
}
.GanttTable-headerItem {
display: table-cell;
padding-top: 24px;
vertical-align: text-bottom;
border-left: #e6e4e4 1px solid;
border-top: #e6e4e4 1px solid;
border-bottom: #e6e4e4 1px solid;
}
.GanttTable-row {
display: table-row;
text-overflow: ellipsis;
}
.GanttTable-row:nth-of-type(odd) {
background-color: #f5f5f5;
}
.GanttTable-cell {
display: table-cell;
vertical-align: middle;
border-left: #e6e4e4 1px solid;
}
.GanttTable-icon {
padding-right: 2px;
}
.ViewContainer {
list-style: none;
-ms-box-orient: horizontal;
display: flex;
-webkit-justify-content: flex-end;
justify-content: flex-end;
}
.Button {
background-color: #e7e7e7;
color: black;
border: none;
padding: 7px 16px;
text-align: center;
text-decoration: none;
font-size: 14px;
margin: 4px 2px;
cursor: pointer;
}

19
example/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": false,
"target": "es5",
"module": "commonjs",
"jsx": "react",
"moduleResolution": "node",
"noImplicitAny": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"removeComments": true,
"strictNullChecks": true,
"preserveConstEnums": true,
"sourceMap": true,
"lib": ["es2015", "es2016", "dom"],
"baseUrl": ".",
"types": ["node"]
}
}

5
jest.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
moduleNameMapper: {
'^.+\\.(css|less|scss)$': 'babel-jest',
},
};

9676
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@ -0,0 +1,52 @@
{
"version": "0.1.0",
"license": "MIT",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"files": [
"dist",
"src"
],
"engines": {
"node": ">=10"
},
"scripts": {
"start": "tsdx watch",
"build": "tsdx build",
"test": "tsdx test --passWithNoTests",
"lint": "tsdx lint",
"prepare": "tsdx build"
},
"peerDependencies": {
"react": ">=16"
},
"husky": {
"hooks": {
"pre-commit": "tsdx lint"
}
},
"prettier": {
"printWidth": 80,
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
},
"name": "gantt-task-react",
"author": "Maksym Vikarii maksym.vikarii@gmail.com",
"module": "dist/gantt-task-react.esm.js",
"devDependencies": {
"@types/react": "^16.9.41",
"@types/react-dom": "^16.9.8",
"husky": "^4.2.5",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"tsdx": "^0.13.2",
"tslib": "^2.0.0",
"typescript": "^3.9.6"
},
"dependencies": {
"autoprefixer": "^9.8.5",
"cssnano": "^4.1.10",
"rollup-plugin-postcss": "^3.1.2"
}
}

View File

@ -0,0 +1,32 @@
import React from 'react';
import '../../style.css';
type BarDateHandleProps = {
x: number;
y: number;
width: number;
height: number;
barCornerRadius: number;
onMouseDown: (event: React.MouseEvent<SVGRectElement, MouseEvent>) => void;
};
export const BarDateHandle: React.FC<BarDateHandleProps> = ({
x,
y,
width,
height,
barCornerRadius,
onMouseDown,
}) => {
return (
<rect
x={x}
y={y}
width={width}
height={height}
className="GanttBar-handle"
ry={barCornerRadius}
rx={barCornerRadius}
onMouseDown={onMouseDown}
></rect>
);
};

View File

@ -0,0 +1,98 @@
import React, { useRef, useState, useEffect } from 'react';
import '../../style.css';
type BarDisplayProps = {
x: number;
y: number;
width: number;
height: number;
isSelected: boolean;
progressWidth: number;
barCornerRadius: number;
text: string;
hasChild: boolean;
arrowIndent: number;
styles?: {
backgroundColor?: string;
backgroundSelectedColor?: string;
progressColor?: string;
progressSelectedColor?: string;
};
onMouseDown: (event: React.MouseEvent<SVGPolygonElement, MouseEvent>) => void;
};
export const BarDisplay: React.FC<BarDisplayProps> = ({
x,
y,
width,
height,
isSelected,
progressWidth,
barCornerRadius,
text,
hasChild,
arrowIndent,
styles,
onMouseDown,
}) => {
const textRef = useRef<SVGTextElement>(null);
const [isTextInside, setIsTextInside] = useState(true);
useEffect(() => {
if (textRef.current)
setIsTextInside(textRef.current.getBBox().width < width);
}, [textRef, width]);
const getProcessColor = () => {
if (isSelected) {
return styles?.progressSelectedColor || '#8282f5';
} else {
return styles?.progressColor || '#a3a3ff';
}
};
const getBarColor = () => {
if (isSelected) {
return styles?.backgroundSelectedColor || '#aeb8c2';
} else {
return styles?.backgroundColor || '#b8c2cc';
}
};
return (
<g onMouseDown={onMouseDown}>
<rect
x={x}
width={width}
y={y}
height={height}
ry={barCornerRadius}
rx={barCornerRadius}
fill={getBarColor()}
className="GanttBar"
/>
<rect
x={x}
width={progressWidth}
y={y}
height={height}
ry={barCornerRadius}
rx={barCornerRadius}
fill={getProcessColor()}
/>
<text
x={
isTextInside
? x + width * 0.5
: x + width + arrowIndent * +hasChild + arrowIndent * 0.2
}
y={y + height * 0.5}
className={`GanttBar-label ${
isTextInside ? '' : 'GanttBar-label-outside'
}`}
ref={textRef}
>
{text}
</text>
</g>
);
};

View File

@ -0,0 +1,19 @@
import React from 'react';
import '../../style.css';
type BarProgressHandleProps = {
progressPoint: string;
onMouseDown: (event: React.MouseEvent<SVGPolygonElement, MouseEvent>) => void;
};
export const BarProgressHandle: React.FC<BarProgressHandleProps> = ({
progressPoint,
onMouseDown,
}) => {
return (
<polygon
className="GanttBar-handle"
points={progressPoint}
onMouseDown={onMouseDown}
></polygon>
);
};

126
src/components/Bar/bar.tsx Normal file
View File

@ -0,0 +1,126 @@
import React, { useState } from 'react';
import { Task } from '../../types/public-types';
import { BarProgressHandle } from './bar-progress-handle';
import { BarDateHandle } from './bar-date-handle';
import { BarDisplay } from './bar-display';
import { BarTask } from '../../types/bar-task';
import { BarEvent } from '../Gantt/gantt-content';
import {
progressWithByParams,
getProgressPoint,
} from '../../helpers/bar-helper';
import '../../style.css';
export type BarProps = {
task: BarTask;
arrowIndent: number;
onDoubleClick?: (task: Task) => any;
isProgressChangeable: boolean;
isDateChangeable: boolean;
handleMouseEvents: (
event:
| React.MouseEvent<SVGPolygonElement, MouseEvent>
| React.MouseEvent<SVGGElement, MouseEvent>
| React.MouseEvent<SVGRectElement, MouseEvent>,
eventType: BarEvent,
task: BarTask
) => void;
handleButtonSVGEvents: (
event: React.KeyboardEvent<SVGGElement>,
task: BarTask
) => void;
};
export const Bar: React.FC<BarProps> = ({
task,
arrowIndent,
onDoubleClick,
isProgressChangeable,
isDateChangeable,
handleMouseEvents,
handleButtonSVGEvents,
}) => {
const [isSelected, setIsSelected] = useState(false);
const progressWidth = progressWithByParams(task.x1, task.x2, task.progress);
const progressPoint = getProgressPoint(
progressWidth + task.x1,
task.y,
task.height
);
return (
<g
className="GanttBar-wrapper"
onDoubleClick={() => {
!!onDoubleClick && onDoubleClick(task);
}}
tabIndex={0}
onKeyDown={e => {
handleButtonSVGEvents(e, task);
}}
onMouseEnter={e => {
handleMouseEvents(e, 'mouseenter', task);
}}
onMouseLeave={e => {
handleMouseEvents(e, 'mouseleave', task);
}}
onFocus={() => setIsSelected(true)}
onBlur={() => setIsSelected(false)}
>
<BarDisplay
x={task.x1}
y={task.y}
width={task.x2 - task.x1}
height={task.height}
progressWidth={progressWidth}
barCornerRadius={task.barCornerRadius}
text={task.name}
hasChild={task.barChildren.length > 0}
arrowIndent={arrowIndent}
styles={task.styles}
isSelected={isSelected}
onMouseDown={e => {
isDateChangeable && handleMouseEvents(e, 'move', task);
}}
/>
<g className="handleGroup">
{isDateChangeable && (
<>
{/*left*/}
<BarDateHandle
x={task.x1 + 1}
y={task.y + 1}
width={task.handleWidth}
height={task.height - 2}
barCornerRadius={task.barCornerRadius}
onMouseDown={e => {
handleMouseEvents(e, 'start', task);
}}
/>
{/*right*/}
<BarDateHandle
x={task.x2 - task.handleWidth - 1}
y={task.y + 1}
width={task.handleWidth}
height={task.height - 2}
barCornerRadius={task.barCornerRadius}
onMouseDown={e => {
handleMouseEvents(e, 'end', task);
}}
/>
</>
)}
{isProgressChangeable && (
<BarProgressHandle
progressPoint={progressPoint}
onMouseDown={e => {
console.log('progress');
handleMouseEvents(e, 'progress', task);
}}
/>
)}
</g>
</g>
);
};

View File

@ -0,0 +1,215 @@
import React, { ReactChild } from 'react';
import { ViewMode } from '../../types/public-types';
import { TopPartOfCalendar } from './top-part-of-calendar';
import {
getLocaleMonth,
getWeekNumberISO8601,
} from '../../helpers/date-helper';
import '../../style.css';
export type CalendarProps = {
dates: Date[];
locale: string;
viewMode: ViewMode;
headerHeight: number;
columnWidth: number;
fontFamily: string;
fontSize: string;
};
export const Calendar: React.FC<CalendarProps> = ({
dates,
locale,
viewMode,
headerHeight,
columnWidth,
fontFamily,
fontSize,
}) => {
const getCalendarValuesForMonth = () => {
let topValues: ReactChild[] = [];
let bottomValues: ReactChild[] = [];
const topDefaultWidth = columnWidth * 6;
const topDefaultHeight = headerHeight * 0.5;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
let bottomValue = getLocaleMonth(date, locale);
bottomValues.push(
<text
key={bottomValue + date.getFullYear()}
y={headerHeight * 0.8}
x={columnWidth * i + columnWidth * 0.5}
className="GanttCalendar-bottomText"
>
{bottomValue}
</text>
);
if (i === 0 || date.getFullYear() !== dates[i - 1].getFullYear()) {
let topValue = date.getFullYear().toString();
topValues.push(
<TopPartOfCalendar
key={topValue}
value={topValue}
x1Line={columnWidth * i}
y1Line={0}
y2Line={topDefaultHeight}
xText={
topDefaultWidth + columnWidth * i - date.getMonth() * columnWidth
}
yText={topDefaultHeight * 0.9}
/>
);
}
}
return [topValues, bottomValues];
};
const getCalendarValuesForWeek = () => {
let topValues: ReactChild[] = [];
let bottomValues: ReactChild[] = [];
let weeksCount: number = 0;
const topDefaultHeight = headerHeight * 0.5;
for (let i = dates.length - 1; i >= 0; i--) {
const date = dates[i];
let topValue = '';
if (i === 0 || date.getMonth() !== dates[i - 1].getMonth()) {
//top
topValue = `${getLocaleMonth(date, locale)}, ${date.getFullYear()}`;
}
//bottom
const bottomValue = `W${getWeekNumberISO8601(date)}`;
bottomValues.push(
<text
key={date.getTime()}
y={headerHeight * 0.8}
x={columnWidth * i}
className="GanttCalendar-bottomText"
>
{bottomValue}
</text>
);
if (topValue) {
//if last day is new month
if (i !== dates.length - 1) {
topValues.push(
<TopPartOfCalendar
key={topValue}
value={topValue}
x1Line={columnWidth * i + weeksCount * columnWidth}
y1Line={0}
y2Line={topDefaultHeight}
xText={columnWidth * i + columnWidth * weeksCount * 0.5}
yText={topDefaultHeight * 0.9}
/>
);
}
weeksCount = 0;
}
weeksCount++;
}
return [topValues, bottomValues];
};
const getCalendarValuesForDay = () => {
let topValues: ReactChild[] = [];
let bottomValues: ReactChild[] = [];
const topDefaultHeight = headerHeight * 0.5;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
const bottomValue = date.getDate().toString();
bottomValues.push(
<text
key={date.getTime()}
y={headerHeight * 0.8}
x={columnWidth * i + columnWidth * 0.5}
className="GanttCalendar-bottomText"
>
{bottomValue}
</text>
);
if (
i + 1 !== dates.length &&
date.getMonth() !== dates[i + 1].getMonth()
) {
let topValue = getLocaleMonth(date, locale);
topValues.push(
<TopPartOfCalendar
key={topValue}
value={topValue}
x1Line={columnWidth * (i + 1)}
y1Line={0}
y2Line={topDefaultHeight}
xText={columnWidth * (i + 1) - date.getDate() * columnWidth * 0.5}
yText={topDefaultHeight * 0.9}
/>
);
}
}
return [topValues, bottomValues];
};
const getCalendarValuesForOther = () => {
let topValues: ReactChild[] = [];
let bottomValues: ReactChild[] = [];
let ticks = viewMode === ViewMode.HalfDay ? 2 : 4;
const topDefaultHeight = headerHeight * 0.5;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
const bottomValue = Intl.DateTimeFormat(locale, {
hour: 'numeric',
}).format(date);
bottomValues.push(
<text
key={date.getTime()}
y={headerHeight * 0.8}
x={columnWidth * i}
className="GanttCalendar-bottomText"
fontFamily={fontFamily}
>
{bottomValue}
</text>
);
if (i === 0 || date.getDate() !== dates[i - 1].getDate()) {
const topValue = `${date.getDate()} ${getLocaleMonth(date, locale)}`;
topValues.push(
<TopPartOfCalendar
key={topValue}
value={topValue}
x1Line={columnWidth * i + ticks * columnWidth}
y1Line={0}
y2Line={topDefaultHeight}
xText={columnWidth * i + ticks * columnWidth * 0.5}
yText={topDefaultHeight * 0.9}
/>
);
}
}
return [topValues, bottomValues];
};
let topValues: ReactChild[] = [];
let bottomValues: ReactChild[] = [];
switch (viewMode) {
case ViewMode.Month:
[topValues, bottomValues] = getCalendarValuesForMonth();
break;
case ViewMode.Week:
[topValues, bottomValues] = getCalendarValuesForWeek();
break;
case ViewMode.Day:
[topValues, bottomValues] = getCalendarValuesForDay();
break;
default:
[topValues, bottomValues] = getCalendarValuesForOther();
break;
}
return (
<g className="calendar" fontSize={fontSize} fontFamily={fontFamily}>
{bottomValues} {topValues}
</g>
);
};

View File

@ -0,0 +1,41 @@
import React from 'react';
import '../../style.css';
type TopPartOfCalendarProps = {
value: string;
x1Line: number;
y1Line: number;
y2Line: number;
xText: number;
yText: number;
};
export const TopPartOfCalendar: React.FC<TopPartOfCalendarProps> = ({
value,
x1Line,
y1Line,
y2Line,
xText,
yText,
}) => {
return (
<>
<line
x1={x1Line}
y1={y1Line}
x2={x1Line}
y2={y2Line}
className="GanttCalendar-topTick"
key={value + 'line'}
></line>
<text
key={value + 'text'}
y={yText}
x={xText}
className="GanttCalendar-topText"
>
{value}
</text>
</>
);
};

View File

@ -0,0 +1,403 @@
import React, { useState, useEffect } from 'react';
import { Task, EventOption } from '../../types/public-types';
import { Bar } from '../Bar/bar';
import { BarTask } from '../../types/bar-task';
import { Arrow } from '../Other/arrow';
import {
convertToBarTasks,
progressByX,
startByX,
endByX,
moveByX,
dateByX,
} from '../../helpers/bar-helper';
import { Tooltip } from '../Other/tooltip';
export interface GanttTask extends Task {
x1: number;
x2: number;
y: number;
width: number;
height: number;
}
export type GanttContentProps = {
tasks: Task[];
dates: Date[];
rowHeight: number;
barCornerRadius: number;
columnWidth: number;
barFill: number;
headerHeight: number;
handleWidth: number;
svg: React.MutableRefObject<SVGSVGElement | null>;
timeStep: number;
arrowColor: string;
arrowIndent: number;
fontSize: string;
fontFamily: string;
getTooltipContent?: (
task: Task,
fontSize: string,
fontFamily: string
) => JSX.Element;
} & EventOption;
export type BarEvent =
| 'progress'
| 'end'
| 'start'
| 'move'
| 'mouseenter'
| 'mouseleave'
| '';
export const GanttContent: React.FC<GanttContentProps> = ({
tasks,
rowHeight,
barCornerRadius,
columnWidth,
dates,
barFill,
headerHeight,
handleWidth,
arrowColor,
svg,
timeStep,
fontFamily,
fontSize,
arrowIndent,
onDateChange,
onProgressChange,
onDoubleClick,
onTaskDelete,
getTooltipContent,
}) => {
const [barEvent, setBarEvent] = useState<BarEvent>('');
const [selectedTask, setSelectedTask] = useState<BarTask | null>(null);
const [barTasks, setBarTasks] = useState<BarTask[]>([]);
const [xStep, setXStep] = useState(0);
const [initEventX1Delta, setInitEventX1Delta] = useState(0);
const [isSVGListen, setIsSVGListen] = useState(false);
useEffect(() => {
const dateDelta =
dates[1].getTime() -
dates[0].getTime() -
dates[1].getTimezoneOffset() * 60 * 1000 +
dates[0].getTimezoneOffset() * 60 * 1000;
const newXStep = (timeStep * columnWidth) / dateDelta;
if (newXStep !== xStep) {
setXStep(newXStep);
}
}, [tasks, rowHeight, barCornerRadius, columnWidth, dates, timeStep, xStep]);
useEffect(() => {
const dateDelta =
dates[1].getTime() -
dates[0].getTime() -
dates[1].getTimezoneOffset() * 60 * 1000 +
dates[0].getTimezoneOffset() * 60 * 1000;
const taskHeight = (rowHeight * barFill) / 100;
setBarTasks(
convertToBarTasks(
tasks,
dates,
dateDelta,
columnWidth,
rowHeight,
taskHeight,
headerHeight,
barCornerRadius,
handleWidth
)
);
}, [
tasks,
rowHeight,
barCornerRadius,
columnWidth,
dates,
timeStep,
barFill,
handleWidth,
headerHeight,
]);
useEffect(() => {
/**
* Method handles event in real time(mousemove) and on finish(mouseup)
*/
const handleMouseSVGChangeEventsSubscribe = async (event: MouseEvent) => {
if (!selectedTask || !barEvent) return;
const changedTask = { ...selectedTask } as BarTask;
switch (event.type) {
//On Event changing
case 'mousemove': {
switch (barEvent) {
case 'progress':
changedTask.progress = progressByX(event.offsetX, selectedTask);
break;
case 'start':
let newX1 = startByX(event.offsetX, xStep, selectedTask);
changedTask.x1 = newX1;
changedTask.start = dateByX(
newX1,
selectedTask.x1,
selectedTask.start,
xStep,
timeStep
);
break;
case 'end':
let newX2 = endByX(event.offsetX, xStep, selectedTask);
changedTask.x2 = newX2;
changedTask.end = dateByX(
newX2,
selectedTask.x2,
selectedTask.end,
xStep,
timeStep
);
break;
case 'move':
const [newMoveX1, newMoveX2] = moveByX(
event.offsetX - initEventX1Delta,
xStep,
selectedTask
);
changedTask.start = dateByX(
newMoveX1,
selectedTask.x1,
selectedTask.start,
xStep,
timeStep
);
changedTask.end = dateByX(
newMoveX2,
selectedTask.x2,
selectedTask.end,
xStep,
timeStep
);
changedTask.x1 = newMoveX1;
changedTask.x2 = newMoveX2;
break;
}
//Update internal state
setBarTasks(
barTasks.map(t => (t.id === changedTask.id ? changedTask : t))
);
setSelectedTask(changedTask);
break;
}
//On finish Event
case 'mouseup': {
let eventForExecution: (
task: Task
) => void | Promise<void> = () => {};
switch (barEvent) {
case 'progress':
changedTask.progress = progressByX(event.offsetX, selectedTask);
if (onProgressChange) {
eventForExecution = onProgressChange;
}
break;
case 'start':
const newX1 = startByX(event.offsetX, xStep, selectedTask);
changedTask.start = dateByX(
newX1,
selectedTask.x1,
selectedTask.start,
xStep,
timeStep
);
if (onDateChange && newX1 !== selectedTask.x1) {
eventForExecution = onDateChange;
}
break;
case 'end':
const newX2 = endByX(event.offsetX, xStep, selectedTask);
changedTask.end = dateByX(
newX2,
selectedTask.x2,
selectedTask.end,
xStep,
timeStep
);
if (onDateChange && newX2 !== selectedTask.x2) {
eventForExecution = onDateChange;
}
break;
case 'move':
const [newMoveX1, newMoveX2] = moveByX(
event.offsetX - initEventX1Delta,
xStep,
selectedTask
);
changedTask.start = dateByX(
newMoveX1,
selectedTask.x1,
selectedTask.start,
xStep,
timeStep
);
changedTask.end = dateByX(
newMoveX2,
selectedTask.x2,
selectedTask.end,
xStep,
timeStep
);
if (
onDateChange &&
newMoveX1 !== selectedTask.x1 &&
newMoveX2 !== selectedTask.x2
) {
eventForExecution = onDateChange;
}
break;
}
setBarEvent('');
setSelectedTask(null);
setIsSVGListen(false);
svg.current?.removeEventListener(
'mousemove',
handleMouseSVGChangeEventsSubscribe
);
svg.current?.removeEventListener(
'mouseup',
handleMouseSVGChangeEventsSubscribe
);
//If update successful - update Gantt state, otherwise we shell back old Bar state
await eventForExecution(changedTask);
break;
}
}
};
if (selectedTask && barEvent && !isSVGListen) {
svg.current?.addEventListener(
'mousemove',
handleMouseSVGChangeEventsSubscribe
);
svg.current?.addEventListener(
'mouseup',
handleMouseSVGChangeEventsSubscribe
);
setIsSVGListen(true);
}
}, [
barEvent,
selectedTask,
xStep,
svg,
initEventX1Delta,
barTasks,
onProgressChange,
timeStep,
onDateChange,
isSVGListen,
]);
/**
* Method is Start point of task change
* @param event init mouse event
* @param eventType
* @param task events task
*/
const handleMouseEvents = (
event:
| React.MouseEvent<SVGPolygonElement, MouseEvent>
| React.MouseEvent<SVGRectElement, MouseEvent>
| React.MouseEvent<SVGGElement, MouseEvent>,
eventType: BarEvent,
task: BarTask
) => {
switch (event.type) {
case 'mousedown':
setBarEvent(eventType);
setSelectedTask(task);
setInitEventX1Delta(event.nativeEvent.offsetX - task.x1);
event.stopPropagation();
break;
case 'mouseleave':
if (!barEvent) setSelectedTask(null);
break;
case 'mouseenter':
if (!selectedTask) {
setSelectedTask(task);
}
break;
}
};
/**
* Method handles Bar keyboard events
* @param event
* @param task
*/
const handleButtonSVGEvents = async (
event: React.KeyboardEvent<SVGGElement>,
task: BarTask
) => {
if (task.isDisabled) return;
switch (event.key) {
case 'Delete': {
if (onTaskDelete) {
onTaskDelete(task);
}
break;
}
}
};
return (
<>
<g className="arrow" fill={arrowColor} stroke={arrowColor}>
{barTasks.map(task => {
return task.barChildren.map(child => {
return (
<Arrow
key={`Arrow from ${task.id} to ${barTasks[child].id}`}
taskFrom={task}
taskTo={barTasks[child]}
rowHeight={rowHeight}
arrowIndent={arrowIndent}
/>
);
});
})}
</g>
<g className="bar" fontFamily={fontFamily} fontSize={fontSize}>
{barTasks.map(task => {
return (
<Bar
task={task}
arrowIndent={arrowIndent}
isProgressChangeable={!!onProgressChange && !task.isDisabled}
onDoubleClick={onDoubleClick}
isDateChangeable={!!onDateChange && !task.isDisabled}
handleMouseEvents={handleMouseEvents}
handleButtonSVGEvents={handleButtonSVGEvents}
key={task.id}
/>
);
})}
</g>
<g className="toolTip">
{selectedTask && barEvent !== 'end' && barEvent !== 'start' && (
<Tooltip
x={selectedTask.x2 + columnWidth + arrowIndent}
y={selectedTask.y + rowHeight}
task={selectedTask}
fontFamily={fontFamily}
fontSize={fontSize}
getTooltipContent={getTooltipContent}
/>
)}
</g>
</>
);
};

View File

@ -0,0 +1,85 @@
import React, { useRef } from 'react';
import { ViewMode, GanttProps } from '../../types/public-types';
import { Grid, GridProps } from '../Grid/grid';
import { Calendar, CalendarProps } from '../Calendar/calendar';
import { GanttContent, GanttContentProps } from './gantt-content';
import { ganttDateRange, seedDates } from '../../helpers/date-helper';
export const Gantt: React.SFC<GanttProps> = ({
tasks,
headerHeight = 50,
columnWidth = 60,
rowHeight = 50,
viewMode = ViewMode.Day,
locale = 'en-GB',
barFill = 60,
barCornerRadius = 3,
handleWidth = 8,
timeStep = 300000,
arrowColor = 'grey',
fontFamily = 'Arial, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue',
fontSize = '14px',
arrowIndent = 20,
onDateChange,
onProgressChange,
onDoubleClick,
onTaskDelete,
getTooltipContent,
}) => {
const [startDate, endDate] = ganttDateRange(tasks, viewMode);
const dates = seedDates(startDate, endDate, viewMode);
const svg = useRef<SVGSVGElement | null>(null);
const gridProps: GridProps = {
columnWidth,
gridWidth: dates.length * columnWidth,
tasks,
rowHeight,
headerHeight,
dates,
};
const calendarProps: CalendarProps = {
dates,
locale,
viewMode,
headerHeight,
columnWidth,
fontFamily,
fontSize,
};
const barProps: GanttContentProps = {
tasks,
rowHeight,
barCornerRadius,
columnWidth,
dates,
barFill,
headerHeight,
handleWidth,
timeStep,
arrowColor,
svg,
fontFamily,
fontSize,
arrowIndent,
onDateChange,
onProgressChange,
onDoubleClick,
onTaskDelete,
getTooltipContent,
};
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={columnWidth * dates.length}
height={headerHeight + rowHeight * tasks.length}
ref={svg}
fontFamily={fontFamily}
>
<Grid {...gridProps} />
<Calendar {...calendarProps} />
<GanttContent {...barProps} />
</svg>
);
};

View File

@ -0,0 +1,99 @@
import React, { ReactChild } from 'react';
import { Task } from '../../types/public-types';
import { addToDate } from '../../helpers/date-helper';
import '../../style.css';
export type GridBodyProps = {
tasks: Task[];
dates: Date[];
gridWidth: number;
rowHeight: number;
headerHeight: number;
columnWidth: number;
};
export const GridBody: React.FC<GridBodyProps> = ({
tasks,
dates,
rowHeight,
headerHeight,
gridWidth,
columnWidth,
}) => {
let y = headerHeight;
let gridRows: ReactChild[] = [];
let rowLines: ReactChild[] = [];
for (const task of tasks) {
gridRows.push(
<rect
key={'Row' + task.id}
x="0"
y={y}
width={gridWidth}
height={rowHeight}
className="GanttGrid-row"
></rect>
);
rowLines.push(
<line
key={'RowLine' + task.id}
x="0"
y1={y + rowHeight}
x2={gridWidth}
y2={y + rowHeight}
className="GanttGrid-rowLine"
></line>
);
y += rowHeight;
}
const now = new Date();
let tickX = 0;
let ticks: ReactChild[] = [];
let today: ReactChild = <></>;
for (let i = 0; i < dates.length; i++) {
const date = dates[i];
ticks.push(
<line
key={date.getTime()}
x1={tickX}
y1={headerHeight}
x2={tickX}
y2={y}
className="GanttGrid-tick"
></line>
);
if (
(i + 1 !== dates.length &&
date.getTime() < now.getTime() &&
dates[i + 1].getTime() >= now.getTime()) ||
//if current date is last
(i !== 0 &&
i + 1 === dates.length &&
date.getTime() < now.getTime() &&
addToDate(
date,
date.getTime() - dates[i - 1].getTime(),
'millisecond'
).getTime() >= now.getTime())
) {
today = (
<rect
x={tickX}
y={0}
width={columnWidth}
height={y}
className="GanttGrid-todayHighlight"
></rect>
);
}
tickX += columnWidth;
}
return (
<>
<g className="rows">{gridRows}</g>
<g className="rowLines">{rowLines}</g>
<g className="ticks">{ticks}</g>
<g className="today">{today}</g>
</>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import '../../style.css';
export type GridHeaderProps = {
gridWidth: number;
headerHeight: number;
};
export const GridHeader: React.FC<GridHeaderProps> = ({
gridWidth,
headerHeight,
}) => {
return (
<rect
x="0"
y="0"
width={gridWidth}
height={headerHeight}
className="GanttGrid-header"
></rect>
);
};

View File

@ -0,0 +1,13 @@
import React from "react";
import { GridBody, GridBodyProps } from "./grid-body";
import { GridHeader, GridHeaderProps } from "./grid-header";
export type GridProps = GridBodyProps & GridHeaderProps;
export const Grid: React.FC<GridProps> = (props) => {
return (
<g className="grid">
<GridHeader {...props} />
<GridBody {...props} />
</g>
);
};

View File

@ -0,0 +1,34 @@
import React from "react";
import { BarTask } from "../../types/bar-task";
type ArrowProps = {
taskFrom: BarTask;
taskTo: BarTask;
rowHeight: number;
arrowIndent: number;
};
export const Arrow: React.FC<ArrowProps> = ({
taskFrom,
taskTo,
rowHeight,
arrowIndent,
}) => {
const indexCompare = taskFrom.index > taskTo.index ? -1 : 1;
const taskToEndPosition = taskTo.y + taskTo.height / 2;
const path = `M ${taskFrom.x2} ${taskFrom.y + taskFrom.height / 2}
h ${arrowIndent}
v ${(indexCompare * rowHeight) / 2}
H ${taskTo.x1 - arrowIndent}
V ${taskToEndPosition}
h ${arrowIndent}`;
const trianglePoints = `${taskTo.x1},${taskToEndPosition}
${taskTo.x1 - 5},${taskToEndPosition - 5}
${taskTo.x1 - 5},${taskToEndPosition + 5}`;
return (
<>
<path strokeWidth="1.5" d={path} fill="none" />
<polygon points={trianglePoints} />
</>
);
};

View File

@ -0,0 +1,69 @@
import React, { useRef, useEffect, useState } from 'react';
import { Task } from '../../types/public-types';
import '../../style.css';
export type TooltipProps = {
x: number;
y: number;
task: Task;
fontSize: string;
fontFamily: string;
getTooltipContent?: (
task: Task,
fontSize: string,
fontFamily: string
) => JSX.Element;
};
export const Tooltip: React.FC<TooltipProps> = ({
x,
y,
task,
fontSize,
fontFamily,
getTooltipContent = getStandardTooltipContent,
}) => {
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [toolWidth, setToolWidth] = useState(1000);
const [relatedY, setRelatedY] = useState(y);
useEffect(() => {
if (tooltipRef.current) {
const height =
tooltipRef.current.offsetHeight +
tooltipRef.current.offsetHeight * 0.15;
setRelatedY(y - height);
setToolWidth(tooltipRef.current.scrollWidth * 1.1);
}
}, [tooltipRef, y]);
return (
<foreignObject x={x} y={relatedY} width={toolWidth} height={1000}>
<div ref={tooltipRef} className="TooltipDetailsContainer">
{getTooltipContent(task, fontSize, fontFamily)}
</div>
</foreignObject>
);
};
const getStandardTooltipContent = (
task: Task,
fontSize: string,
fontFamily: string
) => {
const style = {
fontSize,
fontFamily,
};
return (
<div className="TooltipDefaultContainer" style={style}>
<b style={{ fontSize: fontSize + 6 }}>{`${
task.name
}: ${task.start.getDate()}-${task.start.getMonth() +
1}-${task.start.getFullYear()} - ${task.end.getDate()}-${task.end.getMonth() +
1}-${task.end.getFullYear()}`}</b>
<p className="TooltipDefaultContainer-paragraph">{`Duration: ${~~(
(task.end.getTime() - task.start.getTime()) /
(1000 * 60 * 60 * 24)
)} day(s)`}</p>
<p className="TooltipDefaultContainer-paragraph">{`Progress: ${task.progress} %`}</p>
</div>
);
};

195
src/helpers/bar-helper.ts Normal file
View File

@ -0,0 +1,195 @@
import { Task } from "../types/public-types";
import { BarTask } from "../types/bar-task";
export const convertToBarTasks = (
tasks: Task[],
dates: Date[],
dateDelta: number,
columnWidth: number,
rowHeight: number,
taskHeight: number,
headerHeight: number,
barCornerRadius: number,
handleWidth: number
) => {
let barTasks = tasks.map((t, i) => {
return convertToBarTask(
t,
i,
dates,
dateDelta,
columnWidth,
rowHeight,
taskHeight,
headerHeight,
barCornerRadius,
handleWidth
);
});
barTasks = barTasks.map((task, i) => {
const dependencies = task.dependencies || [];
for (let j = 0; j < dependencies.length; j++) {
const dependence = barTasks.findIndex(
(value) => value.id === dependencies[j]
);
if (dependence !== -1) barTasks[dependence].barChildren.push(i);
}
return task;
});
return barTasks;
};
export const convertToBarTask = (
task: Task,
index: number,
dates: Date[],
dateDelta: number,
columnWidth: number,
rowHeight: number,
taskHeight: number,
headerHeight: number,
barCornerRadius: number,
handleWidth: number
): BarTask => {
const x1 = taskXCoordinate(task.start, dates, dateDelta, columnWidth);
const x2 = taskXCoordinate(task.end, dates, dateDelta, columnWidth);
const y = taskYCoordinate(index, rowHeight, taskHeight, headerHeight);
return {
...task,
x1,
x2,
y,
index,
barCornerRadius,
handleWidth,
height: taskHeight,
barChildren: [],
};
};
export const taskXCoordinate = (
xDate: Date,
dates: Date[],
dateDelta: number,
columnWidth: number
) => {
const index = ~~(
(xDate.getTime() -
dates[0].getTime() +
xDate.getTimezoneOffset() -
dates[0].getTimezoneOffset()) /
dateDelta
);
const x = Math.round(
(index +
(xDate.getTime() -
dates[index].getTime() -
xDate.getTimezoneOffset() * 60 * 1000 +
dates[index].getTimezoneOffset() * 60 * 1000) /
dateDelta) *
columnWidth
);
return x;
};
export const taskYCoordinate = (
index: number,
rowHeight: number,
taskHeight: number,
headerHeight: number
) => {
const y = index * rowHeight + headerHeight + (rowHeight - taskHeight) / 2;
return y;
};
export const progressWithByParams = (
taskX1: number,
taskX2: number,
progress: number
) => {
return (taskX2 - taskX1) * progress * 0.01;
};
export const progressByProgressWidth = (
progressWidth: number,
barTask: BarTask
) => {
const barWidth = barTask.x2 - barTask.x1;
const progressPercent = Math.round((progressWidth * 100) / barWidth);
if (progressPercent >= 100) return 100;
else if (progressPercent <= 0) return 0;
else {
return progressPercent;
}
};
export const progressByX = (x: number, task: BarTask) => {
if (x >= task.x2) return 100;
else if (x <= task.x1) return 0;
else {
const barWidth = task.x2 - task.x1;
const progressPercent = Math.round(((x - task.x1) * 100) / barWidth);
return progressPercent;
}
};
export const getProgressPoint = (
progressX: number,
taskY: number,
taskHeight: number
) => {
const point = [
progressX - 5,
taskY + taskHeight,
progressX + 5,
taskY + taskHeight,
progressX,
taskY + taskHeight - 8.66,
];
return point.join(",");
};
export const startByX = (x: number, xStep: number, task: BarTask) => {
if (x >= task.x2 - task.handleWidth * 2) {
x = task.x2 - task.handleWidth * 2;
}
const steps = Math.round((x - task.x1) / xStep);
const additionalXValue = steps * xStep;
const newX = task.x1 + additionalXValue;
return newX;
};
export const endByX = (x: number, xStep: number, task: BarTask) => {
if (x <= task.x1 + task.handleWidth * 2) {
x = task.x1 + task.handleWidth * 2;
}
const steps = Math.round((x - task.x2) / xStep);
const additionalXValue = steps * xStep;
const newX = task.x2 + additionalXValue;
return newX;
};
export const moveByX = (x: number, xStep: number, task: BarTask) => {
const steps = Math.round((x - task.x1) / xStep);
const additionalXValue = steps * xStep;
const newX1 = task.x1 + additionalXValue;
const newX2 = newX1 + task.x2 - task.x1;
return [newX1, newX2];
};
export const dateByX = (
x: number,
taskX: number,
taskDate: Date,
xStep: number,
timeStep: number
) => {
let newDate = new Date(((x - taskX) / xStep) * timeStep + taskDate.getTime());
newDate = new Date(
newDate.getTime() +
(newDate.getTimezoneOffset() - taskDate.getTimezoneOffset()) * 60000
);
return newDate;
};

150
src/helpers/date-helper.ts Normal file
View File

@ -0,0 +1,150 @@
import { Task, ViewMode } from '../types/public-types';
type DateHelperScales =
| 'year'
| 'month'
| 'day'
| 'hour'
| 'minute'
| 'second'
| 'millisecond';
export const addToDate = (
date: Date,
quantity: number,
scale: DateHelperScales
) => {
let newDate = new Date(
date.getFullYear() + (scale === 'year' ? quantity : 0),
date.getMonth() + (scale === 'month' ? quantity : 0),
date.getDate() + (scale === 'day' ? quantity : 0),
date.getHours() + (scale === 'hour' ? quantity : 0),
date.getMinutes() + (scale === 'minute' ? quantity : 0),
date.getSeconds() + (scale === 'second' ? quantity : 0),
date.getMilliseconds() + (scale === 'millisecond' ? quantity : 0)
);
return newDate;
};
export const startOfDate = (date: Date, scale: DateHelperScales) => {
const scores = [
'millisecond',
'second',
'minute',
'hour',
'day',
'month',
'year',
];
const shouldReset = (_scale: DateHelperScales) => {
const max_score = scores.indexOf(scale);
return scores.indexOf(_scale) <= max_score;
};
let newDate = new Date(
date.getFullYear(),
shouldReset('year') ? 0 : date.getMonth(),
shouldReset('month') ? 1 : date.getDate(),
shouldReset('day') ? 0 : date.getHours(),
shouldReset('hour') ? 0 : date.getMinutes(),
shouldReset('minute') ? 0 : date.getSeconds(),
shouldReset('second') ? 0 : date.getMilliseconds()
);
return newDate;
};
export const ganttDateRange = (tasks: Task[], viewMode: ViewMode) => {
let newStartDate: Date = tasks[0].start;
let newEndDate: Date = tasks[0].end;
for (let task of tasks) {
if (task.start < newStartDate) {
newStartDate = task.start;
}
if (task.end > newEndDate) {
newEndDate = task.end;
}
}
if (viewMode === ViewMode.Month) {
newStartDate = addToDate(newStartDate, -1, 'month');
newEndDate = addToDate(newEndDate, 1, 'year');
newEndDate = startOfDate(newEndDate, 'year');
} else if (viewMode === ViewMode.Week) {
newStartDate = startOfDate(newStartDate, 'day');
newEndDate = startOfDate(newEndDate, 'day');
newStartDate = addToDate(getMonday(newStartDate), -7, 'day');
newEndDate = addToDate(newEndDate, 1.5, 'month');
} else {
newStartDate = startOfDate(newStartDate, 'day');
newEndDate = startOfDate(newEndDate, 'day');
newStartDate = addToDate(newStartDate, -1, 'day');
newEndDate = addToDate(newEndDate, 19, 'day');
}
return [newStartDate, newEndDate];
};
export const seedDates = (
startDate: Date,
endDate: Date,
viewMode: ViewMode
) => {
let currentDate: Date = new Date(startDate);
let dates: Date[] = [currentDate];
while (currentDate < endDate) {
if (viewMode === ViewMode.Month) {
currentDate = addToDate(currentDate, 1, 'month');
} else if (viewMode === ViewMode.Week) {
currentDate = addToDate(currentDate, 7, 'day');
} else if (viewMode === ViewMode.Day) {
currentDate = addToDate(currentDate, 1, 'day');
} else if (viewMode === ViewMode.HalfDay) {
currentDate = addToDate(currentDate, 12, 'hour');
} else if (viewMode === ViewMode.QuarterDay) {
currentDate = addToDate(currentDate, 6, 'hour');
}
dates.push(currentDate);
}
return dates;
};
export const getLocaleMonth = (date: Date, locale: string) => {
let bottomValue = new Intl.DateTimeFormat(locale, {
month: 'long',
}).format(date);
bottomValue = bottomValue.replace(
bottomValue[0],
bottomValue[0].toLocaleUpperCase()
);
return bottomValue;
};
/**
* Returns monday of current week
* @param date date for modify
*/
const getMonday = (date: Date) => {
const day = date.getDay();
const diff = date.getDate() - day + (day == 0 ? -6 : 1); // adjust when day is sunday
return new Date(date.setDate(diff));
};
export const getWeekNumberISO8601 = (date: Date) => {
let tmpDate = new Date(date.valueOf());
const dayNumber = (tmpDate.getDay() + 6) % 7;
tmpDate.setDate(tmpDate.getDate() - dayNumber + 3);
const firstThursday = tmpDate.valueOf();
tmpDate.setMonth(0, 1);
if (tmpDate.getDay() !== 4) {
tmpDate.setMonth(0, 1 + ((4 - tmpDate.getDay() + 7) % 7));
}
const weekNumber = (
1 + Math.ceil((firstThursday - tmpDate.valueOf()) / 604800000)
).toString();
if (weekNumber.length === 1) {
return `0${weekNumber}`;
} else {
return weekNumber;
}
};

9
src/index.tsx Normal file
View File

@ -0,0 +1,9 @@
export { Gantt } from './components/Gantt/gantt';
export {
GanttProps,
Task,
ViewMode,
StylingOption,
DisplayOption,
EventOption,
} from './types/public-types';

34
src/reducers/reducers.ts Normal file
View File

@ -0,0 +1,34 @@
import { Task } from "../types/public-types";
type GanttReduceState = {
ganttTasks: Task[];
};
export type GanttReduceAction = {
type: "update" | "delete";
changedTask?: Task;
};
export function ganttReducer(
state: GanttReduceState,
action: GanttReduceAction
): GanttReduceState {
switch (action.type) {
case "update": {
return {
ganttTasks: state.ganttTasks.map((t) =>
t.id === action.changedTask?.id ? action.changedTask : t
),
};
}
case "delete": {
return {
ganttTasks: state.ganttTasks.filter(
(t) => t.id !== action.changedTask?.id
),
};
}
default:
return state;
}
}

109
src/style.css Normal file
View File

@ -0,0 +1,109 @@
.GanttBar {
user-select: none;
stroke-width: 0;
}
.GanttBar-wrapper {
cursor: pointer;
outline: none;
}
.GanttBar-wrapper:hover .GanttBar-handle {
visibility: visible;
opacity: 1;
}
.GanttBar-label {
fill: #fff;
text-anchor: middle;
font-weight: lighter;
dominant-baseline: central;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
}
.GanttBar-label-outside {
fill: #555;
text-anchor: start;
}
.GanttBar-handle {
fill: #ddd;
cursor: ew-resize;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease;
}
.GanttCalendar-bottomText {
text-anchor: middle;
fill: #333;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
}
.GanttCalendar-topTick {
stroke: #e6e4e4;
}
.GanttCalendar-topText {
text-anchor: middle;
fill: #555;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
}
.GanttGrid-row {
fill: #ffffff;
}
.GanttGrid-row:nth-child(even) {
fill: #f5f5f5;
}
.GanttGrid-header {
fill: #ffffff;
stroke: #e0e0e0;
stroke-width: 1.4;
}
.GanttGrid-rowLine {
stroke: #ebeff2;
}
.GanttGrid-tick {
stroke: #e6e4e4;
}
.GanttGrid-todayHighlight {
fill: #fcf8e3;
opacity: 0.5;
}
.TooltipDefaultContainer {
background: #fff;
padding: 12px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}
.TooltipDefaultContainer-paragraph {
font-size: 12px;
margin-bottom: 6px;
color: #666;
}
.TooltipDetailsContainer {
display: table;
}

12
src/types/bar-task.ts Normal file
View File

@ -0,0 +1,12 @@
import { Task } from "./public-types";
export interface BarTask extends Task {
index: number;
x1: number;
x2: number;
y: number;
height: number;
barCornerRadius: number;
handleWidth: number;
barChildren: number[];
}

71
src/types/public-types.ts Normal file
View File

@ -0,0 +1,71 @@
export enum ViewMode {
QuarterDay = 'Quarter Day',
HalfDay = 'Half Day',
Day = 'Day',
/** ISO-8601 week */
Week = 'Week',
Month = 'Month',
}
export interface Task {
id: string;
name: string;
start: Date;
end: Date;
/**
* From 0 to 100
*/
progress: number;
styles?: {
backgroundColor?: string;
backgroundSelectedColor?: string;
progressColor?: string;
progressSelectedColor?: string;
};
isDisabled?: boolean;
dependencies?: string[];
}
export interface EventOption {
/**
* Time step value for date changes.
*/
timeStep?: number;
onDoubleClick?: (task: Task) => any;
onDateChange?: (task: Task) => void | Promise<any>;
onProgressChange?: (task: Task) => void | Promise<any>;
onTaskDelete?: (task: Task) => void | Promise<any>;
}
export interface DisplayOption {
viewMode?: ViewMode;
/**
* Display date format. Able formats: ISO 639-2, Java Locale
*/
locale?: string;
}
export interface StylingOption {
headerHeight?: number;
columnWidth?: number;
rowHeight?: number;
barCornerRadius?: number;
handleWidth?: number;
fontFamily?: string;
fontSize?: string;
/**
* How many of row width can be taken by task.
* From 0 to 100
*/
barFill?: number;
arrowColor?: string;
arrowIndent?: number;
getTooltipContent?: (
task: Task,
fontSize: string,
fontFamily: string
) => JSX.Element;
}
export interface GanttProps extends EventOption, DisplayOption, StylingOption {
tasks: Task[];
}

73
test/date-helper.test.tsx Normal file
View File

@ -0,0 +1,73 @@
import {
seedDates,
addToDate,
getWeekNumberISO8601,
} from '../src/helpers/date-helper';
import { ViewMode } from '../src/types/public-types';
describe('seed date', () => {
test('daily', () => {
expect(
seedDates(new Date(2020, 5, 28), new Date(2020, 6, 2), ViewMode.Day)
).toEqual([
new Date(2020, 5, 28),
new Date(2020, 5, 29),
new Date(2020, 5, 30),
new Date(2020, 6, 1),
new Date(2020, 6, 2),
]);
});
test('weekly', () => {
expect(
seedDates(new Date(2020, 5, 28), new Date(2020, 6, 19), ViewMode.Week)
).toEqual([
new Date(2020, 5, 28),
new Date(2020, 6, 5),
new Date(2020, 6, 12),
new Date(2020, 6, 19),
]);
});
test('monthly', () => {
expect(
seedDates(new Date(2020, 5, 28), new Date(2020, 6, 19), ViewMode.Month)
).toEqual([new Date(2020, 5, 28), new Date(2020, 6, 28)]);
});
test('quarterly', () => {
expect(
seedDates(
new Date(2020, 5, 28),
new Date(2020, 5, 29),
ViewMode.QuarterDay
)
).toEqual([
new Date(2020, 5, 28, 0, 0),
new Date(2020, 5, 28, 6, 0),
new Date(2020, 5, 28, 12, 0),
new Date(2020, 5, 28, 18, 0),
new Date(2020, 5, 29, 0, 0),
]);
});
});
describe('add to date', () => {
test('add month', () => {
expect(addToDate(new Date(2020, 0, 1), 40, 'month')).toEqual(
new Date(2023, 4, 1)
);
});
test('add day', () => {
expect(addToDate(new Date(2020, 0, 1), 40, 'day')).toEqual(
new Date(2020, 1, 10)
);
});
});
test('get week number', () => {
expect(getWeekNumberISO8601(new Date(2019, 11, 31))).toEqual('01');
expect(getWeekNumberISO8601(new Date(2021, 0, 1))).toEqual('53');
expect(getWeekNumberISO8601(new Date(2020, 6, 20))).toEqual('30');
});

24
test/gant.test.tsx Normal file
View File

@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Gantt } from '../src/index';
describe('gantt', () => {
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(
<Gantt
tasks={[
{
start: new Date(2020, 0, 1),
end: new Date(2020, 2, 2),
name: 'Redesign website',
id: 'Task 0',
progress: 45,
},
]}
/>,
div
);
ReactDOM.unmountComponentAtNode(div);
});
});

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"include": ["src", "types", "tsdx.config.js", "tsdx.config.js"],
"compilerOptions": {
"module": "esnext",
"lib": ["dom", "esnext"],
"importHelpers": true,
"declaration": true,
"sourceMap": true,
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"*": ["src/*", "node_modules/*"]
},
"jsx": "react",
"esModuleInterop": true
}
}

20
tsdx.config.js Normal file
View File

@ -0,0 +1,20 @@
const postcss = require('rollup-plugin-postcss');
const autoprefixer = require('autoprefixer');
const cssnano = require('cssnano');
module.exports = {
rollup(config, options) {
config.plugins.push(
postcss({
plugins: [
autoprefixer(),
cssnano({
preset: 'default',
}),
],
inject: false,
extract: !!options.writeMeta,
})
);
return config;
},
};

6053
yarn.lock Normal file

File diff suppressed because it is too large Load Diff