diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea2b204 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Node +node_modules/* +npm-debug.log + +# TypeScript +src/*.js +src/*.map +src/*.d.ts + +# JetBrains +.idea +.project +.settings +.idea/* +*.iml + +# VS Code +.vscode/* + +# Windows +Thumbs.db +Desktop.ini + +# Mac +.DS_Store +**/.DS_Store + +# Ngc generated files +**/*.ngfactory.ts + +# Build files +dist/* diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..fdb7f4f --- /dev/null +++ b/.npmignore @@ -0,0 +1,34 @@ +# Node +node_modules/* +npm-debug.log +docs/* +# DO NOT IGNORE TYPESCRIPT FILES FOR NPM +# TypeScript +# *.js +# *.map +# *.d.ts + +# JetBrains +.idea +.project +.settings +.idea/* +*.iml + +# VS Code +.vscode/* + +# Windows +Thumbs.db +Desktop.ini + +# Mac +.DS_Store +**/.DS_Store + +# Ngc generated files +**/*.ngfactory.ts + +# Library files +src/* +build/* diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2567e63 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,4 @@ +language: node_js +sudo: false +node_js: +- '4.2.1' diff --git a/.yo-rc.json b/.yo-rc.json new file mode 100644 index 0000000..f146d25 --- /dev/null +++ b/.yo-rc.json @@ -0,0 +1,7 @@ +{ + "generator-angular2-library": { + "promptValues": { + "gitRepositoryUrl": "https://github.com/jwiesmann/angular4-gantt" + } + } +} \ No newline at end of file diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..94b68ea --- /dev/null +++ b/README.MD @@ -0,0 +1,72 @@ +# angular4-gantt + +## Installation + +To install this library, run: + +```bash +$ npm install angular4-gantt --save +``` + +## Consuming your library + +Once you have published your library to npm, you can import your library in any Angular application by running: + +```bash +$ npm install angular4-gantt +``` + +and then from your Angular `AppModule`: + +```typescript +import { BrowserModule } from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; + +import { AppComponent } from './app.component'; + +// Import your library +import { SampleModule } from 'angular4-gantt'; + +@NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + + // Specify your library as an import + LibraryModule + ], + providers: [], + bootstrap: [AppComponent] +}) +export class AppModule { } +``` + +Once your library is imported, you can use its components, directives and pipes in your Angular application: + +```xml + +

+ {{title}} +

