feat(table): enable multiple data rows (#10964)

This commit is contained in:
Andrew Seguin
2018-05-02 08:51:38 -07:00
committed by Joey Perrott
parent 5393bfe898
commit 0e466eebb1
15 changed files with 712 additions and 103 deletions
+2 -2
View File
@@ -4489,7 +4489,7 @@
},
"event-stream": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
"resolved": "http://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
"integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=",
"dev": true,
"requires": {
@@ -13291,7 +13291,7 @@
},
"onetime": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz",
"resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz",
"integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=",
"dev": true
},
+1
View File
@@ -9,6 +9,7 @@ ng_module(
module_name = "@angular/cdk/table",
deps = [
"//src/cdk/collections",
"//src/cdk/coercion",
"@rxjs",
],
tsconfig = "//src/cdk:tsconfig-build.json",
+51
View File
@@ -0,0 +1,51 @@
# Rendering Data Rows
The table's primary responsibility is to render rows of cells. The types of rows that may be rendered are header,
footer, and data rows. This document focuses on how the table tries to efficienctly render the data rows.
## Background
Each table's template is defined as a set of row and column templates. The row template defines the template that should
be rendered for a header, footer, or data row. The column templates include the cell templates that will be inserted
into each rendered row.
Each data object may be rendered with one or more row templates. When new data in provided to the table, the table
determines which rows need to be rendered. In order to be efficient, the table attempts to understand how the new list
of rendered rows differs from the previous list of rendered rows so that it can re-use the current list of rendered rows
if possible.
## Rendering
Each time data is provided, the table needs to create the list of rows that will be rendered and keep track of which
data object will be provided as context for each row. For each item in the list, this pair is combined into an object
that uses the `RenderRow` interface. The interface also helps keep track of the data object's index in the provided
data array input.
```ts
export interface RenderRow<T> {
data: T;
dataIndex: number;
rowDef: CdkRowDef<T>;
}
```
When possible, `RenderRow` objects are re-used from the previous rendering. That is, if a particular data object and row
template pairing was previously rendered, it should be used for the new list as well. This makes sure that the
differ can use check-by-reference logic to find the changes between two lists. Note that if a `RenderRow` object is
reused, it should be updated with the correct data index, in case it has moved since last used.
Once the list of `RenderRow` objects has been created, it should be compared to the previous list of `RenderRow`
objects to find the difference in terms of inserts/deletions/moves. This is trivially done using the `IterableDiffer`
logic provided by Angular Core.
Finally, the table uses the list of operations and manipulates the rows through add/remove/move operations.
## Caching `RenderRow` objects
Each `RenderRow` should be cached such that it is a constant-time lookup and retrieval based on the data object and
row template pairing.
In order to achieve this, the cache is built as a map of maps where the key of the outer map is the data object and
the key of the inner map is the row template. The value of the inner map should be an array of the matching cached
`RenderRow` objects that were previously rendered.
+33 -2
View File
@@ -130,12 +130,12 @@ export class CdkRowDef<T> extends BaseRowDef {
}
}
/** Context provided to the row cells */
/** Context provided to the row cells when `multiTemplateDataRows` is false */
export interface CdkCellOutletRowContext<T> {
/** Data for the row that this cell is located within. */
$implicit?: T;
/** Index location of the row that this cell is located within. */
/** Index of the data object in the provided data array. */
index?: number;
/** Length of the number of total rows. */
@@ -154,6 +154,37 @@ export interface CdkCellOutletRowContext<T> {
odd?: boolean;
}
/**
* Context provided to the row cells when `multiTemplateDataRows` is true. This context is the same
* as CdkCellOutletRowContext except that the single `index` value is replaced by `dataIndex` and
* `renderIndex`.
*/
export interface CdkCellOutletMultiRowContext<T> {
/** Data for the row that this cell is located within. */
$implicit?: T;
/** Index of the data object in the provided data array. */
dataIndex?: number;
/** Index location of the rendered row that this cell is located within. */
renderIndex?: number;
/** Length of the number of total rows. */
count?: number;
/** True if this cell is contained in the first row. */
first?: boolean;
/** True if this cell is contained in the last row. */
last?: boolean;
/** True if this cell is contained in a row with an even-numbered index. */
even?: boolean;
/** True if this cell is contained in a row with an odd-numbered index. */
odd?: boolean;
}
/**
* Outlet for rendering cells inside of a row or header row.
* @docs-private
+3 -2
View File
@@ -35,8 +35,9 @@ export function getTableMultipleDefaultRowDefsError() {
* Returns an error to be thrown when there are no matching row defs for a particular set of data.
* @docs-private
*/
export function getTableMissingMatchingRowDefError() {
return Error(`Could not find a matching row definition for the provided row data.`);
export function getTableMissingMatchingRowDefError(data: any) {
return Error(`Could not find a matching row definition for the` +
`provided row data: ${JSON.stringify(data)}`);
}
/**
+198 -42
View File
@@ -10,7 +10,7 @@ import {
import {ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing';
import {CdkTable} from './table';
import {CollectionViewer, DataSource} from '@angular/cdk/collections';
import {combineLatest, BehaviorSubject, Observable} from 'rxjs';
import {combineLatest, BehaviorSubject, Observable, of as observableOf} from 'rxjs';
import {map} from 'rxjs/operators';
import {CdkTableModule} from './index';
import {
@@ -133,37 +133,70 @@ describe('CdkTable', () => {
});
});
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) => {
row.setAttribute('initialIndex', index.toString());
describe('should correctly use the differ to add/remove/move rows', () => {
function addInitialIndexAttribute() {
// 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');
}
it('when the data is heterogeneous', () => {
addInitialIndexAttribute();
// Swap first and second data in data array
const copiedData = component.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
component.dataSource!.data = copiedData;
component.dataSource!.addData();
// 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);
});
// 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');
it('when the data contains multiple occurrences of the same object instance', () => {
const obj = {value: true};
component.dataSource!.data = [obj, obj, obj];
addInitialIndexAttribute();
// Swap first and second data in data array
const copiedData = component.dataSource!.data.slice();
const temp = copiedData[0];
copiedData[0] = copiedData[1];
copiedData[1] = temp;
const copiedData = component.dataSource!.data.slice();
// Remove the third element
copiedData.splice(2, 1);
// Remove the third element and add a new different obj in the beginning.
copiedData.splice(2, 1);
copiedData.unshift({value: false});
// Add new data
component.dataSource!.data = copiedData;
component.dataSource!.addData();
// Add new data
component.dataSource!.data = copiedData;
// 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);
// Expect that two of the three rows still have an initial index. Not as concerned about
// the order they are in, but more important that there was no unnecessary removes/inserts.
const changedRows = getRows(tableElement);
expect(changedRows.length).toBe(3);
let numInitialRows = 0;
changedRows.forEach(row => {
if (row.getAttribute('initialIndex') !== null) {
numInitialRows++;
}
});
expect(numInitialRows).toBe(2);
});
});
it('should clear the row view containers on destroy', () => {
@@ -238,6 +271,13 @@ describe('CdkTable', () => {
});
});
it('should render no rows when the data is null', fakeAsync(() => {
setupTableTestApp(NullDataCdkTableApp);
fixture.detectChanges();
expect(getRows(tableElement).length).toBe(0);
}));
describe('with different data inputs other than data source', () => {
let baseData: TestData[] = [
{a: 'a_1', b: 'b_1', c: 'c_1'},
@@ -247,7 +287,6 @@ describe('CdkTable', () => {
beforeEach(() => {
setupTableTestApp(CdkTableWithDifferentDataInputsApp);
component = fixture.componentInstance;
});
it('should render with data array input', () => {
@@ -523,24 +562,98 @@ describe('CdkTable', () => {
});
it('should error if there is row data that does not have a matching row template',
fakeAsync(() => {
expect(() => {
try {
createComponent(WhenRowWithoutDefaultCdkTableApp).detectChanges();
flush();
} catch {
flush();
}
}).toThrowError(getTableMissingMatchingRowDefError().message);
}));
fakeAsync(() => {
const whenRowWithoutDefaultFixture = createComponent(WhenRowWithoutDefaultCdkTableApp);
const data = whenRowWithoutDefaultFixture.componentInstance.dataSource.data;
expect(() => {
try {
whenRowWithoutDefaultFixture.detectChanges();
flush();
} catch {
flush();
}
}).toThrowError(getTableMissingMatchingRowDefError(data[0]).message);
}));
it('should error if there are multiple rows that do not have a when function', fakeAsync(() => {
it('should fail when multiple rows match data without multiTemplateDataRows', fakeAsync(() => {
let whenFixture = createComponent(WhenRowMultipleDefaultsCdkTableApp);
expect(() => {
whenFixture.detectChanges();
flush();
}).toThrowError(getTableMultipleDefaultRowDefsError().message);
}));
describe('with multiTemplateDataRows', () => {
it('should be able to render multiple rows per data object', () => {
setupTableTestApp(WhenRowCdkTableApp);
component.multiTemplateDataRows = true;
fixture.detectChanges();
const data = component.dataSource.data;
expectTableToMatchContent(tableElement, [
['Column A', 'Column B', 'Column C'],
[data[0].a, data[0].b, data[0].c],
[data[1].a, data[1].b, data[1].c],
['index_1_special_row'],
[data[2].a, data[2].b, data[2].c],
['c3_special_row'],
[data[3].a, data[3].b, data[3].c],
]);
});
it('should have the correct data and row indicies', () => {
setupTableTestApp(WhenRowCdkTableApp);
component.multiTemplateDataRows = true;
component.showIndexColumns();
fixture.detectChanges();
expectTableToMatchContent(tableElement, [
['Index', 'Data Index', 'Render Index'],
['', '0', '0'],
['', '1', '1'],
['', '1', '2'],
['', '2', '3'],
['', '2', '4'],
['', '3', '5'],
]);
});
it('should have the correct data and row indicies when data contains multiple instances of ' +
'the same object instance', () => {
setupTableTestApp(WhenRowCdkTableApp);
component.multiTemplateDataRows = true;
component.showIndexColumns();
const obj = {value: true};
component.dataSource.data = [obj, obj, obj, obj];
fixture.detectChanges();
expectTableToMatchContent(tableElement, [
['Index', 'Data Index', 'Render Index'],
['', '0', '0'],
['', '1', '1'],
['', '1', '2'],
['', '2', '3'],
['', '3', '4'],
]);
// Push unique data on the front and add another obj to the array
component.dataSource.data = [{value: false}, obj, obj, obj, obj, obj];
fixture.detectChanges();
expectTableToMatchContent(tableElement, [
['Index', 'Data Index', 'Render Index'],
['', '0', '0'],
['', '1', '1'],
['', '1', '2'],
['', '2', '3'],
['', '3', '4'],
['', '4', '5'],
['', '5', '6'],
]);
});
});
});
describe('with trackBy', () => {
@@ -921,9 +1034,27 @@ class BooleanRowCdkTableApp {
dataSource = new BooleanDataSource();
}
@Component({
template: `
<cdk-table [dataSource]="dataSource">
<ng-container cdkColumnDef="column_a">
<cdk-header-cell *cdkHeaderCellDef></cdk-header-cell>
<cdk-cell *cdkCellDef="let data"> {{data}} </cdk-cell>
</ng-container>
<cdk-header-row *cdkHeaderRowDef="['column_a']"></cdk-header-row>
<cdk-row *cdkRowDef="let row; columns: ['column_a']"></cdk-row>
</cdk-table>
`
})
class NullDataCdkTableApp {
dataSource = observableOf(null);
}
@Component({
template: `
<cdk-table [dataSource]="dataSource" [multiTemplateDataRows]="multiTemplateDataRows">
<ng-container cdkColumnDef="column_a">
<cdk-header-cell *cdkHeaderCellDef> Column A</cdk-header-cell>
<cdk-cell *cdkCellDef="let row"> {{row.a}}</cdk-cell>
@@ -941,30 +1072,55 @@ class BooleanRowCdkTableApp {
<ng-container cdkColumnDef="index1Column">
<cdk-header-cell *cdkHeaderCellDef> Column C</cdk-header-cell>
<cdk-cell *cdkCellDef="let row"> index_1_special_row </cdk-cell>
<cdk-cell *cdkCellDef="let row"> index_1_special_row</cdk-cell>
</ng-container>
<ng-container cdkColumnDef="c3Column">
<cdk-header-cell *cdkHeaderCellDef> Column C</cdk-header-cell>
<cdk-cell *cdkCellDef="let row"> c3_special_row </cdk-cell>
<cdk-cell *cdkCellDef="let row"> c3_special_row</cdk-cell>
</ng-container>
<ng-container cdkColumnDef="index">
<cdk-header-cell *cdkHeaderCellDef> Index</cdk-header-cell>
<cdk-cell *cdkCellDef="let row; let index = index"> {{index}}</cdk-cell>
</ng-container>
<ng-container cdkColumnDef="dataIndex">
<cdk-header-cell *cdkHeaderCellDef> Data Index</cdk-header-cell>
<cdk-cell *cdkCellDef="let row; let dataIndex = dataIndex"> {{dataIndex}}</cdk-cell>
</ng-container>
<ng-container cdkColumnDef="renderIndex">
<cdk-header-cell *cdkHeaderCellDef> Render Index</cdk-header-cell>
<cdk-cell *cdkCellDef="let row; let renderIndex = renderIndex"> {{renderIndex}}</cdk-cell>
</ng-container>
<cdk-header-row *cdkHeaderRowDef="columnsToRender"></cdk-header-row>
<cdk-row *cdkRowDef="let row; columns: columnsToRender"></cdk-row>
<cdk-row *cdkRowDef="let row; columns: ['index1Column']; when: isIndex1"></cdk-row>
<cdk-row *cdkRowDef="let row; columns: ['c3Column']; when: hasC3"></cdk-row>
<cdk-row *cdkRowDef="let row; columns: columnsForIsIndex1Row; when: isIndex1"></cdk-row>
<cdk-row *cdkRowDef="let row; columns: columnsForHasC3Row; when: hasC3"></cdk-row>
</cdk-table>
`
})
class WhenRowCdkTableApp {
multiTemplateDataRows = false;
dataSource: FakeDataSource = new FakeDataSource();
columnsToRender = ['column_a', 'column_b', 'column_c'];
columnsForIsIndex1Row = ['index1Column'];
columnsForHasC3Row = ['c3Column'];
isIndex1 = (index: number, _rowData: TestData) => index == 1;
hasC3 = (_index: number, rowData: TestData) => rowData.c == 'c_3';
constructor() { this.dataSource.addData(); }
@ViewChild(CdkTable) table: CdkTable<TestData>;
showIndexColumns() {
const indexColumns = ['index', 'dataIndex', 'renderIndex'];
this.columnsToRender = indexColumns;
this.columnsForIsIndex1Row = indexColumns;
this.columnsForHasC3Row = indexColumns;
}
}
@Component({
+196 -38
View File
@@ -34,6 +34,7 @@ import {CollectionViewer, DataSource} from '@angular/cdk/collections';
import {
BaseRowDef,
CdkCellOutlet,
CdkCellOutletMultiRowContext,
CdkCellOutletRowContext,
CdkFooterRowDef,
CdkHeaderRowDef,
@@ -50,6 +51,7 @@ import {
getTableUnknownColumnError,
getTableUnknownDataSourceError
} from './table-errors';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
/** Interface used to provide an outlet for rows to be inserted into. */
export interface RowOutlet {
@@ -93,11 +95,37 @@ export const CDK_TABLE_TEMPLATE = `
<ng-container rowOutlet></ng-container>
<ng-container footerRowOutlet></ng-container>`;
/**
* Interface used to conveniently type the possible context interfaces for the render row.
* @docs-private
*/
export interface RowContext<T>
extends CdkCellOutletMultiRowContext<T>, CdkCellOutletRowContext<T> { }
/**
* Class used to conveniently type the embedded view ref for rows with a context.
* @docs-private
*/
abstract class RowViewRef<T> extends EmbeddedViewRef<CdkCellOutletRowContext<T>> { }
abstract class RowViewRef<T> extends EmbeddedViewRef<RowContext<T>> { }
/**
* Set of properties that represents the identity of a single rendered row.
*
* When the table needs to determine the list of rows to render, it will do so by iterating through
* each data object and evaluating its list of row templates to display (when multiTemplateDataRows
* is false, there is only one template per data object). For each pair of data object and row
* template, a `RenderRow` is added to the list of rows to render. If the data object and row
* template pair has already been rendered, the previously used `RenderRow` is added; else a new
* `RenderRow` is * created. Once the list is complete and all data objects have been itereated
* through, a diff is performed to determine the changes that need to be made to the rendered rows.
*
* @docs-private
*/
export interface RenderRow<T> {
data: T;
dataIndex: number;
rowDef: CdkRowDef<T>;
}
/**
* A data table that can render a header row, data rows, and a footer row.
@@ -123,6 +151,9 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
/** Latest data provided by the data source. */
private _data: T[];
/** List of the rendered rows as identified by their `RenderRow` object. */
private _renderRows: RenderRow<T>[];
/** Subscription that listens for the data provided by the data source. */
private _renderChangeSubscription: Subscription | null;
@@ -140,7 +171,7 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
private _rowDefs: CdkRowDef<T>[];
/** Differ used to find the changes in the data provided by the data source. */
private _dataDiffer: IterableDiffer<T>;
private _dataDiffer: IterableDiffer<RenderRow<T>>;
/** Stores the row definition that does not have a when predicate. */
private _defaultRowDef: CdkRowDef<T> | null;
@@ -163,6 +194,21 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
*/
private _footerRowDefChanged = false;
/**
* Cache of the latest rendered `RenderRow` objects as a map for easy retrieval when constructing
* a new list of `RenderRow` objects for rendering rows. Since the new list is constructed with
* the cached `RenderRow` objects when possible, the row identity is preserved when the data
* and row template matches, which allows the `IterableDiffer` to check rows by reference
* and understand which rows are added/moved/removed.
*
* Implemented as a map of maps where the first key is the `data: T` object and the second is the
* `CdkRowDef<T>` object. With the two keys, the cache points to a `RenderRow<T>` object that
* contains an array of created pairs. The array is necessary to handle cases where the data
* array contains multiple duplicate data objects and each instantiated `RenderRow` must be
* stored.
*/
private _cachedRenderRowsMap = new Map<T, WeakMap<CdkRowDef<T>, RenderRow<T>[]>>();
/**
* 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
@@ -210,6 +256,22 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
}
private _dataSource: DataSource<T> | Observable<T[]> | T[] | T[];
/**
* Whether to allow multiple rows per data object by evaluating which rows evaluate their 'when'
* predicate to true. If `multiTemplateDataRows` is false, which is the default value, then each
* dataobject will render the first row that evaluates its when predicate to true, in the order
* defined in the table, or otherwise the default row which does not have a when predicate.
*/
@Input()
get multiTemplateDataRows(): boolean { return this._multiTemplateDataRows; }
set multiTemplateDataRows(v: boolean) {
this._multiTemplateDataRows = coerceBooleanProperty(v);
if (this._rowOutlet.viewContainer.length) {
this._forceRenderRows();
}
}
_multiTemplateDataRows: boolean = false;
// TODO(andrewseguin): Remove max value as the end index
// and instead calculate the view on init and scroll.
/**
@@ -263,8 +325,12 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
this._applyNativeTableSections();
}
// TODO(andrewseguin): Setup a listener for scrolling, emit the calculated view to viewChange
this._dataDiffer = this._differs.find([]).create(this._trackByFn);
// Set up the trackBy function so that it uses the `RenderRow` as its identity by default. If
// the user has provided a custom trackBy, return the result of that function as evaluated
// with the values of the `RenderRow`'s data and index.
this._dataDiffer = this._differs.find([]).create((_i: number, dataRow: RenderRow<T>) => {
return this.trackBy ? this.trackBy(dataRow.dataIndex, dataRow.data) : dataRow;
});
// If the table has header or footer row definitions defined as part of its content, mark that
// there is a change so that the content check will render the row.
@@ -308,6 +374,9 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
this._rowOutlet.viewContainer.clear();
this._headerRowOutlet.viewContainer.clear();
this._footerRowOutlet.viewContainer.clear();
this._cachedRenderRowsMap.clear();
this._onDestroy.next();
this._onDestroy.complete();
@@ -327,18 +396,19 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
* an array, this function will need to be called to render any changes.
*/
renderRows() {
const changes = this._dataDiffer.diff(this._data);
this._renderRows = this._getAllRenderRows();
const changes = this._dataDiffer.diff(this._renderRows);
if (!changes) { return; }
const viewContainer = this._rowOutlet.viewContainer;
changes.forEachOperation(
(record: IterableChangeRecord<T>, adjustedPreviousIndex: number, currentIndex: number) => {
(record: IterableChangeRecord<RenderRow<T>>, prevIndex: number, currentIndex: number) => {
if (record.previousIndex == null) {
this._insertRow(record.item, currentIndex);
} else if (currentIndex == null) {
viewContainer.remove(adjustedPreviousIndex);
viewContainer.remove(prevIndex);
} else {
const view = <RowViewRef<T>>viewContainer.get(adjustedPreviousIndex);
const view = <RowViewRef<T>>viewContainer.get(prevIndex);
viewContainer.move(view!, currentIndex);
}
});
@@ -348,9 +418,9 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
// Update rows that did not get added/removed/moved but may have had their identity changed,
// e.g. if trackBy matched data on some property but the actual data reference changed.
changes.forEachIdentityChange((record: IterableChangeRecord<T>) => {
changes.forEachIdentityChange((record: IterableChangeRecord<RenderRow<T>>) => {
const rowView = <RowViewRef<T>>viewContainer.get(record.currentIndex!);
rowView.context.$implicit = record.item;
rowView.context.$implicit = record.item.data;
});
}
@@ -394,6 +464,66 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
this._customRowDefs.delete(rowDef);
}
/**
* Get the list of RenderRow objects to render according to the current list of data and defined
* row definitions. If the previous list already contained a particular pair, it should be reused
* so that the differ equates their references.
*/
private _getAllRenderRows(): RenderRow<T>[] {
const renderRows: RenderRow<T>[] = [];
// Store the cache and create a new one. Any re-used RenderRow objects will be moved into the
// new cache while unused ones can be picked up by garbage collection.
const prevCachedRenderRows = this._cachedRenderRowsMap;
this._cachedRenderRowsMap = new Map();
// For each data object, get the list of rows that should be rendered, represented by the
// respective `RenderRow` object which is the pair of `data` and `CdkRowDef`.
for (let i = 0; i < this._data.length; i++) {
let data = this._data[i];
const renderRowsForData = this._getRenderRowsForData(data, i, prevCachedRenderRows.get(data));
if (!this._cachedRenderRowsMap.has(data)) {
this._cachedRenderRowsMap.set(data, new WeakMap());
}
for (let j = 0; j < renderRowsForData.length; j++) {
let renderRow = renderRowsForData[j];
const cache = this._cachedRenderRowsMap.get(renderRow.data)!;
if (cache.has(renderRow.rowDef)) {
cache.get(renderRow.rowDef)!.push(renderRow);
} else {
cache.set(renderRow.rowDef, [renderRow]);
}
renderRows.push(renderRow);
}
}
return renderRows;
}
/**
* Gets a list of `RenderRow<T>` for the provided data object and any `CdkRowDef` objects that
* should be rendered for this data. Reuses the cached RenderRow objects if they match the same
* `(T, CdkRowDef)` pair.
*/
private _getRenderRowsForData(
data: T, dataIndex: number, cache?: WeakMap<CdkRowDef<T>, RenderRow<T>[]>): RenderRow<T>[] {
const rowDefs = this._getRowDefs(data, dataIndex);
return rowDefs.map(rowDef => {
const cachedRenderRows = (cache && cache.has(rowDef)) ? cache.get(rowDef)! : [];
if (cachedRenderRows.length) {
const dataRow = cachedRenderRows.shift()!;
dataRow.dataIndex = dataIndex;
return dataRow;
} else {
return {data, rowDef, dataIndex};
}
});
}
/** Update the map containing the content's column definitions. */
private _cacheColumnDefs() {
this._columnDefsByName.clear();
@@ -415,7 +545,9 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
this._customRowDefs.forEach(rowDef => this._rowDefs.push(rowDef));
const defaultRowDefs = this._rowDefs.filter(def => !def.when);
if (defaultRowDefs.length > 1) { throw getTableMultipleDefaultRowDefsError(); }
if (!this.multiTemplateDataRows && defaultRowDefs.length > 1) {
throw getTableMultipleDefaultRowDefsError();
}
this._defaultRowDef = defaultRowDefs[0];
}
@@ -426,12 +558,8 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
private _renderUpdatedColumns() {
// Re-render the rows when the row definition columns change.
this._rowDefs.forEach(def => {
if (!!def.getColumnsDiff()) {
// Reset the data to an empty array so that renderRowChanges will re-render all new rows.
this._dataDiffer.diff([]);
this._rowOutlet.viewContainer.clear();
this.renderRows();
if (def.getColumnsDiff()) {
this._forceRenderRows();
}
});
@@ -500,7 +628,7 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
this._renderChangeSubscription = dataStream
.pipe(takeUntil(this._onDestroy))
.subscribe(data => {
this._data = data;
this._data = data || [];
this.renderRows();
});
}
@@ -532,28 +660,40 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
}
/**
* Finds the matching row definition that should be used for this row data. If there is only
* one row definition, it is returned. Otherwise, find the row definition that has a when
* Get the matching row definitions that should be used for this row data. If there is only
* one row definition, it is returned. Otherwise, find the row definitions that has a when
* predicate that returns true with the data. If none return true, return the default row
* definition.
*/
_getRowDef(data: T, i: number): CdkRowDef<T> {
if (this._rowDefs.length == 1) { return this._rowDefs[0]; }
_getRowDefs(data: T, dataIndex: number): CdkRowDef<T>[] {
if (this._rowDefs.length == 1) { return [this._rowDefs[0]]; }
let rowDef = this._rowDefs.find(def => def.when && def.when(i, data)) || this._defaultRowDef;
if (!rowDef) { throw getTableMissingMatchingRowDefError(); }
let rowDefs: CdkRowDef<T>[] = [];
if (this.multiTemplateDataRows) {
rowDefs = this._rowDefs.filter(def => !def.when || def.when(dataIndex, data));
} else {
let rowDef =
this._rowDefs.find(def => def.when && def.when(dataIndex, data)) || this._defaultRowDef;
if (rowDef) {
rowDefs.push(rowDef);
}
}
return rowDef;
if (!rowDefs.length) {
throw getTableMissingMatchingRowDefError(data);
}
return rowDefs;
}
/**
* Create the embedded view for the data row template and place it in the correct index location
* within the data row view container.
*/
private _insertRow(rowData: T, index: number) {
const rowDef = this._getRowDef(rowData, index);
const context: CdkCellOutletRowContext<T> = {$implicit: rowData};
this._renderRow(this._rowOutlet, rowDef, context, index);
private _insertRow(renderRow: RenderRow<T>, renderIndex: number) {
const rowDef = renderRow.rowDef;
const context: RowContext<T> = {$implicit: renderRow.data};
this._renderRow(this._rowOutlet, rowDef, context, renderIndex);
}
/**
@@ -562,7 +702,7 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
* of where to place the new row template in the outlet.
*/
private _renderRow(
outlet: RowOutlet, rowDef: BaseRowDef, context: CdkCellOutletRowContext<T> = {}, index = 0) {
outlet: RowOutlet, rowDef: BaseRowDef, context: RowContext<T> = {}, index = 0) {
// TODO(andrewseguin): enforce that one outlet was instantiated from createEmbeddedView
outlet.viewContainer.createEmbeddedView(rowDef.template, context, index);
@@ -581,14 +721,21 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
*/
private _updateRowIndexContext() {
const viewContainer = this._rowOutlet.viewContainer;
for (let index = 0, count = viewContainer.length; index < count; index++) {
const viewRef = viewContainer.get(index) as RowViewRef<T>;
viewRef.context.index = index;
viewRef.context.count = count;
viewRef.context.first = index === 0;
viewRef.context.last = index === count - 1;
viewRef.context.even = index % 2 === 0;
viewRef.context.odd = !viewRef.context.even;
for (let renderIndex = 0, count = viewContainer.length; renderIndex < count; renderIndex++) {
const viewRef = viewContainer.get(renderIndex) as RowViewRef<T>;
const context = viewRef.context as RowContext<T>;
context.count = count;
context.first = renderIndex === 0;
context.last = renderIndex === count - 1;
context.even = renderIndex % 2 === 0;
context.odd = !context.even;
if (this.multiTemplateDataRows) {
context.dataIndex = this._renderRows[renderIndex].dataIndex;
context.renderIndex = renderIndex;
} else {
context.index = this._renderRows[renderIndex].dataIndex;
}
}
}
@@ -620,4 +767,15 @@ export class CdkTable<T> implements CollectionViewer, OnInit, AfterContentChecke
this._elementRef.nativeElement.appendChild(element);
}
}
/**
* Forces a re-render of the data rows. Should be called in cases where there has been an input
* change that affects the evaluation of which rows should be rendered, e.g. toggling
* `multiTemplateDataRows` or adding/removing row definitions.
*/
private _forceRenderRows() {
this._dataDiffer.diff([]);
this._rowOutlet.viewContainer.clear();
this.renderRows();
}
}
@@ -0,0 +1,28 @@
<mat-card>
<table mat-table [dataSource]="dataSource" multiTemplateDataRows matSort>
<ng-container matColumnDef="{{column}}" *ngFor="let column of columnsToDisplay">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{column}} </th>
<td mat-cell *matCellDef="let element"> {{element[column]}} </td>
</ng-container>
<!-- Expanded Content Column - The detail row is made up of this one column that spans across all columns -->
<ng-container matColumnDef="expandedDetail">
<td colspan="4" mat-cell *matCellDef="let element">
<div style="overflow: hidden"
[@detailExpand]="element == expandedElement ? 'expanded' : 'collapsed'">
The symbol for {{element.name}} is {{element.symbol}}
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columnsToDisplay"></tr>
<tr mat-row *matRowDef="let row; columns: columnsToDisplay;"
class="demo-element-row"
[class.demo-expanded]="expandedElement == row"
(click)="expandedElement = expandedElement === row ? undefined : row"></tr>
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']"
class="demo-detail-row"></tr>
</table>
<mat-paginator pageSize="20" [pageSizeOptions]="[5, 20]"></mat-paginator>
</mat-card>
@@ -0,0 +1,60 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Component, ViewChild} from '@angular/core';
import {Element, ELEMENT_DATA} from 'table/element-data';
import {animate, state, style, transition, trigger} from '@angular/animations';
import {MatPaginator, MatSort, MatTableDataSource} from '@angular/material';
@Component({
moduleId: module.id,
selector: 'expandable-rows',
templateUrl: 'expandable-rows.html',
animations: [
trigger('detailExpand', [
state('collapsed', style({height: '0px', minHeight: '0'})),
state('expanded', style({height: '*'})),
transition('expanded <=> collapsed',
animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')),
]),
],
styles: [`
table {
width: 100%;
}
tr.demo-detail-row {
height: 0;
}
tr.demo-element-row:not(.demo-expanded):hover {
background: #F5F5F5;
}
tr.demo-element-row:not(.demo-expanded):active {
background: #EFEFEF;
}
.demo-element-row td {
border-bottom-width: 0;
}
`]
})
export class ExpandableRowsDemo {
dataSource = new MatTableDataSource(ELEMENT_DATA.slice());
columnsToDisplay = ['name', 'weight', 'symbol', 'position'];
expandedElement: Element;
@ViewChild(MatSort) sort: MatSort;
@ViewChild(MatPaginator) paginator: MatPaginator;
ngOnInit() {
this.dataSource.sort = this.sort;
this.dataSource.paginator = this.paginator;
}
}
+3 -1
View File
@@ -14,6 +14,7 @@ import {MatTableDataSourceDemo} from './mat-table-data-source/mat-table-data-sou
import {DynamicColumnsDemo} from './dynamic-columns/dynamic-columns';
import {RowContextDemo} from './row-context/row-context';
import {WhenRowsDemo} from './when-rows/when-rows';
import {ExpandableRowsDemo} from './expandable-rows/expandable-rows';
export const TABLE_DEMO_ROUTES: Routes = [
{path: '', redirectTo: 'main-demo', pathMatch: 'full'},
@@ -23,5 +24,6 @@ export const TABLE_DEMO_ROUTES: Routes = [
{path: 'mat-table-data-source', component: MatTableDataSourceDemo},
{path: 'dynamic-columns', component: DynamicColumnsDemo},
{path: 'row-context', component: RowContextDemo},
{path: 'when-rows', component: WhenRowsDemo}
{path: 'when-rows', component: WhenRowsDemo},
{path: 'expandable-rows', component: ExpandableRowsDemo}
];
+8 -2
View File
@@ -16,11 +16,14 @@ import {
MatCardModule,
MatCheckboxModule,
MatIconModule,
MatInputModule, MatMenuModule,
MatInputModule,
MatMenuModule,
MatPaginatorModule,
MatRadioModule,
MatSlideToggleModule,
MatSortModule,
MatTableModule, MatTabsModule
MatTableModule,
MatTabsModule
} from '@angular/material';
import {FormsModule} from '@angular/forms';
import {CdkTableModule} from '@angular/cdk/table';
@@ -33,6 +36,7 @@ import {MatTableDataSourceDemo} from './mat-table-data-source/mat-table-data-sou
import {DynamicColumnsDemo} from './dynamic-columns/dynamic-columns';
import {RowContextDemo} from './row-context/row-context';
import {WhenRowsDemo} from './when-rows/when-rows';
import {ExpandableRowsDemo} from 'table/expandable-rows/expandable-rows';
@NgModule({
@@ -48,6 +52,7 @@ import {WhenRowsDemo} from './when-rows/when-rows';
MatMenuModule,
MatPaginatorModule,
MatRadioModule,
MatSlideToggleModule,
MatSortModule,
MatTableModule,
MatTabsModule,
@@ -64,6 +69,7 @@ import {WhenRowsDemo} from './when-rows/when-rows';
DynamicColumnsDemo,
RowContextDemo,
WhenRowsDemo,
ExpandableRowsDemo
],
providers: [
PeopleDatabase
+2 -1
View File
@@ -21,6 +21,7 @@ export class TableDemoPage {
{name: 'MatTableDataSource', link: 'mat-table-data-source'},
{name: 'Dynamic Columns', link: 'dynamic-columns'},
{name: 'Row Context', link: 'row-context'},
{name: 'When Rows', link: 'when-rows'}
{name: 'When Rows', link: 'when-rows'},
{name: 'Expandable Rows', link: 'expandable-rows'}
];
}
+62 -7
View File
@@ -1,13 +1,68 @@
<div>
<button mat-raised-button (click)="shuffleArray()">Shuffle array</button>
</div>
<div>
<button mat-raised-button (click)="changeRandomNumber()">Randomize</button>
Highlighted rows: {{randomNumber}}
</div>
<div>
<mat-slide-toggle (change)="multiTemplateDataRows = $event.checked">
Enable multiple data rows
</mat-slide-toggle>
</div>
<div>
<mat-slide-toggle (change)="useTrackByValue = $event.checked">
Enable track by value
</mat-slide-toggle>
</div>
<mat-card>
<table cdk-table [dataSource]="dataSource">
<ng-container cdkColumnDef="{{column}}" *ngFor="let column of columns">
<th cdk-header-cell *cdkHeaderCellDef> {{column}} </th>
<td cdk-cell *cdkCellDef="let element"> {{element[column]}} </td>
<h3> MatTable </h3>
<table mat-table style="width: 100%"
[trackBy]="useTrackByValue ? trackByValue : undefined"
[multiTemplateDataRows]="multiTemplateDataRows"
[dataSource]="data">
<!-- Data column -->
<ng-container matColumnDef="data">
<th mat-header-cell *matHeaderCellDef> Data </th>
<td mat-cell *matCellDef="let data">
{{data.value}}
</td>
</ng-container>
<tr cdk-header-row *cdkHeaderRowDef="columns"></tr>
<!-- Index column -->
<ng-container matColumnDef="index">
<th mat-header-cell *matHeaderCellDef> Index </th>
<td mat-cell *matCellDef="let data; let index = index">
{{index}}
</td>
</ng-container>
<tr cdk-row *cdkRowDef="let row; let odd = odd; columns: ['position']; when: isOdd"></tr>
<tr cdk-row *cdkRowDef="let row; columns: columns;"></tr>
<!-- Data index column -->
<ng-container matColumnDef="dataIndex">
<th mat-header-cell *matHeaderCellDef> Data Index </th>
<td mat-cell *matCellDef="let data; let dataIndex = dataIndex">
{{dataIndex}}
</td>
</ng-container>
<!-- Row Index column -->
<ng-container matColumnDef="renderIndex">
<th mat-header-cell *matHeaderCellDef> Render Index </th>
<td mat-cell *matCellDef="let data; let renderIndex = renderIndex">
{{renderIndex}}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columnsToDisplay"></tr>
<tr mat-row style="background: rgba(255, 0, 0, .2);"
*matRowDef="let data; columns: columnsToDisplay; when: whenFn"></tr>
<tr mat-row style="background: rgba(0, 255, 0, .2);"
*matRowDef="let data; columns: columnsToDisplay;"></tr>
</table>
</mat-card>
+46 -5
View File
@@ -6,16 +6,57 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Component} from '@angular/core';
import {Element, ELEMENT_DATA} from '../element-data';
import {Component, ViewChild} from '@angular/core';
import {MatTable} from '@angular/material';
const DATA_LENGTH = 10;
export interface DemoDataObject {
value: boolean;
}
@Component({
moduleId: module.id,
templateUrl: 'when-rows.html',
})
export class WhenRowsDemo {
columns = ['name', 'weight', 'symbol', 'position'];
dataSource: Element[] = ELEMENT_DATA.slice();
columnsToDisplay = ['data', 'index', 'dataIndex', 'renderIndex'];
data: DemoDataObject[] =
(new Array(DATA_LENGTH) as DemoDataObject[]).fill({value: false}, 0, DATA_LENGTH);
randomNumber = 0;
multiTemplateDataRows = false;
useTrackByValue = false;
isOdd = (i: number, _d: Element) => i % 2 !== 0;
whenFn = (_i: number, d: DemoDataObject) => d.value;
trackByValue = (_i: number, d: DemoDataObject) => d.value;
@ViewChild(MatTable) table: MatTable<any>;
constructor() {
this.changeRandomNumber();
}
changeRandomNumber() {
this.randomNumber = Math.floor(Math.random() * DATA_LENGTH);
this.data = this.data.map((_d: DemoDataObject, i: number) => ({value: i < this.randomNumber}));
if (this.table) {
this.table.renderRows();
}
}
shuffleArray() {
let dataToShuffle = this.data.slice();
let currentIndex = dataToShuffle.length;
while (0 !== currentIndex) {
let randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// Swap
let temp = dataToShuffle[currentIndex];
dataToShuffle[currentIndex] = dataToShuffle[randomIndex];
dataToShuffle[randomIndex] = temp;
}
this.data = dataToShuffle;
}
}
+19 -1
View File
@@ -56,6 +56,23 @@ describe('MatTable', () => {
['Footer A', 'Footer B', 'Footer C'],
]);
});
it('should create a table with multiTemplateDataRows true', () => {
let fixture = TestBed.createComponent(MatTableWithWhenRowApp);
fixture.componentInstance.multiTemplateDataRows = true;
fixture.detectChanges();
const tableElement = fixture.nativeElement.querySelector('.mat-table');
expectTableToMatchContent(tableElement, [
['Column A', 'Column B', 'Column C'],
['a_1'],
['a_2'],
['a_3'],
['a_4'], // With multiple rows, this row shows up along with the special 'when' fourth_row
['fourth_row'],
['Footer A', 'Footer B', 'Footer C'],
]);
});
});
it('should be able to render a table correctly with native elements', () => {
@@ -486,7 +503,7 @@ class NativeHtmlTableApp {
@Component({
template: `
<mat-table [dataSource]="dataSource">
<mat-table [dataSource]="dataSource" [multiTemplateDataRows]="multiTemplateDataRows">
<ng-container matColumnDef="column_a">
<mat-header-cell *matHeaderCellDef> Column A</mat-header-cell>
<mat-cell *matCellDef="let row"> {{row.a}}</mat-cell>
@@ -505,6 +522,7 @@ class NativeHtmlTableApp {
`
})
class MatTableWithWhenRowApp {
multiTemplateDataRows = false;
dataSource: FakeDataSource | null = new FakeDataSource();
isFourthRow = (i: number, _rowData: TestData) => i == 3;