diff --git a/src/cdk/table/cell.ts b/src/cdk/table/cell.ts index 1ce2acedece..c63608f951e 100644 --- a/src/cdk/table/cell.ts +++ b/src/cdk/table/cell.ts @@ -8,12 +8,17 @@ import {ContentChild, Directive, ElementRef, Input, TemplateRef} from '@angular/core'; +/** Base interface for a cell definition. Captures a column's cell template definition. */ +export interface CellDef { + template: TemplateRef; +} + /** * Cell definition for a CDK table. * Captures the template of a column's data row cell as well as cell-specific properties. */ @Directive({selector: '[cdkCellDef]'}) -export class CdkCellDef { +export class CdkCellDef implements CellDef { constructor(/** @docs-private */ public template: TemplateRef) { } } @@ -22,7 +27,16 @@ export class CdkCellDef { * Captures the template of a column's header cell and as well as cell-specific properties. */ @Directive({selector: '[cdkHeaderCellDef]'}) -export class CdkHeaderCellDef { +export class CdkHeaderCellDef implements CellDef { + constructor(/** @docs-private */ public template: TemplateRef) { } +} + +/** + * Footer cell definition for a CDK table. + * Captures the template of a column's footer cell and as well as cell-specific properties. + */ +@Directive({selector: '[cdkFooterCellDef]'}) +export class CdkFooterCellDef implements CellDef { constructor(/** @docs-private */ public template: TemplateRef) { } } @@ -51,6 +65,9 @@ export class CdkColumnDef { /** @docs-private */ @ContentChild(CdkHeaderCellDef) headerCell: CdkHeaderCellDef; + /** @docs-private */ + @ContentChild(CdkFooterCellDef) footerCell: CdkFooterCellDef; + /** * Transformed version of the column name that can be used as part of a CSS classname. Excludes * all non-alphanumeric characters and the special characters '-' and '_'. Any characters that @@ -59,6 +76,14 @@ export class CdkColumnDef { cssClassFriendlyName: string; } +/** Base class for the cells. Adds a CSS classname that identifies the column it renders in. */ +export class BaseCdkCell { + constructor(columnDef: CdkColumnDef, elementRef: ElementRef) { + const columnClassName = `cdk-column-${columnDef.cssClassFriendlyName}`; + elementRef.nativeElement.classList.add(columnClassName); + } +} + /** Header cell template container that adds the right classes and role. */ @Directive({ selector: 'cdk-header-cell, th[cdk-header-cell]', @@ -67,9 +92,23 @@ export class CdkColumnDef { 'role': 'columnheader', }, }) -export class CdkHeaderCell { +export class CdkHeaderCell extends BaseCdkCell { constructor(columnDef: CdkColumnDef, elementRef: ElementRef) { - elementRef.nativeElement.classList.add(`cdk-column-${columnDef.cssClassFriendlyName}`); + super(columnDef, elementRef); + } +} + +/** Footer cell template container that adds the right classes and role. */ +@Directive({ + selector: 'cdk-footer-cell, td[cdk-footer-cell]', + host: { + 'class': 'cdk-footer-cell', + 'role': 'gridcell', + }, +}) +export class CdkFooterCell extends BaseCdkCell { + constructor(columnDef: CdkColumnDef, elementRef: ElementRef) { + super(columnDef, elementRef); } } @@ -81,8 +120,8 @@ export class CdkHeaderCell { 'role': 'gridcell', }, }) -export class CdkCell { +export class CdkCell extends BaseCdkCell { constructor(columnDef: CdkColumnDef, elementRef: ElementRef) { - elementRef.nativeElement.classList.add(`cdk-column-${columnDef.cssClassFriendlyName}`); + super(columnDef, elementRef); } } diff --git a/src/cdk/table/row.ts b/src/cdk/table/row.ts index 87b2b0d5a70..76a8eadac12 100644 --- a/src/cdk/table/row.ts +++ b/src/cdk/table/row.ts @@ -18,7 +18,7 @@ import { ViewContainerRef, ViewEncapsulation, } from '@angular/core'; -import {CdkCellDef} from './cell'; +import {CdkCellDef, CdkColumnDef} from './cell'; /** * The row template that can be used by the mat-table. Should not be used outside of the @@ -57,6 +57,9 @@ export abstract class BaseRowDef { getColumnsDiff(): IterableChanges | null { return this._columnsDiffer.diff(this.columns); } + + /** Gets this row def's relevant cell template from the provided column def. */ + abstract extractCellTemplate(column: CdkColumnDef): TemplateRef; } /** @@ -71,6 +74,30 @@ export class CdkHeaderRowDef extends BaseRowDef { constructor(template: TemplateRef, _differs: IterableDiffers) { super(template, _differs); } + + /** Gets this row def's relevant cell template from the provided column def. */ + extractCellTemplate(column: CdkColumnDef): TemplateRef { + return column.headerCell.template; + } +} + +/** + * Footer row definition for the CDK table. + * Captures the footer row's template and other footer properties such as the columns to display. + */ +@Directive({ + selector: '[cdkFooterRowDef]', + inputs: ['columns: cdkFooterRowDef'], +}) +export class CdkFooterRowDef extends BaseRowDef { + constructor(template: TemplateRef, _differs: IterableDiffers) { + super(template, _differs); + } + + /** Gets this row def's relevant cell template from the provided column def. */ + extractCellTemplate(column: CdkColumnDef): TemplateRef { + return column.footerCell.template; + } } /** @@ -96,12 +123,17 @@ export class CdkRowDef extends BaseRowDef { constructor(template: TemplateRef, _differs: IterableDiffers) { super(template, _differs); } + + /** Gets this row def's relevant cell template from the provided column def. */ + extractCellTemplate(column: CdkColumnDef): TemplateRef { + return column.cell.template; + } } /** Context provided to the row cells */ export interface CdkCellOutletRowContext { /** Data for the row that this cell is located within. */ - $implicit: T; + $implicit?: T; /** Index location of the row that this cell is located within. */ index?: number; @@ -162,6 +194,21 @@ export class CdkCellOutlet { }) export class CdkHeaderRow { } + +/** Footer template container that contains the cell outlet. Adds the right class and role. */ +@Component({ + moduleId: module.id, + selector: 'cdk-footer-row, tr[cdk-footer-row]', + template: CDK_ROW_TEMPLATE, + host: { + 'class': 'cdk-footer-row', + 'role': 'row', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class CdkFooterRow { } + /** Data row template container that contains the cell outlet. Adds the right class and role. */ @Component({ moduleId: module.id, diff --git a/src/cdk/table/table-module.ts b/src/cdk/table/table-module.ts index 5bca33d90c9..84de5c960ce 100644 --- a/src/cdk/table/table-module.ts +++ b/src/cdk/table/table-module.ts @@ -8,9 +8,15 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; -import {CdkTable, HeaderRowPlaceholder, RowPlaceholder} from './table'; -import {CdkCellOutlet, CdkHeaderRow, CdkHeaderRowDef, CdkRow, CdkRowDef} from './row'; -import {CdkCell, CdkCellDef, CdkColumnDef, CdkHeaderCell, CdkHeaderCellDef} from './cell'; +import {HeaderRowOutlet, DataRowOutlet, CdkTable, FooterRowOutlet} from './table'; +import { + CdkCellOutlet, CdkFooterRow, CdkFooterRowDef, CdkHeaderRow, CdkHeaderRowDef, CdkRow, + CdkRowDef +} from './row'; +import { + CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCell, CdkCellDef, + CdkFooterCellDef, CdkFooterCell +} from './cell'; const EXPORTED_DECLARATIONS = [ CdkTable, @@ -18,14 +24,19 @@ const EXPORTED_DECLARATIONS = [ CdkCellDef, CdkCellOutlet, CdkHeaderCellDef, + CdkFooterCellDef, CdkColumnDef, CdkCell, CdkRow, CdkHeaderCell, + CdkFooterCell, CdkHeaderRow, CdkHeaderRowDef, - RowPlaceholder, - HeaderRowPlaceholder, + CdkFooterRow, + CdkFooterRowDef, + DataRowOutlet, + HeaderRowOutlet, + FooterRowOutlet, ]; @NgModule({ diff --git a/src/cdk/table/table.md b/src/cdk/table/table.md index 1d8255def8e..c005c43a915 100644 --- a/src/cdk/table/table.md +++ b/src/cdk/table/table.md @@ -18,13 +18,15 @@ top of the CDK data-table. The first step to writing the data-table template is to define the columns. A column definition is specified via an `` with the `cdkColumnDef` directive, giving -the column a name. Each column definition then further defines both a header-cell template -(`cdkHeaderCellDef`) and a data-cell template (`cdkCellDef`). +the column a name. Each column definition can contain a header-cell template +(`cdkHeaderCellDef`), data-cell template (`cdkCellDef`), and footer-cell +template (`cdkFooterCellDef`). ```html User name {{row.a}} + User name ``` @@ -35,11 +37,15 @@ Note that `cdkCellDef` exports the row context such that the row data can be ref template. The directive also exports the same properties as `ngFor` (index, even, odd, first, last). -The next step is to define the table's header-row (`cdkHeaderRowDef`) and data-row (`cdkRowDef`). +The next step is to define the table's header-row (`cdkHeaderRowDef`), data-row (`cdkRowDef`), +and fitler-row (`cdkFooterRowDef`). Note that each of these are optional to include, depending on +what type of rows you want rendered (e.g. if you do not need a footer row, simply do not add +its definition). ```html + ``` These row templates accept the specific columns to be rendered via the name given to the diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index 89b751b292e..79c19e3fd5f 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -4,8 +4,8 @@ import { ContentChildren, Input, QueryList, - ViewChild, Type, + ViewChild } from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing'; import {CdkTable} from './table'; @@ -25,88 +25,220 @@ import {CdkHeaderRowDef, CdkRowDef} from './row'; import {CdkColumnDef} from './cell'; describe('CdkTable', () => { - function createComponent(component: Type, declarations: any[] = []): ComponentFixture { + let fixture: ComponentFixture; + let component: any; + let tableElement: HTMLElement; + + function createComponent(componentType: Type, declarations: any[] = []): + ComponentFixture { TestBed.configureTestingModule({ imports: [CdkTableModule], - declarations: [component, ...declarations], + declarations: [componentType, ...declarations], }).compileComponents(); - return TestBed.createComponent(component); + return TestBed.createComponent(componentType); } - describe('should initialize', () => { - let fixture: ComponentFixture; - let component: SimpleCdkTableApp; + function setupTableTestApp(componentType: Type, declarations: any[] = []) { + fixture = createComponent(componentType, declarations); + component = fixture.componentInstance; + tableElement = fixture.nativeElement.querySelector('cdk-table'); + + fixture.detectChanges(); + } + + describe('in a typical simple use case', () => { let dataSource: FakeDataSource; - let table: CdkTable; - let tableElement: HTMLElement; + let table: CdkTable; beforeEach(() => { - fixture = createComponent(SimpleCdkTableApp); - fixture.detectChanges(); - component = fixture.componentInstance; - dataSource = component.dataSource as FakeDataSource; + setupTableTestApp(SimpleCdkTableApp); + + component = fixture.componentInstance as SimpleCdkTableApp; + dataSource = component.dataSource; table = component.table; - tableElement = fixture.nativeElement.querySelector('cdk-table'); + + fixture.detectChanges(); }); - it('with a connected data source', () => { - expect(table.dataSource).toBe(dataSource); + describe('should initialize', () => { + it('with a connected data source', () => { + expect(table.dataSource).toBe(dataSource); + expect(dataSource.isConnected).toBe(true); + }); + + it('with a rendered header with the right number of header cells', () => { + const header = getHeaderRow(tableElement); + + expect(header).toBeTruthy(); + expect(header.classList).toContain('customHeaderRowClass'); + expect(getHeaderCells(tableElement).length).toBe(component.columnsToRender.length); + }); + + it('with rendered rows with right number of row cells', () => { + const rows = getRows(tableElement); + + expect(rows.length).toBe(dataSource.data.length); + rows.forEach(row => { + expect(row.classList).toContain('customRowClass'); + expect(getCells(row).length).toBe(component.columnsToRender.length); + }); + }); + + it('with column class names provided to header and data row cells', () => { + getHeaderCells(tableElement).forEach((headerCell, index) => { + expect(headerCell.classList).toContain(`cdk-column-${component.columnsToRender[index]}`); + }); + + getRows(tableElement).forEach(row => { + getCells(row).forEach((cell, index) => { + expect(cell.classList).toContain(`cdk-column-${component.columnsToRender[index]}`); + }); + }); + }); + + it('with the right accessibility roles', () => { + expect(tableElement.getAttribute('role')).toBe('grid'); + + expect(getHeaderRow(tableElement).getAttribute('role')).toBe('row'); + getHeaderCells(tableElement).forEach(cell => { + expect(cell.getAttribute('role')).toBe('columnheader'); + }); + + getRows(tableElement).forEach(row => { + expect(row.getAttribute('role')).toBe('row'); + getCells(row).forEach(cell => { + expect(cell.getAttribute('role')).toBe('gridcell'); + }); + }); + }); + }); + + it('should disconnect the data source when table is destroyed', () => { expect(dataSource.isConnected).toBe(true); + + fixture.destroy(); + expect(dataSource.isConnected).toBe(false); }); - it('with a rendered header with the right number of header cells', () => { - const header = getHeaderRow(tableElement); + it('should re-render the rows when the data changes', () => { + dataSource.addData(); + fixture.detectChanges(); - expect(header).toBeTruthy(); - expect(header.classList).toContain('customHeaderRowClass'); - expect(getHeaderCells(tableElement).length).toBe(component.columnsToRender.length); - }); + expect(getRows(tableElement).length).toBe(dataSource.data.length); - it('with rendered rows with right number of row cells', () => { - const rows = getRows(tableElement); - - expect(rows.length).toBe(dataSource.data.length); - rows.forEach(row => { - expect(row.classList).toContain('customRowClass'); + // Check that the number of cells is correct + getRows(tableElement).forEach(row => { expect(getCells(row).length).toBe(component.columnsToRender.length); }); }); - it('with column class names provided to header and data row cells', () => { - getHeaderCells(tableElement).forEach((headerCell, index) => { - expect(headerCell.classList).toContain(`cdk-column-${component.columnsToRender[index]}`); + 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()); }); - getRows(tableElement).forEach(row => { - getCells(row).forEach((cell, index) => { - expect(cell.classList).toContain(`cdk-column-${component.columnsToRender[index]}`); - }); - }); + // 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'); + + // 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); }); - it('with the right accessibility roles', () => { - expect(tableElement.getAttribute('role')).toBe('grid'); + it('should clear the row view containers on destroy', () => { + const rowOutlet = fixture.componentInstance.table._rowOutlet.viewContainer; + const headerPlaceholder = fixture.componentInstance.table._headerRowOutlet.viewContainer; - expect(getHeaderRow(tableElement).getAttribute('role')).toBe('row'); - getHeaderCells(tableElement).forEach(cell => { - expect(cell.getAttribute('role')).toBe('columnheader'); - }); + spyOn(rowOutlet, 'clear').and.callThrough(); + spyOn(headerPlaceholder, 'clear').and.callThrough(); - getRows(tableElement).forEach(row => { - expect(row.getAttribute('role')).toBe('row'); - getCells(row).forEach(cell => { - expect(cell.getAttribute('role')).toBe('gridcell'); - }); - }); + fixture.destroy(); + + expect(rowOutlet.clear).toHaveBeenCalled(); + expect(headerPlaceholder.clear).toHaveBeenCalled(); + }); + + it('should match the right table content with dynamic data', () => { + const initialDataLength = dataSource.data.length; + expect(dataSource.data.length).toBe(3); + + let data = 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], + [data[2].a, data[2].b, data[2].c], + ['Footer A', 'Footer B', 'Footer C'], + ]); + + // Add data to the table and recreate what the rendered output should be. + dataSource.addData(); + expect(dataSource.data.length).toBe(initialDataLength + 1); // Make sure data was added + fixture.detectChanges(); + + data = 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], + [data[2].a, data[2].b, data[2].c], + [data[3].a, data[3].b, data[3].c], + ['Footer A', 'Footer B', 'Footer C'], + ]); + }); + + it('should be able to dynamically change the columns for header and rows', () => { + expect(dataSource.data.length).toBe(3); + + let data = 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], + [data[2].a, data[2].b, data[2].c], + ['Footer A', 'Footer B', 'Footer C'], + ]); + + // Remove column_a and swap column_b/column_c. + component.columnsToRender = ['column_c', 'column_b']; + fixture.detectChanges(); + + let changedTableContent = [['Column C', 'Column B']]; + dataSource.data.forEach(rowData => changedTableContent.push([rowData.c, rowData.b])); + + data = dataSource.data; + expectTableToMatchContent(tableElement, [ + ['Column C', 'Column B'], + [data[0].c, data[0].b], + [data[1].c, data[1].b], + [data[2].c, data[2].b], + ['Footer C', 'Footer B'], + ]); }); }); describe('with different data inputs other than data source', () => { - let dataInputFixture: ComponentFixture; - let dataInputComponent: CdkTableWithDifferentDataInputsApp; - let dataInputTableElement: HTMLElement; - let baseData: TestData[] = [ {a: 'a_1', b: 'b_1', c: 'c_1'}, {a: 'a_2', b: 'b_2', c: 'c_2'}, @@ -114,16 +246,14 @@ describe('CdkTable', () => { ]; beforeEach(() => { - dataInputFixture = createComponent(CdkTableWithDifferentDataInputsApp); - dataInputFixture.detectChanges(); - dataInputComponent = dataInputFixture.componentInstance; - dataInputTableElement = dataInputFixture.nativeElement.querySelector('cdk-table'); + setupTableTestApp(CdkTableWithDifferentDataInputsApp); + component = fixture.componentInstance; }); it('should render with data array input', () => { const data = baseData.slice(); - dataInputComponent.dataSource = data; - dataInputFixture.detectChanges(); + component.dataSource = data; + fixture.detectChanges(); const expectedRender = [ ['Column A', 'Column B', 'Column C'], @@ -131,45 +261,45 @@ describe('CdkTable', () => { ['a_2', 'b_2', 'c_2'], ['a_3', 'b_3', 'c_3'], ]; - expectTableToMatchContent(dataInputTableElement, expectedRender); + expectTableToMatchContent(tableElement, expectedRender); // Push data to the array but neglect to tell the table, should be no change data.push({a: 'a_4', b: 'b_4', c: 'c_4'}); - expectTableToMatchContent(dataInputTableElement, expectedRender); + expectTableToMatchContent(tableElement, expectedRender); // Notify table of the change, expect another row - dataInputComponent.table.renderRows(); - dataInputFixture.detectChanges(); + component.table.renderRows(); + fixture.detectChanges(); expectedRender.push(['a_4', 'b_4', 'c_4']); - expectTableToMatchContent(dataInputTableElement, expectedRender); + expectTableToMatchContent(tableElement, expectedRender); // Remove a row and expect the change in rows data.pop(); - dataInputComponent.table.renderRows(); + component.table.renderRows(); expectedRender.pop(); - expectTableToMatchContent(dataInputTableElement, expectedRender); + expectTableToMatchContent(tableElement, expectedRender); // Remove the data input entirely and expect no rows - just header. - dataInputComponent.dataSource = null; - dataInputFixture.detectChanges(); + component.dataSource = null; + fixture.detectChanges(); - expectTableToMatchContent(dataInputTableElement, [expectedRender[0]]); + expectTableToMatchContent(tableElement, [expectedRender[0]]); // Add back the data to verify that it renders rows - dataInputComponent.dataSource = data; - dataInputFixture.detectChanges(); + component.dataSource = data; + fixture.detectChanges(); - expectTableToMatchContent(dataInputTableElement, expectedRender); + expectTableToMatchContent(tableElement, expectedRender); }); it('should render with data stream input', () => { const data = baseData.slice(); const stream = new BehaviorSubject(data); - dataInputComponent.dataSource = stream; - dataInputFixture.detectChanges(); + component.dataSource = stream; + fixture.detectChanges(); const expectedRender = [ ['Column A', 'Column B', 'Column C'], @@ -177,62 +307,92 @@ describe('CdkTable', () => { ['a_2', 'b_2', 'c_2'], ['a_3', 'b_3', 'c_3'], ]; - expectTableToMatchContent(dataInputTableElement, expectedRender); + expectTableToMatchContent(tableElement, expectedRender); // Push data to the array and emit the data array on the stream data.push({a: 'a_4', b: 'b_4', c: 'c_4'}); stream.next(data); - dataInputFixture.detectChanges(); + fixture.detectChanges(); expectedRender.push(['a_4', 'b_4', 'c_4']); - expectTableToMatchContent(dataInputTableElement, expectedRender); + expectTableToMatchContent(tableElement, expectedRender); // Push data to the array but rather than emitting, call renderRows. data.push({a: 'a_5', b: 'b_5', c: 'c_5'}); - dataInputComponent.table.renderRows(); - dataInputFixture.detectChanges(); + component.table.renderRows(); + fixture.detectChanges(); expectedRender.push(['a_5', 'b_5', 'c_5']); - expectTableToMatchContent(dataInputTableElement, expectedRender); + expectTableToMatchContent(tableElement, expectedRender); // Remove a row and expect the change in rows data.pop(); expectedRender.pop(); stream.next(data); - expectTableToMatchContent(dataInputTableElement, expectedRender); + expectTableToMatchContent(tableElement, expectedRender); // Remove the data input entirely and expect no rows - just header. - dataInputComponent.dataSource = null; - dataInputFixture.detectChanges(); + component.dataSource = null; + fixture.detectChanges(); - expectTableToMatchContent(dataInputTableElement, [expectedRender[0]]); + expectTableToMatchContent(tableElement, [expectedRender[0]]); // Add back the data to verify that it renders rows - dataInputComponent.dataSource = stream; - dataInputFixture.detectChanges(); + component.dataSource = stream; + fixture.detectChanges(); - expectTableToMatchContent(dataInputTableElement, expectedRender); + expectTableToMatchContent(tableElement, expectedRender); }); it('should throw an error if the data source is not valid', () => { - dataInputComponent.dataSource = {invalid: 'dataSource'}; + component.dataSource = {invalid: 'dataSource'}; - expect(() => dataInputFixture.detectChanges()) + expect(() => fixture.detectChanges()) .toThrowError(getTableUnknownDataSourceError().message); }); it('should throw an error if the data source is not valid', () => { - dataInputComponent.dataSource = undefined; - dataInputFixture.detectChanges(); + component.dataSource = undefined; + fixture.detectChanges(); // Expect the table to render just the header, no rows - expectTableToMatchContent(dataInputTableElement, [ + expectTableToMatchContent(tableElement, [ ['Column A', 'Column B', 'Column C'] ]); }); }); + describe('missing row defs', () => { + it('should be able to render without a header row def', () => { + setupTableTestApp(MissingHeaderRowDefCdkTableApp); + expectTableToMatchContent(tableElement, [ + ['a_1'], // Data rows + ['a_2'], + ['a_3'], + ['Footer A'], // Footer row + ]); + }); + + it('should be able to render without a data row def', () => { + setupTableTestApp(MissingRowDefCdkTableApp); + expectTableToMatchContent(tableElement, [ + ['Column A'], // Header row + ['Footer A'], // Footer row + ]); + }); + + it('should be able to render without a footer row def', () => { + setupTableTestApp(MissingFooterRowDefCdkTableApp); + expectTableToMatchContent(tableElement, [ + ['Column A'], // Header row + ['a_1'], // Data rows + ['a_2'], + ['a_3'], + ]); + }); + }); + it('should render correctly when using native HTML tags', () => { const thisFixture = createComponent(NativeHtmlTableApp); const thisTableElement = thisFixture.nativeElement.querySelector('table'); @@ -247,13 +407,8 @@ describe('CdkTable', () => { }); it('should render cells even if row data is falsy', () => { - const booleanRowCdkTableAppFixture = createComponent(BooleanRowCdkTableApp); - booleanRowCdkTableAppFixture.detectChanges(); - - const booleanRowCdkTableElement = - booleanRowCdkTableAppFixture.nativeElement.querySelector('cdk-table'); - - expectTableToMatchContent(booleanRowCdkTableElement, [ + setupTableTestApp(BooleanRowCdkTableApp); + expectTableToMatchContent(tableElement, [ [''], // Header row ['false'], // Data rows ['true'], @@ -263,33 +418,15 @@ describe('CdkTable', () => { }); it('should be able to apply class-friendly css class names for the column cells', () => { - const crazyColumnNameAppFixture = createComponent(CrazyColumnNameCdkTableApp); - crazyColumnNameAppFixture.detectChanges(); - - const crazyColumnNameTableElement = - crazyColumnNameAppFixture.nativeElement.querySelector('cdk-table'); - + setupTableTestApp(CrazyColumnNameCdkTableApp); // Column was named 'crazy-column-NAME-1!@#$%^-_&*()2' - expect(getHeaderCells(crazyColumnNameTableElement)[0].classList) + expect(getHeaderCells(tableElement)[0].classList) .toContain('cdk-column-crazy-column-NAME-1-------_----2'); }); - it('should disconnect the data source when table is destroyed', () => { - const fixture = createComponent(SimpleCdkTableApp); - fixture.detectChanges(); - - const dataSource = fixture.componentInstance.dataSource as FakeDataSource; - - expect(dataSource.isConnected).toBe(true); - fixture.destroy(); - expect(dataSource.isConnected).toBe(false); - }); - it('should not clobber an existing table role', () => { - const fixture = createComponent(CustomRoleCdkTableApp); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('cdk-table').getAttribute('role')).toBe('treegrid'); + setupTableTestApp(CustomRoleCdkTableApp); + expect(tableElement.getAttribute('role')).toBe('treegrid'); }); it('should throw an error if two column definitions have the same name', () => { @@ -303,17 +440,16 @@ describe('CdkTable', () => { }); it('should throw an error if the row definitions are missing', () => { - expect(() => createComponent(MissingRowDefsCdkTableApp).detectChanges()) + expect(() => createComponent(MissingAllRowDefsCdkTableApp).detectChanges()) .toThrowError(getTableMissingRowDefsError().message); }); it('should not throw an error if columns are undefined on initialization', () => { - const undefinedColumnsFixture = createComponent(UndefinedColumnsCdkTableApp); - undefinedColumnsFixture.detectChanges(); + setupTableTestApp(UndefinedColumnsCdkTableApp); - const tableElement = undefinedColumnsFixture.nativeElement.querySelector('cdk-table'); - - expect(getHeaderRow(tableElement)).toBeNull('Should be no header without cells'); + // Header should be empty since there are no columns to display. + const headerRow = getHeaderRow(tableElement); + expect(headerRow.textContent).toBe(''); // Rows should be empty since there are no columns to display. const rows = getRows(tableElement); @@ -323,17 +459,13 @@ describe('CdkTable', () => { }); it('should be able to dynamically add/remove column definitions', () => { - const dynamicColumnDefFixture = createComponent(DynamicColumnDefinitionsCdkTableApp); - dynamicColumnDefFixture.detectChanges(); - - const dynamicColumnDefTable = dynamicColumnDefFixture.nativeElement.querySelector('cdk-table'); - const dynamicColumnDefComp = dynamicColumnDefFixture.componentInstance; + setupTableTestApp(DynamicColumnDefinitionsCdkTableApp); // Add a new column and expect it to show up in the table let columnA = 'columnA'; - dynamicColumnDefComp.dynamicColumns.push(columnA); - dynamicColumnDefFixture.detectChanges(); - expectTableToMatchContent(dynamicColumnDefTable, [ + component.dynamicColumns.push(columnA); + fixture.detectChanges(); + expectTableToMatchContent(tableElement, [ [columnA], // Header row [columnA], // Data rows [columnA], @@ -342,9 +474,9 @@ describe('CdkTable', () => { // Add another new column and expect it to show up in the table let columnB = 'columnB'; - dynamicColumnDefComp.dynamicColumns.push(columnB); - dynamicColumnDefFixture.detectChanges(); - expectTableToMatchContent(dynamicColumnDefTable, [ + component.dynamicColumns.push(columnB); + fixture.detectChanges(); + expectTableToMatchContent(tableElement, [ [columnA, columnB], // Header row [columnA, columnB], // Data rows [columnA, columnB], @@ -352,9 +484,9 @@ describe('CdkTable', () => { ]); // Remove column A expect only column B to be rendered - dynamicColumnDefComp.dynamicColumns.shift(); - dynamicColumnDefFixture.detectChanges(); - expectTableToMatchContent(dynamicColumnDefTable, [ + component.dynamicColumns.shift(); + fixture.detectChanges(); + expectTableToMatchContent(tableElement, [ [columnB], // Header row [columnB], // Data rows [columnB], @@ -362,34 +494,14 @@ describe('CdkTable', () => { ]); }); - it('should re-render the rows when the data changes', () => { - const fixture = createComponent(SimpleCdkTableApp); - fixture.detectChanges(); - - const component = fixture.componentInstance; - const dataSource = component.dataSource as FakeDataSource; - const tableElement = fixture.nativeElement.querySelector('cdk-table'); - - dataSource.addData(); - fixture.detectChanges(); - - expect(getRows(tableElement).length).toBe(dataSource.data.length); - - // Check that the number of cells is correct - getRows(tableElement).forEach(row => { - expect(getCells(row).length).toBe(component.columnsToRender.length); - }); - }); - it('should be able to register column, row, and header row definitions outside content', () => { - const outerTableAppFixture = createComponent(OuterTableApp, [WrapperCdkTableApp]); - outerTableAppFixture.detectChanges(); + setupTableTestApp(OuterTableApp, [WrapperCdkTableApp]); // The first two columns were defined in the wrapped table component as content children, // while the injected columns were provided to the wrapped table from the outer component. // A special row was provided with a when predicate that shows the single column with text. // The header row was defined by the outer component. - expectTableToMatchContent(outerTableAppFixture.nativeElement.querySelector('cdk-table'), [ + expectTableToMatchContent(tableElement, [ ['Content Column A', 'Content Column B', 'Injected Column A', 'Injected Column B'], ['injected row with when predicate'], ['a_2', 'b_2', 'a_2', 'b_2'], @@ -399,12 +511,9 @@ describe('CdkTable', () => { describe('using when predicate', () => { it('should be able to display different row templates based on the row data', () => { - const whenFixture = createComponent(WhenRowCdkTableApp); - whenFixture.detectChanges(); - - const data = whenFixture.componentInstance.dataSource.data; - - expectTableToMatchContent(whenFixture.nativeElement.querySelector('cdk-table'), [ + setupTableTestApp(WhenRowCdkTableApp); + let data = component.dataSource.data; + expectTableToMatchContent(tableElement, [ ['Column A', 'Column B', 'Column C'], [data[0].a, data[0].b, data[0].c], ['index_1_special_row'], @@ -434,76 +543,15 @@ describe('CdkTable', () => { })); }); - it('should use differ to add/remove/move rows', () => { - const fixture = createComponent(SimpleCdkTableApp); - fixture.detectChanges(); - - const component = fixture.componentInstance; - const tableElement = fixture.nativeElement.querySelector('cdk-table'); - - // 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'); - - // 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); - }); - - it('should clear the row view containers on destroy', () => { - const fixture = createComponent(SimpleCdkTableApp); - fixture.detectChanges(); - - const component = fixture.componentInstance; - const rowPlaceholder = component.table._rowPlaceholder.viewContainer; - const headerPlaceholder = component.table._headerRowPlaceholder.viewContainer; - - spyOn(rowPlaceholder, 'clear').and.callThrough(); - spyOn(headerPlaceholder, 'clear').and.callThrough(); - - fixture.destroy(); - - expect(rowPlaceholder.clear).toHaveBeenCalled(); - expect(headerPlaceholder.clear).toHaveBeenCalled(); - }); - describe('with trackBy', () => { - - let trackByComponent: TrackByCdkTableApp; - let trackByFixture: ComponentFixture; - let tableElement: HTMLElement; - function createTestComponentWithTrackyByTable(trackByStrategy) { - trackByFixture = createComponent(TrackByCdkTableApp); + fixture = createComponent(TrackByCdkTableApp); - trackByComponent = trackByFixture.componentInstance; - trackByComponent.trackByStrategy = trackByStrategy; + component = fixture.componentInstance; + component.trackByStrategy = trackByStrategy; - tableElement = trackByFixture.nativeElement.querySelector('cdk-table'); - trackByFixture.detectChanges(); + tableElement = fixture.nativeElement.querySelector('cdk-table'); + fixture.detectChanges(); // Each row receives an attribute 'initialIndex' the element's original place getRows(tableElement).forEach((row: Element, index: number) => { @@ -520,7 +568,7 @@ describe('CdkTable', () => { // Swap first two elements, remove the third, add new data function mutateData() { // Swap first and second data in data array - const copiedData = trackByComponent.dataSource.data.slice(); + const copiedData = component.dataSource.data.slice(); const temp = copiedData[0]; copiedData[0] = copiedData[1]; copiedData[1] = temp; @@ -529,8 +577,8 @@ describe('CdkTable', () => { copiedData.splice(2, 1); // Add new data - trackByComponent.dataSource.data = copiedData; - trackByComponent.dataSource.addData(); + component.dataSource.data = copiedData; + component.dataSource.addData(); } it('should add/remove/move rows with reference-based trackBy', () => { @@ -550,7 +598,7 @@ describe('CdkTable', () => { mutateData(); // Change each item reference to show that the trackby is not checking the item properties. - trackByComponent.dataSource.data = trackByComponent.dataSource.data + component.dataSource.data = component.dataSource.data .map(item => { return {a: item.a, b: item.b, c: item.c}; }); // Expect that all the rows are considered new since their references are all different @@ -567,7 +615,7 @@ describe('CdkTable', () => { // Change each item reference to show that the trackby is checking the item properties. // Otherwise this would cause them all to be removed/added. - trackByComponent.dataSource.data = trackByComponent.dataSource.data + component.dataSource.data = component.dataSource.data .map(item => { return {a: item.a, b: item.b, c: item.c}; }); // Expect that the first and second rows were swapped and that the last row is new @@ -584,7 +632,7 @@ describe('CdkTable', () => { // Change each item reference to show that the trackby is checking the index. // Otherwise this would cause them all to be removed/added. - trackByComponent.dataSource.data = trackByComponent.dataSource.data + component.dataSource.data = component.dataSource.data .map(item => { return {a: item.a, b: item.b, c: item.c}; }); // Expect first two to be the same since they were swapped but indicies are consistent. @@ -606,55 +654,18 @@ describe('CdkTable', () => { // Change each item reference to show that the trackby is checking the index. // Otherwise this would cause them all to be removed/added. - trackByComponent.dataSource.data = trackByComponent.dataSource.data + component.dataSource.data = component.dataSource.data .map(item => ({a: item.a, b: item.b, c: item.c})); // Expect the rows were given the right implicit data even though the rows were not moved. - trackByFixture.detectChanges(); + fixture.detectChanges(); expect(firstRow.textContent!.trim()).toBe('a_2 b_2'); expect(firstRow.getAttribute('initialIndex')).toBe('0'); }); }); - it('should match the right table content with dynamic data', () => { - const fixture = createComponent(SimpleCdkTableApp); - fixture.detectChanges(); - - const dataSource = fixture.componentInstance.dataSource as FakeDataSource; - const tableElement = fixture.nativeElement.querySelector('cdk-table'); - const initialDataLength = dataSource.data.length; - - expect(dataSource.data.length).toBe(3); - - let data = 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], - [data[2].a, data[2].b, data[2].c], - ]); - - // Add data to the table and recreate what the rendered output should be. - dataSource.addData(); - expect(dataSource.data.length).toBe(initialDataLength + 1); // Make sure data was added - fixture.detectChanges(); - - data = 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], - [data[2].a, data[2].b, data[2].c], - [data[3].a, data[3].b, data[3].c], - ]); - }); - it('should match the right table content with dynamic data source', () => { - const dynamicDataSourceFixture = createComponent(DynamicDataSourceCdkTableApp); - dynamicDataSourceFixture.detectChanges(); - - const component = dynamicDataSourceFixture.componentInstance; - const tableElement = dynamicDataSourceFixture.nativeElement.querySelector('cdk-table'); + setupTableTestApp(DynamicDataSourceCdkTableApp); // Expect that the component has no data source and the table element reflects empty data. expect(component.dataSource).toBeUndefined(); @@ -665,7 +676,7 @@ describe('CdkTable', () => { // Add a data source that has initialized data. Expect that the table shows this data. const dynamicDataSource = new FakeDataSource(); component.dataSource = dynamicDataSource; - dynamicDataSourceFixture.detectChanges(); + fixture.detectChanges(); expect(dynamicDataSource.isConnected).toBe(true); const data = component.dataSource.data; @@ -677,8 +688,8 @@ describe('CdkTable', () => { ]); // Remove the data source and check to make sure the table is empty again. - component.dataSource = undefined!; - dynamicDataSourceFixture.detectChanges(); + component.dataSource = undefined; + fixture.detectChanges(); // Expect that the old data source has been disconnected. expect(dynamicDataSource.isConnected).toBe(false); @@ -689,7 +700,7 @@ describe('CdkTable', () => { // Reconnect a data source and check that the table is populated const newDynamicDataSource = new FakeDataSource(); component.dataSource = newDynamicDataSource; - dynamicDataSourceFixture.detectChanges(); + fixture.detectChanges(); expect(newDynamicDataSource.isConnected).toBe(true); const newData = component.dataSource.data; @@ -702,11 +713,9 @@ describe('CdkTable', () => { }); it('should be able to apply classes to rows based on their context', () => { - const contextFixture = createComponent(RowContextCdkTableApp); - contextFixture.detectChanges(); + setupTableTestApp(RowContextCdkTableApp); - const contextComponent = contextFixture.componentInstance; - const rowElements = contextFixture.nativeElement.querySelectorAll('cdk-row'); + let rowElements = tableElement.querySelectorAll('cdk-row'); // Rows should not have any context classes for (let i = 0; i < rowElements.length; i++) { @@ -717,8 +726,8 @@ describe('CdkTable', () => { } // Enable all the context classes - contextComponent.enableRowContextClasses = true; - contextFixture.detectChanges(); + component.enableRowContextClasses = true; + fixture.detectChanges(); expect(rowElements[0].classList.contains('custom-row-class-first')).toBe(true); expect(rowElements[0].classList.contains('custom-row-class-last')).toBe(false); @@ -737,11 +746,9 @@ describe('CdkTable', () => { }); it('should be able to apply classes to cells based on their row context', () => { - const contextFixture = createComponent(RowContextCdkTableApp); - contextFixture.detectChanges(); + setupTableTestApp(RowContextCdkTableApp); - const contextComponent = contextFixture.componentInstance; - const rowElements = contextFixture.nativeElement.querySelectorAll('cdk-row'); + const rowElements = fixture.nativeElement.querySelectorAll('cdk-row'); for (let i = 0; i < rowElements.length; i++) { // Cells should not have any context classes @@ -755,8 +762,8 @@ describe('CdkTable', () => { } // Enable the context classes - contextComponent.enableCellContextClasses = true; - contextFixture.detectChanges(); + component.enableCellContextClasses = true; + fixture.detectChanges(); let cellElement = rowElements[0].querySelectorAll('cdk-cell')[0]; expect(cellElement.classList.contains('custom-cell-class-first')).toBe(true); @@ -776,40 +783,6 @@ describe('CdkTable', () => { expect(cellElement.classList.contains('custom-cell-class-even')).toBe(true); expect(cellElement.classList.contains('custom-cell-class-odd')).toBe(false); }); - - it('should be able to dynamically change the columns for header and rows', () => { - const fixture = createComponent(SimpleCdkTableApp); - fixture.detectChanges(); - - const component = fixture.componentInstance; - const dataSource = component.dataSource as FakeDataSource; - const tableElement = fixture.nativeElement.querySelector('cdk-table'); - - expect(dataSource.data.length).toBe(3); - - let data = 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], - [data[2].a, data[2].b, data[2].c], - ]); - - // Remove column_a and swap column_b/column_c. - component.columnsToRender = ['column_c', 'column_b']; - fixture.detectChanges(); - - let changedTableContent = [['Column C', 'Column B']]; - dataSource.data.forEach(rowData => changedTableContent.push([rowData.c, rowData.b])); - - data = dataSource.data; - expectTableToMatchContent(tableElement, [ - ['Column C', 'Column B'], - [data[0].c, data[0].b], - [data[1].c, data[1].b], - [data[2].c, data[2].b], - ]); - }); }); interface TestData { @@ -868,24 +841,29 @@ class BooleanDataSource extends DataSource { template: ` - Column A - {{row.a}} + Column A + {{row.a}} + Footer A - Column B - {{row.b}} + Column B + {{row.b}} + Footer B - Column C - {{row.c}} + Column C + {{row.c}} + Footer C + ` }) @@ -1089,7 +1067,7 @@ class WhenRowMultipleDefaultsCdkTableApp { ` }) class DynamicDataSourceCdkTableApp { - dataSource: FakeDataSource; + dataSource: FakeDataSource | undefined; columnsToRender = ['column_a']; @ViewChild(CdkTable) table: CdkTable; @@ -1239,7 +1217,61 @@ class MissingColumnDefCdkTableApp { ` }) -class MissingRowDefsCdkTableApp { +class MissingAllRowDefsCdkTableApp { + dataSource: FakeDataSource = new FakeDataSource(); +} + +@Component({ + template: ` + + + Column A + {{row.a}} + Footer A + + + + + + ` +}) +class MissingHeaderRowDefCdkTableApp { + dataSource: FakeDataSource = new FakeDataSource(); +} + +@Component({ + template: ` + + + Column A + {{row.a}} + Footer A + + + + + + ` +}) +class MissingRowDefCdkTableApp { + dataSource: FakeDataSource = new FakeDataSource(); +} + +@Component({ + template: ` + + + Column A + {{row.a}} + Footer A + + + + + + ` +}) +class MissingFooterRowDefCdkTableApp { dataSource: FakeDataSource = new FakeDataSource(); } @@ -1401,6 +1433,10 @@ function getHeaderRow(tableElement: Element): Element { return tableElement.querySelector('.cdk-header-row')!; } +function getFooterRow(tableElement: Element): Element { + return tableElement.querySelector('.cdk-footer-row')!; +} + function getRows(tableElement: Element): Element[] { return getElements(tableElement, '.cdk-row'); } @@ -1412,41 +1448,56 @@ function getHeaderCells(tableElement: Element): Element[] { return getElements(getHeaderRow(tableElement), '.cdk-header-cell'); } -function expectTableToMatchContent(tableElement: Element, expectedTableContent: any[]) { +function getFooterCells(tableElement: Element): Element[] { + return getElements(getFooterRow(tableElement), '.cdk-footer-cell'); +} + +function getActualTableContent(tableElement: Element): string[][] { + let actualTableContent: Element[][] = []; + if (getHeaderRow(tableElement)) { + actualTableContent.push(getHeaderCells(tableElement)); + } + + // Check data row cells + const rows = getRows(tableElement).map(row => getCells(row)); + actualTableContent = actualTableContent.concat(rows); + + if (getFooterRow(tableElement)) { + actualTableContent.push(getFooterCells(tableElement)); + } + + // Convert the nodes into their text content; + return actualTableContent.map(row => row.map(cell => cell.textContent!.trim())); +} + +function expectTableToMatchContent(tableElement: Element, expected: any[]) { const missedExpectations: string[] = []; - function checkCellContent(cell: Element, expectedTextContent: string) { - const actualTextContent = cell.textContent!.trim(); - if (actualTextContent !== expectedTextContent) { - missedExpectations.push( - `Expected cell contents to be ${expectedTextContent} but was ${actualTextContent}`); + function checkCellContent(actualCell: string, expectedCell: string) { + if (actualCell !== expectedCell) { + missedExpectations.push(`Expected cell contents to be ${expectedCell} but was ${actualCell}`); } } - // Copy the expected data array to avoid mutating the test's array - expectedTableContent = expectedTableContent.slice(); + const actual = getActualTableContent(tableElement); - // Check header cells - const expectedHeaderContent = expectedTableContent.shift(); - getHeaderCells(tableElement).forEach((cell, index) => { - const expected = expectedHeaderContent ? - expectedHeaderContent[index] : - null; - checkCellContent(cell, expected); - }); - - // Check data row cells - const rows = getRows(tableElement); - if (rows.length !== expectedTableContent.length) { - missedExpectations.push( - `Expected ${expectedTableContent.length} rows but found ${rows.length}`); + // Make sure the number of rows match + if (actual.length !== expected.length) { + missedExpectations.push(`Expected ${expected.length} total rows but got ${actual.length}`); fail(missedExpectations.join('\n')); } - rows.forEach((row, rowIndex) => { - getCells(row).forEach((cell, cellIndex) => { - const expected = expectedTableContent.length ? - expectedTableContent[rowIndex][cellIndex] : - null; - checkCellContent(cell, expected); + + actual.forEach((row, rowIndex) => { + const expectedRow = expected[rowIndex]; + + // Make sure the number of cells match + if (row.length !== expectedRow.length) { + missedExpectations.push(`Expected ${expectedRow.length} cells in row but got ${row.length}`); + fail(missedExpectations.join('\n')); + } + + row.forEach((actualCell, cellIndex) => { + const expectedCell = expectedRow ? expectedRow[cellIndex] : null; + checkCellContent(actualCell, expectedCell); }); }); diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index bf5d990c665..e6845a3567c 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -23,17 +23,24 @@ import { IterableDiffer, IterableDiffers, OnInit, - QueryList, + QueryList, TemplateRef, TrackByFunction, ViewChild, ViewContainerRef, ViewEncapsulation, } from '@angular/core'; import {CollectionViewer, DataSource} from '@angular/cdk/collections'; -import {CdkCellOutlet, CdkCellOutletRowContext, CdkHeaderRowDef, CdkRowDef} from './row'; +import { + BaseRowDef, + CdkCellOutlet, + CdkCellOutletRowContext, + CdkFooterRowDef, + CdkHeaderRowDef, + CdkRowDef +} from './row'; import {takeUntil} from 'rxjs/operators'; import {of as observableOf, BehaviorSubject, Observable, Subscription, Subject} from 'rxjs'; -import {CdkCellDef, CdkColumnDef, CdkHeaderCellDef} from './cell'; +import {CdkColumnDef} from './cell'; import { getTableDuplicateColumnNameError, getTableMissingMatchingRowDefError, @@ -43,12 +50,17 @@ import { getTableUnknownDataSourceError } from './table-errors'; +/** Interface used to provide an outlet for rows to be inserted into. */ +export interface RowOutlet { + viewContainer: ViewContainerRef; +} + /** * Provides a handle for the table to grab the view container's ng-container to insert data rows. * @docs-private */ -@Directive({selector: '[rowPlaceholder]'}) -export class RowPlaceholder { +@Directive({selector: '[rowOutlet]'}) +export class DataRowOutlet implements RowOutlet { constructor(public viewContainer: ViewContainerRef, public elementRef: ElementRef) { } } @@ -56,18 +68,29 @@ export class RowPlaceholder { * Provides a handle for the table to grab the view container's ng-container to insert the header. * @docs-private */ -@Directive({selector: '[headerRowPlaceholder]'}) -export class HeaderRowPlaceholder { +@Directive({selector: '[headerRowOutlet]'}) +export class HeaderRowOutlet implements RowOutlet { + constructor(public viewContainer: ViewContainerRef, public elementRef: ElementRef) { } +} + +/** + * Provides a handle for the table to grab the view container's ng-container to insert the footer. + * @docs-private + */ +@Directive({selector: '[footerRowOutlet]'}) +export class FooterRowOutlet implements RowOutlet { constructor(public viewContainer: ViewContainerRef, public elementRef: ElementRef) { } } /** * The table template that can be used by the mat-table. Should not be used outside of the * material library. + * @docs-private */ export const CDK_TABLE_TEMPLATE = ` - - `; + + + `; /** * Class used to conveniently type the embedded view ref for rows with a context. @@ -76,10 +99,10 @@ export const CDK_TABLE_TEMPLATE = ` abstract class RowViewRef extends EmbeddedViewRef> { } /** - * A data table that renders a header row and data rows. Uses the dataSource input to determine - * the data to be rendered. The data can be provided either as a data array, an Observable stream - * that emits the data array to render, or a DataSource with a connect function that will - * return an Observable stream that emits the data array to render. + * A data table that can render a header row, data rows, and a footer row. + * Uses the dataSource input to determine the data to be rendered. The data can be provided either + * as a data array, an Observable stream that emits the data array to render, or a DataSource with a + * connect function that will return an Observable stream that emits the data array to render. */ @Component({ moduleId: module.id, @@ -103,9 +126,9 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke private _renderChangeSubscription: Subscription | null; /** - * Map of all the user's defined columns (header and data cell template) identified by name. - * Collection populated by the column definitions gathered by `ContentChildren` as well as any - * custom column definitions added to `_customColumnDefs`. + * Map of all the user's defined columns (header, data, and footer cell template) identified by + * name. Collection populated by the column definitions gathered by `ContentChildren` as well as + * any custom column definitions added to `_customColumnDefs`. */ private _columnDefsByName = new Map(); @@ -133,6 +156,12 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke */ private _headerRowDefChanged = false; + /** + * Whether the footer row definition has been changed. Triggers an update to the footer row after + * content is checked. + */ + private _footerRowDefChanged = false; + /** * 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 @@ -178,7 +207,7 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke this._switchDataSource(dataSource); } } - private _dataSource: DataSource | Observable | T[]; + private _dataSource: DataSource | Observable | T[] | T[]; // TODO(andrewseguin): Remove max value as the end index // and instead calculate the view on init and scroll. @@ -189,13 +218,14 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke viewChange: BehaviorSubject<{start: number, end: number}> = new BehaviorSubject<{start: number, end: number}>({start: 0, end: Number.MAX_VALUE}); - // Placeholders within the table's template where the header and data rows will be inserted. - @ViewChild(RowPlaceholder) _rowPlaceholder: RowPlaceholder; - @ViewChild(HeaderRowPlaceholder) _headerRowPlaceholder: HeaderRowPlaceholder; + // Outlets in the table's template where the header, data rows, and footer will be inserted. + @ViewChild(DataRowOutlet) _rowOutlet: DataRowOutlet; + @ViewChild(HeaderRowOutlet) _headerRowOutlet: HeaderRowOutlet; + @ViewChild(FooterRowOutlet) _footerRowOutlet: FooterRowOutlet; /** - * The column definitions provided by the user that contain what the header and cells should - * render for each column. + * The column definitions provided by the user that contain what the header, data, and footer + * cells should render for each column. */ @ContentChildren(CdkColumnDef) _contentColumnDefs: QueryList; @@ -210,6 +240,14 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke */ @ContentChild(CdkHeaderRowDef) _headerRowDef: CdkHeaderRowDef; + /** + * Template definition used as the footer container. By default it stores the footer row + * definition found as a direct content child. Override this value through `setFooterRowDef` if + * the footer row definition should be changed or was not defined as a part of the table's + * content. + */ + @ContentChild(CdkFooterRowDef) _footerRowDef: CdkFooterRowDef; + constructor(private readonly _differs: IterableDiffers, private readonly _changeDetectorRef: ChangeDetectorRef, private readonly _elementRef: ElementRef, @@ -227,11 +265,10 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke // TODO(andrewseguin): Setup a listener for scrolling, emit the calculated view to viewChange this._dataDiffer = this._differs.find([]).create(this._trackByFn); - // If the table has a header row definition defined as part of its content, flag this as a - // header row def change so that the content check will render the header row. - if (this._headerRowDef) { - this._headerRowDefChanged = true; - } + // 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. + this._headerRowDefChanged = !!this._headerRowDef; + this._footerRowDefChanged = !!this._footerRowDef; } ngAfterContentChecked() { @@ -239,12 +276,12 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke this._cacheRowDefs(); this._cacheColumnDefs(); - // Make sure that the user has at least added a header row or row def. - if (!this._headerRowDef && !this._rowDefs.length) { + // Make sure that the user has at least added header, footer, or data row def. + if (!this._headerRowDef && !this._footerRowDef && !this._rowDefs.length) { throw getTableMissingRowDefsError(); } - // Render updates if the list of columns have been changed for the header or row definitions. + // Render updates if the list of columns have been changed for the header, row, or footer defs. this._renderUpdatedColumns(); // If the header row definition has been changed, trigger a render to the header row. @@ -253,6 +290,12 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke this._headerRowDefChanged = false; } + // If the footer row definition has been changed, trigger a render to the footer row. + if (this._footerRowDefChanged) { + this._renderFooterRow(); + this._footerRowDefChanged = false; + } + // If there is a data source and row definitions, connect to the data source unless a // connection has already been made. if (this.dataSource && this._rowDefs.length > 0 && !this._renderChangeSubscription) { @@ -261,8 +304,9 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke } ngOnDestroy() { - this._rowPlaceholder.viewContainer.clear(); - this._headerRowPlaceholder.viewContainer.clear(); + this._rowOutlet.viewContainer.clear(); + this._headerRowOutlet.viewContainer.clear(); + this._footerRowOutlet.viewContainer.clear(); this._onDestroy.next(); this._onDestroy.complete(); @@ -285,7 +329,7 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke const changes = this._dataDiffer.diff(this._data); if (!changes) { return; } - const viewContainer = this._rowPlaceholder.viewContainer; + const viewContainer = this._rowOutlet.viewContainer; changes.forEachOperation( (record: IterableChangeRecord, adjustedPreviousIndex: number, currentIndex: number) => { if (record.previousIndex == null) { @@ -319,6 +363,16 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke this._headerRowDefChanged = true; } + /** + * Sets the footer row definition to be used. Overrides the footer row definition gathered by + * using `ContentChild`, if one exists. Sets a flag that will re-render the footer row after the + * table's content is checked. + */ + setFooterRowDef(footerRowDef: CdkFooterRowDef) { + this._footerRowDef = footerRowDef; + this._footerRowDefChanged = true; + } + /** Adds a column definition that was not included as part of the direct content children. */ addColumnDef(columnDef: CdkColumnDef) { this._customColumnDefs.add(columnDef); @@ -365,8 +419,8 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke } /** - * Check if the header or rows have changed what columns they want to display. If there is a diff, - * then re-render that section. + * Check if the header, data, or footer rows have changed what columns they want to display. + * If there is a diff, then re-render that section. */ private _renderUpdatedColumns() { // Re-render the rows when the row definition columns change. @@ -375,7 +429,7 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke // Reset the data to an empty array so that renderRowChanges will re-render all new rows. this._dataDiffer.diff([]); - this._rowPlaceholder.viewContainer.clear(); + this._rowOutlet.viewContainer.clear(); this.renderRows(); } }); @@ -384,12 +438,17 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke if (this._headerRowDef && this._headerRowDef.getColumnsDiff()) { this._renderHeaderRow(); } + + // Re-render the footer row if there is a difference in its columns. + if (this._footerRowDef && this._footerRowDef.getColumnsDiff()) { + this._renderFooterRow(); + } } /** * Switch to the provided data source by resetting the data and unsubscribing from the current * render change subscription if one exists. If the data source is null, interpret this by - * clearing the row placeholder. Otherwise start listening for new data. + * clearing the row outlet. Otherwise start listening for new data. */ private _switchDataSource(dataSource: DataSource | Observable | T[]) { this._data = []; @@ -408,7 +467,7 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke if (this._dataDiffer) { this._dataDiffer.diff([]); } - this._rowPlaceholder.viewContainer.clear(); + this._rowOutlet.viewContainer.clear(); } this._dataSource = dataSource; @@ -446,31 +505,29 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke } /** - * Clears any existing content in the header row placeholder and creates a new embedded view - * in the placeholder using the header row definition. + * Clears any existing content in the header row outlet and creates a new embedded view + * in the outlet using the header row definition. */ private _renderHeaderRow() { - // Clear the header row placeholder if any content exists. - if (this._headerRowPlaceholder.viewContainer.length > 0) { - this._headerRowPlaceholder.viewContainer.clear(); + // Clear the footer row outlet if any content exists. + if (this._headerRowOutlet.viewContainer.length > 0) { + this._headerRowOutlet.viewContainer.clear(); } - const cells = this._getHeaderCellTemplatesForRow(this._headerRowDef); - if (!cells.length) { return; } + this._renderRow(this._headerRowOutlet, this._headerRowDef); + } - // TODO(andrewseguin): add some code to enforce that exactly - // one CdkCellOutlet was instantiated as a result - // of `createEmbeddedView`. - this._headerRowPlaceholder.viewContainer - .createEmbeddedView(this._headerRowDef.template, {cells}); + /** + * Clears any existing content in the footer row outlet and creates a new embedded view + * in the outlet using the footer row definition. + */ + private _renderFooterRow() { + // Clear the footer row outlet if any content exists. + if (this._footerRowOutlet.viewContainer.length > 0) { + this._footerRowOutlet.viewContainer.clear(); + } - cells.forEach(cell => { - if (CdkCellOutlet.mostRecentCellOutlet) { - CdkCellOutlet.mostRecentCellOutlet._viewContainer.createEmbeddedView(cell.template, {}); - } - }); - - this._changeDetectorRef.markForCheck(); + this._renderRow(this._footerRowOutlet, this._footerRowDef); } /** @@ -493,21 +550,26 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke * within the data row view container. */ private _insertRow(rowData: T, index: number) { - const row = this._getRowDef(rowData, index); - - // Row context that will be provided to both the created embedded row view and its cells. + const rowDef = this._getRowDef(rowData, index); const context: CdkCellOutletRowContext = {$implicit: rowData}; + this._renderRow(this._rowOutlet, rowDef, context, index); + } - // TODO(andrewseguin): add some code to enforce that exactly one - // CdkCellOutlet was instantiated as a result of `createEmbeddedView`. - this._rowPlaceholder.viewContainer.createEmbeddedView(row.template, context, index); + /** + * Creates a new row template in the outlet and fills it with the set of cell templates. + * Optionally takes a context to provide to the row and cells, as well as an optional index + * of where to place the new row template in the outlet. + */ + private _renderRow( + outlet: RowOutlet, rowDef: BaseRowDef, context: CdkCellOutletRowContext = {}, index = 0) { + // TODO(andrewseguin): enforce that one outlet was instantiated from createEmbeddedView + outlet.viewContainer.createEmbeddedView(rowDef.template, context, index); - this._getCellTemplatesForRow(row).forEach(cell => { + for (let cellTemplate of this._getCellTemplates(rowDef)) { if (CdkCellOutlet.mostRecentCellOutlet) { - CdkCellOutlet.mostRecentCellOutlet._viewContainer - .createEmbeddedView(cell.template, context); + CdkCellOutlet.mostRecentCellOutlet._viewContainer.createEmbeddedView(cellTemplate, context); } - }); + } this._changeDetectorRef.markForCheck(); } @@ -517,7 +579,7 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke * e.g. first/last/even/odd. */ private _updateRowIndexContext() { - const viewContainer = this._rowPlaceholder.viewContainer; + 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; @@ -529,30 +591,9 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke } } - /** - * Returns the cell template definitions to insert into the header - * as defined by its list of columns to display. - */ - private _getHeaderCellTemplatesForRow(headerDef: CdkHeaderRowDef): CdkHeaderCellDef[] { - if (!headerDef || !headerDef.columns) { return []; } - return Array.from(headerDef.columns, columnId => { - const column = this._columnDefsByName.get(columnId); - - if (!column) { - throw getTableUnknownColumnError(columnId); - } - - return column.headerCell; - }); - } - - - /** - * 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[] { - if (!rowDef.columns) { return []; } + /** Gets the column definitions for the provided row def. */ + private _getCellTemplates(rowDef: BaseRowDef): TemplateRef[] { + if (!rowDef || !rowDef.columns) { return []; } return Array.from(rowDef.columns, columnId => { const column = this._columnDefsByName.get(columnId); @@ -560,19 +601,22 @@ export class CdkTable implements CollectionViewer, OnInit, AfterContentChecke throw getTableUnknownColumnError(columnId); } - return column.cell; + return rowDef.extractCellTemplate(column); }); } - /** Adds native table sections (e.g. tbody) and moves the row placeholders into them. */ + /** Adds native table sections (e.g. tbody) and moves the row outlets into them. */ private _applyNativeTableSections() { - const thead = document.createElement('thead'); - const tbody = document.createElement('tbody'); + const sections = [ + {tag: 'thead', outlet: this._headerRowOutlet}, + {tag: 'tbody', outlet: this._rowOutlet}, + {tag: 'tfoot', outlet: this._footerRowOutlet}, + ]; - this._elementRef.nativeElement.appendChild(thead); - this._elementRef.nativeElement.appendChild(tbody); - - thead.appendChild(this._headerRowPlaceholder.elementRef.nativeElement); - tbody.appendChild(this._rowPlaceholder.elementRef.nativeElement); + for (const section of sections) { + const element = document.createElement(section.tag); + element.appendChild(section.outlet.elementRef.nativeElement); + this._elementRef.nativeElement.appendChild(element); + } } } diff --git a/src/demo-app/table/table-demo.html b/src/demo-app/table/table-demo.html index daefe9d0eef..5bc1e3174d4 100644 --- a/src/demo-app/table/table-demo.html +++ b/src/demo-app/table/table-demo.html @@ -4,10 +4,12 @@ {{column}} {{element[column]}} + Footer {{column}} + @@ -17,10 +19,12 @@ {{column}} {{element[column]}} + Footer {{column}} + > @@ -30,10 +34,12 @@ {{column}} {{element[column]}} + Footer {{column}} + @@ -43,9 +49,11 @@ {{column}} {{element[column]}} + Footer {{column}} + > \ No newline at end of file diff --git a/src/lib/table/_table-theme.scss b/src/lib/table/_table-theme.scss index 812a1b65f2d..e386dee8cc7 100644 --- a/src/lib/table/_table-theme.scss +++ b/src/lib/table/_table-theme.scss @@ -10,8 +10,8 @@ background: mat-color($background, 'card'); } - - mat-row, mat-header-row, th.mat-header-cell, td.mat-cell { + mat-row, mat-header-row, mat-footer-row, + th.mat-header-cell, td.mat-cell, td.mat-footer-cell { border-bottom-color: mat-color($foreground, divider); } @@ -19,7 +19,7 @@ color: mat-color($foreground, secondary-text); } - .mat-cell { + .mat-cell, .mat-footer-cell { color: mat-color($foreground, text); } } @@ -34,7 +34,7 @@ font-weight: mat-font-weight($config, body-2); } - .mat-cell { + .mat-cell, .mat-footer-cell { font-size: mat-font-size($config, body-1); } } diff --git a/src/lib/table/cell.ts b/src/lib/table/cell.ts index 7e0ae29e43d..fd6a9f525a2 100644 --- a/src/lib/table/cell.ts +++ b/src/lib/table/cell.ts @@ -10,7 +10,7 @@ import {Directive, ElementRef, Input} from '@angular/core'; import { CdkCell, CdkCellDef, - CdkColumnDef, + CdkColumnDef, CdkFooterCell, CdkFooterCellDef, CdkHeaderCell, CdkHeaderCellDef, } from '@angular/cdk/table'; @@ -35,6 +35,16 @@ export class MatCellDef extends CdkCellDef { } }) export class MatHeaderCellDef extends CdkHeaderCellDef { } +/** + * Footer cell definition for the mat-table. + * Captures the template of a column's footer cell and as well as cell-specific properties. + */ +@Directive({ + selector: '[matFooterCellDef]', + providers: [{provide: CdkFooterCellDef, useExisting: MatFooterCellDef}] +}) +export class MatFooterCellDef extends CdkFooterCellDef { } + /** * Column definition for the mat-table. * Defines a set of cells available for a table column. @@ -64,6 +74,22 @@ export class MatHeaderCell extends CdkHeaderCell { } } +/** Footer cell template container that adds the right classes and role. */ +@Directive({ + selector: 'mat-footer-cell, td[mat-footer-cell]', + host: { + 'class': 'mat-footer-cell', + 'role': 'gridcell', + }, +}) +export class MatFooterCell extends CdkFooterCell { + constructor(columnDef: CdkColumnDef, + elementRef: ElementRef) { + super(columnDef, elementRef); + elementRef.nativeElement.classList.add(`mat-column-${columnDef.cssClassFriendlyName}`); + } +} + /** Cell template container that adds the right classes and role. */ @Directive({ selector: 'mat-cell, td[mat-cell]', diff --git a/src/lib/table/row.ts b/src/lib/table/row.ts index 6e68efdf3f0..7a3db6bf7bd 100644 --- a/src/lib/table/row.ts +++ b/src/lib/table/row.ts @@ -8,7 +8,7 @@ import {ChangeDetectionStrategy, Component, Directive, ViewEncapsulation} from '@angular/core'; import { - CDK_ROW_TEMPLATE, + CDK_ROW_TEMPLATE, CdkFooterRow, CdkFooterRowDef, CdkHeaderRow, CdkHeaderRowDef, CdkRow, @@ -26,9 +26,20 @@ import { }) export class MatHeaderRowDef extends CdkHeaderRowDef { } +/** + * Footer row definition for the mat-table. + * Captures the footer row's template and other footer properties such as the columns to display. + */ +@Directive({ + selector: '[matFooterRowDef]', + providers: [{provide: CdkFooterRowDef, useExisting: MatFooterRowDef}], + inputs: ['columns: matFooterRowDef'], +}) +export class MatFooterRowDef extends CdkFooterRowDef { } + /** * Data row definition for the mat-table. - * Captures the header row's template and other row properties such as the columns to display and + * Captures the footer row's template and other footer properties such as the columns to display and * a when predicate that describes when this row should be used. */ @Directive({ @@ -39,7 +50,7 @@ export class MatHeaderRowDef extends CdkHeaderRowDef { } export class MatRowDef extends CdkRowDef { } -/** Header template container that contains the cell outlet. Adds the right class and role. */ +/** Footer template container that contains the cell outlet. Adds the right class and role. */ @Component({ moduleId: module.id, selector: 'mat-header-row, tr[mat-header-row]', @@ -54,6 +65,21 @@ export class MatRowDef extends CdkRowDef { }) export class MatHeaderRow extends CdkHeaderRow { } +/** Footer template container that contains the cell outlet. Adds the right class and role. */ +@Component({ + moduleId: module.id, + selector: 'mat-footer-row, tr[mat-footer-row]', + template: CDK_ROW_TEMPLATE, + host: { + 'class': 'mat-footer-row', + 'role': 'row', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + exportAs: 'matFooterRow', +}) +export class MatFooterRow extends CdkFooterRow { } + /** Data row template container that contains the cell outlet. Adds the right class and role. */ @Component({ moduleId: module.id, diff --git a/src/lib/table/table-module.ts b/src/lib/table/table-module.ts index edf816b977b..fbb82071df2 100644 --- a/src/lib/table/table-module.ts +++ b/src/lib/table/table-module.ts @@ -9,8 +9,23 @@ import {NgModule} from '@angular/core'; import {MatTable} from './table'; import {CdkTableModule} from '@angular/cdk/table'; -import {MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef} from './cell'; -import {MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef} from './row'; +import { + MatCell, + MatCellDef, + MatColumnDef, + MatFooterCell, + MatFooterCellDef, + MatHeaderCell, + MatHeaderCellDef +} from './cell'; +import { + MatFooterRow, + MatFooterRowDef, + MatHeaderRow, + MatHeaderRowDef, + MatRow, + MatRowDef +} from './row'; import {CommonModule} from '@angular/common'; import {MatCommonModule} from '@angular/material/core'; @@ -19,19 +34,23 @@ const EXPORTED_DECLARATIONS = [ MatTable, // Template defs - MatCellDef, MatHeaderCellDef, - MatColumnDef, MatHeaderRowDef, + MatColumnDef, + MatCellDef, MatRowDef, + MatFooterCellDef, + MatFooterRowDef, // Cell directives MatHeaderCell, MatCell, + MatFooterCell, // Row directions MatHeaderRow, MatRow, + MatFooterRow, ]; @NgModule({ diff --git a/src/lib/table/table.md b/src/lib/table/table.md index b15713fa65f..20741e22192 100644 --- a/src/lib/table/table.md +++ b/src/lib/table/table.md @@ -257,6 +257,28 @@ masterToggle() { } ``` +#### Footer row + +A footer row can be added to the table by adding a footer row definition to the table and adding +footer cell templates to column definitions. The footer row will be rendered after the rendered +data rows. + +```html + + Cost + {{data.cost}} + {{totalCost}} + + +... + + + + + ##### 4. Include overflow styling Finally, adjust the styling for the select column so that its overflow is not hidden. This allows diff --git a/src/lib/table/table.scss b/src/lib/table/table.scss index af03f40093c..150514aa96e 100644 --- a/src/lib/table/table.scss +++ b/src/lib/table/table.scss @@ -13,14 +13,17 @@ mat-header-row { min-height: $mat-header-row-height; } -mat-row { +mat-row, mat-footer-row { min-height: $mat-row-height; } -mat-row, mat-header-row { +mat-row, mat-header-row, mat-footer-row { display: flex; + // Define a border style, but then widths default to 3px. Reset them to 0px except the bottom + // which should be 1px; + border-width: 0; border-bottom-width: 1px; - border-bottom-style: solid; + border-style: solid; align-items: center; box-sizing: border-box; @@ -34,7 +37,7 @@ mat-row, mat-header-row { } } -mat-cell:first-child, mat-header-cell:first-child { +mat-cell:first-child, mat-header-cell:first-child, mat-footer-cell:first-child { padding-left: $mat-row-horizontal-padding; [dir='rtl'] & { @@ -43,7 +46,7 @@ mat-cell:first-child, mat-header-cell:first-child { } } -mat-cell:last-child, mat-header-cell:last-child { +mat-cell:last-child, mat-header-cell:last-child, mat-footer-cell:last-child { padding-right: $mat-row-horizontal-padding; [dir='rtl'] & { @@ -52,7 +55,7 @@ mat-cell:last-child, mat-header-cell:last-child { } } -mat-cell, mat-header-cell { +mat-cell, mat-header-cell, mat-footer-cell { flex: 1; display: flex; align-items: center; @@ -72,7 +75,7 @@ tr.mat-header-row { height: $mat-header-row-height; } -tr.mat-row { +tr.mat-row, tr.mat-footer-row { height: $mat-row-height; } @@ -80,16 +83,16 @@ th.mat-header-cell { text-align: left; } -th.mat-header-cell, td.mat-cell { +th.mat-header-cell, td.mat-cell, td.mat-footer-cell { padding: 0; border-bottom-width: 1px; border-bottom-style: solid; } -th.mat-header-cell:first-child, td.mat-cell:first-child { +th.mat-header-cell:first-child, td.mat-cell:first-child, td.mat-footer-cell:first-child { padding-left: $mat-row-horizontal-padding; } -th.mat-header-cell:last-child, td.mat-cell:last-child { +th.mat-header-cell:last-child, td.mat-cell:last-child, td.mat-footer-cell:last-child { padding-right: $mat-row-horizontal-padding; } diff --git a/src/lib/table/table.spec.ts b/src/lib/table/table.spec.ts index 0e5e1f809cb..401407fe188 100644 --- a/src/lib/table/table.spec.ts +++ b/src/lib/table/table.spec.ts @@ -37,7 +37,8 @@ describe('MatTable', () => { [data[0].a, data[0].b, data[0].c], [data[1].a, data[1].b, data[1].c], [data[2].a, data[2].b, data[2].c], - ['fourth_row'] + ['fourth_row'], + ['Footer A', 'Footer B', 'Footer C'], ]); }); @@ -51,7 +52,8 @@ describe('MatTable', () => { ['a_1'], ['a_2'], ['a_3'], - ['fourth_row'] + ['fourth_row'], + ['Footer A', 'Footer B', 'Footer C'], ]); }); }); @@ -120,6 +122,7 @@ describe('MatTable', () => { ['a_1', 'b_1', 'c_1'], ['a_2', 'b_2', 'c_2'], ['a_3', 'b_3', 'c_3'], + ['Footer A', 'Footer B', 'Footer C'], ]); }); @@ -133,6 +136,7 @@ describe('MatTable', () => { ['a_2', 'b_2', 'c_2'], ['a_3', 'b_3', 'c_3'], ['a_4', 'b_4', 'c_4'], + ['Footer A', 'Footer B', 'Footer C'], ]); // Remove data @@ -145,6 +149,7 @@ describe('MatTable', () => { ['a_2', 'b_2', 'c_2'], ['a_3', 'b_3', 'c_3'], ['a_4', 'b_4', 'c_4'], + ['Footer A', 'Footer B', 'Footer C'], ]); }); @@ -157,6 +162,7 @@ describe('MatTable', () => { expectTableToMatchContent(tableElement, [ ['Column A', 'Column B', 'Column C'], ['a_1', 'b_1', 'c_1'], + ['Footer A', 'Footer B', 'Footer C'], ]); flushMicrotasks(); // Resolve promise that updates paginator's length @@ -170,6 +176,7 @@ describe('MatTable', () => { expectTableToMatchContent(tableElement, [ ['Column A', 'Column B', 'Column C'], ['a_2', 'b_2', 'c_2'], + ['Footer A', 'Footer B', 'Footer C'], ]); // Change filter to empty string, should match all rows @@ -184,6 +191,7 @@ describe('MatTable', () => { ['a_1', 'b_1', 'c_1'], ['a_2', 'b_2', 'c_2'], ['a_3', 'b_3', 'c_3'], + ['Footer A', 'Footer B', 'Footer C'], ]); // Change filter function and filter, should match to rows with zebra. @@ -203,6 +211,7 @@ describe('MatTable', () => { expectTableToMatchContent(tableElement, [ ['Column A', 'Column B', 'Column C'], ['a_2', 'b_2', 'c_2'], + ['Footer A', 'Footer B', 'Footer C'], ]); })); @@ -215,6 +224,7 @@ describe('MatTable', () => { ['a_1', 'b_1', 'c_1'], ['a_2', 'b_2', 'c_2'], ['a_3', 'b_3', 'c_3'], + ['Footer A', 'Footer B', 'Footer C'], ]); // Activate column A sort again (reverse direction) @@ -225,6 +235,7 @@ describe('MatTable', () => { ['a_3', 'b_3', 'c_3'], ['a_2', 'b_2', 'c_2'], ['a_1', 'b_1', 'c_1'], + ['Footer A', 'Footer B', 'Footer C'], ]); // Change sort function to customize how it sorts - first column 1, then 3, then 2 @@ -243,6 +254,7 @@ describe('MatTable', () => { ['a_1', 'b_1', 'c_1'], ['a_3', 'b_3', 'c_3'], ['a_2', 'b_2', 'c_2'], + ['Footer A', 'Footer B', 'Footer C'], ]); }); @@ -258,6 +270,7 @@ describe('MatTable', () => { ['', 'b_1', 'c_1'], ['a_2', 'b_2', 'c_2'], ['a_3', 'b_3', 'c_3'], + ['Footer A', 'Footer B', 'Footer C'], ]); // Expect that empty string row comes before the other values @@ -268,6 +281,7 @@ describe('MatTable', () => { ['a_3', 'b_3', 'c_3'], ['a_2', 'b_2', 'c_2'], ['', 'b_1', 'c_1'], + ['Footer A', 'Footer B', 'Footer C'], ]); }); @@ -283,6 +297,7 @@ describe('MatTable', () => { ['', 'b_1', 'c_1'], ['a_2', 'b_2', 'c_2'], ['a_3', 'b_3', 'c_3'], + ['Footer A', 'Footer B', 'Footer C'], ]); @@ -294,6 +309,7 @@ describe('MatTable', () => { ['a_3', 'b_3', 'c_3'], ['a_2', 'b_2', 'c_2'], ['', 'b_1', 'c_1'], + ['Footer A', 'Footer B', 'Footer C'], ]); }); @@ -311,6 +327,7 @@ describe('MatTable', () => { ['-1', 'b_3', 'c_3'], ['0', 'b_2', 'c_2'], ['1', 'b_1', 'c_1'], + ['Footer A', 'Footer B', 'Footer C'], ]); @@ -323,6 +340,7 @@ describe('MatTable', () => { ['1', 'b_1', 'c_1'], ['0', 'b_2', 'c_2'], ['-1', 'b_3', 'c_3'], + ['Footer A', 'Footer B', 'Footer C'], ]); }); @@ -340,6 +358,7 @@ describe('MatTable', () => { ['a_3', 'b_3', 'c_3'], ['a_4', 'b_4', 'c_4'], ['a_5', 'b_5', 'c_5'], + ['Footer A', 'Footer B', 'Footer C'], ]); // Navigate to the next page @@ -352,6 +371,7 @@ describe('MatTable', () => { ['a_8', 'b_8', 'c_8'], ['a_9', 'b_9', 'c_9'], ['a_10', 'b_10', 'c_10'], + ['Footer A', 'Footer B', 'Footer C'], ]); })); }); @@ -399,16 +419,19 @@ class FakeDataSource extends DataSource { Column A {{row.a}} + Footer A Column B {{row.b}} + Footer B Column C {{row.c}} + Footer C @@ -418,6 +441,7 @@ class FakeDataSource extends DataSource { + ` }) @@ -466,6 +490,7 @@ class NativeHtmlTableApp { Column A {{row.a}} + Footer A @@ -475,6 +500,7 @@ class NativeHtmlTableApp { + ` }) @@ -492,20 +518,24 @@ class MatTableWithWhenRowApp { Column A {{row.a}} + Footer A Column B {{row.b}} + Footer B Column C {{row.c}} + Footer C + @@ -641,7 +671,6 @@ class MatTableWithPaginatorApp { } } -// Utilities copied from CDKTable's spec function getElements(element: Element, query: string): Element[] { return [].slice.call(element.querySelectorAll(query)); } @@ -650,6 +679,10 @@ function getHeaderRow(tableElement: Element): Element { return tableElement.querySelector('.mat-header-row')!; } +function getFooterRow(tableElement: Element): Element { + return tableElement.querySelector('.mat-footer-row')!; +} + function getRows(tableElement: Element): Element[] { return getElements(tableElement, '.mat-row'); } @@ -661,35 +694,46 @@ function getHeaderCells(tableElement: Element): Element[] { return getElements(getHeaderRow(tableElement), '.mat-header-cell'); } -function expectTableToMatchContent(tableElement: Element, expectedTableContent: any[]) { +function getFooterCells(tableElement: Element): Element[] { + return getElements(getFooterRow(tableElement), '.mat-footer-cell'); +} + +function getActualTableContent(tableElement: Element): string[][] { + let actualTableContent: Element[][] = []; + if (getHeaderRow(tableElement)) { + actualTableContent.push(getHeaderCells(tableElement)); + } + + // Check data row cells + const rows = getRows(tableElement).map(row => getCells(row)); + actualTableContent = actualTableContent.concat(rows); + + if (getFooterRow(tableElement)) { + actualTableContent.push(getFooterCells(tableElement)); + } + + // Convert the nodes into their text content; + return actualTableContent.map(row => row.map(cell => cell.textContent!.trim())); +} + +function expectTableToMatchContent(tableElement: Element, expected: any[]) { const missedExpectations: string[] = []; - function checkCellContent(cell: Element, expectedTextContent: string) { - const actualTextContent = cell.textContent!.trim(); - if (actualTextContent !== expectedTextContent) { - missedExpectations.push( - `Expected cell contents to be ${expectedTextContent} but was ${actualTextContent}`); + function checkCellContent(actualCell: string, expectedCell: string) { + if (actualCell !== expectedCell) { + missedExpectations.push(`Expected cell contents to be ${actualCell} but was ${expectedCell}`); } } - // Check header cells - const expectedHeaderContent = expectedTableContent.shift(); - getHeaderCells(tableElement).forEach((cell, index) => { - const expected = expectedHeaderContent ? - expectedHeaderContent[index] : - null; - checkCellContent(cell, expected); - }); + const actual = getActualTableContent(tableElement); - // Check data row cells - const rows = getRows(tableElement); - expect(rows.length).toBe(expectedTableContent.length, - `Found ${rows.length} rows but expected ${expectedTableContent.length}`); - rows.forEach((row, rowIndex) => { - getCells(row).forEach((cell, cellIndex) => { - const expected = expectedTableContent.length ? - expectedTableContent[rowIndex][cellIndex] : - null; - checkCellContent(cell, expected); + if (actual.length !== expected.length) { + missedExpectations.push(`Expected ${expected.length} total rows but got ${actual.length}`); + fail(missedExpectations.join('\n')); + } + actual.forEach((row, rowIndex) => { + row.forEach((actualCell, cellIndex) => { + const expectedCell = expected[rowIndex] ? expected[rowIndex][cellIndex] : null; + checkCellContent(actualCell, expectedCell); }); }); diff --git a/src/material-examples/table-footer-row/table-footer-row-example.css b/src/material-examples/table-footer-row/table-footer-row-example.css new file mode 100644 index 00000000000..8b311e6d363 --- /dev/null +++ b/src/material-examples/table-footer-row/table-footer-row-example.css @@ -0,0 +1,11 @@ +.example-container { + display: flex; + flex-direction: column; + max-height: 500px; + min-width: 300px; +} + +.mat-table { + overflow: auto; + max-height: 500px; +} diff --git a/src/material-examples/table-footer-row/table-footer-row-example.html b/src/material-examples/table-footer-row/table-footer-row-example.html new file mode 100644 index 00000000000..11aad5cd943 --- /dev/null +++ b/src/material-examples/table-footer-row/table-footer-row-example.html @@ -0,0 +1,22 @@ +
+ + + + + Item + {{transaction.item}} + + + + + + Cost + {{transaction.cost | currency}} + getTotalCost() + + + + + + +
diff --git a/src/material-examples/table-footer-row/table-footer-row-example.ts b/src/material-examples/table-footer-row/table-footer-row-example.ts new file mode 100644 index 00000000000..c4df488b29c --- /dev/null +++ b/src/material-examples/table-footer-row/table-footer-row-example.ts @@ -0,0 +1,31 @@ +import {Component} from '@angular/core'; + +export interface Transaction { + item: string; + cost: number; +} + +/** + * @title Footer row table + */ +@Component({ + selector: 'table-footer-row-example', + styleUrls: ['table-footer-row-example.css'], + templateUrl: 'table-footer-row-example.html', +}) +export class TableFooterRowExample { + displayedColumns = ['item', 'cost']; + transactions: Transaction[] = [ + {item: 'Beach ball', cost: 4}, + {item: 'Towel', cost: 5}, + {item: 'Frisbee', cost: 2}, + {item: 'Sunscreen', cost: 4}, + {item: 'Cooler', cost: 25}, + {item: 'Swim suit', cost: 15}, + ]; + + /** Gets the total cost of all transactions. */ + getTotalCost() { + return this.transactions.map(t => t.cost).reduce((acc, value) => acc + value, 0); + } +}