Skip to content

Commit 66e222f

Browse files
authored
feat(table): support dynamic column definitions (#5545)
* checkin * move logic from baserow to table * update * tests * add tests * lint * fix imports, revert columnsDiffer as protected * add tests for errors * add license to errors file * review * remove unnecessary imports * add comment about ngDoCheck() * cleanup imports * remove fdescribe * rebase * ugh, fdescribe * rebase
1 parent e9ab9b4 commit 66e222f

File tree

7 files changed

+322
-128
lines changed

7 files changed

+322
-128
lines changed

src/cdk/table/row.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ import {
1010
ChangeDetectionStrategy,
1111
Component,
1212
Directive,
13+
IterableChanges,
1314
IterableDiffer,
1415
IterableDiffers,
1516
SimpleChanges,
1617
TemplateRef,
1718
ViewContainerRef
1819
} from '@angular/core';
1920
import {CdkCellDef} from './cell';
20-
import {Subject} from 'rxjs/Subject';
2121

2222
/**
2323
* The row template that can be used by the md-table. Should not be used outside of the
@@ -33,21 +33,12 @@ export abstract class BaseRowDef {
3333
/** The columns to be displayed on this row. */
3434
columns: string[];
3535

36-
/** Event stream that emits when changes are made to the columns. */
37-
columnsChange: Subject<void> = new Subject<void>();
38-
3936
/** Differ used to check if any changes were made to the columns. */
4037
protected _columnsDiffer: IterableDiffer<any>;
4138

42-
private viewInitialized = false;
43-
4439
constructor(public template: TemplateRef<any>,
4540
protected _differs: IterableDiffers) { }
4641

47-
ngAfterViewInit() {
48-
this.viewInitialized = true;
49-
}
50-
5142
ngOnChanges(changes: SimpleChanges): void {
5243
// Create a new columns differ if one does not yet exist. Initialize it based on initial value
5344
// of the columns property.
@@ -58,12 +49,12 @@ export abstract class BaseRowDef {
5849
}
5950
}
6051

61-
ngDoCheck(): void {
62-
if (!this.viewInitialized || !this._columnsDiffer || !this.columns) { return; }
63-
64-
// Notify the table if there are any changes to the columns.
65-
const changes = this._columnsDiffer.diff(this.columns);
66-
if (changes) { this.columnsChange.next(); }
52+
/**
53+
* Returns the difference between the current columns and the columns from the last diff, or null
54+
* if there is no difference.
55+
*/
56+
getColumnsDiff(): IterableChanges<any> | null {
57+
return this._columnsDiffer.diff(this.columns);
6758
}
6859
}
6960

src/cdk/table/table-errors.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
/**
10+
* Returns an error to be thrown when attempting to find an unexisting column.
11+
* @param id Id whose lookup failed.
12+
* @docs-private
13+
*/
14+
export function getTableUnknownColumnError(id: string) {
15+
return Error(`cdk-table: Could not find column with id "${id}".`);
16+
}
17+
18+
/**
19+
* Returns an error to be thrown when two column definitions have the same name.
20+
* @docs-private
21+
*/
22+
export function getTableDuplicateColumnNameError(name: string) {
23+
return Error(`cdk-table: Duplicate column definition name provided: "${name}".`);
24+
}

src/cdk/table/table.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {Observable} from 'rxjs/Observable';
77
import {combineLatest} from 'rxjs/observable/combineLatest';
88
import {CdkTableModule} from './index';
99
import {map} from 'rxjs/operator/map';
10+
import {getTableDuplicateColumnNameError, getTableUnknownColumnError} from './table-errors';
1011

1112
describe('CdkTable', () => {
1213
let fixture: ComponentFixture<SimpleCdkTableApp>;
@@ -24,7 +25,10 @@ describe('CdkTable', () => {
2425
DynamicDataSourceCdkTableApp,
2526
CustomRoleCdkTableApp,
2627
TrackByCdkTableApp,
28+
DynamicColumnDefinitionsCdkTableApp,
2729
RowContextCdkTableApp,
30+
DuplicateColumnDefNameCdkTableApp,
31+
MissingColumnDefCdkTableApp,
2832
],
2933
}).compileComponents();
3034
}));
@@ -107,6 +111,57 @@ describe('CdkTable', () => {
107111
expect(fixture.nativeElement.querySelector('cdk-table').getAttribute('role')).toBe('treegrid');
108112
});
109113

