Skip to content

Commit c81e608

Browse files
andrewseguinjelbourn
authored andcommitted
feat(data-table): add trackby (#5097)
1 parent d84998b commit c81e608

File tree

7 files changed

+246
-14
lines changed

7 files changed

+246
-14
lines changed

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@
44
<button md-raised-button (click)="disconnect()" [disabled]="!dataSource">Disconnect Data Source</button>
55
</div>
66

7+
<div>
8+
<button md-raised-button (click)="_peopleDatabase.shuffle(changeReferences)">Randomize Data</button>
9+
<md-checkbox [(ngModel)]="changeReferences">Change references</md-checkbox>
10+
</div>
11+
12+
<div class="demo-track-by-select">
13+
<div class="demo-track-by-label">Track By</div>
14+
<md-radio-group [(ngModel)]="trackByStrategy">
15+
<md-radio-button [value]="'reference'">Reference</md-radio-button>
16+
<md-radio-button [value]="'id'">ID</md-radio-button>
17+
<md-radio-button [value]="'index'">Index</md-radio-button>
18+
</md-radio-group>
19+
</div>
20+
721
<div class="demo-highlighter">
822
Highlight:
923
<md-checkbox (change)="toggleHighlight('first', $event.checked)">First Row</md-checkbox>
@@ -19,7 +33,7 @@
1933
(toggleColorColumn)="toggleColorColumn()">
2034
</table-header-demo>
2135

22-
<cdk-table #table [dataSource]="dataSource">
36+
<cdk-table #table [dataSource]="dataSource" [trackBy]="userTrackBy">
2337

2438
<!-- Column Definition: ID -->
2539
<ng-container cdkColumnDef="userId">

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@
1717
margin: 16px 0;
1818
}
1919

20+
.demo-track-by-select {
21+
display: flex;
22+
align-items: center;
23+
24+
.demo-track-by-label {
25+
margin-right: 8px;
26+
}
27+
}
28+
2029
.demo-highlighter .mat-checkbox {
2130
margin: 0 8px;
2231
}
@@ -102,4 +111,3 @@
102111
font-family: cursive;
103112
}
104113
}
105-

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
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' | undefined;
66

7+
export type TrackByStrategy = 'id' | 'reference' | 'index';
8+
79
@Component({
810
moduleId: module.id,
911
selector: 'data-table-demo',
@@ -13,9 +15,11 @@ export type UserProperties = 'userId' | 'userName' | 'progress' | 'color' | unde
1315
export class DataTableDemo {
1416
dataSource: PersonDataSource | null;
1517
propertiesToDisplay: UserProperties[] = [];
18+
trackByStrategy: TrackByStrategy = 'reference';
19+
changeReferences = false;
1620
highlights = new Set<string>();
1721

18-
constructor(private _peopleDatabase: PeopleDatabase) {
22+
constructor(public _peopleDatabase: PeopleDatabase) {
1923
this.connect();
2024
}
2125

@@ -35,6 +39,14 @@ export class DataTableDemo {
3539
return distanceFromMiddle / 50 + .3;
3640
}
3741

42+
userTrackBy = (index: number, item: UserData) => {
43+
switch (this.trackByStrategy) {
44+
case 'id': return item.id;
45+
case 'reference': return item;
46+
case 'index': return index;
47+
}
48+
}
49+
3850
toggleColorColumn() {
3951
let colorColumnIndex = this.propertiesToDisplay.indexOf('color');
4052
if (colorColumnIndex == -1) {

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

Lines changed: 30 additions & 3 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,28 +14,54 @@ 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();
2023
}
2124

2225
initialize() {
2326
LATEST_ID = 0;
24-
this.data = [];
27+
this.dataChange.next([]);
2528
for (let i = 0; i < 100; i++) { this.addPerson(); }
2629
}
2730

31+
shuffle(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: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ describe('CdkTable', () => {
2525
SimpleCdkTableApp,
2626
DynamicDataSourceCdkTableApp,
2727
CustomRoleCdkTableApp,
28-
RowContextCdkTableApp
28+
TrackByCdkTableApp,
29+
RowContextCdkTableApp,
2930
],
3031
}).compileComponents();
3132
}));
@@ -145,6 +146,117 @@ describe('CdkTable', () => {
145146
expect(changedRows[2].getAttribute('initialIndex')).toBe(null);
146147
});
147148

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

521+
@Component({
522+
template: `
523+
<cdk-table [dataSource]="dataSource" [trackBy]="trackBy">
524+
<ng-container cdkColumnDef="column_a">
525+
<cdk-header-cell *cdkHeaderCellDef> Column A</cdk-header-cell>
526+
<cdk-cell *cdkCellDef="let row"> {{row.a}}</cdk-cell>
527+
</ng-container>
528+
529+
<ng-container cdkColumnDef="column_b">
530+
<cdk-header-cell *cdkHeaderCellDef> Column B</cdk-header-cell>
531+
<cdk-cell *cdkCellDef="let row"> {{row.b}}</cdk-cell>
532+
</ng-container>
533+
534+
<cdk-header-row *cdkHeaderRowDef="columnsToRender"></cdk-header-row>
535+
<cdk-row *cdkRowDef="let row; columns: columnsToRender"></cdk-row>
536+
</cdk-table>
537+
`
538+
})
539+
class TrackByCdkTableApp {
540+
trackByStrategy: 'reference' | 'propertyA' | 'index' = 'reference';
541+
542+
dataSource: FakeDataSource = new FakeDataSource();
543+
columnsToRender = ['column_a', 'column_b'];
544+
545+
@ViewChild(CdkTable) table: CdkTable<TestData>;
546+
547+
trackBy = (index: number, item: TestData) => {
548+
switch (this.trackByStrategy) {
549+
case 'reference': return item;
550+
case 'propertyA': return item.a;
551+
case 'index': return index;
552+
}
553+
}
554+
}
555+
409556
@Component({
410557
template: `
411558
<cdk-table [dataSource]="dataSource" role="treegrid">

0 commit comments

Comments
 (0)