Skip to content

Commit e512581

Browse files
authored
feat(table): add the ability to show a data row when no data is available (#18041)
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.
1 parent c67337b commit e512581

File tree

15 files changed

+239
-19
lines changed

15 files changed

+239
-19
lines changed

src/cdk/table/row.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,11 @@ export class CdkFooterRow {
306306
})
307307
export class CdkRow {
308308
}
309+
310+
/** Row that can be used to display a message when no data is shown in the table. */
311+
@Directive({
312+
selector: 'ng-template[cdkNoDataRow]'
313+
})
314+
export class CdkNoDataRow {
315+
constructor(public templateRef: TemplateRef<any>) {}
316+
}

src/cdk/table/table-module.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
*/
88

99
import {NgModule} from '@angular/core';
10-
import {HeaderRowOutlet, DataRowOutlet, CdkTable, FooterRowOutlet} from './table';
10+
import {HeaderRowOutlet, DataRowOutlet, CdkTable, FooterRowOutlet, NoDataRowOutlet} from './table';
1111
import {
1212
CdkCellOutlet, CdkFooterRow, CdkFooterRowDef, CdkHeaderRow, CdkHeaderRowDef, CdkRow,
13-
CdkRowDef
13+
CdkRowDef,
14+
CdkNoDataRow
1415
} from './row';
1516
import {
1617
CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCell, CdkCellDef,
@@ -38,6 +39,8 @@ const EXPORTED_DECLARATIONS = [
3839
HeaderRowOutlet,
3940
FooterRowOutlet,
4041
CdkTextColumn,
42+
CdkNoDataRow,
43+
NoDataRowOutlet,
4144
];
4245

4346
@NgModule({

src/cdk/table/table.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,22 @@ describe('CdkTable', () => {
285285
['Footer C', 'Footer B'],
286286
]);
287287
});
288+
289+
it('should be able to show a message when no data is being displayed', () => {
290+
expect(tableElement.textContent!.trim()).not.toContain('No data');
291+
292+
const originalData = dataSource.data;
293+
dataSource.data = [];
294+
fixture.detectChanges();
295+
296+
expect(tableElement.textContent!.trim()).toContain('No data');
297+
298+
dataSource.data = originalData;
299+
fixture.detectChanges();
300+
301+
expect(tableElement.textContent!.trim()).not.toContain('No data');
302+
});
303+
288304
});
289305

