Skip to content

Commit 4754608

Browse files
committed
feat(data-table): add trackby input
1 parent 665e667 commit 4754608

File tree

7 files changed

+243
-15
lines changed

7 files changed

+243
-15
lines changed

src/demo-app/data-table/data-table-demo.html

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1-
<div class="demo-data-source-actions">
1+
<div class="demo-database-actions">
22
<button md-raised-button (click)="connect()">Connect New Data Source</button>
33
<button md-raised-button (click)="disconnect()" [disabled]="!dataSource">Disconnect Data Source</button>
4+
5+
<button md-raised-button (click)="_peopleDatabase.shuffle(changeReferences)">Randomize Data</button>
6+
<md-checkbox [(ngModel)]="changeReferences">Change references</md-checkbox>
7+
</div>
8+
9+
<span>Track By</span>
10+
<md-radio-group [(ngModel)]="trackBy">
11+
<md-radio-button [value]="'id'"> ID </md-radio-button>
12+
<md-radio-button [value]="'reference'"> Reference </md-radio-button>
13+
<md-radio-button [value]="'index'"> Index </md-radio-button>
14+
</md-radio-group>
15+
16+
<div class="demo-data-source-actions">
417
</div>
518

619
<div class="demo-table-container mat-elevation-z4">
@@ -9,7 +22,7 @@
922
(toggleColorColumn)="toggleColorColumn()">
1023
</table-header-demo>
1124

12-
<cdk-table #table [dataSource]="dataSource">
25+
<cdk-table #table [dataSource]="dataSource" [trackBy]="userTrackBy">
1326

1427
<!-- Column Definition: ID -->
1528
<ng-container cdkColumnDef="userId">

src/demo-app/data-table/data-table-demo.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,3 @@
9191
font-family: cursive;
9292
}
9393
}
94-

src/demo-app/data-table/data-table-demo.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
import {Component} from '@angular/core';
2-
import {PeopleDatabase} from './people-database';
2+
import {PeopleDatabase, UserData} from './people-database';
33
import {PersonDataSource} from './person-data-source';
44

55
export type UserProperties = 'userId' | 'userName' | 'progress' | 'color';
66

