Skip to content

Commit 9f1409b

Browse files
committed
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.
1 parent a30094b commit 9f1409b

File tree

15 files changed

+240
-19
lines changed

15 files changed

+240
-19
lines changed

src/cdk/table/row.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,11 @@ export class CdkFooterRow {
294294
})
295295
export class CdkRow {
296296
}
297+
298+
/** Row that can be used to display a message when no data is shown in the table. */
299+
@Directive({
300+
selector: 'ng-template[cdkNoDataRow]'
301+
})
302+
export class CdkNoDataRow {
303+
constructor(public templateRef: TemplateRef<any>) {}
304+
}

src/cdk/table/table-module.ts

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

99
import {CommonModule} from '@angular/common';
1010
import {NgModule} from '@angular/core';
11-
import {HeaderRowOutlet, DataRowOutlet, CdkTable, FooterRowOutlet} from './table';
11+
import {HeaderRowOutlet, DataRowOutlet, CdkTable, FooterRowOutlet, NoDataRowOutlet} from './table';
1212
import {
1313
CdkCellOutlet, CdkFooterRow, CdkFooterRowDef, CdkHeaderRow, CdkHeaderRowDef, CdkRow,
14-
CdkRowDef
14+
CdkRowDef,
15+
CdkNoDataRow
1516
} from './row';
1617
import {
1718
CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCell, CdkCellDef,
@@ -39,6 +40,8 @@ const EXPORTED_DECLARATIONS = [
3940
HeaderRowOutlet,
4041
FooterRowOutlet,
4142
CdkTextColumn,
43+
CdkNoDataRow,
44+
NoDataRowOutlet,
4245
];
4346

4447
@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(() => {
@@ -482,6 +498,28 @@ describe('CdkTable', () => {
482498
]);
483499
});
484500

501+
it('should be able to show a message when no data is being displayed in a native table', () => {
502+
const thisFixture = createComponent(NativeHtmlTableApp);
503+
thisFixture.detectChanges();
504+
505+
// Assert that the data is inside the tbody specifically.
506+
const tbody = thisFixture.nativeElement.querySelector('tbody');
507+
const dataSource = thisFixture.componentInstance.dataSource!;
508+
const originalData = dataSource.data;
509+
510+
expect(tbody.textContent!.trim()).not.toContain('No data');
511+
512+
dataSource.data = [];
513+
thisFixture.detectChanges();
514+
515+
expect(tbody.textContent!.trim()).toContain('No data');
516+
517+
dataSource.data = originalData;
518+
thisFixture.detectChanges();
519+
520+
expect(tbody.textContent!.trim()).not.toContain('No data');
521+
});
522+
485523
it('should apply correct roles for native table elements', () => {
486524
const thisFixture = createComponent(NativeHtmlTableApp);
487525
const thisTableElement: HTMLTableElement = thisFixture.nativeElement.querySelector('table');
@@ -1459,6 +1497,8 @@ class BooleanDataSource extends DataSource<boolean> {
14591497
*cdkRowDef="let row; columns: columnsToRender"></cdk-row>
14601498
<cdk-footer-row class="customFooterRowClass"
14611499
*cdkFooterRowDef="columnsToRender"></cdk-footer-row>
1500+
1501+
<div *cdkNoDataRow>No data</div>
14621502
</cdk-table>
14631503
`
14641504
})
@@ -2266,6 +2306,9 @@ class OuterTableApp {
22662306
22672307
<tr cdk-header-row *cdkHeaderRowDef="columnsToRender"></tr>
22682308
<tr cdk-row *cdkRowDef="let row; columns: columnsToRender" class="customRowClass"></tr>
2309+
<tr *cdkNoDataRow>
2310+
<td>No data</td>
2311+
</tr>
22692312
</table>
22702313
`
22712314
})

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 {BehaviorSubject, Observable, of as observableOf, Subject, Subscription} from 'rxjs';
4142
import {takeUntil} from 'rxjs/operators';
@@ -47,7 +48,8 @@ import {
4748
CdkCellOutletRowContext,
4849
CdkFooterRowDef,
4950
CdkHeaderRowDef,
50-
CdkRowDef
51+
CdkRowDef,
52+
CdkNoDataRow
5153
} from './row';
5254
import {StickyStyler} from './sticky-styler';
5355
import {
@@ -98,6 +100,16 @@ export class FooterRowOutlet implements RowOutlet {
98100
constructor(public viewContainer: ViewContainerRef, public elementRef: ElementRef) {}
99101
}
100102

103+
/**
104+
* Provides a handle for the table to grab the view
105+
* container's ng-container to insert the no data row.
106+
* @docs-private
107+
*/
108+
@Directive({selector: '[noDataRowOutlet]'})
109+
export class NoDataRowOutlet implements RowOutlet {
110+
constructor(public viewContainer: ViewContainerRef, public elementRef: ElementRef) {}
111+
}
112+
101113
/**
102114
* The table template that can be used by the mat-table. Should not be used outside of the
103115
* material library.
@@ -110,6 +122,7 @@ export const CDK_TABLE_TEMPLATE =
110122
<ng-content select="caption"></ng-content>
111123
<ng-container headerRowOutlet></ng-container>
112124
<ng-container rowOutlet></ng-container>
125+
<ng-container noDataRowOutlet></ng-container>
113126
<ng-container footerRowOutlet></ng-container>
114127
`;
115128

@@ -283,6 +296,9 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
283296
*/
284297
protected stickyCssClass: string = 'cdk-table-sticky';
285298

299+
/** Whether the no data row is currently showing anything. */
300+
private _isShowingNoDataRow = false;
301+
286302
/**
287303
* Tracking function that will be used to check the differences in data changes. Used similarly
288304
* to `ngFor` `trackBy` function. Optimize row operations by identifying a row based on its data
@@ -369,6 +385,7 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
369385
@ViewChild(DataRowOutlet, {static: true}) _rowOutlet: DataRowOutlet;
370386
@ViewChild(HeaderRowOutlet, {static: true}) _headerRowOutlet: HeaderRowOutlet;
371387
@ViewChild(FooterRowOutlet, {static: true}) _footerRowOutlet: FooterRowOutlet;
388+
@ViewChild(NoDataRowOutlet, {static: true}) _noDataRowOutlet: NoDataRowOutlet;
372389

373390
/**
374391
* The column definitions provided by the user that contain what the header, data, and footer
@@ -389,6 +406,9 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
389406
descendants: true
390407
}) _contentFooterRowDefs: QueryList<CdkFooterRowDef>;
391408

409+
/** Row definition that will only be rendered if there's no data in the table. */
410+
@ContentChild(CdkNoDataRow) _noDataRow: CdkNoDataRow;
411+
392412
constructor(
393413
protected readonly _differs: IterableDiffers,
394414
protected readonly _changeDetectorRef: ChangeDetectorRef,
@@ -454,6 +474,7 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
454474

455475
ngOnDestroy() {
456476
this._rowOutlet.viewContainer.clear();
477+
this._noDataRowOutlet.viewContainer.clear();
457478
this._headerRowOutlet.viewContainer.clear();
458479
this._footerRowOutlet.viewContainer.clear();
459480

@@ -509,6 +530,7 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
509530
rowView.context.$implicit = record.item.data;
510531
});
511532

533+
this._updateNoDataRow();
512534
this.updateStickyColumnStyles();
513535
}
514536

@@ -1005,15 +1027,19 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
10051027
private _applyNativeTableSections() {
10061028
const documentFragment = this._document.createDocumentFragment();
10071029
const sections = [
1008-
{tag: 'thead', outlet: this._headerRowOutlet},
1009-
{tag: 'tbody', outlet: this._rowOutlet},
1010-
{tag: 'tfoot', outlet: this._footerRowOutlet},
1030+
{tag: 'thead', outlets: [this._headerRowOutlet]},
1031+
{tag: 'tbody', outlets: [this._rowOutlet, this._noDataRowOutlet]},
1032+
{tag: 'tfoot', outlets: [this._footerRowOutlet]},
10111033
];
10121034

10131035
for (const section of sections) {
10141036
const element = this._document.createElement(section.tag);
10151037
element.setAttribute('role', 'rowgroup');
1016-
element.appendChild(section.outlet.elementRef.nativeElement);
1038+
1039+
for (const outlet of section.outlets) {
1040+
element.appendChild(outlet.elementRef.nativeElement);
1041+
}
1042+
10171043
documentFragment.appendChild(element);
10181044
}
10191045

@@ -1077,6 +1103,19 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
10771103
});
10781104
}
10791105

