From 6b0cc42da4667d5b3183c35ff12adb16902bd9a0 Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Wed, 2 May 2018 16:12:58 -0700 Subject: [PATCH] feat(table): enable multiple data rows --- src/cdk/table/BUILD.bazel | 1 + src/cdk/table/render-rows.md | 51 ++++ src/cdk/table/row.ts | 35 ++- src/cdk/table/table-errors.ts | 5 +- src/cdk/table/table.spec.ts | 242 ++++++++++++++---- src/cdk/table/table.ts | 234 ++++++++++++++--- .../expandable-rows/expandable-rows.html | 28 ++ .../table/expandable-rows/expandable-rows.ts | 60 +++++ src/demo-app/table/routes.ts | 4 +- src/demo-app/table/table-demo-module.ts | 10 +- src/demo-app/table/table-demo-page.ts | 3 +- src/demo-app/table/when-rows/when-rows.html | 69 ++++- src/demo-app/table/when-rows/when-rows.ts | 51 +++- src/lib/table/table.spec.ts | 20 +- 14 files changed, 711 insertions(+), 102 deletions(-) create mode 100644 src/cdk/table/render-rows.md create mode 100644 src/demo-app/table/expandable-rows/expandable-rows.html create mode 100644 src/demo-app/table/expandable-rows/expandable-rows.ts diff --git a/src/cdk/table/BUILD.bazel b/src/cdk/table/BUILD.bazel index 95ff1277167f..6c47fcead887 100644 --- a/src/cdk/table/BUILD.bazel +++ b/src/cdk/table/BUILD.bazel @@ -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", diff --git a/src/cdk/table/render-rows.md b/src/cdk/table/render-rows.md new file mode 100644 index 000000000000..66e603f14c2f --- /dev/null +++ b/src/cdk/table/render-rows.md @@ -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 { + data: T; + dataIndex: number; + rowDef: CdkRowDef; +} +``` + +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. + diff --git a/src/cdk/table/row.ts b/src/cdk/table/row.ts index 76a8eadac122..7366ee2567a9 100644 --- a/src/cdk/table/row.ts +++ b/src/cdk/table/row.ts @@ -130,12 +130,12 @@ export class CdkRowDef extends BaseRowDef { } } -/** Context provided to the row cells */ +/** Context provided to the row cells when `multiTemplateDataRows` is false */ export interface CdkCellOutletRowContext { /** 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 { 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 { + /** 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 diff --git a/src/cdk/table/table-errors.ts b/src/cdk/table/table-errors.ts index bad08865cbb6..6f9922794a7e 100644 --- a/src/cdk/table/table-errors.ts +++ b/src/cdk/table/table-errors.ts @@ -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)}`); } /** diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index 79c19e3fd5f7..4bbc80daac7c 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -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); - })); - - it('should error if there are multiple rows that do not have a when function', fakeAsync(() => { + 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 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: ` + + + {{data}} + + + + + + ` +}) +class NullDataCdkTableApp { + dataSource = observableOf(null); +} + +@Component({ + template: ` + Column A {{row.a}} @@ -941,30 +1072,55 @@ class BooleanRowCdkTableApp { Column C - index_1_special_row + index_1_special_row Column C - c3_special_row + c3_special_row + + + + Index + {{index}} + + + + Data Index + {{dataIndex}} + + + + Render Index + {{renderIndex}} - - + + ` }) 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; + + showIndexColumns() { + const indexColumns = ['index', 'dataIndex', 'renderIndex']; + this.columnsToRender = indexColumns; + this.columnsForIsIndex1Row = indexColumns; + this.columnsForHasC3Row = indexColumns; + } } @Component({ diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 87a13de66c2c..8f35ffea9e90 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -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 = ` `; +/** + * Interface used to conveniently type the possible context interfaces for the render row. + * @docs-private + */ +export interface RowContext + extends CdkCellOutletMultiRowContext, CdkCellOutletRowContext { } + /** * Class used to conveniently type the embedded view ref for rows with a context. * @docs-private */ -abstract class RowViewRef extends EmbeddedViewRef> { } +abstract class RowViewRef extends EmbeddedViewRef> { } + +/** + * 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 { + data: T; + dataIndex: number; + rowDef: CdkRowDef; +} /** * A data table that can render a header row, data rows, and a footer row. @@ -123,6 +151,9 @@ export class CdkTable 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[]; + /** Subscription that listens for the data provided by the data source. */ private _renderChangeSubscription: Subscription | null; @@ -140,7 +171,7 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke private _rowDefs: CdkRowDef[]; /** Differ used to find the changes in the data provided by the data source. */ - private _dataDiffer: IterableDiffer; + private _dataDiffer: IterableDiffer>; /** Stores the row definition that does not have a when predicate. */ private _defaultRowDef: CdkRowDef | null; @@ -163,6 +194,21 @@ export class CdkTable 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` object. With the two keys, the cache points to a `RenderRow` 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, RenderRow[]>>(); + /** * 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 implements CollectionViewer, OnInit, AfterContentChecke } private _dataSource: DataSource | Observable | 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 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) => { + 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 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 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, adjustedPreviousIndex: number, currentIndex: number) => { + (record: IterableChangeRecord>, 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 = >viewContainer.get(adjustedPreviousIndex); + const view = >viewContainer.get(prevIndex); viewContainer.move(view!, currentIndex); } }); @@ -348,9 +418,9 @@ export class CdkTable 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) => { + changes.forEachIdentityChange((record: IterableChangeRecord>) => { const rowView = >viewContainer.get(record.currentIndex!); - rowView.context.$implicit = record.item; + rowView.context.$implicit = record.item.data; }); } @@ -394,6 +464,66 @@ export class CdkTable 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[] { + const renderRows: RenderRow[] = []; + + // 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` 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, RenderRow[]>): RenderRow[] { + 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 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 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 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 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 { - if (this._rowDefs.length == 1) { return this._rowDefs[0]; } + _getRowDefs(data: T, dataIndex: number): CdkRowDef[] { + if (this._rowDefs.length == 1) { return [this._rowDefs[0]]; } + + let rowDefs: CdkRowDef[] = []; + 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); + } + } - let rowDef = this._rowDefs.find(def => def.when && def.when(i, data)) || this._defaultRowDef; - if (!rowDef) { throw getTableMissingMatchingRowDefError(); } + if (!rowDefs.length) { + throw getTableMissingMatchingRowDefError(data); + } - return rowDef; + 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 = {$implicit: rowData}; - this._renderRow(this._rowOutlet, rowDef, context, index); + private _insertRow(renderRow: RenderRow, renderIndex: number) { + const rowDef = renderRow.rowDef; + const context: RowContext = {$implicit: renderRow.data}; + this._renderRow(this._rowOutlet, rowDef, context, renderIndex); } /** @@ -562,7 +702,7 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke * of where to place the new row template in the outlet. */ private _renderRow( - outlet: RowOutlet, rowDef: BaseRowDef, context: CdkCellOutletRowContext = {}, index = 0) { + outlet: RowOutlet, rowDef: BaseRowDef, context: RowContext = {}, 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 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; - 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; + const context = viewRef.context as RowContext; + 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 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(); + } } diff --git a/src/demo-app/table/expandable-rows/expandable-rows.html b/src/demo-app/table/expandable-rows/expandable-rows.html new file mode 100644 index 000000000000..488f7a4ee671 --- /dev/null +++ b/src/demo-app/table/expandable-rows/expandable-rows.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + +
{{column}} {{element[column]}} +
+ The symbol for {{element.name}} is {{element.symbol}} +
+
+ + +
\ No newline at end of file diff --git a/src/demo-app/table/expandable-rows/expandable-rows.ts b/src/demo-app/table/expandable-rows/expandable-rows.ts new file mode 100644 index 000000000000..260822cfc085 --- /dev/null +++ b/src/demo-app/table/expandable-rows/expandable-rows.ts @@ -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; + } +} diff --git a/src/demo-app/table/routes.ts b/src/demo-app/table/routes.ts index 2fdabc1d718c..8cfad71911ec 100644 --- a/src/demo-app/table/routes.ts +++ b/src/demo-app/table/routes.ts @@ -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} ]; diff --git a/src/demo-app/table/table-demo-module.ts b/src/demo-app/table/table-demo-module.ts index 4d3f86427957..42e59741e47f 100644 --- a/src/demo-app/table/table-demo-module.ts +++ b/src/demo-app/table/table-demo-module.ts @@ -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 diff --git a/src/demo-app/table/table-demo-page.ts b/src/demo-app/table/table-demo-page.ts index 12a3db517a3c..78f05907d0f3 100644 --- a/src/demo-app/table/table-demo-page.ts +++ b/src/demo-app/table/table-demo-page.ts @@ -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'} ]; } diff --git a/src/demo-app/table/when-rows/when-rows.html b/src/demo-app/table/when-rows/when-rows.html index 401fa2ec974a..ed56164f3a0a 100644 --- a/src/demo-app/table/when-rows/when-rows.html +++ b/src/demo-app/table/when-rows/when-rows.html @@ -1,13 +1,68 @@ +
+ +
+ +
+ + Highlighted rows: {{randomNumber}} +
+ +
+ + Enable multiple data rows + +
+ +
+ + Enable track by value + +
+ - - - - +

