Skip to content

Commit afad09e

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 97a7e2b commit afad09e

File tree

15 files changed

+253
-19
lines changed

15 files changed

+253
-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[cdkEmptyPlaceholderRowDef]'
301+
})
302+
export class CdkEmptyPlaceholderRowDef {
303+
constructor(public templateRef: TemplateRef<any>) {}
304+
}

src/cdk/table/table-module.ts

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

99
import {CommonModule} from '@angular/common';
1010
import {NgModule} from '@angular/core';
11-
import {HeaderRowOutlet, DataRowOutlet, CdkTable, FooterRowOutlet} from './table';
11+
import {
12+
HeaderRowOutlet,
13+
DataRowOutlet,
14+
CdkTable,
15+
FooterRowOutlet,
16+
EmptyPlaceholderRowOutlet,
17+
} from './table';
1218
import {
1319
CdkCellOutlet, CdkFooterRow, CdkFooterRowDef, CdkHeaderRow, CdkHeaderRowDef, CdkRow,
14-
CdkRowDef
20+
CdkRowDef,
21+
CdkEmptyPlaceholderRowDef
1522
} from './row';
1623
import {
1724
CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCell, CdkCellDef,
@@ -39,6 +46,8 @@ const EXPORTED_DECLARATIONS = [
3946
HeaderRowOutlet,
4047
FooterRowOutlet,
4148
CdkTextColumn,
49+
CdkEmptyPlaceholderRowDef,
50+
EmptyPlaceholderRowOutlet,
4251
];
4352

4453
@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 *cdkEmptyPlaceholderRowDef>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 *cdkEmptyPlaceholderRowDef>
2310+
<td>No data</td>
2311+
</tr>
22692312
</table>
22702313
`
22712314
})

src/cdk/table/table.ts

Lines changed: 52 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+
CdkEmptyPlaceholderRowDef
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 placeholder row.
106+
* @docs-private
107+
*/
108+
@Directive({selector: '[emptyPlaceholderRowOutlet]'})
109+
export class EmptyPlaceholderRowOutlet 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 emptyPlaceholderRowOutlet></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 placeholder row is currently showing anything. */
300+
private _isShowingEmptyPlaceholderRow = 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,8 @@ 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(EmptyPlaceholderRowOutlet, {static: true})
389+
_emptyPlaceholderRowOutlet: EmptyPlaceholderRowOutlet;
372390

373391
/**
374392
* The column definitions provided by the user that contain what the header, data, and footer
@@ -389,6 +407,9 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
389407
descendants: true
390408
}) _contentFooterRowDefs: QueryList<CdkFooterRowDef>;
391409

410+
/** Row definition that will only be rendered if there's no data in the table. */
411+
@ContentChild(CdkEmptyPlaceholderRowDef) _emptyPlaceholderRowDef: CdkEmptyPlaceholderRowDef;
412+
392413
constructor(
393414
protected readonly _differs: IterableDiffers,
394415
protected readonly _changeDetectorRef: ChangeDetectorRef,
@@ -454,6 +475,7 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
454475

455476
ngOnDestroy() {
456477
this._rowOutlet.viewContainer.clear();
478+
this._emptyPlaceholderRowOutlet.viewContainer.clear();
457479
this._headerRowOutlet.viewContainer.clear();
458480
this._footerRowOutlet.viewContainer.clear();
459481

@@ -509,6 +531,7 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
509531
rowView.context.$implicit = record.item.data;
510532
});
511533

534+
this._updateEmptyPlaceholderRow();
512535
this.updateStickyColumnStyles();
513536
}
514537

@@ -1005,15 +1028,19 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
10051028
private _applyNativeTableSections() {
10061029
const documentFragment = this._document.createDocumentFragment();
10071030
const sections = [
1008-
{tag: 'thead', outlet: this._headerRowOutlet},
1009-
{tag: 'tbody', outlet: this._rowOutlet},
1010-
{tag: 'tfoot', outlet: this._footerRowOutlet},
1031+
{tag: 'thead', outlets: [this._headerRowOutlet]},
1032+
{tag: 'tbody', outlets: [this._rowOutlet, this._emptyPlaceholderRowOutlet]},
1033+
{tag: 'tfoot', outlets: [this._footerRowOutlet]},
10111034
];
10121035

10131036
for (const section of sections) {
10141037
const element = this._document.createElement(section.tag);
10151038
element.setAttribute('role', 'rowgroup');
1016-
element.appendChild(section.outlet.elementRef.nativeElement);
1039+
1040+
for (const outlet of section.outlets) {
1041+
element.appendChild(outlet.elementRef.nativeElement);
1042+
}
1043+
10171044
documentFragment.appendChild(element);
10181045
}
10191046

@@ -1077,6 +1104,25 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
10771104
});
10781105
}
10791106

1107+
/** Creates or removes the empty placeholder row, depending on whether any data is being shown. */
1108+
private _updateEmptyPlaceholderRow() {
1109+
if (this._emptyPlaceholderRowDef) {
1110+
const shouldShow = this._rowOutlet.viewContainer.length === 0;
1111+
1112+
if (shouldShow !== this._isShowingEmptyPlaceholderRow) {
1113+
const container = this._emptyPlaceholderRowOutlet.viewContainer;
1114+
1115+
if (shouldShow) {
1116+
container.createEmbeddedView(this._emptyPlaceholderRowDef.templateRef);
1117+
} else {
1118+
container.clear();
1119+
}
1120+
1121+
this._isShowingEmptyPlaceholderRow = shouldShow;
1122+
}
1123+
}
1124+
}
1125+
10801126
static ngAcceptInputType_multiTemplateDataRows: BooleanInput;
10811127
}
10821128

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" *matEmptyPlaceholderRowDef>
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" *matEmptyPlaceholderRowDef>
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+
MatEmptyPlaceholderRowDef
3031
} from './row';
3132

3233
const EXPORTED_DECLARATIONS = [
@@ -51,6 +52,7 @@ const EXPORTED_DECLARATIONS = [
5152
MatHeaderRow,
5253
MatRow,
5354
MatFooterRow,
55+
MatEmptyPlaceholderRowDef,
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+
CdkEmptyPlaceholderRowDef
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[matEmptyPlaceholderRowDef]',
118+
providers: [{provide: CdkEmptyPlaceholderRowDef, useExisting: MatEmptyPlaceholderRowDef}],
119+
})
120+
export class MatEmptyPlaceholderRowDef extends CdkEmptyPlaceholderRowDef {
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 *matEmptyPlaceholderRowDef>
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+
CdkEmptyPlaceholderRowDef
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[matEmptyPlaceholderRowDef]',
118+
providers: [{provide: CdkEmptyPlaceholderRowDef, useExisting: MatEmptyPlaceholderRowDef}],
119+
})
120+
export class MatEmptyPlaceholderRowDef extends CdkEmptyPlaceholderRowDef {
121+
}

0 commit comments

Comments
 (0)