1106+
/** Creates or removes the no data row, depending on whether any data is being shown. */
1107+
private _updateNoDataRow() {
1108+
if (this._noDataRow) {
1109+
const shouldShow = this._rowOutlet.viewContainer.length === 0;
1110+
1111+
if (shouldShow !== this._isShowingNoDataRow) {
1112+
const container = this._noDataRowOutlet.viewContainer;
1113+
shouldShow ? container.createEmbeddedView(this._noDataRow.templateRef) : container.clear();
1114+
this._isShowingNoDataRow = shouldShow;
1115+
}
1116+
}
1117+
}
1118+
10801119
static ngAcceptInputType_multiTemplateDataRows: BooleanInput;
10811120
}
10821121

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,5 +1,5 @@
11
<mat-form-field>
2-
<input matInput (keyup)="applyFilter($event.target.value)" placeholder="Filter">
2+
<input matInput (keyup)="applyFilter($event.target.value)" placeholder="Filter" #input>
33
</mat-form-field>
44

55
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
@@ -30,4 +30,9 @@
3030

3131
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
3232
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
33+
34+
<!-- Row shown when there is no matching data. -->
35+
<tr class="mat-row" *matNoDataRow>
36+
<td class="mat-cell" colspan="4">No data matching the filter "{{input.value}}"</td>
37+
</tr>
3338
</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,5 +1,5 @@
11
<mat-form-field>
2-
<input matInput (keyup)="applyFilter($event.target.value)" placeholder="Filter">
2+
<input matInput (keyup)="applyFilter($event.target.value)" placeholder="Filter" #input>
33
</mat-form-field>
44

