diff --git a/src/cdk/table/row.ts b/src/cdk/table/row.ts index b6f2e44ac4cb..91331a49f9e1 100644 --- a/src/cdk/table/row.ts +++ b/src/cdk/table/row.ts @@ -10,6 +10,7 @@ import { ChangeDetectionStrategy, Component, Directive, + IterableChanges, IterableDiffer, IterableDiffers, SimpleChanges, @@ -17,7 +18,6 @@ import { ViewContainerRef } from '@angular/core'; import {CdkCellDef} from './cell'; -import {Subject} from 'rxjs/Subject'; /** * The row template that can be used by the md-table. Should not be used outside of the @@ -33,21 +33,12 @@ export abstract class BaseRowDef { /** The columns to be displayed on this row. */ columns: string[]; - /** Event stream that emits when changes are made to the columns. */ - columnsChange: Subject = new Subject(); - /** Differ used to check if any changes were made to the columns. */ protected _columnsDiffer: IterableDiffer; - private viewInitialized = false; - constructor(public template: TemplateRef, protected _differs: IterableDiffers) { } - ngAfterViewInit() { - this.viewInitialized = true; - } - ngOnChanges(changes: SimpleChanges): void { // Create a new columns differ if one does not yet exist. Initialize it based on initial value // of the columns property. @@ -58,12 +49,12 @@ export abstract class BaseRowDef { } } - ngDoCheck(): void { - if (!this.viewInitialized || !this._columnsDiffer || !this.columns) { return; } - - // Notify the table if there are any changes to the columns. - const changes = this._columnsDiffer.diff(this.columns); - if (changes) { this.columnsChange.next(); } + /** + * Returns the difference between the current columns and the columns from the last diff, or null + * if there is no difference. + */ + getColumnsDiff(): IterableChanges | null { + return this._columnsDiffer.diff(this.columns); } } diff --git a/src/cdk/table/table-errors.ts b/src/cdk/table/table-errors.ts new file mode 100644 index 000000000000..8a667cf926e0 --- /dev/null +++ b/src/cdk/table/table-errors.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Returns an error to be thrown when attempting to find an unexisting column. + * @param id Id whose lookup failed. + * @docs-private + */ +export function getTableUnknownColumnError(id: string) { + return Error(`cdk-table: Could not find column with id "${id}".`); +} + +/** + * Returns an error to be thrown when two column definitions have the same name. + * @docs-private + */ +export function getTableDuplicateColumnNameError(name: string) { + return Error(`cdk-table: Duplicate column definition name provided: "${name}".`); +} diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index b378e427f001..8f614bff24e1 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -7,6 +7,7 @@ import {Observable} from 'rxjs/Observable'; import {combineLatest} from 'rxjs/observable/combineLatest'; import {CdkTableModule} from './index'; import {map} from 'rxjs/operator/map'; +import {getTableDuplicateColumnNameError, getTableUnknownColumnError} from './table-errors'; describe('CdkTable', () => { let fixture: ComponentFixture; @@ -24,7 +25,10 @@ describe('CdkTable', () => { DynamicDataSourceCdkTableApp, CustomRoleCdkTableApp, TrackByCdkTableApp, + DynamicColumnDefinitionsCdkTableApp, RowContextCdkTableApp, + DuplicateColumnDefNameCdkTableApp, + MissingColumnDefCdkTableApp, ], }).compileComponents(); })); @@ -107,6 +111,57 @@ describe('CdkTable', () => { expect(fixture.nativeElement.querySelector('cdk-table').getAttribute('role')).toBe('treegrid'); }); + it('should throw an error if two column definitions have the same name', () => { + expect(() => TestBed.createComponent(DuplicateColumnDefNameCdkTableApp).detectChanges()) + .toThrowError(getTableDuplicateColumnNameError('column_a').message); + }); + + it('should throw an error if a column definition is requested but not defined', () => { + expect(() => TestBed.createComponent(MissingColumnDefCdkTableApp).detectChanges()) + .toThrowError(getTableUnknownColumnError('column_a').message); + }); + + it('should be able to dynamically add/remove column definitions', () => { + const dynamicColumnDefFixture = TestBed.createComponent(DynamicColumnDefinitionsCdkTableApp); + dynamicColumnDefFixture.detectChanges(); + dynamicColumnDefFixture.detectChanges(); + + const dynamicColumnDefTable = dynamicColumnDefFixture.nativeElement.querySelector('cdk-table'); + const dynamicColumnDefComp = dynamicColumnDefFixture.componentInstance; + + // Add a new column and expect it to show up in the table + let columnA = 'columnA'; + dynamicColumnDefComp.dynamicColumns.push(columnA); + dynamicColumnDefFixture.detectChanges(); + expectTableToMatchContent(dynamicColumnDefTable, [ + [columnA], // Header row + [columnA], // Data rows + [columnA], + [columnA], + ]); + + // Add another new column and expect it to show up in the table + let columnB = 'columnB'; + dynamicColumnDefComp.dynamicColumns.push(columnB); + dynamicColumnDefFixture.detectChanges(); + expectTableToMatchContent(dynamicColumnDefTable, [ + [columnA, columnB], // Header row + [columnA, columnB], // Data rows + [columnA, columnB], + [columnA, columnB], + ]); + + // Remove column A expect only column B to be rendered + dynamicColumnDefComp.dynamicColumns.shift(); + dynamicColumnDefFixture.detectChanges(); + expectTableToMatchContent(dynamicColumnDefTable, [ + [columnB], // Header row + [columnB], // Data rows + [columnB], + [columnB], + ]); + }); + it('should re-render the rows when the data changes', () => { dataSource.addData(); fixture.detectChanges(); @@ -587,6 +642,26 @@ class TrackByCdkTableApp { } } +@Component({ + template: ` + + + {{column}} + {{column}} + + + + + + ` +}) +class DynamicColumnDefinitionsCdkTableApp { + dynamicColumns: any[] = []; + dataSource: FakeDataSource = new FakeDataSource(); + + @ViewChild(CdkTable) table: CdkTable; +} + @Component({ template: ` @@ -607,6 +682,45 @@ class CustomRoleCdkTableApp { @ViewChild(CdkTable) table: CdkTable; } +@Component({ + template: ` + + + Column A + {{row.a}} + + + + Column A + {{row.a}} + + + + + + ` +}) +class DuplicateColumnDefNameCdkTableApp { + dataSource: FakeDataSource = new FakeDataSource(); +} + +@Component({ + template: ` + + + Column A + {{row.a}} + + + + + + ` +}) +class MissingColumnDefCdkTableApp { + dataSource: FakeDataSource = new FakeDataSource(); +} + @Component({ template: ` diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index df8bda6d9479..14f4b72cf557 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -31,21 +31,12 @@ import { } from '@angular/core'; import {CollectionViewer, DataSource} from './data-source'; import {CdkCellOutlet, CdkCellOutletRowContext, CdkHeaderRowDef, CdkRowDef} from './row'; -import {merge} from 'rxjs/observable/merge'; import {takeUntil} from 'rxjs/operator/takeUntil'; import {BehaviorSubject} from 'rxjs/BehaviorSubject'; import {Subscription} from 'rxjs/Subscription'; import {Subject} from 'rxjs/Subject'; import {CdkCellDef, CdkColumnDef, CdkHeaderCellDef} from './cell'; - -/** - * Returns an error to be thrown when attempting to find an unexisting column. - * @param id Id whose lookup failed. - * @docs-private - */ -export function getTableUnknownColumnError(id: string) { - return new Error(`cdk-table: Could not find column with id "${id}".`); -} +import {getTableDuplicateColumnNameError, getTableUnknownColumnError} from './table-errors'; /** * Provides a handle for the table to grab the view container's ng-container to insert data rows. @@ -96,10 +87,7 @@ export class CdkTable implements CollectionViewer { /** Subscription that listens for the data provided by the data source. */ private _renderChangeSubscription: Subscription | null; - /** - * Map of all the user's defined columns identified by name. - * Contains the header and data-cell templates. - */ + /** Map of all the user's defined columns (header and data cell template) identified by name. */ private _columnDefinitionsByName = new Map(); /** Differ used to find the changes in the data provided by the data source. */ @@ -123,15 +111,6 @@ export class CdkTable implements CollectionViewer { get trackBy(): TrackByFunction { return this._trackByFn; } private _trackByFn: TrackByFunction; - // TODO(andrewseguin): Remove max value as the end index - // and instead calculate the view on init and scroll. - /** - * Stream containing the latest information on the range of rows being displayed on screen. - * Can be used by the data source to as a heuristic of what data should be provided. - */ - viewChange = - new BehaviorSubject<{start: number, end: number}>({start: 0, end: Number.MAX_VALUE}); - /** * Provides a stream containing the latest data array to render. Influenced by the table's * stream of view window (what rows are currently on screen). @@ -145,6 +124,15 @@ export class CdkTable implements CollectionViewer { } private _dataSource: DataSource; + // TODO(andrewseguin): Remove max value as the end index + // and instead calculate the view on init and scroll. + /** + * Stream containing the latest information on what rows are being displayed on screen. + * Can be used by the data source to as a heuristic of what data should be provided. + */ + viewChange = + new BehaviorSubject<{start: number, end: number}>({start: 0, end: Number.MAX_VALUE}); + // Placeholders within the table's template where the header and data rows will be inserted. @ViewChild(RowPlaceholder) _rowPlaceholder: RowPlaceholder; @ViewChild(HeaderRowPlaceholder) _headerRowPlaceholder: HeaderRowPlaceholder; @@ -171,6 +159,24 @@ export class CdkTable implements CollectionViewer { } } + ngOnInit() { + // TODO(andrewseguin): Setup a listener for scrolling, emit the calculated view to viewChange + this._dataDiffer = this._differs.find([]).create(this._trackByFn); + } + + ngAfterContentInit() { + this._cacheColumnDefinitionsByName(); + this._columnDefinitions.changes.subscribe(() => this._cacheColumnDefinitionsByName()); + this._renderHeaderRow(); + } + + ngAfterContentChecked() { + this._renderUpdatedColumns(); + if (this.dataSource && !this._renderChangeSubscription) { + this._observeRenderChanges(); + } + } + ngOnDestroy() { this._rowPlaceholder.viewContainer.clear(); this._headerRowPlaceholder.viewContainer.clear(); @@ -182,41 +188,38 @@ export class CdkTable implements CollectionViewer { } } - ngOnInit() { - // TODO(andrewseguin): Setup a listener for scroll events - // and emit the calculated view to this.viewChange - this._dataDiffer = this._differs.find([]).create(this._trackByFn); - } - ngAfterContentInit() { - // TODO(andrewseguin): Throw an error if two columns share the same name + /** Update the map containing the content's column definitions. */ + private _cacheColumnDefinitionsByName() { + this._columnDefinitionsByName.clear(); this._columnDefinitions.forEach(columnDef => { + if (this._columnDefinitionsByName.has(columnDef.name)) { + throw getTableDuplicateColumnNameError(columnDef.name); + } this._columnDefinitionsByName.set(columnDef.name, columnDef); }); + } - // Re-render the rows if any of their columns change. - // TODO(andrewseguin): Determine how to only re-render the rows that have their columns changed. - const columnChangeEvents = this._rowDefinitions.map(rowDef => rowDef.columnsChange); - - takeUntil.call(merge(...columnChangeEvents), this._onDestroy).subscribe(() => { - // Reset the data to an empty array so that renderRowChanges will re-render all new rows. - this._rowPlaceholder.viewContainer.clear(); - this._dataDiffer.diff([]); - this._renderRowChanges(); + /** + * Check if the header or rows have changed what columns they want to display. If there is a diff, + * then re-render that section. + */ + private _renderUpdatedColumns() { + // Re-render the rows when the row definition columns change. + this._rowDefinitions.forEach(rowDefinition => { + if (!!rowDefinition.getColumnsDiff()) { + // Reset the data to an empty array so that renderRowChanges will re-render all new rows. + this._dataDiffer.diff([]); + + this._rowPlaceholder.viewContainer.clear(); + this._renderRowChanges(); + } }); - // Re-render the header row if the columns change - takeUntil.call(this._headerDefinition.columnsChange, this._onDestroy).subscribe(() => { + // Re-render the header row if there is a difference in its columns. + if (this._headerDefinition.getColumnsDiff()) { this._headerRowPlaceholder.viewContainer.clear(); this._renderHeaderRow(); - }); - - this._renderHeaderRow(); - } - - ngAfterContentChecked() { - if (this.dataSource && !this._renderChangeSubscription) { - this._observeRenderChanges(); } } diff --git a/src/demo-app/table/table-demo.html b/src/demo-app/table/table-demo.html index f78038be890a..563a70f3ea0b 100644 --- a/src/demo-app/table/table-demo.html +++ b/src/demo-app/table/table-demo.html @@ -17,6 +17,38 @@ Index + + + +

+ CdkTable With Dynamic Column Def +
+ + +
+

+ + + + {{column.headerText}} + {{row[column.property]}} + + + + + +
+ + +

CdkTable Example

Highlight: @@ -25,68 +57,66 @@ Even Rows Odd Rows
- -

CdkTable Example

- - - - - - - ID - - {{row.id}} - - - - - - Progress - - -
{{row.progress}}%
-
-
-
-
-
- - - - - Name - - {{row.name}} - - - - - - Color - - {{row.color}} - - - - - -
+ + + + + + ID + + {{row.id}} + + + + + + Progress + + +
{{row.progress}}%
+
+
+
+
+
+ + + + + Name + + {{row.name}} + + + + + + Color + + {{row.color}} + + + + + +
+

MdTable Example

diff --git a/src/demo-app/table/table-demo.scss b/src/demo-app/table/table-demo.scss index 939e7acf1ae5..2f6ce9cb5478 100644 --- a/src/demo-app/table/table-demo.scss +++ b/src/demo-app/table/table-demo.scss @@ -22,6 +22,18 @@ .demo-row-highlight-even { background: #ff0099; } .demo-row-highlight-odd { background: #83f52c; } +.demo-table-card { + margin: 24px 0; + max-height: 200px; + overflow: auto; + + h3 { + display: flex; + justify-content: space-between; + align-items: center; + } +} + /** Styles so that the CDK Table columns have width and font size. */ .cdk-table { font-size: 12px; diff --git a/src/demo-app/table/table-demo.ts b/src/demo-app/table/table-demo.ts index 86e6a3301c7c..887f33960aa7 100644 --- a/src/demo-app/table/table-demo.ts +++ b/src/demo-app/table/table-demo.ts @@ -1,13 +1,14 @@ import {Component, ViewChild} from '@angular/core'; import {PeopleDatabase, UserData} from './people-database'; import {PersonDataSource} from './person-data-source'; -import {MdPaginator} from '@angular/material'; -import {MdSort} from '@angular/material'; +import {MdPaginator, MdSort} from '@angular/material'; export type UserProperties = 'userId' | 'userName' | 'progress' | 'color' | undefined; export type TrackByStrategy = 'id' | 'reference' | 'index'; +const properties = ['id', 'name', 'progress', 'color']; + @Component({ moduleId: module.id, selector: 'table-demo', @@ -21,6 +22,9 @@ export class TableDemo { changeReferences = false; highlights = new Set(); + dynamicColumnDefs: any[] = []; + dynamicColumnIds: string[] = []; + @ViewChild(MdPaginator) _paginator: MdPaginator; @ViewChild(MdSort) sort: MdSort; @@ -31,6 +35,22 @@ export class TableDemo { this.connect(); } + addDynamicColumnDef() { + const nextProperty = properties[this.dynamicColumnDefs.length]; + this.dynamicColumnDefs.push({ + id: nextProperty.toUpperCase(), + property: nextProperty, + headerText: nextProperty + }); + + this.dynamicColumnIds = this.dynamicColumnDefs.map(columnDef => columnDef.id); + } + + removeDynamicColumnDef() { + this.dynamicColumnDefs.pop(); + this.dynamicColumnIds.pop(); + } + connect() { this.displayedColumns = ['userId', 'userName', 'progress', 'color']; this.dataSource = new PersonDataSource(this._peopleDatabase,