From 9f1409bbdc2964dac38bbd15cc5b5c9f0becca6f Mon Sep 17 00:00:00 2001 From: crisbeto Date: Wed, 25 Dec 2019 14:41:03 +0200 Subject: [PATCH] feat(table): add the ability to show a data row when no data is available As the table is set up at the moment, there's no convenient way to show the user something when their filtered table didn't match any data. These changes add a new directive that renders out a single row when no other data is available which can be used to show a message. --- src/cdk/table/row.ts | 8 +++ src/cdk/table/table-module.ts | 7 ++- src/cdk/table/table.spec.ts | 43 ++++++++++++++++ src/cdk/table/table.ts | 51 ++++++++++++++++--- .../table-filtering-example.html | 7 ++- .../table-overview-example.html | 8 ++- src/material-experimental/mdc-table/module.ts | 4 +- src/material-experimental/mdc-table/row.ts | 11 +++- .../mdc-table/table.spec.ts | 25 +++++++++ src/material/table/row.ts | 11 +++- src/material/table/table-module.ts | 4 +- src/material/table/table.md | 3 ++ src/material/table/table.spec.ts | 47 +++++++++++++++++ tools/public_api_guard/cdk/table.d.ts | 23 +++++++-- tools/public_api_guard/material/table.d.ts | 7 ++- 15 files changed, 240 insertions(+), 19 deletions(-) diff --git a/src/cdk/table/row.ts b/src/cdk/table/row.ts index aaab0b864dde..11a54a27b4f9 100644 --- a/src/cdk/table/row.ts +++ b/src/cdk/table/row.ts @@ -294,3 +294,11 @@ export class CdkFooterRow { }) export class CdkRow { } + +/** Row that can be used to display a message when no data is shown in the table. */ +@Directive({ + selector: 'ng-template[cdkNoDataRow]' +}) +export class CdkNoDataRow { + constructor(public templateRef: TemplateRef) {} +} diff --git a/src/cdk/table/table-module.ts b/src/cdk/table/table-module.ts index 8e0f47281d56..00f55f0e2677 100644 --- a/src/cdk/table/table-module.ts +++ b/src/cdk/table/table-module.ts @@ -8,10 +8,11 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; -import {HeaderRowOutlet, DataRowOutlet, CdkTable, FooterRowOutlet} from './table'; +import {HeaderRowOutlet, DataRowOutlet, CdkTable, FooterRowOutlet, NoDataRowOutlet} from './table'; import { CdkCellOutlet, CdkFooterRow, CdkFooterRowDef, CdkHeaderRow, CdkHeaderRowDef, CdkRow, - CdkRowDef + CdkRowDef, + CdkNoDataRow } from './row'; import { CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCell, CdkCellDef, @@ -39,6 +40,8 @@ const EXPORTED_DECLARATIONS = [ HeaderRowOutlet, FooterRowOutlet, CdkTextColumn, + CdkNoDataRow, + NoDataRowOutlet, ]; @NgModule({ diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index 3a00f6910f24..25e89b30e755 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -285,6 +285,22 @@ describe('CdkTable', () => { ['Footer C', 'Footer B'], ]); }); + + it('should be able to show a message when no data is being displayed', () => { + expect(tableElement.textContent!.trim()).not.toContain('No data'); + + const originalData = dataSource.data; + dataSource.data = []; + fixture.detectChanges(); + + expect(tableElement.textContent!.trim()).toContain('No data'); + + dataSource.data = originalData; + fixture.detectChanges(); + + expect(tableElement.textContent!.trim()).not.toContain('No data'); + }); + }); it('should render no rows when the data is null', fakeAsync(() => { @@ -482,6 +498,28 @@ describe('CdkTable', () => { ]); }); + it('should be able to show a message when no data is being displayed in a native table', () => { + const thisFixture = createComponent(NativeHtmlTableApp); + thisFixture.detectChanges(); + + // Assert that the data is inside the tbody specifically. + const tbody = thisFixture.nativeElement.querySelector('tbody'); + const dataSource = thisFixture.componentInstance.dataSource!; + const originalData = dataSource.data; + + expect(tbody.textContent!.trim()).not.toContain('No data'); + + dataSource.data = []; + thisFixture.detectChanges(); + + expect(tbody.textContent!.trim()).toContain('No data'); + + dataSource.data = originalData; + thisFixture.detectChanges(); + + expect(tbody.textContent!.trim()).not.toContain('No data'); + }); + it('should apply correct roles for native table elements', () => { const thisFixture = createComponent(NativeHtmlTableApp); const thisTableElement: HTMLTableElement = thisFixture.nativeElement.querySelector('table'); @@ -1459,6 +1497,8 @@ class BooleanDataSource extends DataSource { *cdkRowDef="let row; columns: columnsToRender"> + +
No data
` }) @@ -2266,6 +2306,9 @@ class OuterTableApp { + + No data + ` }) diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 0be54297e67f..1d5a25fc3526 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -35,7 +35,8 @@ import { TrackByFunction, ViewChild, ViewContainerRef, - ViewEncapsulation + ViewEncapsulation, + ContentChild } from '@angular/core'; import {BehaviorSubject, Observable, of as observableOf, Subject, Subscription} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; @@ -47,7 +48,8 @@ import { CdkCellOutletRowContext, CdkFooterRowDef, CdkHeaderRowDef, - CdkRowDef + CdkRowDef, + CdkNoDataRow } from './row'; import {StickyStyler} from './sticky-styler'; import { @@ -98,6 +100,16 @@ export class FooterRowOutlet 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 no data row. + * @docs-private + */ +@Directive({selector: '[noDataRowOutlet]'}) +export class NoDataRowOutlet 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. @@ -110,6 +122,7 @@ export const CDK_TABLE_TEMPLATE = + `; @@ -283,6 +296,9 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes */ protected stickyCssClass: string = 'cdk-table-sticky'; + /** Whether the no data row is currently showing anything. */ + private _isShowingNoDataRow = 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 @@ -369,6 +385,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes @ViewChild(DataRowOutlet, {static: true}) _rowOutlet: DataRowOutlet; @ViewChild(HeaderRowOutlet, {static: true}) _headerRowOutlet: HeaderRowOutlet; @ViewChild(FooterRowOutlet, {static: true}) _footerRowOutlet: FooterRowOutlet; + @ViewChild(NoDataRowOutlet, {static: true}) _noDataRowOutlet: NoDataRowOutlet; /** * The column definitions provided by the user that contain what the header, data, and footer @@ -389,6 +406,9 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes descendants: true }) _contentFooterRowDefs: QueryList; + /** Row definition that will only be rendered if there's no data in the table. */ + @ContentChild(CdkNoDataRow) _noDataRow: CdkNoDataRow; + constructor( protected readonly _differs: IterableDiffers, protected readonly _changeDetectorRef: ChangeDetectorRef, @@ -454,6 +474,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes ngOnDestroy() { this._rowOutlet.viewContainer.clear(); + this._noDataRowOutlet.viewContainer.clear(); this._headerRowOutlet.viewContainer.clear(); this._footerRowOutlet.viewContainer.clear(); @@ -509,6 +530,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes rowView.context.$implicit = record.item.data; }); + this._updateNoDataRow(); this.updateStickyColumnStyles(); } @@ -1005,15 +1027,19 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes private _applyNativeTableSections() { const documentFragment = this._document.createDocumentFragment(); const sections = [ - {tag: 'thead', outlet: this._headerRowOutlet}, - {tag: 'tbody', outlet: this._rowOutlet}, - {tag: 'tfoot', outlet: this._footerRowOutlet}, + {tag: 'thead', outlets: [this._headerRowOutlet]}, + {tag: 'tbody', outlets: [this._rowOutlet, this._noDataRowOutlet]}, + {tag: 'tfoot', outlets: [this._footerRowOutlet]}, ]; for (const section of sections) { const element = this._document.createElement(section.tag); element.setAttribute('role', 'rowgroup'); - element.appendChild(section.outlet.elementRef.nativeElement); + + for (const outlet of section.outlets) { + element.appendChild(outlet.elementRef.nativeElement); + } + documentFragment.appendChild(element); } @@ -1077,6 +1103,19 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes }); } + /** Creates or removes the no data row, depending on whether any data is being shown. */ + private _updateNoDataRow() { + if (this._noDataRow) { + const shouldShow = this._rowOutlet.viewContainer.length === 0; + + if (shouldShow !== this._isShowingNoDataRow) { + const container = this._noDataRowOutlet.viewContainer; + shouldShow ? container.createEmbeddedView(this._noDataRow.templateRef) : container.clear(); + this._isShowingNoDataRow = shouldShow; + } + } + } + static ngAcceptInputType_multiTemplateDataRows: BooleanInput; } diff --git a/src/components-examples/material/table/table-filtering/table-filtering-example.html b/src/components-examples/material/table/table-filtering/table-filtering-example.html index f0e4e00af65f..ec540101323c 100644 --- a/src/components-examples/material/table/table-filtering/table-filtering-example.html +++ b/src/components-examples/material/table/table-filtering/table-filtering-example.html @@ -1,5 +1,5 @@ - + @@ -30,4 +30,9 @@ + + + + +
No data matching the filter "{{input.value}}"
diff --git a/src/components-examples/material/table/table-overview/table-overview-example.html b/src/components-examples/material/table/table-overview/table-overview-example.html index 20620a8ef313..e4861a7c7fc2 100644 --- a/src/components-examples/material/table/table-overview/table-overview-example.html +++ b/src/components-examples/material/table/table-overview/table-overview-example.html @@ -1,5 +1,5 @@ - +
@@ -30,7 +30,11 @@ - + + + + + No data matching the filter "{{input.value}}" diff --git a/src/material-experimental/mdc-table/module.ts b/src/material-experimental/mdc-table/module.ts index 4b78cccc69f8..5fc901851d04 100644 --- a/src/material-experimental/mdc-table/module.ts +++ b/src/material-experimental/mdc-table/module.ts @@ -26,7 +26,8 @@ import { MatHeaderRow, MatHeaderRowDef, MatRow, - MatRowDef + MatRowDef, + MatNoDataRow } from './row'; const EXPORTED_DECLARATIONS = [ @@ -51,6 +52,7 @@ const EXPORTED_DECLARATIONS = [ MatHeaderRow, MatRow, MatFooterRow, + MatNoDataRow, ]; @NgModule({ diff --git a/src/material-experimental/mdc-table/row.ts b/src/material-experimental/mdc-table/row.ts index 4c953e3610d2..09d11e899962 100644 --- a/src/material-experimental/mdc-table/row.ts +++ b/src/material-experimental/mdc-table/row.ts @@ -14,7 +14,8 @@ import { CdkHeaderRow, CdkHeaderRowDef, CdkRow, - CdkRowDef + CdkRowDef, + CdkNoDataRow } from '@angular/cdk/table'; import {ChangeDetectionStrategy, Component, Directive, ViewEncapsulation} from '@angular/core'; @@ -110,3 +111,11 @@ export class MatFooterRow extends CdkFooterRow { }) export class MatRow extends CdkRow { } + +/** Row that can be used to display a message when no data is shown in the table. */ +@Directive({ + selector: 'ng-template[matNoDataRow]', + providers: [{provide: CdkNoDataRow, useExisting: MatNoDataRow}], +}) +export class MatNoDataRow extends CdkNoDataRow { +} diff --git a/src/material-experimental/mdc-table/table.spec.ts b/src/material-experimental/mdc-table/table.spec.ts index e7c52d536c52..31ba77191679 100644 --- a/src/material-experimental/mdc-table/table.spec.ts +++ b/src/material-experimental/mdc-table/table.spec.ts @@ -79,6 +79,28 @@ describe('MDC-based MatTable', () => { ['Footer A'], ]); }); + + it('should be able to show a message when no data is being displayed', () => { + const fixture = TestBed.createComponent(MatTableApp); + fixture.detectChanges(); + + // Assert that the data is inside the tbody specifically. + const tbody = fixture.nativeElement.querySelector('tbody')!; + const initialData = fixture.componentInstance.dataSource!.data; + + expect(tbody.textContent.trim()).not.toContain('No data'); + + fixture.componentInstance.dataSource!.data = []; + fixture.detectChanges(); + + expect(tbody.textContent.trim()).toContain('No data'); + + fixture.componentInstance.dataSource!.data = initialData; + fixture.detectChanges(); + + expect(tbody.textContent.trim()).not.toContain('No data'); + }); + }); it('should render with MatTableDataSource and sort', () => { @@ -538,6 +560,9 @@ class FakeDataSource extends DataSource { + + No data + ` diff --git a/src/material/table/row.ts b/src/material/table/row.ts index 68a08e523930..288f5235a089 100644 --- a/src/material/table/row.ts +++ b/src/material/table/row.ts @@ -14,7 +14,8 @@ import { CdkHeaderRow, CdkHeaderRowDef, CdkRow, - CdkRowDef + CdkRowDef, + CdkNoDataRow } from '@angular/cdk/table'; import {ChangeDetectionStrategy, Component, Directive, ViewEncapsulation} from '@angular/core'; @@ -110,3 +111,11 @@ export class MatFooterRow extends CdkFooterRow { }) export class MatRow extends CdkRow { } + +/** Row that can be used to display a message when no data is shown in the table. */ +@Directive({ + selector: 'ng-template[matNoDataRow]', + providers: [{provide: CdkNoDataRow, useExisting: MatNoDataRow}], +}) +export class MatNoDataRow extends CdkNoDataRow { +} diff --git a/src/material/table/table-module.ts b/src/material/table/table-module.ts index 19ecfd11d48f..1aefdc584855 100644 --- a/src/material/table/table-module.ts +++ b/src/material/table/table-module.ts @@ -24,7 +24,8 @@ import { MatHeaderRow, MatHeaderRowDef, MatRow, - MatRowDef + MatRowDef, + MatNoDataRow } from './row'; import {MatTextColumn} from './text-column'; import {CommonModule} from '@angular/common'; @@ -52,6 +53,7 @@ const EXPORTED_DECLARATIONS = [ MatHeaderRow, MatRow, MatFooterRow, + MatNoDataRow, MatTextColumn, ]; diff --git a/src/material/table/table.md b/src/material/table/table.md index 567a8ebb7aa2..1ca30a8a2f28 100644 --- a/src/material/table/table.md +++ b/src/material/table/table.md @@ -210,6 +210,9 @@ it is contained in the reduced string, and the row would be displayed in the tab To override the default filtering behavior, a custom `filterPredicate` function can be set which takes a data object and filter string and returns true if the data object is considered a match. +If you want to show a message when not data matches the filter, you can use the `*matNoDataRow` +directive. + #### Selection diff --git a/src/material/table/table.spec.ts b/src/material/table/table.spec.ts index 5a20e3f37026..36f09dc441cf 100644 --- a/src/material/table/table.spec.ts +++ b/src/material/table/table.spec.ts @@ -82,6 +82,27 @@ describe('MatTable', () => { ['Footer A'], ]); }); + + it('should be able to show a message when no data is being displayed', () => { + const fixture = TestBed.createComponent(MatTableApp); + fixture.detectChanges(); + + const table = fixture.nativeElement.querySelector('.mat-table')!; + const initialData = fixture.componentInstance.dataSource!.data; + + expect(table.textContent.trim()).not.toContain('No data'); + + fixture.componentInstance.dataSource!.data = []; + fixture.detectChanges(); + + expect(table.textContent.trim()).toContain('No data'); + + fixture.componentInstance.dataSource!.data = initialData; + fixture.detectChanges(); + + expect(table.textContent.trim()).not.toContain('No data'); + }); + }); it('should be able to render a table correctly with native elements', () => { @@ -99,6 +120,28 @@ describe('MatTable', () => { ]); }); + it('should be able to show a message when no data is being displayed in a native table', () => { + const fixture = TestBed.createComponent(NativeHtmlTableApp); + fixture.detectChanges(); + + // Assert that the data is inside the tbody specifically. + const tbody = fixture.nativeElement.querySelector('tbody')!; + const dataSource = fixture.componentInstance.dataSource!; + const initialData = dataSource.data; + + expect(tbody.textContent.trim()).not.toContain('No data'); + + dataSource.data = []; + fixture.detectChanges(); + + expect(tbody.textContent.trim()).toContain('No data'); + + dataSource.data = initialData; + fixture.detectChanges(); + + expect(tbody.textContent.trim()).not.toContain('No data'); + }); + it('should render with MatTableDataSource and sort', () => { let fixture = TestBed.createComponent(MatTableWithSortApp); fixture.detectChanges(); @@ -556,6 +599,7 @@ class FakeDataSource extends DataSource { +
No data
` @@ -588,6 +632,9 @@ class MatTableApp { + + No data + ` }) diff --git a/tools/public_api_guard/cdk/table.d.ts b/tools/public_api_guard/cdk/table.d.ts index d6ba8c0ad4c9..6667377d14a9 100644 --- a/tools/public_api_guard/cdk/table.d.ts +++ b/tools/public_api_guard/cdk/table.d.ts @@ -25,7 +25,7 @@ export declare type CanStickCtor = Constructor; export declare const CDK_ROW_TEMPLATE = ""; -export declare const CDK_TABLE_TEMPLATE = "\n \n \n \n \n"; +export declare const CDK_TABLE_TEMPLATE = "\n \n \n \n \n \n"; export declare class CdkCell extends BaseCdkCell { constructor(columnDef: CdkColumnDef, elementRef: ElementRef); @@ -139,6 +139,13 @@ export declare class CdkHeaderRowDef extends _CdkHeaderRowDefBase implements Can static ɵfac: i0.ɵɵFactoryDef; } +export declare class CdkNoDataRow { + templateRef: TemplateRef; + constructor(templateRef: TemplateRef); + static ɵdir: i0.ɵɵDirectiveDefWithMeta; + static ɵfac: i0.ɵɵFactoryDef; +} + export declare class CdkRow { static ɵcmp: i0.ɵɵComponentDefWithMeta; static ɵfac: i0.ɵɵFactoryDef; @@ -164,6 +171,8 @@ export declare class CdkTable implements AfterContentChecked, CollectionViewe _footerRowOutlet: FooterRowOutlet; _headerRowOutlet: HeaderRowOutlet; _multiTemplateDataRows: boolean; + _noDataRow: CdkNoDataRow; + _noDataRowOutlet: NoDataRowOutlet; _rowOutlet: DataRowOutlet; dataSource: CdkTableDataSourceInput; multiTemplateDataRows: boolean; @@ -194,13 +203,13 @@ export declare class CdkTable implements AfterContentChecked, CollectionViewe updateStickyFooterRowStyles(): void; updateStickyHeaderRowStyles(): void; static ngAcceptInputType_multiTemplateDataRows: BooleanInput; - static ɵcmp: i0.ɵɵComponentDefWithMeta, "cdk-table, table[cdk-table]", ["cdkTable"], { 'trackBy': "trackBy", 'dataSource': "dataSource", 'multiTemplateDataRows': "multiTemplateDataRows" }, {}, ["_contentColumnDefs", "_contentRowDefs", "_contentHeaderRowDefs", "_contentFooterRowDefs"]>; + static ɵcmp: i0.ɵɵComponentDefWithMeta, "cdk-table, table[cdk-table]", ["cdkTable"], { 'trackBy': "trackBy", 'dataSource': "dataSource", 'multiTemplateDataRows': "multiTemplateDataRows" }, {}, ["_noDataRow", "_contentColumnDefs", "_contentRowDefs", "_contentHeaderRowDefs", "_contentFooterRowDefs"]>; static ɵfac: i0.ɵɵFactoryDef>; } export declare class CdkTableModule { static ɵinj: i0.ɵɵInjectorDef; - static ɵmod: i0.ɵɵNgModuleDefWithMeta; + static ɵmod: i0.ɵɵNgModuleDefWithMeta; } export declare class CdkTextColumn implements OnDestroy, OnInit { @@ -252,6 +261,14 @@ export declare class HeaderRowOutlet implements RowOutlet { export declare function mixinHasStickyInput>(base: T): CanStickCtor & T; +export declare class NoDataRowOutlet implements RowOutlet { + elementRef: ElementRef; + viewContainer: ViewContainerRef; + constructor(viewContainer: ViewContainerRef, elementRef: ElementRef); + static ɵdir: i0.ɵɵDirectiveDefWithMeta; + static ɵfac: i0.ɵɵFactoryDef; +} + export interface RenderRow { data: T; dataIndex: number; diff --git a/tools/public_api_guard/material/table.d.ts b/tools/public_api_guard/material/table.d.ts index 2719bdfe909f..83152ac751fa 100644 --- a/tools/public_api_guard/material/table.d.ts +++ b/tools/public_api_guard/material/table.d.ts @@ -61,6 +61,11 @@ export declare class MatHeaderRowDef extends CdkHeaderRowDef { static ɵfac: i0.ɵɵFactoryDef; } +export declare class MatNoDataRow extends CdkNoDataRow { + static ɵdir: i0.ɵɵDirectiveDefWithMeta; + static ɵfac: i0.ɵɵFactoryDef; +} + export declare class MatRow extends CdkRow { static ɵcmp: i0.ɵɵComponentDefWithMeta; static ɵfac: i0.ɵɵFactoryDef; @@ -100,7 +105,7 @@ export declare class MatTableDataSource extends DataSource { export declare class MatTableModule { static ɵinj: i0.ɵɵInjectorDef; - static ɵmod: i0.ɵɵNgModuleDefWithMeta; + static ɵmod: i0.ɵɵNgModuleDefWithMeta; } export declare class MatTextColumn extends CdkTextColumn {