7+
export type TrackByStrategy = 'id' | 'reference' | 'index';
8+
79
@Component({
810
moduleId: module.id,
911
selector: 'data-table-demo',
1012
templateUrl: 'data-table-demo.html',
1113
styleUrls: ['data-table-demo.css'],
1214
})
1315
export class DataTableDemo {
16+
static TRACK_BY: 'id' | 'reference' | 'index' = 'reference';
17+
18+
get trackBy(): TrackByStrategy { return DataTableDemo.TRACK_BY; }
19+
set trackBy(trackBy: TrackByStrategy) { DataTableDemo.TRACK_BY = trackBy; }
20+
1421
dataSource: PersonDataSource;
1522
propertiesToDisplay: UserProperties[] = [];
23+
changeReferences = false;
1624

17-
constructor(private _peopleDatabase: PeopleDatabase) {
25+
constructor(public _peopleDatabase: PeopleDatabase) {
1826
this.connect();
1927
}
2028

@@ -34,6 +42,14 @@ export class DataTableDemo {
3442
return distanceFromMiddle / 50 + .3;
3543
}
3644

45+
userTrackBy(index: number, item: UserData) {
46+
switch (DataTableDemo.TRACK_BY) {
47+
case 'id': return item.id;
48+
case 'reference': return item;
49+
case 'index': return index;
50+
}
51+
}
52+
3753
toggleColorColumn() {
3854
let colorColumnIndex = this.propertiesToDisplay.indexOf('color');
3955
if (colorColumnIndex == -1) {

src/demo-app/data-table/people-database.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Injectable} from '@angular/core';
22
import {NAMES} from '../dataset/names';
33
import {COLORS} from '../dataset/colors';
4+
import {BehaviorSubject} from 'rxjs/BehaviorSubject';
45

56
export let LATEST_ID: number = 0;
67

@@ -13,7 +14,9 @@ export interface UserData {
1314

1415
@Injectable()
1516
export class PeopleDatabase {
16-
data: UserData[] = [];
17+
dataChange: BehaviorSubject<UserData[]> = new BehaviorSubject<UserData[]>([]);
18+
19+
get data(): UserData[] { return this.dataChange.value; }
1720

1821
constructor() {
1922
this.initialize();
@@ -25,16 +28,40 @@ export class PeopleDatabase {
2528
for (let i = 0; i < 100; i++) { this.addPerson(); }
2629
}
2730

31+
randomize(changeReferences: boolean) {
32+
let copiedData = this.data.slice();
33+
for (let i = copiedData.length; i; i--) {
34+
let j = Math.floor(Math.random() * i);
35+
[copiedData[i - 1], copiedData[j]] = [copiedData[j], copiedData[i - 1]];
36+
}
37+
38+
if (changeReferences) {
39+
copiedData = copiedData.map(userData => {
40+
return {
41+
id: userData.id,
42+
name: userData.name,
43+
progress: userData.progress,
44+
color: userData.color
45+
};
46+
});
47+
}
48+
49+
this.dataChange.next(copiedData);
50+
}
51+
2852
addPerson() {
2953
const name =
3054
NAMES[Math.round(Math.random() * (NAMES.length - 1))] + ' ' +
3155
NAMES[Math.round(Math.random() * (NAMES.length - 1))].charAt(0) + '.';
3256

33-
this.data.push({
57+
const copiedData = this.data.slice();
58+
copiedData.push({
3459
id: (++LATEST_ID).toString(),
3560
name: name,
3661
progress: Math.round(Math.random() * 100).toString(),
3762
color: COLORS[Math.round(Math.random() * (COLORS.length - 1))]
3863
});
64+
65+
this.dataChange.next(copiedData);
3966
}
4067
}

src/demo-app/data-table/person-data-source.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ export class PersonDataSource extends DataSource<any> {
1010
}
1111

1212
connect(collectionViewer: CollectionViewer): Observable<UserData[]> {
13-
return collectionViewer.viewChange.map((view: {start: number, end: number}) => {
13+
const changeStreams = Observable.combineLatest(
14+
collectionViewer.viewChange,
15+
this._peopleDatabase.dataChange);
16+
return changeStreams.map((result: any[]) => {
17+
const view: {start: number, end: number} = result[0];
18+
1419
// Set the rendered rows length to the virtual page size. Fill in the data provided
1520
// from the index start until the end index or pagination size, whichever is smaller.
1621
this._renderedData.length = this._peopleDatabase.data.length;

src/lib/core/data-table/data-table.spec.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ describe('CdkTable', () => {
2121

2222
TestBed.configureTestingModule({
2323
imports: [CdkDataTableModule],
24-
declarations: [SimpleCdkTableApp, DynamicDataSourceCdkTableApp, CustomRoleCdkTableApp],
24+
declarations: [
25+
SimpleCdkTableApp,
26+
DynamicDataSourceCdkTableApp,
27+
CustomRoleCdkTableApp,
28+
TrackByCdkTableApp,
29+
],
2530
}).compileComponents();
2631

2732
fixture = TestBed.createComponent(SimpleCdkTableApp);
@@ -138,6 +143,119 @@ describe('CdkTable', () => {
138143
expect(changedRows[2].getAttribute('initialIndex')).toBe(null);
139144
});
140145

146+
describe('should properly use trackBy when diffing to add/remove/move rows', () => {
147+
148+
afterEach(() => {
149+
// Resetting the static value of the trackby function for TrackByCdkTableApp
150+
TrackByCdkTableApp.TRACK_BY = 'reference';
151+
});
152+
153+
function createComponent() {
154+
fixture = TestBed.createComponent(TrackByCdkTableApp);
155+
156+
component = fixture.componentInstance;
157+
dataSource = component.dataSource as FakeDataSource;
158+
table = component.table;
159+
tableElement = fixture.nativeElement.querySelector('cdk-table');
160+
161+
fixture.detectChanges(); // Let the component and table create embedded views
162+
fixture.detectChanges(); // Let the cells render
163+
164+
// Each row receives an attribute 'initialIndex' the element's original place
165+
getRows(tableElement).forEach((row: Element, index: number) => {
166+
row.setAttribute('initialIndex', index.toString());
167+
});
168+
169+
// Prove that the attributes match their indicies
170+
const initialRows = getRows(tableElement);
171+
expect(initialRows[0].getAttribute('initialIndex')).toBe('0');
172+
expect(initialRows[1].getAttribute('initialIndex')).toBe('1');
173+
expect(initialRows[2].getAttribute('initialIndex')).toBe('2');
174+
}
175+
176+
// Swap first two elements, remove the third, add new data
177+
function changeData() {
178+
// Swap first and second data in data array
179+
const copiedData = component.dataSource.data.slice();
180+
const temp = copiedData[0];
181+
copiedData[0] = copiedData[1];
182+
copiedData[1] = temp;
183+
184+
// Remove the third element
185+
copiedData.splice(2, 1);
186+
187+
// Add new data
188+
component.dataSource.data = copiedData;
189+
component.dataSource.addData();
190+
}
191+
192+
it('with reference-based trackBy', () => {
193+
createComponent();
194+
changeData();
195+
196+
// Expect that the first and second rows were swapped and that the last row is new
197+
const changedRows = getRows(tableElement);
198+
expect(changedRows.length).toBe(3);
199+
expect(changedRows[0].getAttribute('initialIndex')).toBe('1');
200+
expect(changedRows[1].getAttribute('initialIndex')).toBe('0');
201+
expect(changedRows[2].getAttribute('initialIndex')).toBe(null);
202+
});
203+
204+
it('with changed references without property-based trackBy', () => {
205+
createComponent();
206+
changeData();
207+
208+
// Change each item reference to show that the trackby is not checking the item properties.
209+
component.dataSource.data = component.dataSource.data
210+
.map(item => { return {a: item.a, b: item.b, c: item.c}; });
211+
212+
// Expect that all the rows are considered new since their references are all different
213+
const changedRows = getRows(tableElement);
214+
expect(changedRows.length).toBe(3);
215+
expect(changedRows[0].getAttribute('initialIndex')).toBe(null);
216+
expect(changedRows[1].getAttribute('initialIndex')).toBe(null);
217+
expect(changedRows[2].getAttribute('initialIndex')).toBe(null);
218+
});
219+
220+
it('with changed references with property-based trackBy', () => {
221+
TrackByCdkTableApp.TRACK_BY = 'propertyA';
222+
createComponent();
223+
changeData();
224+
225+
// Change each item reference to show that the trackby is checking the item properties.
226+
// Otherwise this would cause them all to be removed/added.
227+
component.dataSource.data = component.dataSource.data
228+
.map(item => { return {a: item.a, b: item.b, c: item.c}; });
229+
230+
// Expect that the first and second rows were swapped and that the last row is new
231+
const changedRows = getRows(tableElement);
232+
expect(changedRows.length).toBe(3);
233+
expect(changedRows[0].getAttribute('initialIndex')).toBe('1');
234+
expect(changedRows[1].getAttribute('initialIndex')).toBe('0');
235+
expect(changedRows[2].getAttribute('initialIndex')).toBe(null);
236+
});
237+
238+
it('with changed references with index-based trackBy', () => {
239+
TrackByCdkTableApp.TRACK_BY = 'index';
240+
createComponent();
241+
changeData();
242+
243+
// Change each item reference to show that the trackby is checking the index.
244+
// Otherwise this would cause them all to be removed/added.
245+
component.dataSource.data = component.dataSource.data
246+
.map(item => { return {a: item.a, b: item.b, c: item.c}; });
247+
248+
// Expect first two to be the same since they were swapped but indicies are consistent.
249+
// The third element was removed and caught by the table so it was removed before another
250+
// item was added, so it is without an initial index.
251+
const changedRows = getRows(tableElement);
252+
expect(changedRows.length).toBe(3);
253+
expect(changedRows[0].getAttribute('initialIndex')).toBe('0');
254+
expect(changedRows[1].getAttribute('initialIndex')).toBe('1');
255+
expect(changedRows[2].getAttribute('initialIndex')).toBe(null);
256+
});
257+
});
258+
141259
it('should match the right table content with dynamic data', () => {
142260
const initialDataLength = dataSource.data.length;
143261
expect(dataSource.data.length).toBe(3);
@@ -316,6 +434,41 @@ class DynamicDataSourceCdkTableApp {
316434
@ViewChild(CdkTable) table: CdkTable<TestData>;
317435
}
318436

437+
@Component({
438+
template: `
439+
<cdk-table [dataSource]="dataSource" [trackBy]="trackBy">
440+
<ng-container cdkColumnDef="column_a">
441+
<cdk-header-cell *cdkHeaderCellDef> Column A</cdk-header-cell>
442+
<cdk-cell *cdkCellDef="let row"> {{row.a}}</cdk-cell>
443+
</ng-container>
444+
445+
<ng-container cdkColumnDef="column_b">
446+
<cdk-header-cell *cdkHeaderCellDef> Column B</cdk-header-cell>
447+
<cdk-cell *cdkCellDef="let row"> {{row.b}}</cdk-cell>
448+
</ng-container>
449+
450+
<cdk-header-row *cdkHeaderRowDef="columnsToRender"></cdk-header-row>
451+
<cdk-row *cdkRowDef="let row; columns: columnsToRender"></cdk-row>
452+
</cdk-table>
453+
`
454+
})
455+
class TrackByCdkTableApp {
456+
static TRACK_BY: 'reference' | 'propertyA' | 'index' = 'reference';
457+
458+
dataSource: FakeDataSource = new FakeDataSource();
459+
columnsToRender = ['column_a', 'column_b'];
460+
461+
@ViewChild(CdkTable) table: CdkTable<TestData>;
462+
463+
trackBy(index: number, item: TestData) {
464+
switch (TrackByCdkTableApp.TRACK_BY) {
465+
case 'reference': return item;
466+
case 'propertyA': return item.a;
467+
case 'index': return index;
468+
}
469+
}
470+
}
471+
319472
@Component({
320473
template: `
321474
<cdk-table [dataSource]="dataSource" role="treegrid">

src/lib/core/data-table/data-table.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ import {
1515
ContentChildren,
1616
Directive,
1717
ElementRef,
18-
Input,
18+
Input, isDevMode,
1919
IterableChangeRecord,
2020
IterableDiffer,
2121
IterableDiffers,
2222
NgIterable,
2323
QueryList,
24-
Renderer2,
24+
Renderer2, TrackByFunction,
2525
ViewChild,
2626
ViewContainerRef,
2727
ViewEncapsulation
@@ -93,6 +93,22 @@ export class CdkTable<T> implements CollectionViewer {
9393
/** Differ used to find the changes in the data provided by the data source. */
9494
private _dataDiffer: IterableDiffer<T> = null;
9595

96+
/**
97+
* Tracking function that will be used to check the differences in data changes.
98+
* Accepts a function that takes two parameters, `index` and `item`.
99+
*/
100+
@Input()
101+
set trackBy(fn: TrackByFunction<T>) {
102+
if (isDevMode() &&
103+
fn != null && typeof fn !== 'function' &&
104+
<any>console && <any>console.warn) {
105+
console.warn(`trackBy must be a function, but received ${JSON.stringify(fn)}.`);
106+
}
107+
this._trackByFn = fn;
108+
}
109+
get trackBy(): TrackByFunction<T> { return this._trackByFn; }
110+
private _trackByFn: TrackByFunction<T>;
111+
96112
// TODO(andrewseguin): Remove max value as the end index
97113
// and instead calculate the view on init and scroll.
98114
/**
@@ -146,10 +162,6 @@ export class CdkTable<T> implements CollectionViewer {
146162
if (!role) {
147163
renderer.setAttribute(elementRef.nativeElement, 'role', 'grid');
148164
}
149-
150-
// TODO(andrewseguin): Add trackby function input.
151-
// Find and construct an iterable differ that can be used to find the diff in an array.
152-
this._dataDiffer = this._differs.find(this._data).create();
153165
}
154166

155167
ngOnDestroy() {
@@ -189,6 +201,9 @@ export class CdkTable<T> implements CollectionViewer {
189201
}
190202

191203
ngAfterViewInit() {
204+
// Find and construct an iterable differ that can be used to find the diff in an array.
205+
this._dataDiffer = this._differs.find([]).create(this._trackByFn);
206+
192207
this._renderHeaderRow();
193208

194209
if (this.dataSource) {

0 commit comments

Comments
 (0)