From e81619bed0148b651604092e481f99a20b9151f6 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Wed, 7 Jun 2017 16:56:29 -0700 Subject: [PATCH] feat(data-table): re-render when columns change (#4830) * feat(data-table): re-render when columns change * minor changes * comment stuff * add column type * explicit testing: * fix observable type * sync * remove fdescribe * fix test --- src/demo-app/data-table/data-table-demo.html | 4 ++ src/demo-app/data-table/data-table-demo.scss | 2 +- src/demo-app/data-table/data-table-demo.ts | 13 +++- src/demo-app/data-table/person-data-source.ts | 2 +- .../data-table/table-header-demo.html | 19 +++++ .../data-table/table-header-demo.scss | 15 ++++ src/demo-app/data-table/table-header-demo.ts | 12 ++++ src/demo-app/demo-app-module.ts | 2 + src/lib/core/data-table/data-source.ts | 2 +- src/lib/core/data-table/data-table.spec.ts | 60 ++++++++++++---- src/lib/core/data-table/data-table.ts | 45 ++++++++---- src/lib/core/data-table/row.ts | 72 +++++++++++++++---- 12 files changed, 205 insertions(+), 43 deletions(-) create mode 100644 src/demo-app/data-table/table-header-demo.html create mode 100644 src/demo-app/data-table/table-header-demo.scss create mode 100644 src/demo-app/data-table/table-header-demo.ts diff --git a/src/demo-app/data-table/data-table-demo.html b/src/demo-app/data-table/data-table-demo.html index bbaab9e33c2..fe424389c4c 100644 --- a/src/demo-app/data-table/data-table-demo.html +++ b/src/demo-app/data-table/data-table-demo.html @@ -1,5 +1,9 @@
+ + + diff --git a/src/demo-app/data-table/data-table-demo.scss b/src/demo-app/data-table/data-table-demo.scss index bfbf160f52e..a6f5c6cd082 100644 --- a/src/demo-app/data-table/data-table-demo.scss +++ b/src/demo-app/data-table/data-table-demo.scss @@ -3,6 +3,7 @@ display: flex; flex-direction: column; max-height: 800px; + background: white; // Table fills in the remaining area with a scroll .cdk-table { @@ -17,7 +18,6 @@ */ .cdk-table { display: block; - background: white; } .cdk-row, .cdk-header-row { diff --git a/src/demo-app/data-table/data-table-demo.ts b/src/demo-app/data-table/data-table-demo.ts index f72e6fb232f..0f9e29fb7e8 100644 --- a/src/demo-app/data-table/data-table-demo.ts +++ b/src/demo-app/data-table/data-table-demo.ts @@ -2,6 +2,8 @@ import {Component} from '@angular/core'; import {PeopleDatabase} from './people-database'; import {PersonDataSource} from './person-data-source'; +type UserProperties = 'userId' | 'userName' | 'progress' | 'color'; + @Component({ moduleId: module.id, selector: 'data-table-demo', @@ -10,7 +12,7 @@ import {PersonDataSource} from './person-data-source'; }) export class DataTableDemo { dataSource: PersonDataSource; - propertiesToDisplay = ['userId', 'userName', 'progress', 'color']; + propertiesToDisplay: UserProperties[] = ['userId', 'userName', 'progress', 'color']; constructor(private _peopleDatabase: PeopleDatabase) { } @@ -22,4 +24,13 @@ export class DataTableDemo { let distanceFromMiddle = Math.abs(50 - progress); return distanceFromMiddle / 50 + .3; } + + toggleColorColumn() { + let colorColumnIndex = this.propertiesToDisplay.indexOf('color'); + if (colorColumnIndex == -1) { + this.propertiesToDisplay.push('color'); + } else { + this.propertiesToDisplay.splice(colorColumnIndex, 1); + } + } } diff --git a/src/demo-app/data-table/person-data-source.ts b/src/demo-app/data-table/person-data-source.ts index c21eade02d4..18904b2b3d1 100644 --- a/src/demo-app/data-table/person-data-source.ts +++ b/src/demo-app/data-table/person-data-source.ts @@ -10,7 +10,7 @@ export class PersonDataSource extends DataSource { } connect(collectionViewer: CollectionViewer): Observable { - return collectionViewer.viewChanged.map((view: {start: number, end: number}) => { + return collectionViewer.viewChange.map((view: {start: number, end: number}) => { // Set the rendered rows length to the virtual page size. Fill in the data provided // from the index start until the end index or pagination size, whichever is smaller. this._renderedData.length = this._peopleDatabase.data.length; diff --git a/src/demo-app/data-table/table-header-demo.html b/src/demo-app/data-table/table-header-demo.html new file mode 100644 index 00000000000..e03ccb1e682 --- /dev/null +++ b/src/demo-app/data-table/table-header-demo.html @@ -0,0 +1,19 @@ +
+ Users +
+ +
+ + + + + +
diff --git a/src/demo-app/data-table/table-header-demo.scss b/src/demo-app/data-table/table-header-demo.scss new file mode 100644 index 00000000000..7304532ab81 --- /dev/null +++ b/src/demo-app/data-table/table-header-demo.scss @@ -0,0 +1,15 @@ +:host { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 64px; + padding: 0 16px; +} + +.title { + font-size: 20px; +} + +.actions { + color: rgba(0, 0, 0, 0.54); +} diff --git a/src/demo-app/data-table/table-header-demo.ts b/src/demo-app/data-table/table-header-demo.ts new file mode 100644 index 00000000000..3c6d1b5afc9 --- /dev/null +++ b/src/demo-app/data-table/table-header-demo.ts @@ -0,0 +1,12 @@ +import {Component, EventEmitter, Output} from '@angular/core'; + +@Component({ + moduleId: module.id, + selector: 'table-header-demo', + templateUrl: 'table-header-demo.html', + styleUrls: ['table-header-demo.css'], +}) +export class TableHeaderDemo { + @Output() shiftColumns = new EventEmitter(); + @Output() toggleColorColumn = new EventEmitter(); +} diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 38ec54b7ce1..86fd2322361 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -75,6 +75,7 @@ import { MdTooltipModule, OverlayContainer } from '@angular/material'; +import {TableHeaderDemo} from './data-table/table-header-demo'; /** * NgModule that includes all Material modules that are required to serve the demo-app. @@ -165,6 +166,7 @@ export class DemoMaterialModule {} SlideToggleDemo, SpagettiPanel, StyleDemo, + TableHeaderDemo, ToolbarDemo, TooltipDemo, TabsDemo, diff --git a/src/lib/core/data-table/data-source.ts b/src/lib/core/data-table/data-source.ts index 8314d1299bd..b0a57593ef4 100644 --- a/src/lib/core/data-table/data-source.ts +++ b/src/lib/core/data-table/data-source.ts @@ -1,7 +1,7 @@ import {Observable} from 'rxjs/Observable'; export interface CollectionViewer { - viewChanged: Observable<{start: number, end: number}>; + viewChange: Observable<{start: number, end: number}>; } export abstract class DataSource { diff --git a/src/lib/core/data-table/data-table.spec.ts b/src/lib/core/data-table/data-table.spec.ts index ff0d7a36b15..b463faa77a0 100644 --- a/src/lib/core/data-table/data-table.spec.ts +++ b/src/lib/core/data-table/data-table.spec.ts @@ -96,6 +96,8 @@ describe('CdkTable', () => { }); }); + // TODO(andrewseguin): Add test for dynamic classes on header/rows + it('should use differ to add/remove/move rows', () => { // Each row receives an attribute 'initialIndex' the element's original place getRows(tableElement).forEach((row: Element, index: number) => { @@ -129,26 +131,57 @@ describe('CdkTable', () => { expect(changedRows[2].getAttribute('initialIndex')).toBe(null); }); - // TODO(andrewseguin): Add test for dynamic classes on header/rows - it('should match the right table content with dynamic data', () => { const initialDataLength = dataSource.data.length; expect(dataSource.data.length).toBe(3); - const headerContent = ['Column A', 'Column B', 'Column C']; - const initialTableContent = [headerContent]; - dataSource.data.forEach(rowData => initialTableContent.push([rowData.a, rowData.b, rowData.c])); - expect(tableElement).toMatchTableContent(initialTableContent); + let data = dataSource.data; + expect(tableElement).toMatchTableContent([ + ['Column A', 'Column B', 'Column C'], + [data[0].a, data[0].b, data[0].c], + [data[1].a, data[1].b, data[1].c], + [data[2].a, data[2].b, data[2].c], + ]); // Add data to the table and recreate what the rendered output should be. dataSource.addData(); expect(dataSource.data.length).toBe(initialDataLength + 1); // Make sure data was added - fixture.detectChanges(); + + data = dataSource.data; + expect(tableElement).toMatchTableContent([ + ['Column A', 'Column B', 'Column C'], + [data[0].a, data[0].b, data[0].c], + [data[1].a, data[1].b, data[1].c], + [data[2].a, data[2].b, data[2].c], + [data[3].a, data[3].b, data[3].c], + ]); + }); + + it('should be able to dynamically change the columns for header and rows', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expect(tableElement).toMatchTableContent([ + ['Column A', 'Column B', 'Column C'], + [data[0].a, data[0].b, data[0].c], + [data[1].a, data[1].b, data[1].c], + [data[2].a, data[2].b, data[2].c], + ]); + + // Remove column_a and swap column_b/column_c. + component.columnsToRender = ['column_c', 'column_b']; fixture.detectChanges(); - const changedTableContent = [headerContent]; - dataSource.data.forEach(rowData => changedTableContent.push([rowData.a, rowData.b, rowData.c])); - expect(tableElement).toMatchTableContent(changedTableContent); + let changedTableContent = [['Column C', 'Column B']]; + dataSource.data.forEach(rowData => changedTableContent.push([rowData.c, rowData.b])); + + data = dataSource.data; + expect(tableElement).toMatchTableContent([ + ['Column C', 'Column B'], + [data[0].c, data[0].b], + [data[1].c, data[1].b], + [data[2].c, data[2].b], + ]); }); }); @@ -172,11 +205,8 @@ class FakeDataSource extends DataSource { connect(collectionViewer: CollectionViewer): Observable { this.isConnected = true; - const streams = [collectionViewer.viewChanged, this._dataChange]; - return Observable.combineLatest(streams).map((results: any[]) => { - const [view, data] = results; - return data; - }); + const streams = [this._dataChange, collectionViewer.viewChange]; + return Observable.combineLatest(streams).map(([data]) => data); } addData() { diff --git a/src/lib/core/data-table/data-table.ts b/src/lib/core/data-table/data-table.ts index 2533e360e89..fbe26f7b0b2 100644 --- a/src/lib/core/data-table/data-table.ts +++ b/src/lib/core/data-table/data-table.ts @@ -15,13 +15,14 @@ import { ViewContainerRef, ViewEncapsulation } from '@angular/core'; +import {CollectionViewer, DataSource} from './data-source'; +import {BaseRowDef, CdkCellOutlet, CdkHeaderRowDef, CdkRowDef} from './row'; +import {CdkCellDef, CdkColumnDef, CdkHeaderCellDef} from './cell'; +import {Observable} from 'rxjs/Observable'; import {BehaviorSubject} from 'rxjs/BehaviorSubject'; import 'rxjs/add/operator/let'; import 'rxjs/add/operator/debounceTime'; import 'rxjs/add/observable/combineLatest'; -import {CollectionViewer, DataSource} from './data-source'; -import {CdkCellOutlet, CdkHeaderRowDef, CdkRowDef} from './row'; -import {CdkCellDef, CdkColumnDef, CdkHeaderCellDef} from './cell'; /** * Provides a handle for the table to grab the view container's ng-container to insert data rows. @@ -66,14 +67,17 @@ export class CdkTable implements CollectionViewer { @Input() dataSource: DataSource; // TODO(andrewseguin): Remove max value as the end index - // and instead calculate the view on init and scroll. + // 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. */ - viewChanged = + viewChange = new BehaviorSubject<{start: number, end: number}>({start: 0, end: Number.MAX_VALUE}); + /** Stream that emits when a row def has a change to its array of columns to render. */ + _columnsChange = new Observable(); + /** * Map of all the user's defined columns identified by name. * Contains the header and data-cell templates. @@ -115,12 +119,12 @@ export class CdkTable implements CollectionViewer { ngOnDestroy() { // TODO(andrewseguin): Disconnect from the data source so - // that it can unsubscribe from its streams. + // that it can unsubscribe from its streams. } ngOnInit() { // TODO(andrewseguin): Setup a listener for scroll events - // and emit the calculated view to this.viewChanged + // and emit the calculated view to this.viewChange } ngAfterContentInit() { @@ -128,17 +132,32 @@ export class CdkTable implements CollectionViewer { this._columnDefinitions.forEach(columnDef => { this._columnDefinitionsByName.set(columnDef.name, columnDef); }); + + // Get and merge the streams for column changes made to the row defs + const rowDefs = [...this._rowDefinitions.toArray(), this._headerDefinition]; + const columnChangeStreams = + rowDefs.map((rowDef: BaseRowDef) => rowDef.columnsChange); + this._columnsChange = Observable.merge(...columnChangeStreams); } ngAfterViewInit() { - // TODO(andrewseguin): Re-render the header when the header's columns change. this.renderHeaderRow(); - // TODO(andrewseguin): Re-render rows when their list of columns change. + // Re-render the header row if the columns changed. + this._columnsChange.subscribe(() => { + this._headerRowPlaceholder.viewContainer.clear(); + this.renderHeaderRow(); + + // Reset the data to an empty array so that renderRowChanges will re-render all new rows. + this._rowPlaceholder.viewContainer.clear(); + this._dataDiffer.diff([]); + }); + // TODO(andrewseguin): If the data source is not // present after view init, connect it when it is defined. // TODO(andrewseguin): Unsubscribe from this on destroy. - this.dataSource.connect(this).subscribe((rowsData: NgIterable) => { + const streams = [this.dataSource.connect(this), this._columnsChange]; + Observable.combineLatest(streams).subscribe(([rowsData]) => { this.renderRowChanges(rowsData); }); } @@ -150,8 +169,8 @@ export class CdkTable implements CollectionViewer { const cells = this.getHeaderCellTemplatesForRow(this._headerDefinition); // TODO(andrewseguin): add some code to enforce that exactly - // one CdkCellOutlet was instantiated as a result - // of `createEmbeddedView`. + // one CdkCellOutlet was instantiated as a result + // of `createEmbeddedView`. this._headerRowPlaceholder.viewContainer .createEmbeddedView(this._headerDefinition.template, {cells}); CdkCellOutlet.mostRecentCellOutlet.cells = cells; @@ -206,6 +225,7 @@ export class CdkTable implements CollectionViewer { */ getHeaderCellTemplatesForRow(headerDef: CdkHeaderRowDef): CdkHeaderCellDef[] { return headerDef.columns.map(columnId => { + // TODO(andrewseguin): Throw an error if there is no column with this columnId return this._columnDefinitionsByName.get(columnId).headerCell; }); } @@ -216,6 +236,7 @@ export class CdkTable implements CollectionViewer { */ getCellTemplatesForRow(rowDef: CdkRowDef): CdkCellDef[] { return rowDef.columns.map(columnId => { + // TODO(andrewseguin): Throw an error if there is no column with this columnId return this._columnDefinitionsByName.get(columnId).cell; }); } diff --git a/src/lib/core/data-table/row.ts b/src/lib/core/data-table/row.ts index 79baa7bfb21..5c794b04733 100644 --- a/src/lib/core/data-table/row.ts +++ b/src/lib/core/data-table/row.ts @@ -2,35 +2,83 @@ import { ChangeDetectionStrategy, Component, Directive, - Input, + IterableDiffer, + IterableDiffers, + SimpleChanges, TemplateRef, ViewContainerRef } from '@angular/core'; import {CdkCellDef} from './cell'; +import {Subject} from 'rxjs/Subject'; + +/** + * Base class for the CdkHeaderRowDef and CdkRowDef that handles checking their columns inputs + * for changes and notifying the table. + */ +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. + if (!this._columnsDiffer) { + this._columnsDiffer = this._differs.find(changes['columns'].currentValue).create(); + } + } + + 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(); } + } +} /** * Header row definition for the CDK data-table. * Captures the header row's template and other header properties such as the columns to display. */ -@Directive({selector: '[cdkHeaderRowDef]'}) -export class CdkHeaderRowDef { - @Input('cdkHeaderRowDef') columns: string[]; - - constructor(public template: TemplateRef) { } +@Directive({ + selector: '[cdkHeaderRowDef]', + inputs: ['columns: cdkHeaderRowDef'], +}) +export class CdkHeaderRowDef extends BaseRowDef { + constructor(template: TemplateRef, _differs: IterableDiffers) { + super(template, _differs); + } } /** * Data row definition for the CDK data-table. * Captures the header row's template and other row properties such as the columns to display. */ -@Directive({selector: '[cdkRowDef]'}) -export class CdkRowDef { - @Input('cdkRowDefColumns') columns: string[]; - +@Directive({ + selector: '[cdkRowDef]', + inputs: ['columns: cdkRowDefColumns'], +}) +export class CdkRowDef extends BaseRowDef { // TODO(andrewseguin): Add an input for providing a switch function to determine // if this template should be used. - - constructor(public template: TemplateRef) { } + constructor(template: TemplateRef, _differs: IterableDiffers) { + super(template, _differs); + } } /**