From 2bdafd8485c71baf003bcb362d0dec2df0d888b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Barbeau?= Date: Mon, 7 Aug 2017 15:27:49 -0400 Subject: [PATCH] feat(table): add generic table component --- package.json | 2 +- src/lib/shared/index.ts | 2 + src/lib/shared/module.ts | 20 ++++- src/lib/shared/table/index.ts | 5 ++ .../shared/table/table-action-color.enum.ts | 5 ++ src/lib/shared/table/table-database.ts | 30 ++++++++ src/lib/shared/table/table-datasource.ts | 65 ++++++++++++++++ src/lib/shared/table/table-model.interface.ts | 24 ++++++ src/lib/shared/table/table.component.html | 40 ++++++++++ src/lib/shared/table/table.component.spec.ts | 36 +++++++++ src/lib/shared/table/table.component.styl | 48 ++++++++++++ src/lib/shared/table/table.component.ts | 75 +++++++++++++++++++ src/lib/utils/object-utils.ts | 42 ++++++++++- 13 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 src/lib/shared/table/index.ts create mode 100644 src/lib/shared/table/table-action-color.enum.ts create mode 100644 src/lib/shared/table/table-database.ts create mode 100644 src/lib/shared/table/table-datasource.ts create mode 100644 src/lib/shared/table/table-model.interface.ts create mode 100644 src/lib/shared/table/table.component.html create mode 100644 src/lib/shared/table/table.component.spec.ts create mode 100644 src/lib/shared/table/table.component.styl create mode 100644 src/lib/shared/table/table.component.ts diff --git a/package.json b/package.json index 8fc1f04947..7b3d03ea3c 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@angular/core": "4.1.3", "@angular/forms": "4.1.3", "@angular/http": "4.1.3", - "@angular/material": "^2.0.0-beta.7", + "@angular/material": "^2.0.0-beta.8", "@angular/platform-browser": "4.1.3", "@ngx-translate/core": "^6.0.1", "@types/jspdf": "^1.1.31", diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts index 14a01a21ad..9d16c358d9 100644 --- a/src/lib/shared/index.ts +++ b/src/lib/shared/index.ts @@ -1,6 +1,7 @@ export * from './module'; export * from './collapsible'; +export * from './confirm-dialog'; export * from './clickout'; export * from './clone'; export * from './keyvalue'; @@ -8,3 +9,4 @@ export * from './list'; export * from './panel'; export * from './sidenav'; export * from './spinner'; +export * from './table'; diff --git a/src/lib/shared/module.ts b/src/lib/shared/module.ts index d0e7b56053..5e2cfdfff8 100644 --- a/src/lib/shared/module.ts +++ b/src/lib/shared/module.ts @@ -1,5 +1,6 @@ import { NgModule, ModuleWithProviders } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { CdkTableModule } from '@angular/cdk'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { HttpModule } from '@angular/http'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; @@ -8,17 +9,20 @@ import { TranslateModule } from '@ngx-translate/core'; import { ClickoutDirective } from './clickout'; import { CollapsibleComponent, CollapseDirective } from './collapsible'; +import { ConfirmDialogComponent, ConfirmDialogService } from './confirm-dialog'; import { ClonePipe } from './clone'; import { KeyvaluePipe } from './keyvalue'; import { ListComponent, ListItemDirective } from './list'; import { PanelComponent } from './panel'; import { SidenavShimDirective } from './sidenav'; import { SpinnerComponent, SpinnerBindingDirective } from './spinner'; +import { TableComponent } from './table'; @NgModule({ imports: [ CommonModule, + CdkTableModule, FormsModule, ReactiveFormsModule, HttpModule, @@ -43,12 +47,14 @@ import { SpinnerComponent, SpinnerBindingDirective } from './spinner'; PanelComponent, SidenavShimDirective, SpinnerComponent, - SpinnerBindingDirective + SpinnerBindingDirective, + TableComponent ], declarations: [ ClickoutDirective, CollapsibleComponent, CollapseDirective, + ConfirmDialogComponent, ClonePipe, KeyvaluePipe, ListComponent, @@ -56,14 +62,20 @@ import { SpinnerComponent, SpinnerBindingDirective } from './spinner'; PanelComponent, SidenavShimDirective, SpinnerComponent, - SpinnerBindingDirective + SpinnerBindingDirective, + TableComponent + ], + entryComponents: [ + ConfirmDialogComponent + ], + providers: [ + ConfirmDialogService ] }) export class IgoSharedModule { static forRoot(): ModuleWithProviders { return { - ngModule: IgoSharedModule, - providers: [] + ngModule: IgoSharedModule }; } } diff --git a/src/lib/shared/table/index.ts b/src/lib/shared/table/index.ts new file mode 100644 index 0000000000..ed25a50009 --- /dev/null +++ b/src/lib/shared/table/index.ts @@ -0,0 +1,5 @@ +export * from './table.component'; +export * from './table-database'; +export * from './table-datasource'; +export * from './table-model.interface'; +export * from './table-action-color.enum'; diff --git a/src/lib/shared/table/table-action-color.enum.ts b/src/lib/shared/table/table-action-color.enum.ts new file mode 100644 index 0000000000..f29b7da960 --- /dev/null +++ b/src/lib/shared/table/table-action-color.enum.ts @@ -0,0 +1,5 @@ +export enum TableActionColor { + primary, + accent, + warn +} diff --git a/src/lib/shared/table/table-database.ts b/src/lib/shared/table/table-database.ts new file mode 100644 index 0000000000..04028149d1 --- /dev/null +++ b/src/lib/shared/table/table-database.ts @@ -0,0 +1,30 @@ +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; + +export class TableDatabase { + /** Stream that emits whenever the data has been modified. */ + dataChange: BehaviorSubject = new BehaviorSubject([]); + get data(): any[] { return this.dataChange.value; } + + constructor(data?) { + if (data) { + this.dataChange.next(data); + } + } + + set(data) { + this.dataChange.next(data); + } + + add(item) { + const copiedData = this.data.slice(); + copiedData.push(item); + this.set(copiedData); + } + + remove(item) { + const copiedData = this.data.slice(); + const index = copiedData.indexOf(item); + copiedData.splice(index, 1); + this.set(copiedData); + } +} diff --git a/src/lib/shared/table/table-datasource.ts b/src/lib/shared/table/table-datasource.ts new file mode 100644 index 0000000000..ea489d1bbe --- /dev/null +++ b/src/lib/shared/table/table-datasource.ts @@ -0,0 +1,65 @@ +import { DataSource } from '@angular/cdk'; +import { MdSort } from '@angular/material'; + +import { Observable } from 'rxjs/Observable'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +import { TableDatabase, TableModel } from './index'; + +export class TableDataSource extends DataSource { + _filterChange = new BehaviorSubject(''); + get filter(): string { return this._filterChange.value; } + set filter(filter: string) { this._filterChange.next(filter); } + + constructor(private _database: TableDatabase, + private _model: TableModel, + private _sort: MdSort) { + super(); + } + + /** Connect function called by the table to retrieve one stream containing the data to render. */ + connect(): Observable { + if (!this._database) { return Observable.merge([]); } + const displayDataChanges = [ + this._database.dataChange, + this._filterChange, + this._sort.mdSortChange + ]; + + return Observable.merge(...displayDataChanges) + .map(() => { + return this.getFilteredData(this._database.data); + }).map((data) => { + return this.getSortedData(data); + }); + } + + disconnect() {} + + getFilteredData(data): any[] { + if (!this.filter) { return data; } + return data.slice().filter((item: any) => { + + let searchStr: string = this._model.columns + .filter((c) => c.filterable) + .map((c) => item[c.name]) + .join(' ').toLowerCase(); + + return searchStr.indexOf(this.filter.toLowerCase()) != -1; + }); + } + + getSortedData(data): any[] { + if (!this._sort.active || this._sort.direction == '') { return data; } + + return data.sort((a, b) => { + let propertyA: number|string = a[this._sort.active]; + let propertyB: number|string = b[this._sort.active]; + + let valueA = isNaN(+propertyA) ? propertyA : +propertyA; + let valueB = isNaN(+propertyB) ? propertyB : +propertyB; + + return (valueA < valueB ? -1 : 1) * (this._sort.direction == 'asc' ? 1 : -1); + }); + } +} diff --git a/src/lib/shared/table/table-model.interface.ts b/src/lib/shared/table/table-model.interface.ts new file mode 100644 index 0000000000..9c42f06202 --- /dev/null +++ b/src/lib/shared/table/table-model.interface.ts @@ -0,0 +1,24 @@ +import { TableActionColor } from './table-action-color.enum'; + +export interface TableColumn { + name: string, + title: string, + sortable?: boolean, + filterable?: boolean, + displayed?: boolean +} + +export interface ClickAction { + ( item: any ): void; +} + +export interface TableAction { + icon: string, + color?: TableActionColor + click: ClickAction +} + +export interface TableModel { + columns: TableColumn[], + actions?: TableAction[] +} diff --git a/src/lib/shared/table/table.component.html b/src/lib/shared/table/table.component.html new file mode 100644 index 0000000000..f0ae41e3b4 --- /dev/null +++ b/src/lib/shared/table/table.component.html @@ -0,0 +1,40 @@ +
+
+ + + +
+ + + + + + {{column.title}} + + + + {{column.title}} + + + {{getValue(row, column.name)}} + + + + + + + + + + + + + + + +
diff --git a/src/lib/shared/table/table.component.spec.ts b/src/lib/shared/table/table.component.spec.ts new file mode 100644 index 0000000000..f440311db2 --- /dev/null +++ b/src/lib/shared/table/table.component.spec.ts @@ -0,0 +1,36 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IgoTestModule } from '../../../test/module'; +import { IgoSharedModule } from '../../shared'; + +import { TableComponent } from './table.component'; + + +describe('TableComponent', () => { + let component: TableComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + IgoTestModule, + IgoSharedModule + ], + declarations: [ + TableComponent + ], + providers: [] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/lib/shared/table/table.component.styl b/src/lib/shared/table/table.component.styl new file mode 100644 index 0000000000..15b8ea670b --- /dev/null +++ b/src/lib/shared/table/table.component.styl @@ -0,0 +1,48 @@ +@require '../../../style/var.styl'; +@require '../../../style/media.styl'; + +/*** Main ***/ +:host { + width: 100%; + height: 100%; + display: block; +} + +.table-container { + display: flex; + flex-direction: column; + max-height: 100%; +} + +.table-header { + min-height: 64px; + max-width: 500px; + display: flex; + align-items: baseline; + padding: 8px 24px 0; + font-size: 20px; + justify-content: space-between; +} + +.mat-input-container { + font-size: 14px; + flex-grow: 1; + margin-left: 32px; +} + +.mat-table { + overflow: auto; +} + +.mat-header-row, .mat-row { + height: 60px; +} + +.mat-cell-text { + overflow: hidden; + word-wrap: break-word; +} + +button { + margin-right: 10px; +} diff --git a/src/lib/shared/table/table.component.ts b/src/lib/shared/table/table.component.ts new file mode 100644 index 0000000000..5412a2fc97 --- /dev/null +++ b/src/lib/shared/table/table.component.ts @@ -0,0 +1,75 @@ +import { Component, ElementRef, ViewChild, Input, + OnChanges, OnInit } from '@angular/core'; +import { MdSort } from '@angular/material'; + +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/fromEvent'; +import 'rxjs/add/observable/merge'; +import 'rxjs/add/operator/debounceTime'; +import 'rxjs/add/operator/distinctUntilChanged'; +import 'rxjs/add/operator/map'; + +import { ObjectUtils } from '../../utils'; + +import { TableDataSource, TableDatabase, + TableModel, TableActionColor } from './index'; + +@Component({ + selector: 'igo-table', + templateUrl: './table.component.html', + styleUrls: ['./table.component.styl'] +}) +export class TableComponent implements OnChanges, OnInit { + + @Input() + get database(): TableDatabase { return this._database; } + set database(value: TableDatabase) { + this._database = value; + } + private _database: TableDatabase; + + @Input() + get model(): TableModel { return this._model; } + set model(value: TableModel) { + this._model = value; + } + private _model: TableModel; + + private displayedColumns; + private dataSource: TableDataSource | null; + + @ViewChild('filter') filter: ElementRef; + @ViewChild(MdSort) sort: MdSort; + + ngOnInit() { + this.dataSource = new TableDataSource(this.database, this.model, this.sort); + this.displayedColumns = this.model.columns + .filter((c) => c.displayed !== false) + .map((c) => c.name); + if (this.model.actions && this.model.actions.length) { + this.displayedColumns.push('action'); + } + + Observable.fromEvent(this.filter.nativeElement, 'keyup') + .debounceTime(150) + .distinctUntilChanged() + .subscribe(() => { + if (!this.dataSource) { return; } + this.dataSource.filter = this.filter.nativeElement.value; + }); + } + + ngOnChanges(change) { + if (change.database) { + this.dataSource = new TableDataSource(this.database, this.model, this.sort); + } + } + + getActionColor(colorId: number): string { + return TableActionColor[colorId]; + } + + getValue(row, key) { + return ObjectUtils.resolve(row, key); + } +} diff --git a/src/lib/utils/object-utils.ts b/src/lib/utils/object-utils.ts index d66dbb62fa..c3e953115d 100644 --- a/src/lib/utils/object-utils.ts +++ b/src/lib/utils/object-utils.ts @@ -13,7 +13,9 @@ export class ObjectUtils { } static isObject(item: Object) { - return (item && typeof item === 'object' && !Array.isArray(item) && item !== null); + return (item && typeof item === 'object' && + !Array.isArray(item) && item !== null && + !(item instanceof Date)); } static mergeDeep(target: Object, source: Object, ignoreUndefined = false): any { @@ -42,13 +44,47 @@ export class ObjectUtils { Object.keys(obj) .filter((key) => obj[key] !== undefined) .forEach(key => { - if (ObjectUtils.isObject(obj[key])) { + if (ObjectUtils.isObject(obj[key]) || Array.isArray(obj[key])) { output[key] = ObjectUtils.removeUndefined(obj[key]); } else { output[key] = obj[key]; } }); + + return output; } - return output; + + if (Array.isArray(obj)) { + return obj.map( + (o) => ObjectUtils.removeUndefined(o) + ); + } + + return obj; + } + + static removeNull(obj: Object): any { + const output = {}; + if (ObjectUtils.isObject(obj)) { + Object.keys(obj) + .filter((key) => obj[key] !== null) + .forEach(key => { + if (ObjectUtils.isObject(obj[key]) || Array.isArray(obj[key])) { + output[key] = ObjectUtils.removeNull(obj[key]); + } else { + output[key] = obj[key]; + } + }); + + return output; + } + + if (Array.isArray(obj)) { + return obj.map( + (o) => ObjectUtils.removeNull(o) + ); + } + + return obj; } }