mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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
This commit is contained in:
@@ -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 -->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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<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() {
|
||||
|
||||
@@ -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<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.
|
||||
@@ -115,12 +119,12 @@ 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() {
|
||||
@@ -128,17 +132,32 @@ export class CdkTable<T> 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<T>) => {
|
||||
const streams = [this.dataSource.connect(this), this._columnsChange];
|
||||
Observable.combineLatest(streams).subscribe(([rowsData]) => {
|
||||
this.renderRowChanges(rowsData);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> = new Subject<void>();
|
||||
|
||||
/** Differ used to check if any changes were made to the columns. */
|
||||
protected _columnsDiffer: IterableDiffer<any>;
|
||||
|
||||
private viewInitialized = false;
|
||||
|
||||
constructor(public template: TemplateRef<any>,
|
||||
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<any>) { }
|
||||
@Directive({
|
||||
selector: '[cdkHeaderRowDef]',
|
||||
inputs: ['columns: cdkHeaderRowDef'],
|
||||
})
|
||||
export class CdkHeaderRowDef extends BaseRowDef {
|
||||
constructor(template: TemplateRef<any>, _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<any>) { }
|
||||
constructor(template: TemplateRef<any>, _differs: IterableDiffers) {
|
||||
super(template, _differs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user