diff --git a/src/demo-app/data-table/data-table-demo.html b/src/demo-app/data-table/data-table-demo.html index f4673313634..859c8d03b53 100644 --- a/src/demo-app/data-table/data-table-demo.html +++ b/src/demo-app/data-table/data-table-demo.html @@ -4,6 +4,20 @@ +
+ + Change references +
+ +
+
Track By
+ + Reference + ID + Index + +
+
Highlight: First Row @@ -19,7 +33,7 @@ (toggleColorColumn)="toggleColorColumn()"> - + diff --git a/src/demo-app/data-table/data-table-demo.scss b/src/demo-app/data-table/data-table-demo.scss index acc053225d4..a6ebd784876 100644 --- a/src/demo-app/data-table/data-table-demo.scss +++ b/src/demo-app/data-table/data-table-demo.scss @@ -17,6 +17,15 @@ margin: 16px 0; } + .demo-track-by-select { + display: flex; + align-items: center; + + .demo-track-by-label { + margin-right: 8px; + } + } + .demo-highlighter .mat-checkbox { margin: 0 8px; } @@ -102,4 +111,3 @@ font-family: cursive; } } - diff --git a/src/demo-app/data-table/data-table-demo.ts b/src/demo-app/data-table/data-table-demo.ts index 8fa8bccd856..63732a2833c 100644 --- a/src/demo-app/data-table/data-table-demo.ts +++ b/src/demo-app/data-table/data-table-demo.ts @@ -1,9 +1,11 @@ import {Component} from '@angular/core'; -import {PeopleDatabase} from './people-database'; +import {PeopleDatabase, UserData} from './people-database'; import {PersonDataSource} from './person-data-source'; export type UserProperties = 'userId' | 'userName' | 'progress' | 'color' | undefined; +export type TrackByStrategy = 'id' | 'reference' | 'index'; + @Component({ moduleId: module.id, selector: 'data-table-demo', @@ -13,9 +15,11 @@ export type UserProperties = 'userId' | 'userName' | 'progress' | 'color' | unde export class DataTableDemo { dataSource: PersonDataSource | null; propertiesToDisplay: UserProperties[] = []; + trackByStrategy: TrackByStrategy = 'reference'; + changeReferences = false; highlights = new Set(); - constructor(private _peopleDatabase: PeopleDatabase) { + constructor(public _peopleDatabase: PeopleDatabase) { this.connect(); } @@ -35,6 +39,14 @@ export class DataTableDemo { return distanceFromMiddle / 50 + .3; } + userTrackBy = (index: number, item: UserData) => { + switch (this.trackByStrategy) { + case 'id': return item.id; + case 'reference': return item; + case 'index': return index; + } + } + toggleColorColumn() { let colorColumnIndex = this.propertiesToDisplay.indexOf('color'); if (colorColumnIndex == -1) { diff --git a/src/demo-app/data-table/people-database.ts b/src/demo-app/data-table/people-database.ts index d10027e7ed1..8cc070fbe3a 100644 --- a/src/demo-app/data-table/people-database.ts +++ b/src/demo-app/data-table/people-database.ts @@ -1,6 +1,7 @@ import {Injectable} from '@angular/core'; import {NAMES} from '../dataset/names'; import {COLORS} from '../dataset/colors'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; export let LATEST_ID: number = 0; @@ -13,7 +14,9 @@ export interface UserData { @Injectable() export class PeopleDatabase { - data: UserData[] = []; + dataChange: BehaviorSubject = new BehaviorSubject([]); + + get data(): UserData[] { return this.dataChange.value; } constructor() { this.initialize(); @@ -21,20 +24,44 @@ export class PeopleDatabase { initialize() { LATEST_ID = 0; - this.data = []; + this.dataChange.next([]); for (let i = 0; i < 100; i++) { this.addPerson(); } } + shuffle(changeReferences: boolean) { + let copiedData = this.data.slice(); + for (let i = copiedData.length; i; i--) { + let j = Math.floor(Math.random() * i); + [copiedData[i - 1], copiedData[j]] = [copiedData[j], copiedData[i - 1]]; + } + + if (changeReferences) { + copiedData = copiedData.map(userData => { + return { + id: userData.id, + name: userData.name, + progress: userData.progress, + color: userData.color + }; + }); + } + + this.dataChange.next(copiedData); + } + addPerson() { const name = NAMES[Math.round(Math.random() * (NAMES.length - 1))] + ' ' + NAMES[Math.round(Math.random() * (NAMES.length - 1))].charAt(0) + '.'; - this.data.push({ + const copiedData = this.data.slice(); + copiedData.push({ id: (++LATEST_ID).toString(), name: name, progress: Math.round(Math.random() * 100).toString(), color: COLORS[Math.round(Math.random() * (COLORS.length - 1))] }); + + this.dataChange.next(copiedData); } } diff --git a/src/demo-app/data-table/person-data-source.ts b/src/demo-app/data-table/person-data-source.ts index 18904b2b3d1..4fa67f98172 100644 --- a/src/demo-app/data-table/person-data-source.ts +++ b/src/demo-app/data-table/person-data-source.ts @@ -10,7 +10,12 @@ export class PersonDataSource extends DataSource { } connect(collectionViewer: CollectionViewer): Observable { - return collectionViewer.viewChange.map((view: {start: number, end: number}) => { + const changeStreams = Observable.combineLatest( + collectionViewer.viewChange, + this._peopleDatabase.dataChange); + return changeStreams.map((result: any[]) => { + const view: {start: number, end: number} = result[0]; + // 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/lib/core/data-table/data-table.spec.ts b/src/lib/core/data-table/data-table.spec.ts index 8186adc6315..78ef5c4c215 100644 --- a/src/lib/core/data-table/data-table.spec.ts +++ b/src/lib/core/data-table/data-table.spec.ts @@ -25,7 +25,8 @@ describe('CdkTable', () => { SimpleCdkTableApp, DynamicDataSourceCdkTableApp, CustomRoleCdkTableApp, - RowContextCdkTableApp + TrackByCdkTableApp, + RowContextCdkTableApp, ], }).compileComponents(); })); @@ -145,6 +146,117 @@ describe('CdkTable', () => { expect(changedRows[2].getAttribute('initialIndex')).toBe(null); }); + describe('with trackBy', () => { + + let trackByComponent: TrackByCdkTableApp; + let trackByFixture: ComponentFixture; + + function createTestComponentWithTrackyByTable(trackByStrategy) { + trackByFixture = TestBed.createComponent(TrackByCdkTableApp); + + trackByComponent = trackByFixture.componentInstance; + trackByComponent.trackByStrategy = trackByStrategy; + + dataSource = trackByComponent.dataSource as FakeDataSource; + table = trackByComponent.table; + tableElement = trackByFixture.nativeElement.querySelector('cdk-table'); + + trackByFixture.detectChanges(); // Let the component and table create embedded views + trackByFixture.detectChanges(); // Let the cells render + + // Each row receives an attribute 'initialIndex' the element's original place + getRows(tableElement).forEach((row: Element, index: number) => { + row.setAttribute('initialIndex', index.toString()); + }); + + // Prove that the attributes match their indicies + const initialRows = getRows(tableElement); + expect(initialRows[0].getAttribute('initialIndex')).toBe('0'); + expect(initialRows[1].getAttribute('initialIndex')).toBe('1'); + expect(initialRows[2].getAttribute('initialIndex')).toBe('2'); + } + + // Swap first two elements, remove the third, add new data + function mutateData() { + // Swap first and second data in data array + const copiedData = trackByComponent.dataSource.data.slice(); + const temp = copiedData[0]; + copiedData[0] = copiedData[1]; + copiedData[1] = temp; + + // Remove the third element + copiedData.splice(2, 1); + + // Add new data + trackByComponent.dataSource.data = copiedData; + trackByComponent.dataSource.addData(); + } + + it('should add/remove/move rows with reference-based trackBy', () => { + createTestComponentWithTrackyByTable('reference'); + mutateData(); + + // Expect that the first and second rows were swapped and that the last row is new + const changedRows = getRows(tableElement); + expect(changedRows.length).toBe(3); + expect(changedRows[0].getAttribute('initialIndex')).toBe('1'); + expect(changedRows[1].getAttribute('initialIndex')).toBe('0'); + expect(changedRows[2].getAttribute('initialIndex')).toBe(null); + }); + + it('should add/remove/move rows with changed references without property-based trackBy', () => { + createTestComponentWithTrackyByTable('reference'); + mutateData(); + + // Change each item reference to show that the trackby is not checking the item properties. + trackByComponent.dataSource.data = trackByComponent.dataSource.data + .map(item => { return {a: item.a, b: item.b, c: item.c}; }); + + // Expect that all the rows are considered new since their references are all different + const changedRows = getRows(tableElement); + expect(changedRows.length).toBe(3); + expect(changedRows[0].getAttribute('initialIndex')).toBe(null); + expect(changedRows[1].getAttribute('initialIndex')).toBe(null); + expect(changedRows[2].getAttribute('initialIndex')).toBe(null); + }); + + it('should add/remove/move rows with changed references with property-based trackBy', () => { + createTestComponentWithTrackyByTable('propertyA'); + mutateData(); + + // Change each item reference to show that the trackby is checking the item properties. + // Otherwise this would cause them all to be removed/added. + trackByComponent.dataSource.data = trackByComponent.dataSource.data + .map(item => { return {a: item.a, b: item.b, c: item.c}; }); + + // Expect that the first and second rows were swapped and that the last row is new + const changedRows = getRows(tableElement); + expect(changedRows.length).toBe(3); + expect(changedRows[0].getAttribute('initialIndex')).toBe('1'); + expect(changedRows[1].getAttribute('initialIndex')).toBe('0'); + expect(changedRows[2].getAttribute('initialIndex')).toBe(null); + }); + + it('should add/remove/move rows with changed references with index-based trackBy', () => { + createTestComponentWithTrackyByTable('index'); + mutateData(); + + // Change each item reference to show that the trackby is checking the index. + // Otherwise this would cause them all to be removed/added. + trackByComponent.dataSource.data = trackByComponent.dataSource.data + .map(item => { return {a: item.a, b: item.b, c: item.c}; }); + + // Expect first two to be the same since they were swapped but indicies are consistent. + // The third element was removed and caught by the table so it was removed before another + // item was added, so it is without an initial index. + const changedRows = getRows(tableElement); + expect(changedRows.length).toBe(3); + expect(changedRows[0].getAttribute('initialIndex')).toBe('0'); + expect(changedRows[1].getAttribute('initialIndex')).toBe('1'); + expect(changedRows[2].getAttribute('initialIndex')).toBe(null); + }); + }); + it('should match the right table content with dynamic data', () => { const initialDataLength = dataSource.data.length; expect(dataSource.data.length).toBe(3); @@ -406,6 +518,41 @@ class DynamicDataSourceCdkTableApp { @ViewChild(CdkTable) table: CdkTable; } +@Component({ + template: ` + + + Column A + {{row.a}} + + + + Column B + {{row.b}} + + + + + + ` +}) +class TrackByCdkTableApp { + trackByStrategy: 'reference' | 'propertyA' | 'index' = 'reference'; + + dataSource: FakeDataSource = new FakeDataSource(); + columnsToRender = ['column_a', 'column_b']; + + @ViewChild(CdkTable) table: CdkTable; + + trackBy = (index: number, item: TestData) => { + switch (this.trackByStrategy) { + case 'reference': return item; + case 'propertyA': return item.a; + case 'index': return index; + } + } +} + @Component({ template: ` diff --git a/src/lib/core/data-table/data-table.ts b/src/lib/core/data-table/data-table.ts index cb554c136f3..52e1bf26a61 100644 --- a/src/lib/core/data-table/data-table.ts +++ b/src/lib/core/data-table/data-table.ts @@ -17,12 +17,14 @@ import { ElementRef, EmbeddedViewRef, Input, + isDevMode, IterableChangeRecord, IterableDiffer, IterableDiffers, NgIterable, QueryList, Renderer2, + TrackByFunction, ViewChild, ViewContainerRef, ViewEncapsulation @@ -103,6 +105,24 @@ export class CdkTable implements CollectionViewer { /** Differ used to find the changes in the data provided by the data source. */ private _dataDiffer: IterableDiffer; + /** + * Tracking function that will be used to check the differences in data changes. Used similarly + * to ngFor trackBy function. Optimize row operations by identifying a row based on its data + * relative to the function to know if a row should be added/removed/moved. + * Accepts a function that takes two parameters, `index` and `item`. + */ + @Input() + set trackBy(fn: TrackByFunction) { + if (isDevMode() && + fn != null && typeof fn !== 'function' && + console && console.warn) { + console.warn(`trackBy must be a function, but received ${JSON.stringify(fn)}.`); + } + this._trackByFn = fn; + } + 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. /** @@ -156,10 +176,6 @@ export class CdkTable implements CollectionViewer { if (!role) { renderer.setAttribute(elementRef.nativeElement, 'role', 'grid'); } - - // TODO(andrewseguin): Add trackby function input. - // Find and construct an iterable differ that can be used to find the diff in an array. - this._dataDiffer = this._differs.find(this._data).create(); } ngOnDestroy() { @@ -199,8 +215,11 @@ export class CdkTable implements CollectionViewer { } ngAfterViewInit() { - this._isViewInitialized = true; + // Find and construct an iterable differ that can be used to find the diff in an array. + this._dataDiffer = this._differs.find([]).create(this._trackByFn); + this._renderHeaderRow(); + this._isViewInitialized = true; } ngDoCheck() {