+ +``` + +## Development + +To generate all `*.js`, `*.d.ts` and `*.metadata.json` files: + +```bash +$ npm run build +``` + +To lint all `*.ts` files: + +```bash +$ npm run lint +``` + +## License + +MIT © [joerg.wiesmann](mailto:joerg.wiesmann@gmail.com) diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..59bff56 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,227 @@ +/* eslint-disable */ +var gulp = require('gulp'), + path = require('path'), + ngc = require('@angular/compiler-cli/src/main').main, + rollup = require('gulp-rollup'), + rename = require('gulp-rename'), + del = require('del'), + runSequence = require('run-sequence'), + inlineResources = require('./tools/gulp/inline-resources'); + +const rootFolder = path.join(__dirname); +const srcFolder = path.join(rootFolder, 'src'); +const tmpFolder = path.join(rootFolder, '.tmp'); +const buildFolder = path.join(rootFolder, 'build'); +const distFolder = path.join(rootFolder, 'dist'); + +/** + * 1. Delete /dist folder + */ +gulp.task('clean:dist', function () { + + // Delete contents but not dist folder to avoid broken npm links + // when dist directory is removed while npm link references it. + return deleteFolders([distFolder + '/**', '!' + distFolder]); +}); + +/** + * 2. Clone the /src folder into /.tmp. If an npm link inside /src has been made, + * then it's likely that a node_modules folder exists. Ignore this folder + * when copying to /.tmp. + */ +gulp.task('copy:source', function () { + return gulp.src([`${srcFolder}/**/*`, `!${srcFolder}/node_modules`]) + .pipe(gulp.dest(tmpFolder)); +}); + +/** + * 3. Inline template (.html) and style (.css) files into the the component .ts files. + * We do this on the /.tmp folder to avoid editing the original /src files + */ +gulp.task('inline-resources', function () { + return Promise.resolve() + .then(() => inlineResources(tmpFolder)); +}); + + +/** + * 4. Run the Angular compiler, ngc, on the /.tmp folder. This will output all + * compiled modules to the /build folder. + */ +gulp.task('ngc', function () { + return ngc({ + project: `${tmpFolder}/tsconfig.es5.json` + }) + .then((exitCode) => { + if (exitCode === 1) { + // This error is caught in the 'compile' task by the runSequence method callback + // so that when ngc fails to compile, the whole compile process stops running + throw new Error('ngc compilation failed'); + } + }); +}); + +/** + * 5. Run rollup inside the /build folder to generate our Flat ES module and place the + * generated file into the /dist folder + */ +gulp.task('rollup:fesm', function () { + return gulp.src(`${buildFolder}/**/*.js`) + // transform the files here. + .pipe(rollup({ + + // Bundle's entry point + // See https://github.com/rollup/rollup/wiki/JavaScript-API#entry + entry: `${buildFolder}/index.js`, + + // Allow mixing of hypothetical and actual files. "Actual" files can be files + // accessed by Rollup or produced by plugins further down the chain. + // This prevents errors like: 'path/file' does not exist in the hypothetical file system + // when subdirectories are used in the `src` directory. + allowRealFiles: true, + + // A list of IDs of modules that should remain external to the bundle + // See https://github.com/rollup/rollup/wiki/JavaScript-API#external + external: [ + '@angular/core', + '@angular/common' + ], + + // Format of generated bundle + // See https://github.com/rollup/rollup/wiki/JavaScript-API#format + format: 'es' + })) + .pipe(gulp.dest(distFolder)); +}); + +/** + * 6. Run rollup inside the /build folder to generate our UMD module and place the + * generated file into the /dist folder + */ +gulp.task('rollup:umd', function () { + return gulp.src(`${buildFolder}/**/*.js`) + // transform the files here. + .pipe(rollup({ + + // Bundle's entry point + // See https://github.com/rollup/rollup/wiki/JavaScript-API#entry + entry: `${buildFolder}/index.js`, + + // Allow mixing of hypothetical and actual files. "Actual" files can be files + // accessed by Rollup or produced by plugins further down the chain. + // This prevents errors like: 'path/file' does not exist in the hypothetical file system + // when subdirectories are used in the `src` directory. + allowRealFiles: true, + + // A list of IDs of modules that should remain external to the bundle + // See https://github.com/rollup/rollup/wiki/JavaScript-API#external + external: [ + '@angular/core', + '@angular/common' + ], + + // Format of generated bundle + // See https://github.com/rollup/rollup/wiki/JavaScript-API#format + format: 'umd', + + // Export mode to use + // See https://github.com/rollup/rollup/wiki/JavaScript-API#exports + exports: 'named', + + // The name to use for the module for UMD/IIFE bundles + // (required for bundles with exports) + // See https://github.com/rollup/rollup/wiki/JavaScript-API#modulename + moduleName: 'angular4-gantt', + + // See https://github.com/rollup/rollup/wiki/JavaScript-API#globals + globals: { + typescript: 'ts' + } + + })) + .pipe(rename('angular4-gantt.umd.js')) + .pipe(gulp.dest(distFolder)); +}); + +/** + * 7. Copy all the files from /build to /dist, except .js files. We ignore all .js from /build + * because with don't need individual modules anymore, just the Flat ES module generated + * on step 5. + */ +gulp.task('copy:build', function () { + return gulp.src([`${buildFolder}/**/*`, `!${buildFolder}/**/*.js`]) + .pipe(gulp.dest(distFolder)); +}); + +/** + * 8. Copy package.json from /src to /dist + */ +gulp.task('copy:manifest', function () { + return gulp.src([`${srcFolder}/package.json`]) + .pipe(gulp.dest(distFolder)); +}); + +/** + * 9. Copy README.md from / to /dist + */ +gulp.task('copy:readme', function () { + return gulp.src([path.join(rootFolder, 'README.MD')]) + .pipe(gulp.dest(distFolder)); +}); + +/** + * 10. Delete /.tmp folder + */ +gulp.task('clean:tmp', function () { + return deleteFolders([tmpFolder]); +}); + +/** + * 11. Delete /build folder + */ +gulp.task('clean:build', function () { + return deleteFolders([buildFolder]); +}); + +gulp.task('compile', function () { + runSequence( + 'clean:dist', + 'copy:source', + 'inline-resources', + 'ngc', + 'rollup:fesm', + 'rollup:umd', + 'copy:build', + 'copy:manifest', + 'copy:readme', + 'clean:build', + 'clean:tmp', + function (err) { + if (err) { + console.log('ERROR:', err.message); + deleteFolders([distFolder, tmpFolder, buildFolder]); + } else { + console.log('Compilation finished succesfully'); + } + }); +}); + +/** + * Watch for any change in the /src folder and compile files + */ +gulp.task('watch', function () { + gulp.watch(`${srcFolder}/**/*`, ['compile']); +}); + +gulp.task('clean', ['clean:dist', 'clean:tmp', 'clean:build']); + +gulp.task('build', ['clean', 'compile']); +gulp.task('build:watch', ['build', 'watch']); +gulp.task('default', ['build:watch']); + +/** + * Deletes the specified folder + */ +function deleteFolders(folders) { + return del(folders); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4367584 --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "angular4-gantt", + "version": "0.1.0", + "scripts": { + "build": "gulp build", + "build:watch": "gulp", + "docs": "npm run docs:build", + "docs:build": "compodoc -p tsconfig.json -n angular4-gantt -d docs --hideGenerator", + "docs:serve": "npm run docs:build -- -s", + "docs:watch": "npm run docs:build -- -s -w", + "lint": "tslint --type-check --project tsconfig.json src/**/*.ts", + "test": "tsc && karma start" + }, + "repository": { + "type": "git", + "url": "https://github.com/jwiesmann/angular4-gantt" + }, + "author": { + "name": "joerg.wiesmann", + "email": "joerg.wiesmann@gmail.com" + }, + "keywords": [ + "angular" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/jwiesmann/angular4-gantt/issues" + }, + "devDependencies": { + "@angular/common": "^4.0.0", + "@angular/compiler": "^4.0.0", + "@angular/compiler-cli": "^4.0.0", + "@angular/core": "^4.0.0", + "@angular/platform-browser": "^4.0.0", + "@angular/platform-browser-dynamic": "^4.0.0", + "@compodoc/compodoc": "^1.0.0-beta.10", + "@types/jasmine": "2.5.38", + "@types/node": "~6.0.60", + "codelyzer": "~2.0.0", + "core-js": "^2.4.1", + "del": "^2.2.2", + "gulp": "^3.9.1", + "gulp-rename": "^1.2.2", + "gulp-rollup": "^2.11.0", + "jasmine-core": "~2.5.2", + "jasmine-spec-reporter": "~3.2.0", + "karma": "~1.4.1", + "karma-chrome-launcher": "~2.0.0", + "karma-cli": "~1.0.1", + "karma-coverage-istanbul-reporter": "^0.2.0", + "karma-jasmine": "~1.1.0", + "karma-jasmine-html-reporter": "^0.2.2", + "node-sass": "^4.5.2", + "node-sass-tilde-importer": "^1.0.0", + "node-watch": "^0.5.2", + "protractor": "~5.1.0", + "rollup": "^0.41.6", + "run-sequence": "^1.2.2", + "rxjs": "^5.1.0", + "ts-node": "~2.0.0", + "tslint": "~4.5.0", + "typescript": "~2.2.0", + "zone.js": "^0.8.4" + }, + "engines": { + "node": ">=6.0.0" + } +} diff --git a/src/gantt-activity-background/gantt-activity-background.component.css b/src/gantt-activity-background/gantt-activity-background.component.css new file mode 100644 index 0000000..298df07 --- /dev/null +++ b/src/gantt-activity-background/gantt-activity-background.component.css @@ -0,0 +1,19 @@ +.gantt_activity_bg { + overflow: hidden; +} + +.gantt_activity_row { + border-bottom: 1px solid #ebebeb; + background-color: #fff; + box-sizing: border-box; +} + +.gantt_activity_cell { + display: inline-block; + height: 100%; + border-right: 1px solid #ebebeb; +} + +.weekend { + background-color:whitesmoke; +} diff --git a/src/gantt-activity-background/gantt-activity-background.component.html b/src/gantt-activity-background/gantt-activity-background.component.html new file mode 100644 index 0000000..e16bff1 --- /dev/null +++ b/src/gantt-activity-background/gantt-activity-background.component.html @@ -0,0 +1,7 @@ +
+
+
+
+
+
diff --git a/src/gantt-activity-background/gantt-activity-background.component.spec.ts b/src/gantt-activity-background/gantt-activity-background.component.spec.ts new file mode 100644 index 0000000..133f071 --- /dev/null +++ b/src/gantt-activity-background/gantt-activity-background.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GanttActivityBackgroundComponent } from './gantt-activity-background.component'; + +describe('GanttActivityBackgroundComponent', () => { + let component: GanttActivityBackgroundComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ GanttActivityBackgroundComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GanttActivityBackgroundComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/gantt-activity-background/gantt-activity-background.component.ts b/src/gantt-activity-background/gantt-activity-background.component.ts new file mode 100644 index 0000000..7a4103b --- /dev/null +++ b/src/gantt-activity-background/gantt-activity-background.component.ts @@ -0,0 +1,68 @@ +import {Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core'; +import {Zooming} from '../interfaces'; +import {GanttService} from '../gantt.service'; + +@Component({ + selector: 'app-gantt-activity-background', + templateUrl: './gantt-activity-background.component.html', + styleUrls: ['./gantt-activity-background.component.css'] +}) +export class GanttActivityBackgroundComponent implements OnInit { + + @Input() tasks: any; + @Input() timeScale: any; + @Input() zoom: any; + @Input() zoomLevel: string; + + @ViewChild('bg') bg: ElementRef; + + cells: any[] = []; + + constructor(public ganttService: GanttService) { + } + + ngOnInit() { + this.drawGrid(); + + this.zoom.subscribe((zoomLevel: string) => { + this.zoomLevel = zoomLevel; + this.drawGrid(); + }); + } + + isDayWeekend(date: Date): boolean { + return this.ganttService.isDayWeekend(date); + } + + private setRowStyle() { + return { + 'height': this.ganttService.rowHeight + 'px' + }; + } + + private setCellStyle() { + let width = this.ganttService.cellWidth; + + if (this.zoomLevel === Zooming[Zooming.hours]) { + width = this.ganttService.hourCellWidth; + } + + return { + 'width': width + 'px' + }; + } + + private drawGrid(): void { + if (this.zoomLevel === Zooming[Zooming.hours]) { + this.cells = []; + + this.timeScale.forEach((date: any) => { + for (let i = 0; i <= 23; i++) { + this.cells.push(date); + } + }); + } else { + this.cells = this.timeScale; + } + } +} diff --git a/src/gantt-activity-bars/gantt-activity-bars.component.css b/src/gantt-activity-bars/gantt-activity-bars.component.css new file mode 100644 index 0000000..059b915 --- /dev/null +++ b/src/gantt-activity-bars/gantt-activity-bars.component.css @@ -0,0 +1,68 @@ +.gantt_activity_line { + /*border-radius: 2px;*/ + position: absolute; + box-sizing: border-box; + background-color: rgb(18,195,244); + border: 1px solid #2196F3; + -webkit-user-select: none; +} + +.gantt_activity_line:hover { + /*cursor: move;*/ +} + +.gantt_activity_progress { + text-align: center; + z-index: 0; + background: #2196F3; + position: absolute; + min-height: 18px; + display: block; + height: 18px; +} + +.gantt_activity_progress_drag { + height: 8px; + width: 8px; + bottom: -4px; + margin-left: 4px; + background-position: bottom; + background-image: ""; + background-repeat: no-repeat; + z-index: 2; +} + +.gantt_activity_content { + font-size: 12px; + color: #fff; + width: 100%; + top: 0; + position: absolute; + white-space: nowrap; + text-align: center; + line-height: inherit; + overflow: hidden; + height: 100%; +} + +.gantt_activity_link_control { + position: absolute; + width: 13px; + top: 0; +} + +.gantt_activity_right { + right: 0; +} + +.gantt_activity_left { + left: 0; +} + +.gantt_activity_right:hover { + cursor:w-resize; +} + +.gantt_activity_left:hover { + cursor:w-resize; +} diff --git a/src/gantt-activity-bars/gantt-activity-bars.component.html b/src/gantt-activity-bars/gantt-activity-bars.component.html new file mode 100644 index 0000000..f74eaa0 --- /dev/null +++ b/src/gantt-activity-bars/gantt-activity-bars.component.html @@ -0,0 +1,14 @@ +
+
+
+
+
+ + +
+
diff --git a/src/gantt-activity-bars/gantt-activity-bars.component.spec.ts b/src/gantt-activity-bars/gantt-activity-bars.component.spec.ts new file mode 100644 index 0000000..21b6c97 --- /dev/null +++ b/src/gantt-activity-bars/gantt-activity-bars.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GanttActivityBarsComponent } from './gantt-activity-bars.component'; + +describe('GanttActivityBarsComponent', () => { + let component: GanttActivityBarsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ GanttActivityBarsComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GanttActivityBarsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/gantt-activity-bars/gantt-activity-bars.component.ts b/src/gantt-activity-bars/gantt-activity-bars.component.ts new file mode 100644 index 0000000..318a160 --- /dev/null +++ b/src/gantt-activity-bars/gantt-activity-bars.component.ts @@ -0,0 +1,147 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {Zooming} from '../interfaces'; +import {GanttService} from '../gantt.service'; + +@Component({ + selector: 'app-gantt-activity-bars', + templateUrl: './gantt-activity-bars.component.html', + styleUrls: ['./gantt-activity-bars.component.css'] +}) +export class GanttActivityBarsComponent implements OnInit { + + @Input() timeScale: any; + @Input() dimensions: any; + @Input() tasks: any; + @Input() zoom: any; + @Input() zoomLevel: any; + + private containerHeight = 0; + private containerWidth = 0; + + constructor(public ganttService: GanttService) { + } + + ngOnInit() { + this.containerHeight = this.dimensions.height; + this.containerWidth = this.dimensions.width; + + this.zoom.subscribe((zoomLevel: string) => { + this.zoomLevel = zoomLevel; + }); + } + + // TODO(dale): the ability to move bars needs reviewing and there are a few quirks + expandLeft($event: any, bar: any) { + $event.stopPropagation(); + + const ganttService = this.ganttService; + const startX = $event.clientX; + const startBarWidth = bar.style.width; + const startBarLeft = bar.style.left; + + function doDrag(e: any) { + const cellWidth = ganttService.cellWidth; + const barWidth = startBarWidth - e.clientX + startX; + const days = Math.round(barWidth / cellWidth); + + bar.style.width = days * cellWidth + days; + bar.style.left = (startBarLeft - (days * cellWidth) - days); + } + + this.addMouseEventListeners(doDrag); + + return false; + } + + expandRight($event: any, bar: any) { + $event.stopPropagation(); + + const ganttService = this.ganttService; + const startX = $event.clientX; + const startBarWidth = bar.style.width; + const startBarEndDate = bar.task.end; + const startBarLeft = bar.style.left; + + function doDrag(e: any) { + const cellWidth = ganttService.cellWidth; + let barWidth = startBarWidth + e.clientX - startX; + let days = Math.round(barWidth / cellWidth); + + if (barWidth < cellWidth) { + barWidth = cellWidth; + days = Math.round(barWidth / cellWidth); + } + bar.style.width = ((days * cellWidth) + days); // rounds to the nearest cell + } + + this.addMouseEventListeners(doDrag); + + return false; + } + + move($event: any, bar: any) { + $event.stopPropagation(); + + const ganttService = this.ganttService; + const startX = $event.clientX; + const startBarLeft = bar.style.left; + + function doDrag(e: any) { + const cellWidth = ganttService.cellWidth; + const barLeft = startBarLeft + e.clientX - startX; + const days = Math.round(barLeft / cellWidth); + + // TODO: determine how many days the bar can be moved + // if (days < maxDays) { + bar.style.left = ((days * cellWidth) + days); // rounded to nearest cell + + // keep bar in bounds of grid + if (barLeft < 0) { + bar.style.left = 0; + } + // } + // TODO: it needs to take into account the max number of days. + // TODO: it needs to take into account the current days. + // TODO: it needs to take into account the right boundary. + } + + this.addMouseEventListeners(doDrag); + + return false; + } + + private drawBar(task: any, index: number) { + let style = {}; + + if (this.zoomLevel === Zooming[Zooming.hours]) { + style = this.ganttService.calculateBar(task, index, this.timeScale, true); + } else { + style = this.ganttService.calculateBar(task, index, this.timeScale); + } + return style; + } + + private drawProgress(task: any, bar: any): any { + const barStyle = this.ganttService.getBarProgressStyle(task.status); + const width = this.ganttService.calculateBarProgress(this.ganttService.getComputedStyle(bar, 'width'), task.percentComplete); + + return { + 'width': width, + 'background-color': barStyle['background-color'], + }; + } + + private addMouseEventListeners(dragFn: any) { + + function stopFn() { + document.documentElement.removeEventListener('mousemove', dragFn, false); + document.documentElement.removeEventListener('mouseup', stopFn, false); + document.documentElement.removeEventListener('mouseleave', stopFn, false); + } + + document.documentElement.addEventListener('mousemove', dragFn, false); + document.documentElement.addEventListener('mouseup', stopFn, false); + document.documentElement.addEventListener('mouseleave', stopFn, false); + } + +} diff --git a/src/gantt-activity/gantt-activity.component.css b/src/gantt-activity/gantt-activity.component.css new file mode 100644 index 0000000..0f3466d --- /dev/null +++ b/src/gantt-activity/gantt-activity.component.css @@ -0,0 +1,110 @@ +/* You can add global styles to this file, and also import other style files */ +.gantt_activity { + /*overflow-x: hidden;*/ + height: 250px; + overflow-y: hidden; + overflow-x: scroll; + display: inline-block; + vertical-align: top; + position: relative; +} + +.gantt_activity_area { + position: relative; + overflow-x: hidden; + overflow-y: hidden; + -webkit-user-select: none; +} + +.gantt_vertical_scroll { + background-color: transparent; + overflow-x: hidden; + overflow-y: scroll; + position: absolute; + right: 0; + display: block; + height: 283px; + width: 18px; + top: 70px; +} + +.grid { + overflow-x: hidden; + overflow-y: hidden; + display: inline-block; + vertical-align: top; + border-right: 1px solid #cecece; +} + +.grid_scale { + color: #6b6b6b; + font-size: 12px; + border-bottom: 1px solid #e0e0e0; + background-color: whitesmoke; +} + +.grid_head_cell { + /*color: #a6a6a6;*/ + border-top: none !important; + border-right: none !important; + line-height: inherit; + box-sizing: border-box; + display: inline-block; + vertical-align: top; + border-right: 1px solid #cecece; + /*text-align: center;*/ + position: relative; + cursor: default; + height: 100%; + -moz-user-select: -moz-none; + -webkit-user-select: none; + overflow: hidden; +} + +.grid_data { + overflow: hidden; +} + +.grid_row { + box-sizing: border-box; + border-bottom: 1px solid #e0e0e0; + background-color: #fff; + position: relative; + -webkit-user-select: none; +} + +.grid_row:hover { + background-color: #eeeeee; +} + +.grid_cell { + border-right: none; + color: #454545; + display: inline-block; + vertical-align: top; + padding-left: 6px; + padding-right: 6px; + height: 100%; + overflow: hidden; + white-space: nowrap; + font-size: 13px; + box-sizing: border-box; +} + +.actions_bar { + /*border-top: 1px solid #cecece;*/ + border-bottom: 1px solid #e0e0e0; + clear: both; + /*margin-top: 90px;*/ + height: 28px; + background: whitesmoke; + color: #494949; + font-family: Arial, sans-serif; + font-size: 13px; + padding-left: 15px; + line-height: 25px; +} + +.gantt_tree_content { + padding-left: 15px; +} diff --git a/src/gantt-activity/gantt-activity.component.html b/src/gantt-activity/gantt-activity.component.html new file mode 100644 index 0000000..5e378c6 --- /dev/null +++ b/src/gantt-activity/gantt-activity.component.html @@ -0,0 +1,51 @@ +
+
+ + + +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
{{data.name}}
+
+
+ +
{{ data.percentComplete }}
+
+
+
{{ ganttService.calculateDuration(data) }}
+
+
+
+
+ +
+ + +
+
+
+
+
diff --git a/src/gantt-activity/gantt-activity.component.spec.ts b/src/gantt-activity/gantt-activity.component.spec.ts new file mode 100644 index 0000000..f88a0f8 --- /dev/null +++ b/src/gantt-activity/gantt-activity.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GanttActivityComponent } from './gantt-activity.component'; + +describe('GanttActivityComponent', () => { + let component: GanttActivityComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ GanttActivityComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GanttActivityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/gantt-activity/gantt-activity.component.ts b/src/gantt-activity/gantt-activity.component.ts new file mode 100644 index 0000000..defc72b --- /dev/null +++ b/src/gantt-activity/gantt-activity.component.ts @@ -0,0 +1,326 @@ +import {ChangeDetectionStrategy, Component, DoCheck, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {GanttService} from '../gantt.service'; +import {Zooming} from '../interfaces'; + + +@Component({ + selector: 'app-gantt-activity', + templateUrl: './gantt-activity.component.html', + styleUrls: ['./gantt-activity.component.css'], + changeDetection: ChangeDetectionStrategy.Default +}) +export class GanttActivityComponent implements OnInit, DoCheck { + @Input() project: any; + @Input() options: any; + @Output() onGridRowClick: EventEmitter = new EventEmitter(); + + upTriangle = '▲'; // BLACK UP-POINTING TRIANGLE + downTriangle = '▼'; // BLACK DOWN-POINTING TRIANGLE + zoom: EventEmitter = new EventEmitter(); + activityActions = { + expanded: false, + expandedIcon: this.downTriangle + }; + + timeScale: any; + + start: Date; + end: Date; + containerHeight: any; + containerWidth: any; + + activityContainerSizes: any; + ganttActivityHeight: any; + ganttActivityWidth: any; + zoomLevel: string = Zooming[Zooming.hours]; + + treeExpanded = false; + + scale: any = { + start: null, + end: null + }; + + dimensions = { + height: 0, + width: 0 + }; + + data: any[] = []; + + public gridColumns: any[] = [ + {name: '', left: 0, width: 16}, + {name: 'Task', left: 20, width: 330}, + {name: '%', left: 8, width: 40}, + {name: 'Duration', left: 14, width: 140} + ]; + + constructor(public ganttService: GanttService) { + } + + ngOnInit() { + // Cache the project data and only work with that. Only show parent tasks by default + this.ganttService.TASK_CACHE = this.project.tasks.slice(0).filter((item: any) => { + return item.treePath.split('/').length === 1; + }); + this.ganttService.TIME_SCALE = this.ganttService.calculateScale(this.options.scale.start, this.options.scale.end); + + this.zoomLevel = this.options.zooming; + this.start = this.options.scale.start; + this.end = this.options.scale.end; + this.containerWidth = this.calculateContainerWidth(); + this.containerHeight = this.calculateContainerHeight(); + this.activityContainerSizes = this.ganttService.calculateActivityContainerDimensions(); + + // important that these are called last as it relies on values calculated above. + this.setScale(); + this.setDimensions(); + this.setSizes(); + + this.expand(); // default to expanded + } + + /** Custom model check */ + ngDoCheck() { + // do a check to see whether any new tasks have been added. If the task is a child then push into array if tree expanded? + const tasksAdded = this.ganttService.doTaskCheck(this.project.tasks, this.treeExpanded); + + // only force expand if tasks are added and tree is already expanded + if (tasksAdded && this.activityActions.expanded) { + this.expand(true); + } + } + + /** On vertical scroll set the scroll top of grid and activity */ + onVerticalScroll(verticalScroll: any, ganttGrid: any, ganttActivityArea: any): void { + this.ganttService.scrollTop(verticalScroll, ganttGrid, ganttActivityArea); + } + + /** Removes or adds children for given parent tasks back into DOM by updating TASK_CACHE */ + toggleChildren(rowElem: any, task: any) { + try { + const isParent: boolean = 'true' === rowElem.getAttribute('data-isparent'); + const parentId: string = rowElem.getAttribute('data-parentid').replace('_', ''); // remove id prefix + const children: any = document.querySelectorAll('[data-parentid=' + rowElem.getAttribute('data-parentid') + '][data-isparent=false]'); + + // use the task cache to allow deleting of items without polluting the project.tasks array + if (isParent) { + // remove children from the DOM as we don't want them if we are collapsing the parent + if (children.length > 0) { + const childrenIds: any[] = this.ganttService.TASK_CACHE.filter((task1: any) => { + return task1.parentId === parentId && task1.treePath.split('/').length > 1; + }).map((item: any) => item.id); + + childrenIds.forEach((item: any) => { + const removedIndex = this.ganttService.TASK_CACHE.map((item1: any) => item1.id).indexOf(item); + + this.ganttService.TASK_CACHE.splice(removedIndex, 1); + }); + + if (this.activityActions.expanded) { + this.expand(true); + } + + } else { + // CHECK the project cache to see if this parent id has any children + // and if so push them back into array so DOM is updated + const childrenTasks: any[] = this.project.tasks.filter((task1: any) => { + return task1.parentId === parentId && task1.treePath.split('/').length > 1; + }); + + childrenTasks.forEach((task1: any) => { + this.ganttService.TASK_CACHE.push(task1); + }); + + if (this.activityActions.expanded) { + this.expand(true); + } + } + } + + this.onGridRowClick.emit(task); + + } catch (err) { + } + } + + /** Removes or adds children tasks back into DOM by updating TASK_CACHE */ + toggleAllChildren() { + try { + const children: any = document.querySelectorAll('[data-isparent=false]'); + const childrenIds: string[] = Array.prototype.slice.call(children).map((item: any) => { + return item.getAttribute('data-id').replace('_', ''); // remove id prefix + }); + + // push all the children array items into cache + if (this.treeExpanded) { + if (children.length > 0) { + const childIds: string[] = this.ganttService.TASK_CACHE.filter((task: any) => { + return task.treePath.split('/').length > 1; + }).map((item: any) => item.id); + + childIds.forEach((item: any) => { + const removedIndex = this.ganttService.TASK_CACHE.map((item1: any) => item1.id).indexOf(item); + this.ganttService.TASK_CACHE.splice(removedIndex, 1); + }); + } + + this.treeExpanded = false; + + if (this.activityActions.expanded) { + this.expand(true); + } + } else { + // get all children tasks in project input + let childrenTasks: any[] = this.project.tasks.filter((task: any) => { + return task.treePath.split('/').length > 1; + }); + + if (children.length > 0) { + // filter out these children as they already exist in task cache + childrenTasks = childrenTasks.filter((task: any) => { + return childrenIds.indexOf(task.id) === -1; + }); + } + + childrenTasks.forEach((task: any) => { + this.ganttService.TASK_CACHE.push(task); + }); + + this.treeExpanded = true; + + if (this.activityActions.expanded) { + this.expand(true); + } + } + } catch (err) { + } + } + + /** On resize of browser window dynamically adjust gantt activity height and width */ + onResize(event: any): void { + const activityContainerSizes = this.ganttService.calculateActivityContainerDimensions(); + if (this.activityActions.expanded) { + this.ganttActivityHeight = this.ganttService.TASK_CACHE.length * this.ganttService.rowHeight + this.ganttService.rowHeight * 3 + 'px'; + } else { + this.ganttActivityHeight = activityContainerSizes.height + 'px'; + } + + this.ganttActivityWidth = activityContainerSizes.width; + } + + setScale() { + this.scale.start = this.start; + this.scale.end = this.end; + } + + setDimensions() { + this.dimensions.height = this.containerHeight; + this.dimensions.width = this.containerWidth; + } + + setGridRowStyle(isParent: boolean): any { + if (isParent) { + return { + 'height': this.ganttService.rowHeight + 'px', + 'line-height': this.ganttService.rowHeight + 'px', + 'font-weight': 'bold', + 'cursor': 'pointer' + }; + } + + return { + 'height': this.ganttService.rowHeight + 'px', + 'line-height': this.ganttService.rowHeight + 'px' + }; + } + + /** Set the zoom level e.g hours, days */ + zoomTasks(level: string) { + this.zoomLevel = level; + this.zoom.emit(this.zoomLevel); + this.containerWidth = this.calculateContainerWidth(); + this.setDimensions(); + document.querySelector('.gantt_activity').scrollLeft = 0; // reset scroll left, replace with @ViewChild? + } + + /** Expand the gantt grid and activity area height */ + expand(force?: boolean): void { + const verticalScroll = document.querySelector('.gantt_vertical_scroll'); + const ganttActivityHeight = `${this.ganttService.TASK_CACHE.length * this.ganttService.rowHeight + this.ganttService.rowHeight * 3}px`; + + if (force && this.activityActions.expanded) { + this.ganttActivityHeight = ganttActivityHeight; + } else if (this.activityActions.expanded) { + this.activityActions.expanded = false; + this.activityActions.expandedIcon = this.downTriangle; + this.ganttActivityHeight = '300px'; + } else { + verticalScroll.scrollTop = 0; + + this.activityActions.expanded = true; + this.activityActions.expandedIcon = this.upTriangle; + this.ganttActivityHeight = ganttActivityHeight; + } + } + + /** Get the status icon unicode string */ + getStatusIcon(status: string, percentComplete: number): string { + const checkMarkIcon = '✔'; + const upBlackPointer = '▲'; + const crossMarkIcon = '✘'; + + if (status === 'Completed' || percentComplete === 100 && status !== 'Error') { + return checkMarkIcon; + } else if (status === 'Warning') { + return upBlackPointer; + } else if (status === 'Error') { + return crossMarkIcon; + } + return ''; + } + + /** Get the status icon color */ + getStatusIconColor(status: string, percentComplete: number): string { + if (status === 'Completed' || percentComplete === 100 && status !== 'Error') { + return 'green'; + } else if (status === 'Warning') { + return 'orange'; + } else if (status === 'Error') { + return 'red'; + } + return ''; + } + + private setGridScaleStyle() { + let height = this.ganttService.rowHeight; + + if (this.zoomLevel === Zooming[Zooming.hours]) { + height *= 2; + } + + return { + 'height': height + 'px', + 'line-height': height + 'px', + 'width': this.ganttService.gridWidth + 'px' + }; + } + + private calculateContainerHeight(): number { + return this.ganttService.TASK_CACHE.length * this.ganttService.rowHeight; + } + + private calculateContainerWidth(): number { + if (this.zoomLevel === Zooming[Zooming.hours]) { + return this.ganttService.TIME_SCALE.length * this.ganttService.hourCellWidth * 24 + this.ganttService.hourCellWidth; + } else { + return this.ganttService.TIME_SCALE.length * this.ganttService.cellWidth + this.ganttService.cellWidth; + } + } + + private setSizes(): void { + this.ganttActivityHeight = this.activityContainerSizes.height + 'px'; + this.ganttActivityWidth = this.activityContainerSizes.width; + } + +} diff --git a/src/gantt-config.service.ts b/src/gantt-config.service.ts new file mode 100644 index 0000000..513d28a --- /dev/null +++ b/src/gantt-config.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@angular/core'; + +@Injectable() +export class GanttConfig { + public cellWidth: number = 76; + public rowHeight: number = 25; + public activityHeight: number = 300; + public barHeight = 20; + public barLineHeight = 20; + public barMoveable = false; +} diff --git a/src/gantt-footer/gantt-footer.component.css b/src/gantt-footer/gantt-footer.component.css new file mode 100644 index 0000000..822dcdb --- /dev/null +++ b/src/gantt-footer/gantt-footer.component.css @@ -0,0 +1,9 @@ +.gantt-footer { + background-color: whitesmoke; + height: 36px; + border-top: 1px solid #e0e0e0; +} + +.gantt-footer-actions { + float:right; +} diff --git a/src/gantt-footer/gantt-footer.component.html b/src/gantt-footer/gantt-footer.component.html new file mode 100644 index 0000000..d342b40 --- /dev/null +++ b/src/gantt-footer/gantt-footer.component.html @@ -0,0 +1 @@ + diff --git a/src/gantt-footer/gantt-footer.component.spec.ts b/src/gantt-footer/gantt-footer.component.spec.ts new file mode 100644 index 0000000..d74f3ef --- /dev/null +++ b/src/gantt-footer/gantt-footer.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GanttFooterComponent } from './gantt-footer.component'; + +describe('GanttFooterComponent', () => { + let component: GanttFooterComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ GanttFooterComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GanttFooterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/gantt-footer/gantt-footer.component.ts b/src/gantt-footer/gantt-footer.component.ts new file mode 100644 index 0000000..e3cc787 --- /dev/null +++ b/src/gantt-footer/gantt-footer.component.ts @@ -0,0 +1,18 @@ +import {Component, Input, OnInit} from '@angular/core'; + +@Component({ + selector: 'app-gantt-footer', + templateUrl: './gantt-footer.component.html', + styleUrls: ['./gantt-footer.component.css'] +}) +export class GanttFooterComponent implements OnInit { + + @Input() project: any; + + constructor() { + } + + ngOnInit() { + } + +} diff --git a/src/gantt-header/gantt-header.component.css b/src/gantt-header/gantt-header.component.css new file mode 100644 index 0000000..1d9ec19 --- /dev/null +++ b/src/gantt-header/gantt-header.component.css @@ -0,0 +1,19 @@ +.gantt-header { + background-color: whitesmoke; + height: 40px; + border-bottom: 1px solid #e0e0e0; +} + +.gantt-header-title { + padding: 12px; + display: flex; + flex-wrap:wrap; + font-family: Arial, Helvetica, sans-serif; + font-size: 16px; +} + +.gantt-header-actions { + display: inline; + float: right; + padding: 6px; +} diff --git a/src/gantt-header/gantt-header.component.html b/src/gantt-header/gantt-header.component.html new file mode 100644 index 0000000..830bb34 --- /dev/null +++ b/src/gantt-header/gantt-header.component.html @@ -0,0 +1,6 @@ +
+
+
{{ name }}
+
Started: {{ startDate | date: 'medium'}}
+
+
diff --git a/src/gantt-header/gantt-header.component.spec.ts b/src/gantt-header/gantt-header.component.spec.ts new file mode 100644 index 0000000..9e311bc --- /dev/null +++ b/src/gantt-header/gantt-header.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GanttHeaderComponent } from './gantt-header.component'; + +describe('GanttHeaderComponent', () => { + let component: GanttHeaderComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ GanttHeaderComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GanttHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/gantt-header/gantt-header.component.ts b/src/gantt-header/gantt-header.component.ts new file mode 100644 index 0000000..8a5c357 --- /dev/null +++ b/src/gantt-header/gantt-header.component.ts @@ -0,0 +1,19 @@ +import {Component, Input, OnInit} from '@angular/core'; + +@Component({ + selector: 'app-gantt-header', + templateUrl: './gantt-header.component.html', + styleUrls: ['./gantt-header.component.css'] +}) +export class GanttHeaderComponent implements OnInit { + + @Input() name: any; + @Input() startDate: Date; + + constructor() { + } + + ngOnInit() { + } + +} diff --git a/src/gantt-time-scale/gantt-time-scale.component.css b/src/gantt-time-scale/gantt-time-scale.component.css new file mode 100644 index 0000000..c8f19eb --- /dev/null +++ b/src/gantt-time-scale/gantt-time-scale.component.css @@ -0,0 +1,26 @@ +.weekend { + background-color: whitesmoke; +} + +.time_scale { + font-size: 12px; + border-bottom: 1px solid #cecece; + background-color: #fff; +} + +.time_scale_line { + box-sizing: border-box; +} + +.time_scale_line:first-child { + border-top: none; +} + +.time_scale_cell { + display: inline-block; + white-space: nowrap; + overflow: hidden; + border-right: 1px solid #cecece; + text-align: center; + height: 100%; +} diff --git a/src/gantt-time-scale/gantt-time-scale.component.html b/src/gantt-time-scale/gantt-time-scale.component.html new file mode 100644 index 0000000..8e9c650 --- /dev/null +++ b/src/gantt-time-scale/gantt-time-scale.component.html @@ -0,0 +1,10 @@ +
+
+
{{date | date: 'dd-MM-yyyy'}}
+
+
+
{{hour}}
+
+
diff --git a/src/gantt-time-scale/gantt-time-scale.component.spec.ts b/src/gantt-time-scale/gantt-time-scale.component.spec.ts new file mode 100644 index 0000000..30ebc13 --- /dev/null +++ b/src/gantt-time-scale/gantt-time-scale.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GanttTimeScaleComponent } from './gantt-time-scale.component'; + +describe('GanttTimeScaleComponent', () => { + let component: GanttTimeScaleComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ GanttTimeScaleComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GanttTimeScaleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/gantt-time-scale/gantt-time-scale.component.ts b/src/gantt-time-scale/gantt-time-scale.component.ts new file mode 100644 index 0000000..68e4339 --- /dev/null +++ b/src/gantt-time-scale/gantt-time-scale.component.ts @@ -0,0 +1,63 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {Zooming} from '../interfaces'; +import {GanttService} from '../gantt.service'; + +@Component({ + selector: 'app-gantt-time-scale', + templateUrl: './gantt-time-scale.component.html', + styleUrls: ['./gantt-time-scale.component.css'] +}) +export class GanttTimeScaleComponent implements OnInit { + + @Input() timeScale: any; + @Input() dimensions: any; + @Input() zoom: any; + @Input() zoomLevel: any; + + constructor(public ganttService: GanttService) { + } + + ngOnInit() { + this.zoom.subscribe((zoomLevel: string) => { + this.zoomLevel = zoomLevel; + }); + } + + private setTimescaleStyle() { + return { + 'width': this.dimensions.width + 'px' + }; + } + + private setTimescaleLineStyle(borderTop: string) { + return { + 'height': this.ganttService.rowHeight + 'px', + 'line-height': this.ganttService.rowHeight + 'px', + 'position': 'relative', + 'border-top': borderTop + }; + } + + private setTimescaleCellStyle() { + let width = this.ganttService.cellWidth; + const hoursInDay = 24; + const hourSeperatorPixels = 23; // we don't include the first + + if (this.zoomLevel === Zooming[Zooming.hours]) { + width = this.ganttService.hourCellWidth * hoursInDay + hourSeperatorPixels; + } + + return { + 'width': width + 'px' + }; + } + + private isDayWeekend(date: Date): boolean { + return this.ganttService.isDayWeekend(date); + } + + private getHours(): string[] { + return this.ganttService.getHours(this.timeScale.length); + } + +} diff --git a/src/gantt.module.ts b/src/gantt.module.ts new file mode 100644 index 0000000..e276e4c --- /dev/null +++ b/src/gantt.module.ts @@ -0,0 +1,33 @@ +import {NgModule} from '@angular/core'; + +import {GanttActivityComponent} from './gantt-activity/gantt-activity.component'; +import {GroupByPipe} from './group-by.pipe'; +import {GanttComponent} from './gantt/gantt.component'; +import {GanttHeaderComponent} from './gantt-header/gantt-header.component'; +import {GanttFooterComponent} from './gantt-footer/gantt-footer.component'; +import {GanttActivityBackgroundComponent} from './gantt-activity-background/gantt-activity-background.component'; +import {GanttActivityBarsComponent} from './gantt-activity-bars/gantt-activity-bars.component'; +import {GanttTimeScaleComponent} from './gantt-time-scale/gantt-time-scale.component'; +import {CommonModule} from '@angular/common'; + +@NgModule({ + declarations: [ + GanttComponent, + GanttActivityComponent, + GroupByPipe, + GanttComponent, + GanttHeaderComponent, + GanttFooterComponent, + GanttActivityBackgroundComponent, + GanttActivityBarsComponent, + GanttTimeScaleComponent + ], + imports: [ + CommonModule, + ], + providers: [], + exports: [GanttComponent], + bootstrap: [GanttComponent] +}) +export class GanttModule { +} diff --git a/src/gantt.service.spec.ts b/src/gantt.service.spec.ts new file mode 100644 index 0000000..90887a7 --- /dev/null +++ b/src/gantt.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { GanttService } from './gantt.service'; + +describe('GanttService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [GanttService] + }); + }); + + it('should be created', inject([GanttService], (service: GanttService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/src/gantt.service.ts b/src/gantt.service.ts new file mode 100644 index 0000000..26eba04 --- /dev/null +++ b/src/gantt.service.ts @@ -0,0 +1,528 @@ +import {Injectable} from '@angular/core'; +import {IBarStyle, IScale, Task} from './interfaces'; +import {GanttConfig} from './gantt-config.service'; +import {GroupByPipe} from './group-by.pipe'; + +@Injectable() +export class GanttService { + + public rowHeight = 0; + public hourCellWidth = 60; // change to 60 so minutes can been seen more easily + public hoursCellWidth: number = this.hourCellWidth * 25; + public cellWidth = 0; + public windowInnerWidth = 0; + public activityHeight = 0; + public barHeight = 0; + public barLineHeight = 0; + public barTop = 0; + public barMoveable = false; + public gridWidth = 560; + private barStyles: IBarStyle[] = [ + {status: 'information', backgroundColor: 'rgb(18,195, 244)', border: '1px solid #2196F3', progressBackgroundColor: '#2196F3'}, + {status: 'warning', backgroundColor: '#FFA726', border: '1px solid #EF6C00', progressBackgroundColor: '#EF6C00'}, + {status: 'error', backgroundColor: '#EF5350', border: '1px solid #C62828', progressBackgroundColor: '#C62828'}, + {status: 'completed', backgroundColor: '#66BB6A', border: '1px solid #2E7D32', progressBackgroundColor: '#2E7D32'} + ]; + public TASK_CACHE: any[]; + public TIME_SCALE: any[]; + + constructor() { + const _ganttConfig = new GanttConfig(); + + this.rowHeight = _ganttConfig.rowHeight; + this.cellWidth = _ganttConfig.cellWidth; + this.activityHeight = _ganttConfig.activityHeight; + this.barHeight = _ganttConfig.barHeight; + this.barLineHeight = _ganttConfig.barLineHeight; + this.barTop = _ganttConfig.rowHeight; + this.barMoveable = _ganttConfig.barMoveable; + } + + private calculateBarWidth(start: Date, end: Date, hours?: boolean): number { + if (typeof start === 'string') { + start = new Date(start); + } + + if (typeof end === 'string') { + end = new Date(end); + } + + const days = this.calculateDiffDays(start, end); + let width: number = days * this.cellWidth + days; + + if (hours) { + width = days * this.hourCellWidth * 24 + days * 24; + } + + return width; + } + + private calculateBarLeft(start: Date, scale: any[], hours?: boolean): number { + let left = 0; + const hoursInDay = 24; + + if (start != null) { + if (typeof start === 'string') { + start = new Date(); + } + + for (let i = 0; i < scale.length; i++) { + if (start.getDate() === scale[i].getDate()) { + if (hours) { + left = i * hoursInDay * this.hourCellWidth + hoursInDay * i + this.calculateBarLeftDelta(start, hours); + } else { + left = i * this.cellWidth + i + this.calculateBarLeftDelta(start, hours); + } + break; + } + } + } + return left; + } + + /** Calculates the height of the gantt grid, activity and vertical scroll */ + public calculateGanttHeight(): string { + return `${this.TASK_CACHE.length * this.rowHeight + this.rowHeight * 3}px`; + } + + private calculateBarLeftDelta(start: Date, hours?: boolean): number { + let offset = 0; + const hoursInDay = 24; + const minutesInHour = 60; + const secondsInHour = 3600; + const startHours: number = start.getHours() + start.getMinutes() / minutesInHour + start.getSeconds() / secondsInHour; + + if (hours) { + offset = this.hoursCellWidth / hoursInDay * startHours - startHours; + } else { + offset = this.cellWidth / hoursInDay * startHours; + } + return offset; + } + + public isParent(treePath: string): boolean { + + try { + const depth = treePath.split('/').length; + + if (depth === 1) { + return true; + } + } catch (err) { + return false; + } + return false; + } + + public isChild(treePath: string) { + if (this.isParent(treePath)) { + return '0px'; + } + return '20px'; + } + + /** Calculate the bar styles */ + public calculateBar(task: any, index: number, scale: any, hours?: boolean) { + const barStyle = this.getBarStyle(task.status); + return { + 'top': this.barTop * index + 2 + 'px', + 'left': this.calculateBarLeft(task.start, scale, hours) + 'px', + 'height': this.barHeight + 'px', + 'line-height': this.barLineHeight + 'px', + 'width': this.calculateBarWidth(task.start, task.end, hours) + 'px', + 'background-color': barStyle['background-color'], + 'border': barStyle['border'] + }; + } + + /** Get the bar style based on task status */ + private getBarStyle(taskStatus = ''): any { + const style = {}; + + try { + taskStatus = taskStatus.toLowerCase(); + } catch (err) { + taskStatus = ''; + } + + switch (taskStatus) { + case 'information': + style['background-color'] = this.barStyles[0].backgroundColor; + style['border'] = this.barStyles[0].border; + break; + case 'warning': + style['background-color'] = this.barStyles[1].backgroundColor; + style['border'] = this.barStyles[1].border; + break; + case 'error': + style['background-color'] = this.barStyles[2].backgroundColor; + style['border'] = this.barStyles[2].border; + break; + case 'completed': + style['background-color'] = this.barStyles[3].backgroundColor; + style['border'] = this.barStyles[3].border; + break; + default: + style['background-color'] = 'rgb(18,195, 244)'; + style['border'] = '1px solid #2196F3'; + break; + } + + return style; + } + + /** Get the progresss bar background colour based on task status */ + public getBarProgressStyle(taskStatus = ''): any { + const style = {}; + + try { + taskStatus = taskStatus.toLowerCase(); + } catch (err) { + taskStatus = ''; + } + + switch (taskStatus) { + case 'information': + style['background-color'] = this.barStyles[0].progressBackgroundColor; + break; + case 'warning': + style['background-color'] = this.barStyles[1].progressBackgroundColor; + break; + case 'error': + style['background-color'] = this.barStyles[2].progressBackgroundColor; + break; + case 'completed': + style['background-color'] = this.barStyles[3].progressBackgroundColor; + break; + default: + style['background-color'] = this.barStyles[0].progressBackgroundColor; + break; + } + + return style; + } + + /** Calculates the bar progress width in pixels given task percent complete */ + public calculateBarProgress(width: number, percent: number): string { + if (typeof percent === 'number') { + if (percent > 100) { + percent = 100; + } + const progress: number = (width / 100) * percent - 2; + + return `${progress}px`; + } + return `${0}px`; + } + + /** Calculates the difference in two dates and returns number of days */ + public calculateDiffDays(start: Date, end: Date): number { + try { + const oneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds /ms + const diffDays = Math.abs((start.getTime() - end.getTime()) / (oneDay)); + const days = diffDays; // don't use Math.round as it will draw an incorrect bar + + return days; + } catch (err) { + return 0; + } + } + + /** Calculates the difference in two dates and returns number of hours */ + public calculateDuration(task: any): string { + try { + if (task.start != null && task.end != null) { + const oneHour = 60 * 60 * 1000; + const diffHours = (Math.abs((task.start.getTime() - task.end.getTime()) / oneHour)); + const duration = diffHours; + + if (duration > 24) { + return `${Math.round(duration / 24)} day(s)`; // duration in days + } else if (duration > 1) { + return `${Math.round(duration)} hr(s)`; // duration in hours + } else { + const minutes = duration * 60; + + if (minutes < 1) { + return `${Math.round(minutes * 60)} second(s)`; // duration in seconds + } + return `${Math.round(minutes)} min(s)`; // duration in minutes + } + } + + return ''; + } catch (err) { + return ''; + } + } + + calculateTotalDuration(tasks: any[]): string { + try { + tasks = tasks.filter(t => t.parentId === t.id); // only calculate total duration with parent tasks + + let totalHours = 0; + const oneHour = 60 * 60 * 1000; + for (let i = 0; i < tasks.length; i++) { + const start = tasks[i].start; + const end = tasks[i].end; + + if (start != null && end != null) { + const duration = Math.abs(tasks[i].end.getTime() - tasks[i].start.getTime()) / oneHour; // duration in hours + totalHours += duration; + } + } + + if (totalHours === 0) { + return ''; + } + + if (totalHours > 24) { + return `${Math.round(totalHours / 24)} day(s)`; // duration in days + } else if (totalHours > 1) { + return `${Math.round(totalHours)} hr(s)`; // duration in hours + } else { + const minutes = totalHours * 60; + + if (minutes < 1) { + return `${Math.round(minutes * 60)} second(s)`; // duration in seconds + } + return `${Math.round(minutes)} min(s)`; // duration in minutes + } + } catch (err) { + return ''; + } + } + + /** Calculate the total percentage of a group of tasks */ + calculateTotalPercentage(node: any): number { + let totalPercent = 0; + const children = node.children; + + if (children.length > 0) { + children.forEach((child: any) => { + totalPercent += isNaN(child.percentComplete) ? 0 : child.percentComplete; + }); + + return Math.ceil(totalPercent / children.length); + } else { + return isNaN(node.percentComplete) ? 0 : node.percentComplete; + } + } + + /** Calculate the total percent of the parent task */ + calculateParentTotalPercentage(parent: any, tasks: any[]) { + const children = tasks.filter((task: any) => { + return task.parentId === parent.id && task.id !== parent.id; + }); // get only children tasks ignoring parent. + + let totalPercent = 0; + + if (children.length > 0) { + children.forEach((child: any) => { + totalPercent += isNaN(child.percentComplete) ? 0 : child.percentComplete; + }); + + return Math.ceil(totalPercent / children.length); + } else { + return isNaN(parent.percentComplete) ? 0 : parent.percentComplete; + } + } + + /** Calculate the gantt scale range given the start and end date of tasks*/ + public calculateScale(start: Date = new Date(), end: Date = this.addDays(start, 7)) { + const scale: any[] = []; + + try { + while (start.getTime() <= end.getTime()) { + scale.push(start); + start = this.addDays(start, 1); + } + return scale; + + } catch (err) { + return scale; + } + } + + /** Determines whether given date is a weekend */ + public isDayWeekend(date: Date): boolean { + const day = date.getDay(); + + if (day === 6 || day === 0) { + return true; + } + return false; + } + + /** Add x number of days to a date object */ + public addDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; + } + + /** Remove x number of days from a date object */ + public removeDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() - days); + return result; + } + + /** Calculates the grid scale for gantt based on tasks start and end dates */ + public calculateGridScale(tasks: Task[]): IScale { + let start: Date; + let end: Date; + const dates = tasks.map((task: any) => { + return { + start: new Date(task.start), + end: new Date(task.end) + }; + }); + + start = new Date(Math.min.apply(null, dates.map(function (t) { + return t.start; + }))); + + end = new Date(Math.max.apply(null, dates.map(function (t) { + return t.end; + }))); + + return { + start: start, + end: end + }; + } + + /** Create an hours array for use in time scale component */ + public getHours(cols: number): string[] { + const hours: string[] = []; + + while (hours.length <= cols * 24) { + for (let i = 0; i <= 23; i++) { + if (i < 10) { + hours.push('0' + i.toString()); + } else { + hours.push(i.toString()); + } + } + } + + return hours; + } + + public getComputedStyle(element: any, attribute: any) { + return parseInt(document.defaultView.getComputedStyle(element)[attribute], 10); + } + + // TODO(dale): determine whether this is needed + public calculateContainerWidth(): number { + this.windowInnerWidth = window.innerWidth; + const containerWidth = (innerWidth - 18); + + return containerWidth; + } + + public calculateActivityContainerDimensions(): any { + const scrollWidth = 18; + this.windowInnerWidth = window.innerWidth; + const width = this.windowInnerWidth - this.gridWidth - scrollWidth; + + return {height: this.activityHeight, width: width}; + } + + /** Set the vertical scroll top positions for gantt */ + public scrollTop(verticalScrollElem: any, ganttGridElem: any, ganttActivityAreaElem: any) { + const verticalScrollTop = verticalScrollElem.scrollTop; + const scroll = this.setScrollTop; + + // debounce + if (verticalScrollTop !== null && verticalScrollTop !== undefined) { + setTimeout(function () { + scroll(verticalScrollTop, ganttActivityAreaElem); + scroll(ganttActivityAreaElem.scrollTop, ganttGridElem); + + }, 50); + } + } + + /** Group data by id , only supports one level*/ + public groupData(tasks: any): any { + const merged: any = []; + const groups: any = new GroupByPipe().transform(tasks, (task: any) => { + return [task.treePath.split('/')[0]]; + }); + return merged.concat.apply([], groups); + } + + /** Create tree of data */ + public transformData(input: any): any { + const output: any = []; + for (let i = 0; i < input.length; i++) { + const chain: any = input[i].id.split('/'); + let currentNode: any = output; + for (let j = 0; j < chain.length; j++) { + const wantedNode: any = chain[j]; + const lastNode: any = currentNode; + for (let k = 0; k < currentNode.length; k++) { + if (currentNode[k].name === wantedNode) { + currentNode = currentNode[k].children; + break; + } + } + } + } + return output; + } + + /** Checks whether any new data needs to be added to task cache */ + public doTaskCheck(tasks: any[], treeExpanded: boolean): boolean { + const cachedTaskIds = this.TASK_CACHE.map((task: any) => { + return task.id; + }); + const itemsToCache: any[] = []; + + if (treeExpanded) { + // push children and parent tasks that are not cached + tasks.filter((task: any) => { + return cachedTaskIds.indexOf(task.id) === -1; + }).forEach((task: any) => { + itemsToCache.push(task); + }); + } else { + // only look at tasks that are not cached + tasks.filter((task: any) => { + return cachedTaskIds.indexOf(task.id) === -1 && task.treePath.split('/').length === 1; + }).forEach((task: any) => { + itemsToCache.push(task); + }); + } + + itemsToCache.forEach((item: any) => { + this.TASK_CACHE.push(item); + }); + + if (itemsToCache.length > 0) { + return true; + } + + return false; + } + + /** Set a id prefix so CSS3 query selector can work with ids that contain numbers */ + public setIdPrefix(id: string): string { + return `_${id}`; + } + + // /** Remove the id prefix to allow querying of data */ + // public removeIdPrefix(id: string): string { + // return id.substring(1, id.length - 1); + // } + + /** Set the scroll top property of a native DOM element */ + private setScrollTop(scrollTop: number, element: any): void { + if (element !== null && element !== undefined) { + element.scrollTop = scrollTop; + } + } + +} diff --git a/src/gantt/gantt.component.css b/src/gantt/gantt.component.css new file mode 100644 index 0000000..ecae734 --- /dev/null +++ b/src/gantt/gantt.component.css @@ -0,0 +1,8 @@ +.gantt_container { + font-family: Arial, serif; + font-size: 13px; + border: 1px solid #cecece; + position: relative; + white-space: nowrap; + margin-top: 0; +} diff --git a/src/gantt/gantt.component.html b/src/gantt/gantt.component.html new file mode 100644 index 0000000..650a72c --- /dev/null +++ b/src/gantt/gantt.component.html @@ -0,0 +1,7 @@ +
+
+ + + +
+
diff --git a/src/gantt/gantt.component.spec.ts b/src/gantt/gantt.component.spec.ts new file mode 100644 index 0000000..61ef0fe --- /dev/null +++ b/src/gantt/gantt.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GanttComponent } from './gantt.component'; + +describe('GanttComponent', () => { + let component: GanttComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ GanttComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GanttComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/gantt/gantt.component.ts b/src/gantt/gantt.component.ts new file mode 100644 index 0000000..918d848 --- /dev/null +++ b/src/gantt/gantt.component.ts @@ -0,0 +1,81 @@ +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {IGanttOptions, Project} from '../interfaces'; +import {GanttService} from '../gantt.service'; + +@Component({ + selector: 'app-gantt', + templateUrl: './gantt.component.html', + styleUrls: ['./gantt.component.css'] +}) +export class GanttComponent implements OnInit { + _project: Project; + _options: IGanttOptions; + + // TODO(dale): this may be causing an issue in the tree builder? + @Input() + set project(project: any) { + if (project) { + this._project = project; + } else { + this.setDefaultProject(); + } + } + + get project() { + return this._project; + } + + @Input() + set options(options: any) { + if (options.scale) { + this._options = options; + } else { + this.setDefaultOptions(); + } + } + + get options() { + return this._options; + } + + @Output() onGridRowClick: EventEmitter = new EventEmitter(); + + private ganttContainerWidth: number; + + constructor(private ganttService: GanttService) { + } + + ngOnInit() { + + } + + setSizes(): void { + this.ganttContainerWidth = this.ganttService.calculateContainerWidth(); + } + + setDefaultOptions() { + const scale = this.ganttService.calculateGridScale(this._project.tasks); + + this._options = { + scale: scale + }; + } + + setDefaultProject() { + this._project = { + id: '', + name: '', + startDate: null, + tasks: [] + }; + } + + gridRowClicked(task: any) { + this.onGridRowClick.emit(task); + } + + onResize($event: any): void { + this.setSizes(); + } + +} diff --git a/src/group-by.pipe.spec.ts b/src/group-by.pipe.spec.ts new file mode 100644 index 0000000..ebd3bd0 --- /dev/null +++ b/src/group-by.pipe.spec.ts @@ -0,0 +1,8 @@ +import { GroupByPipe } from './group-by.pipe'; + +describe('GroupByPipe', () => { + it('create an instance', () => { + const pipe = new GroupByPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/group-by.pipe.ts b/src/group-by.pipe.ts new file mode 100644 index 0000000..cfcf313 --- /dev/null +++ b/src/group-by.pipe.ts @@ -0,0 +1,20 @@ +import {Pipe, PipeTransform} from '@angular/core'; + +@Pipe({ + name: 'groupBy' +}) +export class GroupByPipe implements PipeTransform { + + transform(value: any, args?: any): any { + const groups = {}; + value.forEach((o: any) => { + const group = JSON.stringify(args(o)); + groups[group] = groups[group] || []; + groups[group].push(o); + }); + return Object.keys(groups).map((group: any) => { + return groups[group]; + }); + } + +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2034557 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,53 @@ +import { NgModule, ModuleWithProviders } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import {GanttActivityComponent} from './gantt-activity/gantt-activity.component'; +import {GroupByPipe} from './group-by.pipe'; +import {GanttComponent} from './gantt/gantt.component'; +import {GanttHeaderComponent} from './gantt-header/gantt-header.component'; +import {GanttFooterComponent} from './gantt-footer/gantt-footer.component'; +import {GanttActivityBackgroundComponent} from './gantt-activity-background/gantt-activity-background.component'; +import {GanttActivityBarsComponent} from './gantt-activity-bars/gantt-activity-bars.component'; +import {GanttTimeScaleComponent} from './gantt-time-scale/gantt-time-scale.component'; +import { GanttService } from './gantt.service'; + +export * from './gantt/gantt.component';; +export * from './group-by.pipe'; +export * from './gantt.service'; + +@NgModule({ + imports: [ + CommonModule + ], + declarations: [ + GanttComponent, + GanttActivityComponent, + GanttComponent, + GanttHeaderComponent, + GanttFooterComponent, + GanttActivityBackgroundComponent, + GanttActivityBarsComponent, + GanttTimeScaleComponent, + GroupByPipe, + GanttService + ], + exports: [ + GanttComponent, + GanttActivityComponent, + GanttComponent, + GanttHeaderComponent, + GanttFooterComponent, + GanttActivityBackgroundComponent, + GanttActivityBarsComponent, + GanttTimeScaleComponent, + GroupByPipe, + GanttService + ] +}) +export class GanttModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: GanttModule, + providers: [GanttService] + }; + } +} diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 0000000..f243112 --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,40 @@ +export interface Project { + id: string; + name: string; + startDate?: Date; + tasks: Task[] +} + +export interface Task { + id: string; + treePath: string; + parentId: string; + name: string; + resource?: string; + start: Date; + end?: Date; + percentComplete?: number; + status?: string; +} + +export interface IGanttOptions { + scale?: IScale; + zooming?: string; +} + +export interface IScale { + start?: Date; + end?: Date; +} + +export interface IBarStyle { + status: string; + backgroundColor: string; + border: string; + progressBackgroundColor: string; +} + +export enum Zooming { + hours, + days +} \ No newline at end of file diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..11de191 --- /dev/null +++ b/src/package.json @@ -0,0 +1,28 @@ +{ + "name": "angular4-gantt", + "version": "0.1.0", + "repository": { + "type": "git", + "url": "https://github.com/jwiesmann/angular4-gantt" + }, + "author": { + "name": "joerg.wiesmann", + "email": "joerg.wiesmann@gmail.com" + }, + "keywords": [ + "angular" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/jwiesmann/angular4-gantt/issues" + }, + "main": "angular4-gantt.umd.js", + "module": "angular4-gantt.js", + "jsnext:main": "angular4-gantt.js", + "typings": "angular4-gantt.d.ts", + "peerDependencies": { + "@angular/core": "^4.0.0", + "rxjs": "^5.1.0", + "zone.js": "^0.8.4" + } +} diff --git a/src/tsconfig.es5.json b/src/tsconfig.es5.json new file mode 100644 index 0000000..fffe094 --- /dev/null +++ b/src/tsconfig.es5.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "declaration": true, + "module": "es2015", + "target": "es5", + "baseUrl": ".", + "stripInternal": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "outDir": "../build", + "rootDir": ".", + "lib": [ + "es2015", + "dom" + ], + "skipLibCheck": true, + "types": [] + }, + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "skipTemplateCodegen": true, + "flatModuleOutFile": "angular4-gantt.js", + "flatModuleId": "angular4-gantt" + }, + "files": [ + "./index.ts" + ] +} diff --git a/src/tsconfig.spec.json b/src/tsconfig.spec.json new file mode 100644 index 0000000..e752544 --- /dev/null +++ b/src/tsconfig.spec.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.es5.json", + "compilerOptions": { + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "outDir": "../out-tsc/spec", + "module": "commonjs", + "target": "es6", + "baseUrl": "", + "types": [ + "jest", + "node" + ] + }, + "files": [ + "**/*.spec.ts" + ] +} diff --git a/tools/gulp/inline-resources.js b/tools/gulp/inline-resources.js new file mode 100644 index 0000000..9601ead --- /dev/null +++ b/tools/gulp/inline-resources.js @@ -0,0 +1,156 @@ +/* eslint-disable */ +// https://github.com/filipesilva/angular-quickstart-lib/blob/master/inline-resources.js +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const glob = require('glob'); +const sass = require('node-sass'); +const tildeImporter = require('node-sass-tilde-importer'); + +/** + * Simple Promiseify function that takes a Node API and return a version that supports promises. + * We use promises instead of synchronized functions to make the process less I/O bound and + * faster. It also simplifies the code. + */ +function promiseify(fn) { + return function () { + const args = [].slice.call(arguments, 0); + return new Promise((resolve, reject) => { + fn.apply(this, args.concat([function (err, value) { + if (err) { + reject(err); + } else { + resolve(value); + } + }])); + }); + }; +} + +const readFile = promiseify(fs.readFile); +const writeFile = promiseify(fs.writeFile); + +/** + * Inline resources in a tsc/ngc compilation. + * @param projectPath {string} Path to the project. + */ +function inlineResources(projectPath) { + + // Match only TypeScript files in projectPath. + const files = glob.sync('**/*.ts', {cwd: projectPath}); + + // For each file, inline the templates and styles under it and write the new file. + return Promise.all(files.map(filePath => { + const fullFilePath = path.join(projectPath, filePath); + return readFile(fullFilePath, 'utf-8') + .then(content => inlineResourcesFromString(content, url => { + // Resolve the template url. + return path.join(path.dirname(fullFilePath), url); + })) + .then(content => writeFile(fullFilePath, content)) + .catch(err => { + console.error('An error occured: ', err); + }); + })); +} + +/** + * Inline resources from a string content. + * @param content {string} The source file's content. + * @param urlResolver {Function} A resolver that takes a URL and return a path. + * @returns {string} The content with resources inlined. + */ +function inlineResourcesFromString(content, urlResolver) { + // Curry through the inlining functions. + return [ + inlineTemplate, + inlineStyle, + removeModuleId + ].reduce((content, fn) => fn(content, urlResolver), content); +} + +/** + * Inline the templates for a source file. Simply search for instances of `templateUrl: ...` and + * replace with `template: ...` (with the content of the file included). + * @param content {string} The source file's content. + * @param urlResolver {Function} A resolver that takes a URL and return a path. + * @return {string} The content with all templates inlined. + */ +function inlineTemplate(content, urlResolver) { + return content.replace(/templateUrl:\s*'([^']+?\.html)'/g, function (m, templateUrl) { + const templateFile = urlResolver(templateUrl); + const templateContent = fs.readFileSync(templateFile, 'utf-8'); + const shortenedTemplate = templateContent + .replace(/([\n\r]\s*)+/gm, ' ') + .replace(/"/g, '\\"'); + return `template: "${shortenedTemplate}"`; + }); +} + + +/** + * Inline the styles for a source file. Simply search for instances of `styleUrls: [...]` and + * replace with `styles: [...]` (with the content of the file included). + * @param urlResolver {Function} A resolver that takes a URL and return a path. + * @param content {string} The source file's content. + * @return {string} The content with all styles inlined. + */ +function inlineStyle(content, urlResolver) { + return content.replace(/styleUrls\s*:\s*(\[[\s\S]*?\])/gm, function (m, styleUrls) { + const urls = eval(styleUrls); + return 'styles: [' + + urls.map(styleUrl => { + const styleFile = urlResolver(styleUrl); + const originContent = fs.readFileSync(styleFile, 'utf-8'); + const styleContent = styleFile.endsWith('.scss') ? buildSass(originContent, styleFile) : originContent; + const shortenedStyle = styleContent + .replace(/([\n\r]\s*)+/gm, ' ') + .replace(/"/g, '\\"'); + return `"${shortenedStyle}"`; + }) + .join(',\n') + + ']'; + }); +} + +/** + * build sass content to css + * @param content {string} the css content + * @param sourceFile {string} the scss file sourceFile + * @return {string} the generated css, empty string if error occured + */ +function buildSass(content, sourceFile) { + try { + const result = sass.renderSync({ + data: content, + file: sourceFile, + importer: tildeImporter + }); + return result.css.toString() + } catch (e) { + console.error('\x1b[41m'); + console.error('at ' + sourceFile + ':' + e.line + ":" + e.column); + console.error(e.formatted); + console.error('\x1b[0m'); + return ""; + } +} + +/** + * Remove every mention of `moduleId: module.id`. + * @param content {string} The source file's content. + * @returns {string} The content with all moduleId: mentions removed. + */ +function removeModuleId(content) { + return content.replace(/\s*moduleId:\s*module\.id\s*,?\s*/gm, ''); +} + +module.exports = inlineResources; +module.exports.inlineResourcesFromString = inlineResourcesFromString; + +// Run inlineResources if module is being called directly from the CLI with arguments. +if (require.main === module && process.argv.length > 2) { + console.log('Inlining resources from project:', process.argv[2]); + return inlineResources(process.argv[2]); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5a61547 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "baseUrl": "./src", + "experimentalDecorators": true, + "moduleResolution": "node", + "rootDir": "./src", + "lib": [ + "es2015", + "dom" + ], + "skipLibCheck": true, + "types": [] + } +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..f341e78 --- /dev/null +++ b/tslint.json @@ -0,0 +1,104 @@ +{ + "rulesDirectory": [ + "node_modules/codelyzer" + ], + "rules": { + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "curly": true, + "eofline": true, + "forin": true, + "indent": [ + true, + "spaces" + ], + "label-position": true, + "max-line-length": [ + true, + 140 + ], + "member-access": false, + "member-ordering": [ + true, + "static-before-instance", + "variables-before-functions" + ], + "no-arg": true, + "no-bitwise": true, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-variable": true, + "no-empty": false, + "no-eval": true, + "no-inferrable-types": true, + "no-shadowed-variable": true, + "no-string-literal": false, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unused-expression": true, + "no-unused-variable": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "object-literal-sort-keys": false, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-whitespace" + ], + "quotemark": [ + true, + "single" + ], + "radix": true, + "semicolon": [ + "always" + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "variable-name": false, + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ], + "directive-selector": [true, "attribute", "", "camelCase"], + "component-selector": [true, "element", "", "kebab-case"], + "use-input-property-decorator": true, + "use-output-property-decorator": true, + "use-host-property-decorator": true, + "no-input-rename": true, + "no-output-rename": true, + "use-life-cycle-interface": true, + "use-pipe-transform-interface": true, + "component-class-suffix": true, + "directive-class-suffix": true + } +}