MatTable

+ +
{{column}} {{element[column]}}
+ + + + + + + + + + + + + + + + + + + + + + - + - - + +
Data + {{data.value}} + Index + {{index}} + Data Index + {{dataIndex}} + Render Index + {{renderIndex}} +
diff --git a/src/demo-app/table/when-rows/when-rows.ts b/src/demo-app/table/when-rows/when-rows.ts index a23c489eca6e..4ce53d65e5b7 100644 --- a/src/demo-app/table/when-rows/when-rows.ts +++ b/src/demo-app/table/when-rows/when-rows.ts @@ -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; + + whenFn = (_i: number, d: DemoDataObject) => d.value; + trackByValue = (_i: number, d: DemoDataObject) => d.value; + + @ViewChild(MatTable) table: MatTable; + + 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; + } - isOdd = (i: number, _d: Element) => i % 2 !== 0; + this.data = dataToShuffle; + } } diff --git a/src/lib/table/table.spec.ts b/src/lib/table/table.spec.ts index 401407fe188c..751a13dfeadf 100644 --- a/src/lib/table/table.spec.ts +++ b/src/lib/table/table.spec.ts @@ -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: ` - + Column A {{row.a}} @@ -505,6 +522,7 @@ class NativeHtmlTableApp { ` }) class MatTableWithWhenRowApp { + multiTemplateDataRows = false; dataSource: FakeDataSource | null = new FakeDataSource(); isFourthRow = (i: number, _rowData: TestData) => i == 3;