Skip to content

Commit

Permalink
feat(data-table): re-render when columns change (angular#4830)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
andrewseguin authored Jun 7, 2017
1 parent cac7610 commit e81619b
Show file tree
Hide file tree
Showing 12 changed files with 205 additions and 43 deletions.
4 changes: 4 additions & 0 deletions src/demo-app/data-table/data-table-demo.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<div class="demo-table-container mat-elevation-z4">

<table-header-demo (shiftColumns)="propertiesToDisplay.push(propertiesToDisplay.shift())"
(toggleColorColumn)="toggleColorColumn()">
</table-header-demo>

<cdk-table #table [dataSource]="dataSource">

<!-- Column Definition: ID -->
Expand Down
2 changes: 1 addition & 1 deletion src/demo-app/data-table/data-table-demo.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -17,7 +18,6 @@
*/
.cdk-table {
display: block;
background: white;
}

.cdk-row, .cdk-header-row {
Expand Down
13 changes: 12 additions & 1 deletion src/demo-app/data-table/data-table-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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) { }

Expand All @@ -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);
}
}
}
2 changes: 1 addition & 1 deletion src/demo-app/data-table/person-data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class PersonDataSource extends DataSource<any> {
}

connect(collectionViewer: CollectionViewer): Observable<UserData[]> {
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;
Expand Down
19 changes: 19 additions & 0 deletions src/demo-app/data-table/table-header-demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<div class="title">
Users
</div>

<div class="actions">
<button md-icon-button [mdMenuTriggerFor]="menu">
<md-icon>more_vert</md-icon>
</button>
<md-menu #menu="mdMenu">
<button md-menu-item (click)="shiftColumns.next()">
<md-icon>subdirectory_arrow_left</md-icon>
Shift Columns Left
</button>
<button md-menu-item (click)="toggleColorColumn.next()">
<md-icon>color_lens</md-icon>
Toggle Color Column
</button>
</md-menu>
</div>
15 changes: 15 additions & 0 deletions src/demo-app/data-table/table-header-demo.scss
Original file line number Diff line number Diff line change
@@ -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);
}
12 changes: 12 additions & 0 deletions src/demo-app/data-table/table-header-demo.ts
Original file line number Diff line number Diff line change
@@ -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<void>();
@Output() toggleColorColumn = new EventEmitter<void>();
}
2 changes: 2 additions & 0 deletions src/demo-app/demo-app-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -165,6 +166,7 @@ export class DemoMaterialModule {}
SlideToggleDemo,
SpagettiPanel,
StyleDemo,
TableHeaderDemo,
ToolbarDemo,
TooltipDemo,
TabsDemo,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/core/data-table/data-source.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
Expand Down
60 changes: 45 additions & 15 deletions src/lib/core/data-table/data-table.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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

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();
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],
]);
});
});

Expand All @@ -172,11 +205,8 @@ class FakeDataSource extends DataSource<TestData> {

connect(collectionViewer: CollectionViewer): Observable<TestData[]> {
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() {
Expand Down
45 changes: 33 additions & 12 deletions src/lib/core/data-table/data-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -66,14 +67,17 @@ export class CdkTable<T> implements CollectionViewer {
@Input() dataSource: DataSource<T>;

// 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<void>();

/**
* Map of all the user's defined columns identified by name.
* Contains the header and data-cell templates.
Expand Down Expand Up @@ -115,30 +119,45 @@ export class CdkTable<T> 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() {
// TODO(andrewseguin): Throw an error if two columns share the same name
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<T>) => {
const streams = [this.dataSource.connect(this), this._columnsChange];
Observable.combineLatest(streams).subscribe(([rowsData]) => {
this.renderRowChanges(rowsData);
});
}
Expand All @@ -150,8 +169,8 @@ export class CdkTable<T> 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;
Expand Down Expand Up @@ -206,6 +225,7 @@ export class CdkTable<T> 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;
});
}
Expand All @@ -216,6 +236,7 @@ export class CdkTable<T> 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;
});
}
Expand Down
Loading

0 comments on commit e81619b

Please sign in to comment.