Skip to content

Commit ac946b3

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 68a2f89 commit ac946b3

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<unknown>) {}
304+
}

src/cdk/table/table-module.ts

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

99
import {NgModule} from '@angular/core';
10-
import {HeaderRowOutlet, DataRowOutlet, CdkTable, FooterRowOutlet} from './table';
10+
import {
11+
HeaderRowOutlet,
12+
DataRowOutlet,
13+
CdkTable,
14+
FooterRowOutlet,
15+
EmptyPlaceholderRowOutlet,
16+
} from './table';
1117
import {
1218
CdkCellOutlet, CdkFooterRow, CdkFooterRowDef, CdkHeaderRow, CdkHeaderRowDef, CdkRow,
13-
CdkRowDef
19+
CdkRowDef,
20+
CdkEmptyPlaceholderRowDef
1421
} from './row';
1522
import {
1623
CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCell, CdkCellDef,
@@ -38,6 +45,8 @@ const EXPORTED_DECLARATIONS = [
3845
HeaderRowOutlet,
3946
FooterRowOutlet,
4047
CdkTextColumn,
48+
CdkEmptyPlaceholderRowDef,
49+
EmptyPlaceholderRowOutlet,
4150
];
4251

4352
@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 {
4142
BehaviorSubject,
@@ -54,7 +55,8 @@ import {
5455
CdkCellOutletRowContext,
5556
CdkFooterRowDef,
5657
CdkHeaderRowDef,
57-
CdkRowDef
58+
CdkRowDef,
59+
CdkEmptyPlaceholderRowDef
5860
} from './row';
5961
import {StickyStyler} from './sticky-styler';
6062
import {
@@ -105,6 +107,16 @@ export class FooterRowOutlet implements RowOutlet {
105107
constructor(public viewContainer: ViewContainerRef, public elementRef: ElementRef) {}
106108
}
107109

110+
/**
111+
* Provides a handle for the table to grab the view
112+
* container's ng-container to insert the placeholder row.
113+
* @docs-private
114+
*/
115+
@Directive({selector: '[emptyPlaceholderRowOutlet]'})
116+
export class EmptyPlaceholderRowOutlet implements RowOutlet {
117+
constructor(public viewContainer: ViewContainerRef, public elementRef: ElementRef) {}
118+
}
119+
108120
/**
109121
* The table template that can be used by the mat-table. Should not be used outside of the
110122
* material library.
@@ -117,6 +129,7 @@ export const CDK_TABLE_TEMPLATE =
117129
<ng-content select="caption"></ng-content>
118130
<ng-container headerRowOutlet></ng-container>
119131
<ng-container rowOutlet></ng-container>
132+
<ng-container emptyPlaceholderRowOutlet></ng-container>
120133
<ng-container footerRowOutlet></ng-container>
121134
`;
122135

@@ -290,6 +303,9 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
290303
*/
291304
protected stickyCssClass: string = 'cdk-table-sticky';
292305

306+
/** Whether the placeholder row is currently showing anything. */
307+
private _isShowingEmptyPlaceholderRow = false;
308+
293309
/**
294310
* Tracking function that will be used to check the differences in data changes. Used similarly
295311
* to `ngFor` `trackBy` function. Optimize row operations by identifying a row based on its data
@@ -376,6 +392,8 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
376392
@ViewChild(DataRowOutlet, {static: true}) _rowOutlet: DataRowOutlet;
377393
@ViewChild(HeaderRowOutlet, {static: true}) _headerRowOutlet: HeaderRowOutlet;
378394
@ViewChild(FooterRowOutlet, {static: true}) _footerRowOutlet: FooterRowOutlet;
395+
@ViewChild(EmptyPlaceholderRowOutlet, {static: true})
396+
_emptyPlaceholderRowOutlet: EmptyPlaceholderRowOutlet;
379397

380398
/**
381399
* The column definitions provided by the user that contain what the header, data, and footer
@@ -396,6 +414,9 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
396414
descendants: true
397415
}) _contentFooterRowDefs: QueryList<CdkFooterRowDef>;
398416

417+
/** Row definition that will only be rendered if there's no data in the table. */
418+
@ContentChild(CdkEmptyPlaceholderRowDef) _emptyPlaceholderRowDef: CdkEmptyPlaceholderRowDef;
419+
399420
constructor(
400421
protected readonly _differs: IterableDiffers,
401422
protected readonly _changeDetectorRef: ChangeDetectorRef,
@@ -461,6 +482,7 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
461482

462483
ngOnDestroy() {
463484
this._rowOutlet.viewContainer.clear();
485+
this._emptyPlaceholderRowOutlet.viewContainer.clear();
464486
this._headerRowOutlet.viewContainer.clear();
465487
this._footerRowOutlet.viewContainer.clear();
466488

@@ -516,6 +538,7 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
516538
rowView.context.$implicit = record.item.data;
517539
});
518540

541+
this._updateEmptyPlaceholderRow();
519542
this.updateStickyColumnStyles();
520543
}
521544

@@ -1012,15 +1035,19 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
10121035
private _applyNativeTableSections() {
10131036
const documentFragment = this._document.createDocumentFragment();
10141037
const sections = [
1015-
{tag: 'thead', outlet: this._headerRowOutlet},
1016-
{tag: 'tbody', outlet: this._rowOutlet},
1017-
{tag: 'tfoot', outlet: this._footerRowOutlet},
1038+
{tag: 'thead', outlets: [this._headerRowOutlet]},
1039+
{tag: 'tbody', outlets: [this._rowOutlet, this._emptyPlaceholderRowOutlet]},
1040+
{tag: 'tfoot', outlets: [this._footerRowOutlet]},
10181041
];
10191042

10201043
for (const section of sections) {
10211044
const element = this._document.createElement(section.tag);
10221045
element.setAttribute('role', 'rowgroup');
1023-
element.appendChild(section.outlet.elementRef.nativeElement);
1046+
1047+
for (const outlet of section.outlets) {
1048+
element.appendChild(outlet.elementRef.nativeElement);
1049+
}
1050+
10241051
documentFragment.appendChild(element);
10251052
}
10261053

@@ -1084,6 +1111,25 @@ export class CdkTable<T> implements AfterContentChecked, CollectionViewer, OnDes
10841111
});
10851112
}
10861113

1114+
/** Creates or removes the empty placeholder row, depending on whether any data is being shown. */
1115+
private _updateEmptyPlaceholderRow() {
1116+
if (this._emptyPlaceholderRowDef) {
1117+
const shouldShow = this._rowOutlet.viewContainer.length === 0;
1118+
1119+
if (shouldShow !== this._isShowingEmptyPlaceholderRow) {
1120+
const container = this._emptyPlaceholderRowOutlet.viewContainer;
1121+
1122+
if (shouldShow) {
1123+
container.createEmbeddedView(this._emptyPlaceholderRowDef.templateRef);
1124+
} else {
1125+
container.clear();
1126+
}
1127+
1128+
this._isShowingEmptyPlaceholderRow = shouldShow;
1129+
}
1130+
}
1131+
}
1132+
10871133
static ngAcceptInputType_multiTemplateDataRows: BooleanInput;
10881134
}
10891135

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

3132
const EXPORTED_DECLARATIONS = [
@@ -50,6 +51,7 @@ const EXPORTED_DECLARATIONS = [
5051
MatHeaderRow,
5152
MatRow,
5253
MatFooterRow,
54+
MatEmptyPlaceholderRowDef,
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+
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)