diff --git a/src/cdk/table/row.ts b/src/cdk/table/row.ts index 989355a209dd..a730ebfb4f4d 100644 --- a/src/cdk/table/row.ts +++ b/src/cdk/table/row.ts @@ -75,13 +75,22 @@ export class CdkHeaderRowDef extends BaseRowDef { /** * Data row definition for the CDK table. - * Captures the header row's template and other row properties such as the columns to display. + * Captures the header row's template and other row properties such as the columns to display and + * a when predicate that describes when this row should be used. */ @Directive({ selector: '[cdkRowDef]', - inputs: ['columns: cdkRowDefColumns'], + inputs: ['columns: cdkRowDefColumns', 'when: cdkRowDefWhen'], }) -export class CdkRowDef extends BaseRowDef { +export class CdkRowDef extends BaseRowDef { + /** + * Function that should return true if this row template should be used for the provided row data + * and index. If left undefined, this row will be considered the default row template to use when + * no other when functions return true for the data. + * For every row, there must be at least one when function that passes or an undefined to default. + */ + when: (rowData: T, index: number) => boolean; + // TODO(andrewseguin): Add an input for providing a switch function to determine // if this template should be used. constructor(template: TemplateRef, _differs: IterableDiffers) { diff --git a/src/cdk/table/table-errors.ts b/src/cdk/table/table-errors.ts index 8a667cf926e0..d2b19ef8e075 100644 --- a/src/cdk/table/table-errors.ts +++ b/src/cdk/table/table-errors.ts @@ -22,3 +22,19 @@ export function getTableUnknownColumnError(id: string) { export function getTableDuplicateColumnNameError(name: string) { return Error(`cdk-table: Duplicate column definition name provided: "${name}".`); } + +/** + * Returns an error to be thrown when there are multiple rows that are missing a when function. + * @docs-private + */ +export function getTableMultipleDefaultRowDefsError() { + return Error(`cdk-table: There can only be one default row without a when predicate function.`); +} + +/** + * 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(`cdk-table: Could not find a matching row definition for the provided row data.`); +} diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index 548b1c794367..72d26ac3d670 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -7,7 +7,12 @@ import {Observable} from 'rxjs/Observable'; import {combineLatest} from 'rxjs/observable/combineLatest'; import {CdkTableModule} from './index'; import {map} from 'rxjs/operator/map'; -import {getTableDuplicateColumnNameError, getTableUnknownColumnError} from './table-errors'; +import { + getTableDuplicateColumnNameError, + getTableMissingMatchingRowDefError, + getTableMultipleDefaultRowDefsError, + getTableUnknownColumnError +} from './table-errors'; describe('CdkTable', () => { let fixture: ComponentFixture; @@ -31,6 +36,9 @@ describe('CdkTable', () => { MissingColumnDefCdkTableApp, CrazyColumnNameCdkTableApp, UndefinedColumnsCdkTableApp, + WhenRowCdkTableApp, + WhenRowWithoutDefaultCdkTableApp, + WhenRowMultipleDefaultsCdkTableApp ], }).compileComponents(); })); @@ -202,6 +210,34 @@ describe('CdkTable', () => { }); }); + describe('using when predicate', () => { + it('should be able to display different row templates based on the row data', () => { + let whenFixture = TestBed.createComponent(WhenRowCdkTableApp); + whenFixture.detectChanges(); + + let data = whenFixture.componentInstance.dataSource.data; + expectTableToMatchContent(whenFixture.nativeElement.querySelector('cdk-table'), [ + ['Column A', 'Column B', 'Column C'], + [data[0].a, data[0].b, data[0].c], + ['index_1_special_row'], + ['c3_special_row'], + [data[3].a, data[3].b, data[3].c], + ]); + }); + + it('should error if there is row data that does not have a matching row template', () => { + let whenFixture = TestBed.createComponent(WhenRowWithoutDefaultCdkTableApp); + expect(() => whenFixture.detectChanges()) + .toThrowError(getTableMissingMatchingRowDefError().message); + }); + + it('should error if there are multiple rows that do not have a when function', () => { + let whenFixture = TestBed.createComponent(WhenRowMultipleDefaultsCdkTableApp); + expect(() => whenFixture.detectChanges()) + .toThrowError(getTableMultipleDefaultRowDefsError().message); + }); + }); + 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) => { @@ -615,6 +651,139 @@ class SimpleCdkTableApp { @ViewChild(CdkTable) table: CdkTable; } +@Component({ + template: ` + + + Column A + {{row.a}} + + + + Column B + {{row.b}} + + + + Column C + {{row.c}} + + + + Column C + index_1_special_row + + + + Column C + c3_special_row + + + + + + + + ` +}) +class WhenRowCdkTableApp { + dataSource: FakeDataSource = new FakeDataSource(); + columnsToRender = ['column_a', 'column_b', 'column_c']; + isIndex1 = (_rowData: TestData, index: number) => index == 1; + hasC3 = (rowData: TestData) => rowData.c == 'c_3'; + + constructor() { this.dataSource.addData(); } + + @ViewChild(CdkTable) table: CdkTable; +} + +@Component({ + template: ` + + + Column A + {{row.a}} + + + + Column B + {{row.b}} + + + + Column C + {{row.c}} + + + + Column C + index_1_special_row + + + + Column C + c3_special_row + + + + + + + ` +}) +class WhenRowWithoutDefaultCdkTableApp { + dataSource: FakeDataSource = new FakeDataSource(); + columnsToRender = ['column_a', 'column_b', 'column_c']; + isIndex1 = (_rowData: TestData, index: number) => index == 1; + hasC3 = (rowData: TestData) => rowData.c == 'c_3'; + + @ViewChild(CdkTable) table: CdkTable; +} + +@Component({ + template: ` + + + Column A + {{row.a}} + + + + Column B + {{row.b}} + + + + Column C + {{row.c}} + + + + Column C + index_1_special_row + + + + Column C + c3_special_row + + + + + + + + ` +}) +class WhenRowMultipleDefaultsCdkTableApp { + dataSource: FakeDataSource = new FakeDataSource(); + columnsToRender = ['column_a', 'column_b', 'column_c']; + isIndex1 = (_rowData: TestData, index: number) => index == 1; + hasC3 = (rowData: TestData) => rowData.c == 'c_3'; + + @ViewChild(CdkTable) table: CdkTable; +} + @Component({ template: ` diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index a76b8afed00d..e93d64901c53 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -36,7 +36,11 @@ import {BehaviorSubject} from 'rxjs/BehaviorSubject'; import {Subscription} from 'rxjs/Subscription'; import {Subject} from 'rxjs/Subject'; import {CdkCellDef, CdkColumnDef, CdkHeaderCellDef} from './cell'; -import {getTableDuplicateColumnNameError, getTableUnknownColumnError} from './table-errors'; +import { + getTableDuplicateColumnNameError, getTableMissingMatchingRowDefError, + getTableMultipleDefaultRowDefsError, + getTableUnknownColumnError +} from './table-errors'; /** * Provides a handle for the table to grab the view container's ng-container to insert data rows. @@ -90,11 +94,14 @@ export class CdkTable implements CollectionViewer { private _renderChangeSubscription: Subscription | null; /** Map of all the user's defined columns (header and data cell template) identified by name. */ - private _columnDefinitionsByName = new Map(); + private _columnDefsByName = new Map(); /** Differ used to find the changes in the data provided by the data source. */ private _dataDiffer: IterableDiffer; + /** Stores the row definition that does not have a when predicate. */ + private _defaultRowDef: CdkRowDef | null; + /** * 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 @@ -143,13 +150,13 @@ export class CdkTable implements CollectionViewer { * The column definitions provided by the user that contain what the header and cells should * render for each column. */ - @ContentChildren(CdkColumnDef) _columnDefinitions: QueryList; + @ContentChildren(CdkColumnDef) _columnDefs: QueryList; - /** Template used as the header container. */ - @ContentChild(CdkHeaderRowDef) _headerDefinition: CdkHeaderRowDef; + /** Template definition used as the header container. */ + @ContentChild(CdkHeaderRowDef) _headerDef: CdkHeaderRowDef; - /** Set of templates that used as the data row containers. */ - @ContentChildren(CdkRowDef) _rowDefinitions: QueryList; + /** Set of template definitions that used as the data row containers. */ + @ContentChildren(CdkRowDef) _rowDefs: QueryList>; constructor(private readonly _differs: IterableDiffers, private readonly _changeDetectorRef: ChangeDetectorRef, @@ -167,13 +174,18 @@ export class CdkTable implements CollectionViewer { } ngAfterContentInit() { - this._cacheColumnDefinitionsByName(); - this._columnDefinitions.changes.subscribe(() => this._cacheColumnDefinitionsByName()); + this._cacheColumnDefsByName(); + this._columnDefs.changes.subscribe(() => this._cacheColumnDefsByName()); this._renderHeaderRow(); } ngAfterContentChecked() { this._renderUpdatedColumns(); + + const defaultRowDefs = this._rowDefs.filter(def => !def.when); + if (defaultRowDefs.length > 1) { throw getTableMultipleDefaultRowDefsError(); } + this._defaultRowDef = defaultRowDefs[0]; + if (this.dataSource && !this._renderChangeSubscription) { this._observeRenderChanges(); } @@ -190,15 +202,14 @@ export class CdkTable implements CollectionViewer { } } - /** Update the map containing the content's column definitions. */ - private _cacheColumnDefinitionsByName() { - this._columnDefinitionsByName.clear(); - this._columnDefinitions.forEach(columnDef => { - if (this._columnDefinitionsByName.has(columnDef.name)) { + private _cacheColumnDefsByName() { + this._columnDefsByName.clear(); + this._columnDefs.forEach(columnDef => { + if (this._columnDefsByName.has(columnDef.name)) { throw getTableDuplicateColumnNameError(columnDef.name); } - this._columnDefinitionsByName.set(columnDef.name, columnDef); + this._columnDefsByName.set(columnDef.name, columnDef); }); } @@ -208,8 +219,8 @@ export class CdkTable implements CollectionViewer { */ private _renderUpdatedColumns() { // Re-render the rows when the row definition columns change. - this._rowDefinitions.forEach(rowDefinition => { - if (!!rowDefinition.getColumnsDiff()) { + 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([]); @@ -219,7 +230,7 @@ export class CdkTable implements CollectionViewer { }); // Re-render the header row if there is a difference in its columns. - if (this._headerDefinition.getColumnsDiff()) { + if (this._headerDef.getColumnsDiff()) { this._headerRowPlaceholder.viewContainer.clear(); this._renderHeaderRow(); } @@ -264,14 +275,14 @@ export class CdkTable implements CollectionViewer { * Create the embedded view for the header template and place it in the header row view container. */ private _renderHeaderRow() { - const cells = this._getHeaderCellTemplatesForRow(this._headerDefinition); + const cells = this._getHeaderCellTemplatesForRow(this._headerDef); if (!cells.length) { return; } // TODO(andrewseguin): add some code to enforce that exactly // one CdkCellOutlet was instantiated as a result // of `createEmbeddedView`. this._headerRowPlaceholder.viewContainer - .createEmbeddedView(this._headerDefinition.template, {cells}); + .createEmbeddedView(this._headerDef.template, {cells}); cells.forEach(cell => { CdkCellOutlet.mostRecentCellOutlet._viewContainer.createEmbeddedView(cell.template, {}); @@ -301,15 +312,27 @@ export class CdkTable implements CollectionViewer { this._updateRowContext(); } + /** + * 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 + * 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.first; } + + let rowDef = this._rowDefs.find(def => def.when && def.when(data, i)) || this._defaultRowDef; + if (!rowDef) { throw getTableMissingMatchingRowDefError(); } + + return rowDef; + } + /** * 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) { - // TODO(andrewseguin): Add when predicates to the row definitions - // to find the right template to used based on - // the data rather than choosing the first row definition. - const row = this._rowDefinitions.first; + const row = this._getRowDef(rowData, index); // Row context that will be provided to both the created embedded row view and its cells. const context: CdkCellOutletRowContext = {$implicit: rowData}; @@ -353,7 +376,7 @@ export class CdkTable implements CollectionViewer { private _getHeaderCellTemplatesForRow(headerDef: CdkHeaderRowDef): CdkHeaderCellDef[] { if (!headerDef.columns) { return []; } return headerDef.columns.map(columnId => { - const column = this._columnDefinitionsByName.get(columnId); + const column = this._columnDefsByName.get(columnId); if (!column) { throw getTableUnknownColumnError(columnId); @@ -367,10 +390,10 @@ export class CdkTable implements CollectionViewer { * Returns the cell template definitions to insert in the provided row * as defined by its list of columns to display. */ - private _getCellTemplatesForRow(rowDef: CdkRowDef): CdkCellDef[] { + private _getCellTemplatesForRow(rowDef: CdkRowDef): CdkCellDef[] { if (!rowDef.columns) { return []; } return rowDef.columns.map(columnId => { - const column = this._columnDefinitionsByName.get(columnId); + const column = this._columnDefsByName.get(columnId); if (!column) { throw getTableUnknownColumnError(columnId); diff --git a/src/demo-app/table/person-detail-data-source.ts b/src/demo-app/table/person-detail-data-source.ts new file mode 100644 index 000000000000..864b49f69849 --- /dev/null +++ b/src/demo-app/table/person-detail-data-source.ts @@ -0,0 +1,33 @@ +import {DataSource} from '@angular/cdk/collections'; +import {Observable} from 'rxjs/Observable'; +import {UserData} from './people-database'; +import 'rxjs/add/observable/merge'; +import 'rxjs/add/operator/map'; +import {PersonDataSource} from './person-data-source'; + +export interface DetailRow { + detailRow: boolean; + data: UserData; +} + +export class PersonDetailDataSource extends DataSource { + constructor(private _personDataSource: PersonDataSource) { + super(); + } + + connect(): Observable<(UserData|DetailRow)[]> { + return this._personDataSource.connect().map(data => { + const rows: (UserData|DetailRow)[] = []; + + // Interweave a detail data object for each row data object that will be used for displaying + // row details. Contains the row data. + data.forEach(person => rows.push(person, {detailRow: true, data: person})); + + return rows; + }); + } + + disconnect() { + // No-op + } +} diff --git a/src/demo-app/table/table-demo.html b/src/demo-app/table/table-demo.html index 1741dc26d2be..a7f4c5b66efd 100644 --- a/src/demo-app/table/table-demo.html +++ b/src/demo-app/table/table-demo.html @@ -167,6 +167,46 @@

MatTable Example

+ + +

MatTable Using 'When' Rows for Interactive Details

+ + + + + ID + {{row.id}} + + + + + Name + {{row.name}} + + + + + + + {{row.data.name.split(' ')[0]}}'s favorite color is {{row.data.color}} and has a progress + of {{row.data.progress}}%. + + + + + + + + + +
\ No newline at end of file diff --git a/src/demo-app/table/table-demo.scss b/src/demo-app/table/table-demo.scss index e968dc3b8e98..db3bf40507be 100644 --- a/src/demo-app/table/table-demo.scss +++ b/src/demo-app/table/table-demo.scss @@ -24,7 +24,7 @@ .demo-table-card { margin: 24px 0; - max-height: 200px; + max-height: 450px; overflow: auto; h3 { @@ -34,6 +34,18 @@ } } +.user-row { + cursor: pointer; + position: relative; // For mdRipple + + &:hover { + background: #f5f5f5; + } + &:active { + background: #eaeaea; + } +} + /** Styles so that the CDK Table columns have width and font size. */ .cdk-table { font-size: 12px; @@ -75,7 +87,6 @@ /* Column max widths */ .cdk-column-userId { max-width: 32px; - text-align: right; justify-content: flex-end; } diff --git a/src/demo-app/table/table-demo.ts b/src/demo-app/table/table-demo.ts index 79067958ae25..d2d123df4cfd 100644 --- a/src/demo-app/table/table-demo.ts +++ b/src/demo-app/table/table-demo.ts @@ -2,6 +2,8 @@ import {Component, ViewChild} from '@angular/core'; import {PeopleDatabase, UserData} from './people-database'; import {PersonDataSource} from './person-data-source'; import {MatPaginator, MatSort} from '@angular/material'; +import {DetailRow, PersonDetailDataSource} from './person-detail-data-source'; +import {animate, state, style, transition, trigger} from '@angular/animations'; export type UserProperties = 'userId' | 'userName' | 'progress' | 'color' | undefined; @@ -14,21 +16,34 @@ const properties = ['id', 'name', 'progress', 'color']; selector: 'table-demo', templateUrl: 'table-demo.html', styleUrls: ['table-demo.css'], + animations: [ + trigger('detailExpand', [ + state('collapsed', style({height: '0px', minHeight: '0', visibility: 'hidden'})), + state('expanded', style({height: '*', visibility: 'visible'})), + transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')), + ]), + ], }) export class TableDemo { dataSource: PersonDataSource | null; + dataSourceWithDetails: PersonDetailDataSource | null; displayedColumns: UserProperties[] = []; trackByStrategy: TrackByStrategy = 'reference'; changeReferences = false; highlights = new Set(); + wasExpanded = new Set(); dynamicColumnDefs: any[] = []; dynamicColumnIds: string[] = []; + expandedPerson: UserData; + @ViewChild(MatPaginator) _paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; + isDetailRow = (row: DetailRow|UserData) => row.hasOwnProperty('detailRow'); + constructor(public _peopleDatabase: PeopleDatabase) { } ngOnInit() { @@ -55,6 +70,7 @@ export class TableDemo { this.displayedColumns = ['userId', 'userName', 'progress', 'color']; this.dataSource = new PersonDataSource(this._peopleDatabase, this._paginator, this.sort); + this.dataSourceWithDetails = new PersonDetailDataSource(this.dataSource); this._peopleDatabase.initialize(); } diff --git a/src/lib/table/row.ts b/src/lib/table/row.ts index 6fb1b2bbd7ed..121a2bc045a1 100644 --- a/src/lib/table/row.ts +++ b/src/lib/table/row.ts @@ -8,11 +8,11 @@ import {ChangeDetectionStrategy, Component, Directive, ViewEncapsulation} from '@angular/core'; import { + CDK_ROW_TEMPLATE, CdkHeaderRow, + CdkHeaderRowDef, CdkRow, - CDK_ROW_TEMPLATE, CdkRowDef, - CdkHeaderRowDef, } from '@angular/cdk/table'; /** Workaround for https://github.com/angular/angular/issues/17849 */ @@ -34,14 +34,16 @@ export class MatHeaderRowDef extends _MatHeaderRowDef { } /** * Data row definition for the mat-table. - * Captures the header row's template and other row properties such as the columns to display. + * Captures the header row's template and other row properties such as the columns to display and + * a when predicate that describes when this row should be used. */ @Directive({ selector: '[matRowDef]', providers: [{provide: CdkRowDef, useExisting: MatRowDef}], - inputs: ['columns: matRowDefColumns'], + inputs: ['columns: matRowDefColumns', 'when: matRowDefWhen'], }) -export class MatRowDef extends _MatCdkRowDef { } +export class MatRowDef extends _MatCdkRowDef { +} /** Header template container that contains the cell outlet. Adds the right class and role. */ @Component({ diff --git a/src/lib/table/table.spec.ts b/src/lib/table/table.spec.ts index 64b8ff5f1c65..5333c2bb0e4b 100644 --- a/src/lib/table/table.spec.ts +++ b/src/lib/table/table.spec.ts @@ -1,4 +1,4 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {async, TestBed} from '@angular/core/testing'; import {Component, ViewChild} from '@angular/core'; import {DataSource} from '@angular/cdk/collections'; import {BehaviorSubject} from 'rxjs/BehaviorSubject'; @@ -7,22 +7,17 @@ import {MatTableModule} from './index'; import {MatTable} from './table'; describe('MatTable', () => { - let fixture: ComponentFixture; - beforeEach(async(() => { TestBed.configureTestingModule({ imports: [MatTableModule], - declarations: [SimpleMatTableApp], + declarations: [MatTableApp, MatTableWithWhenRowApp], }).compileComponents(); })); - beforeEach(() => { - fixture = TestBed.createComponent(SimpleMatTableApp); - fixture.detectChanges(); + it('should be able to create a table with the right content and without when row', () => { + let fixture = TestBed.createComponent(MatTableApp); fixture.detectChanges(); - }); - it('should create a table with the right content', () => { const tableElement = fixture.nativeElement.querySelector('.mat-table'); const headerRow = tableElement.querySelectorAll('.mat-header-cell'); expectTextContent(headerRow[0], 'Column A'); @@ -30,12 +25,32 @@ describe('MatTable', () => { expectTextContent(headerRow[2], 'Column C'); const rows = tableElement.querySelectorAll('.mat-row'); - for (let i = 0; i < rows.length; i++) { + + // First three rows use columns ['column_a', 'column_b', 'column_c'] + for (let i = 0; i < 3; i++) { const cells = rows[i].querySelectorAll('.mat-cell'); expectTextContent(cells[0], `a_${i + 1}`); expectTextContent(cells[1], `b_${i + 1}`); expectTextContent(cells[2], `c_${i + 1}`); } + + // Fourth row uses a special when predicate to show a different colummn + expect(rows[3].textContent.trim()).toBe('fourth_row'); + }); + + it('should create a table with special when row', () => { + let fixture = TestBed.createComponent(MatTableWithWhenRowApp); + fixture.detectChanges(); + + const tableElement = fixture.nativeElement.querySelector('.mat-table'); + const headerRow = tableElement.querySelectorAll('.mat-header-cell'); + expectTextContent(headerRow[0], 'Column A'); + + const rows = tableElement.querySelectorAll('.mat-row'); + expect(rows[0].textContent.trim()).toBe('a_1'); + expect(rows[1].textContent.trim()).toBe('a_2'); + expect(rows[2].textContent.trim()).toBe('a_3'); + expect(rows[3].textContent.trim()).toBe('fourth_row'); }); }); @@ -60,7 +75,7 @@ class FakeDataSource extends DataSource { constructor() { super(); - for (let i = 0; i < 3; i++) { this.addData(); } + for (let i = 0; i < 4; i++) { this.addData(); } } connect(): Observable { @@ -101,15 +116,46 @@ class FakeDataSource extends DataSource { {{row.c}} + + fourth_row + + + ` }) -class SimpleMatTableApp { +class MatTableApp { dataSource: FakeDataSource | null = new FakeDataSource(); columnsToRender = ['column_a', 'column_b', 'column_c']; + isFourthRow = (_rowData: TestData, i: number) => i == 3; @ViewChild(MatTable) table: MatTable; } + +@Component({ + template: ` + + + Column A + {{row.a}} + + + + fourth_row + + + + + + + ` +}) +class MatTableWithWhenRowApp { + dataSource: FakeDataSource | null = new FakeDataSource(); + isFourthRow = (_rowData: TestData, i: number) => i == 3; + + @ViewChild(MatTable) table: MatTable; +}