114+
it('should throw an error if two column definitions have the same name', () => {
115+
expect(() => TestBed.createComponent(DuplicateColumnDefNameCdkTableApp).detectChanges())
116+
.toThrowError(getTableDuplicateColumnNameError('column_a').message);
117+
});
118+
119+
it('should throw an error if a column definition is requested but not defined', () => {
120+
expect(() => TestBed.createComponent(MissingColumnDefCdkTableApp).detectChanges())
121+
.toThrowError(getTableUnknownColumnError('column_a').message);
122+
});
123+
124+
it('should be able to dynamically add/remove column definitions', () => {
125+
const dynamicColumnDefFixture = TestBed.createComponent(DynamicColumnDefinitionsCdkTableApp);
126+
dynamicColumnDefFixture.detectChanges();
127+
dynamicColumnDefFixture.detectChanges();
128+
129+
const dynamicColumnDefTable = dynamicColumnDefFixture.nativeElement.querySelector('cdk-table');
130+
const dynamicColumnDefComp = dynamicColumnDefFixture.componentInstance;
131+
132+
// Add a new column and expect it to show up in the table
133+
let columnA = 'columnA';
134+
dynamicColumnDefComp.dynamicColumns.push(columnA);
135+
dynamicColumnDefFixture.detectChanges();
136+
expectTableToMatchContent(dynamicColumnDefTable, [
137+
[columnA], // Header row
138+
[columnA], // Data rows
139+
[columnA],
140+
[columnA],
141+
]);
142+
143+
// Add another new column and expect it to show up in the table
144+
let columnB = 'columnB';
145+
dynamicColumnDefComp.dynamicColumns.push(columnB);
146+
dynamicColumnDefFixture.detectChanges();
147+
expectTableToMatchContent(dynamicColumnDefTable, [
148+
[columnA, columnB], // Header row
149+
[columnA, columnB], // Data rows
150+
[columnA, columnB],
151+
[columnA, columnB],
152+
]);
153+
154+
// Remove column A expect only column B to be rendered
155+
dynamicColumnDefComp.dynamicColumns.shift();
156+
dynamicColumnDefFixture.detectChanges();
157+
expectTableToMatchContent(dynamicColumnDefTable, [
158+
[columnB], // Header row
159+
[columnB], // Data rows
160+
[columnB],
161+
[columnB],
162+
]);
163+
});
164+
110165
it('should re-render the rows when the data changes', () => {
111166
dataSource.addData();
112167
fixture.detectChanges();
@@ -587,6 +642,26 @@ class TrackByCdkTableApp {
587642
}
588643
}
589644

645+
@Component({
646+
template: `
647+
<cdk-table [dataSource]="dataSource">
648+
<ng-container [cdkColumnDef]="column" *ngFor="let column of dynamicColumns">
649+
<cdk-header-cell *cdkHeaderCellDef> {{column}} </cdk-header-cell>
650+
<cdk-cell *cdkCellDef="let row"> {{column}} </cdk-cell>
651+
</ng-container>
652+
653+
<cdk-header-row *cdkHeaderRowDef="dynamicColumns"></cdk-header-row>
654+
<cdk-row *cdkRowDef="let row; columns: dynamicColumns;"></cdk-row>
655+
</cdk-table>
656+
`
657+
})
658+
class DynamicColumnDefinitionsCdkTableApp {
659+
dynamicColumns: any[] = [];
660+
dataSource: FakeDataSource = new FakeDataSource();
661+
662+
@ViewChild(CdkTable) table: CdkTable<TestData>;
663+
}
664+
590665
@Component({
591666
template: `
592667
<cdk-table [dataSource]="dataSource" role="treegrid">
@@ -607,6 +682,45 @@ class CustomRoleCdkTableApp {
607682
@ViewChild(CdkTable) table: CdkTable<TestData>;
608683
}
609684

685+
@Component({
686+
template: `
687+
<cdk-table [dataSource]="dataSource">
688+
<ng-container cdkColumnDef="column_a">
689+
<cdk-header-cell *cdkHeaderCellDef> Column A</cdk-header-cell>
690+
<cdk-cell *cdkCellDef="let row"> {{row.a}}</cdk-cell>
691+
</ng-container>
692+
693+
<ng-container cdkColumnDef="column_a">
694+
<cdk-header-cell *cdkHeaderCellDef> Column A</cdk-header-cell>
695+
<cdk-cell *cdkCellDef="let row"> {{row.a}}</cdk-cell>
696+
</ng-container>
697+
698+
<cdk-header-row *cdkHeaderRowDef="['column_a']"></cdk-header-row>
699+
<cdk-row *cdkRowDef="let row; columns: ['column_a']"></cdk-row>
700+
</cdk-table>
701+
`
702+
})
703+
class DuplicateColumnDefNameCdkTableApp {
704+
dataSource: FakeDataSource = new FakeDataSource();
705+
}
706+
707+
@Component({
708+
template: `
709+
<cdk-table [dataSource]="dataSource">
710+
<ng-container cdkColumnDef="column_b">
711+
<cdk-header-cell *cdkHeaderCellDef> Column A</cdk-header-cell>
712+
<cdk-cell *cdkCellDef="let row"> {{row.a}}</cdk-cell>
713+
</ng-container>
714+
715+
<cdk-header-row *cdkHeaderRowDef="['column_a']"></cdk-header-row>
716+
<cdk-row *cdkRowDef="let row; columns: ['column_a']"></cdk-row>
717+
</cdk-table>
718+
`
719+
})
720+
class MissingColumnDefCdkTableApp {
721+
dataSource: FakeDataSource = new FakeDataSource();
722+
}
723+
610724
@Component({
611725
template: `
612726
<cdk-table [dataSource]="dataSource">

src/cdk/table/table.ts

Lines changed: 52 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,12 @@ import {
3131
} from '@angular/core';
3232
import {CollectionViewer, DataSource} from './data-source';
3333
import {CdkCellOutlet, CdkCellOutletRowContext, CdkHeaderRowDef, CdkRowDef} from './row';
34-
import {merge} from 'rxjs/observable/merge';
3534
import {takeUntil} from 'rxjs/operator/takeUntil';
3635
import {BehaviorSubject} from 'rxjs/BehaviorSubject';
3736
import {Subscription} from 'rxjs/Subscription';
3837
import {Subject} from 'rxjs/Subject';
3938
import {CdkCellDef, CdkColumnDef, CdkHeaderCellDef} from './cell';
40-
41-
/**
42-
* Returns an error to be thrown when attempting to find an unexisting column.
43-
* @param id Id whose lookup failed.
44-
* @docs-private
45-
*/
46-
export function getTableUnknownColumnError(id: string) {
47-
return new Error(`cdk-table: Could not find column with id "${id}".`);
48-
}
39+
import {getTableDuplicateColumnNameError, getTableUnknownColumnError} from './table-errors';
4940

5041
/**
5142
* Provides a handle for the table to grab the view container's ng-container to insert data rows.
@@ -96,10 +87,7 @@ export class CdkTable<T> implements CollectionViewer {
9687
/** Subscription that listens for the data provided by the data source. */
9788
private _renderChangeSubscription: Subscription | null;
9889

99-
/**
100-
* Map of all the user's defined columns identified by name.
101-
* Contains the header and data-cell templates.
102-
*/
90+
/** Map of all the user's defined columns (header and data cell template) identified by name. */
10391
private _columnDefinitionsByName = new Map<string, CdkColumnDef>();
10492

10593
/** Differ used to find the changes in the data provided by the data source. */
@@ -123,15 +111,6 @@ export class CdkTable<T> implements CollectionViewer {
123111
get trackBy(): TrackByFunction<T> { return this._trackByFn; }
124112
private _trackByFn: TrackByFunction<T>;
125113

126-
// TODO(andrewseguin): Remove max value as the end index
127-
// and instead calculate the view on init and scroll.
128-
/**
129-
* Stream containing the latest information on the range of rows being displayed on screen.
130-
* Can be used by the data source to as a heuristic of what data should be provided.
131-
*/
132-
viewChange =
133-
new BehaviorSubject<{start: number, end: number}>({start: 0, end: Number.MAX_VALUE});
134-
135114
/**
136115
* Provides a stream containing the latest data array to render. Influenced by the table's
137116
* stream of view window (what rows are currently on screen).
@@ -145,6 +124,15 @@ export class CdkTable<T> implements CollectionViewer {
145124
}
146125
private _dataSource: DataSource<T>;
147126

127+
// TODO(andrewseguin): Remove max value as the end index
128+
// and instead calculate the view on init and scroll.
129+
/**
130+
* Stream containing the latest information on what rows are being displayed on screen.
131+
* Can be used by the data source to as a heuristic of what data should be provided.
132+
*/
133+
viewChange =
134+
new BehaviorSubject<{start: number, end: number}>({start: 0, end: Number.MAX_VALUE});
135+
148136
// Placeholders within the table's template where the header and data rows will be inserted.
149137
@ViewChild(RowPlaceholder) _rowPlaceholder: RowPlaceholder;
150138
@ViewChild(HeaderRowPlaceholder) _headerRowPlaceholder: HeaderRowPlaceholder;
@@ -171,6 +159,24 @@ export class CdkTable<T> implements CollectionViewer {
171159
}
172160
}
173161

162+
ngOnInit() {
163+
// TODO(andrewseguin): Setup a listener for scrolling, emit the calculated view to viewChange
164+
this._dataDiffer = this._differs.find([]).create(this._trackByFn);
165+
}
166+
167+
ngAfterContentInit() {
168+
this._cacheColumnDefinitionsByName();
169+
this._columnDefinitions.changes.subscribe(() => this._cacheColumnDefinitionsByName());
170+
this._renderHeaderRow();
171+
}
172+
173+
ngAfterContentChecked() {
174+
this._renderUpdatedColumns();
175+
if (this.dataSource && !this._renderChangeSubscription) {
176+
this._observeRenderChanges();
177+
}
178+
}
179+
174180
ngOnDestroy() {
175181
this._rowPlaceholder.viewContainer.clear();
176182
this._headerRowPlaceholder.viewContainer.clear();
@@ -182,41 +188,38 @@ export class CdkTable<T> implements CollectionViewer {
182188
}
183189
}
184190

185-
ngOnInit() {
186-
// TODO(andrewseguin): Setup a listener for scroll events
187-
// and emit the calculated view to this.viewChange
188-
this._dataDiffer = this._differs.find([]).create(this._trackByFn);
189-
}
190191

191-
ngAfterContentInit() {
192-
// TODO(andrewseguin): Throw an error if two columns share the same name
192+
/** Update the map containing the content's column definitions. */
193+
private _cacheColumnDefinitionsByName() {
194+
this._columnDefinitionsByName.clear();
193195
this._columnDefinitions.forEach(columnDef => {
196+
if (this._columnDefinitionsByName.has(columnDef.name)) {
197+
throw getTableDuplicateColumnNameError(columnDef.name);
198+
}
194199
this._columnDefinitionsByName.set(columnDef.name, columnDef);
195200
});
201+
}
196202

197-
// Re-render the rows if any of their columns change.
198-
// TODO(andrewseguin): Determine how to only re-render the rows that have their columns changed.
199-
const columnChangeEvents = this._rowDefinitions.map(rowDef => rowDef.columnsChange);
200-
201-
takeUntil.call(merge(...columnChangeEvents), this._onDestroy).subscribe(() => {
202-
// Reset the data to an empty array so that renderRowChanges will re-render all new rows.
203-
this._rowPlaceholder.viewContainer.clear();
204-
this._dataDiffer.diff([]);
205-
this._renderRowChanges();
203+
/**
204+
* Check if the header or rows have changed what columns they want to display. If there is a diff,
205+
* then re-render that section.
206+
*/
207+
private _renderUpdatedColumns() {
208+
// Re-render the rows when the row definition columns change.
209+
this._rowDefinitions.forEach(rowDefinition => {
210+
if (!!rowDefinition.getColumnsDiff()) {
211+
// Reset the data to an empty array so that renderRowChanges will re-render all new rows.
212+
this._dataDiffer.diff([]);
213+
214+
this._rowPlaceholder.viewContainer.clear();
215+
this._renderRowChanges();
216+
}
206217
});
207218

208-
// Re-render the header row if the columns change
209-
takeUntil.call(this._headerDefinition.columnsChange, this._onDestroy).subscribe(() => {
219+
// Re-render the header row if there is a difference in its columns.
220+
if (this._headerDefinition.getColumnsDiff()) {
210221
this._headerRowPlaceholder.viewContainer.clear();
211222
this._renderHeaderRow();
212-
});
213-
214-
this._renderHeaderRow();
215-
}
216-
217-
ngAfterContentChecked() {
218-
if (this.dataSource && !this._renderChangeSubscription) {
219-
this._observeRenderChanges();
220223
}
221224
}
222225

0 commit comments

Comments
 (0)