55
<div class="mat-elevation-z8">
@@ -30,7 +30,11 @@
3030
</ng-container>
3131

3232
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
33-
<tr mat-row *matRowDef="let row; columns: displayedColumns;">
33+
<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>
3438
</tr>
3539
</table>
3640

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

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

3233
const EXPORTED_DECLARATIONS = [
@@ -51,6 +52,7 @@ const EXPORTED_DECLARATIONS = [
5152
MatHeaderRow,
5253
MatRow,
5354
MatFooterRow,
55+
MatNoDataRow,
5456
];
5557

5658
@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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,28 @@ describe('MDC-based MatTable', () => {
7979
['Footer A'],
8080
]);
8181
});
82+
83+
it('should be able to show a message when no data is being displayed', () => {
84+
const fixture = TestBed.createComponent(MatTableApp);
85+
fixture.detectChanges();
86+
87+
// Assert that the data is inside the tbody specifically.
88+
const tbody = fixture.nativeElement.querySelector('tbody')!;
89+
const initialData = fixture.componentInstance.dataSource!.data;
90+
91+
expect(tbody.textContent.trim()).not.toContain('No data');
92+
93+
fixture.componentInstance.dataSource!.data = [];
94+
fixture.detectChanges();
95+
96+
expect(tbody.textContent.trim()).toContain('No data');
97+
98+
fixture.componentInstance.dataSource!.data = initialData;
99+
fixture.detectChanges();
100+
101+
expect(tbody.textContent.trim()).not.toContain('No data');
102+
});
103+
82104
});
83105

84106
it('should render with MatTableDataSource and sort', () => {
@@ -538,6 +560,9 @@ class FakeDataSource extends DataSource<TestData> {
538560
<tr mat-header-row *matHeaderRowDef="columnsToRender"></tr>
539561
<tr mat-row *matRowDef="let row; columns: columnsToRender"></tr>
540562
<tr mat-row *matRowDef="let row; columns: ['special_column']; when: isFourthRow"></tr>
563+
<tr *matNoDataRow>
564+
<td>No data</td>
565+
</tr>
541566
<tr mat-footer-row *matFooterRowDef="columnsToRender"></tr>
542567
</table>
543568
`

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 {CommonModule} from '@angular/common';
@@ -52,6 +53,7 @@ const EXPORTED_DECLARATIONS = [
5253
MatHeaderRow,
5354
MatRow,
5455
MatFooterRow,
56+
MatNoDataRow,
5557

5658
MatTextColumn,
5759
];

0 commit comments

Comments
 (0)