290306
it('should render no rows when the data is null', fakeAsync(() => {
@@ -513,6 +529,28 @@ describe('CdkTable', () => {
513529
expect(innerRows.map(row => row.cells.length)).toEqual([3, 3, 3]);
514530
});
515531

532+
it('should be able to show a message when no data is being displayed in a native table', () => {
533+
const thisFixture = createComponent(NativeHtmlTableApp);
534+
thisFixture.detectChanges();
535+
536+
// Assert that the data is inside the tbody specifically.
537+
const tbody = thisFixture.nativeElement.querySelector('tbody');
538+
const dataSource = thisFixture.componentInstance.dataSource!;
539+
const originalData = dataSource.data;
540+
541+
expect(tbody.textContent!.trim()).not.toContain('No data');
542+
543+
dataSource.data = [];
544+
thisFixture.detectChanges();
545+
546+
expect(tbody.textContent!.trim()).toContain('No data');
547+
548+
dataSource.data = originalData;
549+
thisFixture.detectChanges();
550+
551+
expect(tbody.textContent!.trim()).not.toContain('No data');
552+
});
553+
516554
it('should apply correct roles for native table elements', () => {
517555
const thisFixture = createComponent(NativeHtmlTableApp);
518556
const thisTableElement: HTMLTableElement = thisFixture.nativeElement.querySelector('table');
@@ -1490,6 +1528,8 @@ class BooleanDataSource extends DataSource<boolean> {
14901528
*cdkRowDef="let row; columns: columnsToRender"></cdk-row>
14911529
<cdk-footer-row class="customFooterRowClass"
14921530
*cdkFooterRowDef="columnsToRender"></cdk-footer-row>
1531+
1532+
<div *cdkNoDataRow>No data</div>
14931533
</cdk-table>
14941534
`
14951535
})
@@ -2297,6 +2337,9 @@ class OuterTableApp {
22972337
22982338
<tr cdk-header-row *cdkHeaderRowDef="columnsToRender"></tr>
22992339
<tr cdk-row *cdkRowDef="let row; columns: columnsToRender" class="customRowClass"></tr>
2340+
<tr *cdkNoDataRow>
2341+
<td>No data</td>
2342+
</tr>
23002343
</table>
23012344
`
23022345
})

src/cdk/table/table.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ import {
3535
TrackByFunction,
3636
ViewChild,
3737
ViewContainerRef,
38-
ViewEncapsulation
38+
ViewEncapsulation,
39+
ContentChild
3940
} from '@angular/core';
4041
import {
4142
BehaviorSubject,
@@ -54,7 +55,8 @@ import {
5455
CdkCellOutletRowContext,
5556
CdkFooterRowDef,
5657
CdkHeaderRowDef,
57-
CdkRowDef
58+
CdkRowDef,
59+
CdkNoDataRow
5860
} from './row';
5961
import {StickyStyler} from './sticky-styler';
6062
import {
@@ -106,6 +108,16 @@ export class FooterRowOutlet implements RowOutlet {
106108
constructor(public viewContainer: ViewContainerRef, public elementRef: ElementRef) {}
107109
}
108110

111+
/**
112+
* Provides a handle for the table to grab the view
113+
* container's ng-container to insert the no data row.
114+
* @docs-private
115+
*/
116+
@Directive({selector: '[noDataRowOutlet]'})
117+
export class NoDataRowOutlet implements RowOutlet {
118+
constructor(public viewContainer: ViewContainerRef, public elementRef: ElementRef) {}
119+
}
120+
109121
/**
110122
* The table template that can be used by the mat-table. Should not be used outside of the
111123
* material library.
@@ -119,6 +131,7 @@ export const CDK_TABLE_TEMPLATE =
119131
<ng-content select="colgroup, col"></ng-content>
120132
<ng-container headerRowOutlet></ng-container>
121133
<ng-container rowOutlet></ng-container>
134+
<ng-container noDataRowOutlet></ng-container>
122135
<ng-container footerRowOutlet></ng-container>
123136
`;
124137

@@ -293,6 +306,9 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
293306
*/
294307
protected stickyCssClass: string = 'cdk-table-sticky';
295308

309+
/** Whether the no data row is currently showing anything. */
310+
private _isShowingNoDataRow = false;
311+
296312
/**
297313
* Tracking function that will be used to check the differences in data changes. Used similarly
298314
* to `ngFor` `trackBy` function. Optimize row operations by identifying a row based on its data
@@ -379,6 +395,7 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
379395
@ViewChild(DataRowOutlet, {static: true}) _rowOutlet: DataRowOutlet;
380396
@ViewChild(HeaderRowOutlet, {static: true}) _headerRowOutlet: HeaderRowOutlet;
381397
@ViewChild(FooterRowOutlet, {static: true}) _footerRowOutlet: FooterRowOutlet;
398+
@ViewChild(NoDataRowOutlet, {static: true}) _noDataRowOutlet: NoDataRowOutlet;
382399

383400
/**
384401
* The column definitions provided by the user that contain what the header, data, and footer
@@ -399,6 +416,9 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
399416
descendants: true
400417
}) _contentFooterRowDefs: QueryList<CdkFooterRowDef>;
401418

419+
/** Row definition that will only be rendered if there's no data in the table. */
420+
@ContentChild(CdkNoDataRow) _noDataRow: CdkNoDataRow;
421+
402422
constructor(
403423
protected readonly _differs: IterableDiffers,
404424
protected readonly _changeDetectorRef: ChangeDetectorRef,
@@ -464,6 +484,7 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
464484

465485
ngOnDestroy() {
466486
this._rowOutlet.viewContainer.clear();
487+
this._noDataRowOutlet.viewContainer.clear();
467488
this._headerRowOutlet.viewContainer.clear();
468489
this._footerRowOutlet.viewContainer.clear();
469490

@@ -519,6 +540,7 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
519540
rowView.context.$implicit = record.item.data;
520541
});
521542

543+
this._updateNoDataRow();
522544
this.updateStickyColumnStyles();
523545
}
524546

@@ -1017,15 +1039,19 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
10171039
private _applyNativeTableSections() {
10181040
const documentFragment = this._document.createDocumentFragment();
10191041
const sections = [
1020-
{tag: 'thead', outlet: this._headerRowOutlet},
1021-
{tag: 'tbody', outlet: this._rowOutlet},
1022-
{tag: 'tfoot', outlet: this._footerRowOutlet},
1042+
{tag: 'thead', outlets: [this._headerRowOutlet]},
1043+
{tag: 'tbody', outlets: [this._rowOutlet, this._noDataRowOutlet]},
1044+
{tag: 'tfoot', outlets: [this._footerRowOutlet]},
10231045
];
10241046

10251047
for (const section of sections) {
10261048
const element = this._document.createElement(section.tag);
10271049
element.setAttribute('role', 'rowgroup');
1028-
element.appendChild(section.outlet.elementRef.nativeElement);
1050+
1051+
for (const outlet of section.outlets) {
1052+
element.appendChild(outlet.elementRef.nativeElement);
1053+
}
1054+
10291055
documentFragment.appendChild(element);
10301056
}
10311057

@@ -1094,6 +1120,19 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
10941120
return items.filter(item => !item._table || item._table === this);
10951121
}
10961122

1123+
/** Creates or removes the no data row, depending on whether any data is being shown. */
1124+
private _updateNoDataRow() {
1125+
if (this._noDataRow) {
1126+
const shouldShow = this._rowOutlet.viewContainer.length === 0;
1127+
1128+
if (shouldShow !== this._isShowingNoDataRow) {
1129+
const container = this._noDataRowOutlet.viewContainer;
1130+
shouldShow ? container.createEmbeddedView(this._noDataRow.templateRef) : container.clear();
1131+
this._isShowingNoDataRow = shouldShow;
1132+
}
1133+
}
1134+
}
1135+
10971136
static ngAcceptInputType_multiTemplateDataRows: BooleanInput;
10981137
}
10991138

src/components-examples/material/table/table-filtering/table-filtering-example.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<mat-form-field>
22
<mat-label>Filter</mat-label>
3-
<input matInput (keyup)="applyFilter($event)" placeholder="Ex. ium">
3+
<input matInput (keyup)="applyFilter($event)" placeholder="Ex. ium" #input>
44
</mat-form-field>
55

66
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
@@ -31,4 +31,9 @@
3131

3232
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
3333
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
34+
35+
<!-- Row shown when there is no matching data. -->
36+
<tr class="mat-row" *matNoDataRow>
37+
<td class="mat-cell" colspan="4">No data matching the filter "{{input.value}}"</td>
38+
</tr>
3439
</table>

src/components-examples/material/table/table-overview/table-overview-example.html

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<mat-form-field>
22
<mat-label>Filter</mat-label>
3-
<input matInput (keyup)="applyFilter($event)" placeholder="Ex. Mia">
3+
<input matInput (keyup)="applyFilter($event)" placeholder="Ex. Mia" #input>
44
</mat-form-field>
55

66
<div class="mat-elevation-z8">
@@ -31,7 +31,11 @@
3131
</ng-container>
3232

3333
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
34-
<tr mat-row *matRowDef="let row; columns: displayedColumns;">
34+
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
35+
36+
<!-- Row shown when there is no matching data. -->
37+
<tr class="mat-row" *matNoDataRow>
38+
<td class="mat-cell" colspan="4">No data matching the filter "{{input.value}}"</td>
3539
</tr>
3640
</table>
3741

src/material-experimental/mdc-table/module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
MatHeaderRow,
2626
MatHeaderRowDef,
2727
MatRow,
28-
MatRowDef
28+
MatRowDef,
29+
MatNoDataRow
2930
} from './row';
3031

3132
const EXPORTED_DECLARATIONS = [
@@ -50,6 +51,7 @@ const EXPORTED_DECLARATIONS = [
5051
MatHeaderRow,
5152
MatRow,
5253
MatFooterRow,
54+
MatNoDataRow,
5355
];
5456

5557
@NgModule({

src/material-experimental/mdc-table/row.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
CdkHeaderRow,
1515
CdkHeaderRowDef,
1616
CdkRow,
17-
CdkRowDef
17+
CdkRowDef,
18+
CdkNoDataRow
1819
} from '@angular/cdk/table';
1920
import {ChangeDetectionStrategy, Component, Directive, ViewEncapsulation} from '@angular/core';
2021

@@ -110,3 +111,11 @@ export class MatFooterRow extends CdkFooterRow {
110111
})
111112
export class MatRow extends CdkRow {
112113
}
114+
115+
/** Row that can be used to display a message when no data is shown in the table. */
116+
@Directive({
117+
selector: 'ng-template[matNoDataRow]',
118+
providers: [{provide: CdkNoDataRow, useExisting: MatNoDataRow}],
119+
})
120+
export class MatNoDataRow extends CdkNoDataRow {
121+
}

src/material-experimental/mdc-table/table.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,27 @@ describe('MDC-based MatTable', () => {
9696
expect(innerRows.map(row => row.cells.length)).toEqual([3, 3, 3, 3]);
9797
});
9898

99+
it('should be able to show a message when no data is being displayed', () => {
100+
const fixture = TestBed.createComponent(MatTableApp);
101+
fixture.detectChanges();
102+
103+
// Assert that the data is inside the tbody specifically.
104+
const tbody = fixture.nativeElement.querySelector('tbody')!;
105+
const initialData = fixture.componentInstance.dataSource!.data;
106+
107+
expect(tbody.textContent.trim()).not.toContain('No data');
108+
109+
fixture.componentInstance.dataSource!.data = [];
110+
fixture.detectChanges();
111+
112+
expect(tbody.textContent.trim()).toContain('No data');
113+
114+
fixture.componentInstance.dataSource!.data = initialData;
115+
fixture.detectChanges();
116+
117+
expect(tbody.textContent.trim()).not.toContain('No data');
118+
});
119+
99120
});
100121

101122
it('should render with MatTableDataSource and sort', () => {
@@ -555,6 +576,9 @@ class FakeDataSource extends DataSource<TestData> {
555576
<tr mat-header-row *matHeaderRowDef="columnsToRender"></tr>
556577
<tr mat-row *matRowDef="let row; columns: columnsToRender"></tr>
557578
<tr mat-row *matRowDef="let row; columns: ['special_column']; when: isFourthRow"></tr>
579+
<tr *matNoDataRow>
580+
<td>No data</td>
581+
</tr>
558582
<tr mat-footer-row *matFooterRowDef="columnsToRender"></tr>
559583
</table>
560584
`

src/material/table/row.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
CdkHeaderRow,
1515
CdkHeaderRowDef,
1616
CdkRow,
17-
CdkRowDef
17+
CdkRowDef,
18+
CdkNoDataRow
1819
} from '@angular/cdk/table';
1920
import {ChangeDetectionStrategy, Component, Directive, ViewEncapsulation} from '@angular/core';
2021

@@ -110,3 +111,11 @@ export class MatFooterRow extends CdkFooterRow {
110111
})
111112
export class MatRow extends CdkRow {
112113
}
114+
115+
/** Row that can be used to display a message when no data is shown in the table. */
116+
@Directive({
117+
selector: 'ng-template[matNoDataRow]',
118+
providers: [{provide: CdkNoDataRow, useExisting: MatNoDataRow}],
119+
})
120+
export class MatNoDataRow extends CdkNoDataRow {
121+
}

src/material/table/table-module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import {
2424
MatHeaderRow,
2525
MatHeaderRowDef,
2626
MatRow,
27-
MatRowDef
27+
MatRowDef,
28+
MatNoDataRow
2829
} from './row';
2930
import {MatTextColumn} from './text-column';
3031
import {MatCommonModule} from '@angular/material/core';
@@ -51,6 +52,7 @@ const EXPORTED_DECLARATIONS = [
5152
MatHeaderRow,
5253
MatRow,
5354
MatFooterRow,
55+
MatNoDataRow,
5456

5557
MatTextColumn,
5658
];

0 commit comments

Comments
 (0)