Skip to content

feat(cdk/table): add optional footer to cdk-text-column #29399

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 128 additions & 2 deletions src/cdk/table/text-column.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ describe('CdkTextColumn', () => {

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [CdkTableModule, BasicTextColumnApp, MissingTableApp, TextColumnWithoutNameApp],
imports: [
CdkTableModule,
BasicTextColumnApp,
MissingTableApp,
TextColumnWithoutNameApp,
TextColumnWithFooter,
],
});
}));

Expand Down Expand Up @@ -148,12 +154,104 @@ describe('CdkTextColumn', () => {
]);
});
});

describe('with footer', () => {
const expectedDefaultTableHeaderAndData = [
['PropertyA', 'PropertyB', 'PropertyC', 'PropertyD'],
['Laptop', 'Electronics', 'New', '999.99'],
['Charger', 'Accessories', 'Used', '49.99'],
];

function createTestComponent(options: TextColumnOptions<any>) {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CdkTableModule, TextColumnWithFooter],
providers: [{provide: TEXT_COLUMN_OPTIONS, useValue: options}],
});

fixture = TestBed.createComponent(TextColumnWithFooter);
component = fixture.componentInstance;
fixture.detectChanges();

tableElement = fixture.nativeElement.querySelector('.cdk-table');
}

it('should be able to provide a default footer text transformation (function)', () => {
const expectedFooterPropertyA = 'propertyA!';
const expectedFooterPropertyB = 'propertyB!';
const expectedFooterPropertyC = '';
const expectedFooterPropertyD = '';
const defaultFooterTextTransform = (name: string) => `${name}!`;
createTestComponent({defaultFooterTextTransform});

expectTableToMatchContent(tableElement, [
...expectedDefaultTableHeaderAndData,
[
expectedFooterPropertyA,
expectedFooterPropertyB,
expectedFooterPropertyC,
expectedFooterPropertyD,
],
]);
});

it('should be able to provide a footer text transformation (function)', () => {
createTestComponent({});
const expectedFooterPropertyA = '';
const expectedFooterPropertyB = '';
const expectedFooterPropertyC = '';
const expectedFooterPropertyD = '1049.98';
// footer text transformation function
component.getTotal = (): string => {
const total = component.data
.map(t => t.propertyD)
.reduce((acc, value) => (acc || 0) + (value || 0), 0);
return total ? total.toString() : '';
};

fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

expectTableToMatchContent(tableElement, [
...expectedDefaultTableHeaderAndData,
[
expectedFooterPropertyA,
expectedFooterPropertyB,
expectedFooterPropertyC,
expectedFooterPropertyD,
],
]);
});

it('should be able to provide a plain footer text', () => {
createTestComponent({});
const expectedFooterPropertyA = '';
const expectedFooterPropertyB = '';
const expectedFooterPropertyC = 'Total';
const expectedFooterPropertyD = '';

component.footerTextPropertyC = 'Total';
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

expectTableToMatchContent(tableElement, [
...expectedDefaultTableHeaderAndData,
[
expectedFooterPropertyA,
expectedFooterPropertyB,
expectedFooterPropertyC,
expectedFooterPropertyD,
],
]);
});
});
});

interface TestData {
propertyA: string;
propertyB: string;
propertyC: string;
propertyD?: number;
}

@Component({
Expand All @@ -179,8 +277,12 @@ class BasicTextColumnApp {
];

headerTextB: string;
footerTextPropertyC: string = '';
dataAccessorA: (data: TestData) => string;
justifyC = 'start';
justifyC: 'start' | 'end' | 'center' = 'start';
getTotal() {
return '';
}
}

@Component({
Expand All @@ -205,3 +307,27 @@ class MissingTableApp {}
imports: [CdkTableModule],
})
class TextColumnWithoutNameApp extends BasicTextColumnApp {}

@Component({
template: `
<cdk-table [dataSource]="data">
<cdk-text-column name="propertyA"/>
<cdk-text-column name="propertyB"/>
<cdk-text-column name="propertyC" [footerText]="footerTextPropertyC"/>
<cdk-text-column name="propertyD" [footerTextTransform]="getTotal"/>

<cdk-header-row *cdkHeaderRowDef="displayedColumns"/>
<cdk-row *cdkRowDef="let row; columns: displayedColumns"/>
<cdk-footer-row *cdkFooterRowDef="displayedColumns"/>
</cdk-table>
`,
standalone: true,
imports: [CdkTableModule],
})
class TextColumnWithFooter extends BasicTextColumnApp {
override displayedColumns = ['propertyA', 'propertyB', 'propertyC', 'propertyD'];
override data = [
{propertyA: 'Laptop', propertyB: 'Electronics', propertyC: 'New', propertyD: 999.99},
{propertyA: 'Charger', propertyB: 'Accessories', propertyC: 'Used', propertyD: 49.99},
];
}
82 changes: 72 additions & 10 deletions src/cdk/table/text-column.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ import {
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import {CdkCellDef, CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCell} from './cell';
import {
CdkCellDef,
CdkColumnDef,
CdkHeaderCellDef,
CdkHeaderCell,
CdkCell,
CdkFooterCellDef,
CdkFooterCell,
} from './cell';
import {CdkTable} from './table';
import {
getTableTextColumnMissingParentTableError,
Expand All @@ -26,13 +34,15 @@ import {
import {TEXT_COLUMN_OPTIONS, TextColumnOptions} from './tokens';

/**
* Column that simply shows text content for the header and row cells. Assumes that the table
* is using the native table implementation (`<table>`).
* Column that simply shows text content for the header, row cells, and optionally for the footer.
* Assumes that the table is using the native table implementation (`<table>`).
*
* By default, the name of this column will be the header text and data property accessor.
* The header text can be overridden with the `headerText` input. Cell values can be overridden with
* the `dataAccessor` input. Change the text justification to the start or end using the `justify`
* input.
* the `dataAccessor` input. If the table has a footer definition, the default footer text for this
* column will be empty. The footer text can be overridden with the `footerText` or
* `footerDataAccessor` input. Change the text justification to the start or end using the
* `justify` input.
*/
@Component({
selector: 'cdk-text-column',
Expand All @@ -44,6 +54,9 @@ import {TEXT_COLUMN_OPTIONS, TextColumnOptions} from './tokens';
<td cdk-cell *cdkCellDef="let data" [style.text-align]="justify">
{{dataAccessor(data, name)}}
</td>
<td cdk-footer-cell *cdkFooterCellDef [style.text-align]="justify">
{{footerTextTransform(name)}}
</td>
</ng-container>
`,
encapsulation: ViewEncapsulation.None,
Expand All @@ -55,7 +68,15 @@ import {TEXT_COLUMN_OPTIONS, TextColumnOptions} from './tokens';
// tslint:disable-next-line:validate-decorators
changeDetection: ChangeDetectionStrategy.Default,
standalone: true,
imports: [CdkColumnDef, CdkHeaderCellDef, CdkHeaderCell, CdkCellDef, CdkCell],
imports: [
CdkCell,
CdkCellDef,
CdkColumnDef,
CdkFooterCell,
CdkFooterCellDef,
CdkHeaderCell,
CdkHeaderCellDef,
],
})
export class CdkTextColumn<T> implements OnDestroy, OnInit {
/** Column name that should be used to reference this column. */
Expand Down Expand Up @@ -86,6 +107,20 @@ export class CdkTextColumn<T> implements OnDestroy, OnInit {
*/
@Input() dataAccessor: (data: T, name: string) => string;

/**
* Text label that should be used for the column footer. If this property is not
* set, the footer won't be displayed unless `footerDataAccessor` is set.
*/
@Input() footerText: string;

/**
* Footer data accessor function. If this property is set, it will take precedence over the
* footerText property. If footerText is set and footerDataAccessor is not, footerText will be
* used. If neither is set, and the table has a footer defined, the footer cells will render an
* empty string.
*/
@Input() footerTextTransform: (name: string) => string;

/** Alignment of the cell values. */
@Input() justify: 'start' | 'end' | 'center' = 'start';

Expand All @@ -110,12 +145,18 @@ export class CdkTextColumn<T> implements OnDestroy, OnInit {
*/
@ViewChild(CdkHeaderCellDef, {static: true}) headerCell: CdkHeaderCellDef;

/**
* The column footerCell is provided to the column during `ngOnInit` with a static query.
* @docs-private
*/
@ViewChild(CdkFooterCellDef, {static: true}) footerCell: CdkFooterCellDef;

constructor(
// `CdkTextColumn` is always requiring a table, but we just assert it manually
// for better error reporting.
// tslint:disable-next-line: lightweight-tokens
@Optional() private _table: CdkTable<T>,
@Optional() @Inject(TEXT_COLUMN_OPTIONS) private _options: TextColumnOptions<T>,
@Optional() private readonly _table: CdkTable<T>,
@Optional() @Inject(TEXT_COLUMN_OPTIONS) private readonly _options: TextColumnOptions<T>,
) {
this._options = _options || {};
}
Expand All @@ -132,12 +173,15 @@ export class CdkTextColumn<T> implements OnDestroy, OnInit {
this._options.defaultDataAccessor || ((data: T, name: string) => (data as any)[name]);
}

this._defineFooterTextTransform();

if (this._table) {
// Provide the cell and headerCell directly to the table with the static `ViewChild` query,
// since the columnDef will not pick up its content by the time the table finishes checking
// its content and initializing the rows.
this.columnDef.cell = this.cell;
this.columnDef.headerCell = this.headerCell;
this.columnDef.footerCell = this.footerCell;
this._table.addColumnDef(this.columnDef);
} else if (typeof ngDevMode === 'undefined' || ngDevMode) {
throw getTableTextColumnMissingParentTableError();
Expand All @@ -154,7 +198,7 @@ export class CdkTextColumn<T> implements OnDestroy, OnInit {
* Creates a default header text. Use the options' header text transformation function if one
* has been provided. Otherwise simply capitalize the column name.
*/
_createDefaultHeaderText() {
_createDefaultHeaderText(): string {
const name = this.name;

if (!name && (typeof ngDevMode === 'undefined' || ngDevMode)) {
Expand All @@ -169,9 +213,27 @@ export class CdkTextColumn<T> implements OnDestroy, OnInit {
}

/** Synchronizes the column definition name with the text column name. */
private _syncColumnDefName() {
private _syncColumnDefName(): void {
if (this.columnDef) {
this.columnDef.name = this.name;
}
}

/**
* Defines the function to transform the footer text for the column.
* If `footerTextTransform` is not set, it will:
* - Use `footerText` if defined, or
* - Use `defaultFooterTextTransform` from options, or
* - Default to an empty string.
*/
private _defineFooterTextTransform(): void {
if (!this.footerTextTransform) {
// footerText can just be an empty string
if (this.footerText !== undefined) {
this.footerTextTransform = () => this.footerText;
} else {
this.footerTextTransform = this._options.defaultFooterTextTransform || (() => '');
}
}
}
}
3 changes: 3 additions & 0 deletions src/cdk/table/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export interface TextColumnOptions<T> {

/** Default data accessor to use if one is not provided. */
defaultDataAccessor?: (data: T, name: string) => string;

/** Default footer text transform to use if one is not provided. */
defaultFooterTextTransform?: (name: string) => string;
}

/** Injection token that can be used to specify the text column options. */
Expand Down
1 change: 1 addition & 0 deletions src/components-examples/material/table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ export {TableDynamicArrayDataExample} from './table-dynamic-array-data/table-dyn
export {TableDynamicObservableDataExample} from './table-dynamic-observable-data/table-dynamic-observable-data-example';
export {TableGeneratedColumnsExample} from './table-generated-columns/table-generated-columns-example';
export {TableFlexLargeRowExample} from './table-flex-large-row/table-flex-large-row-example';
export {TableTextColumnWithFooterExample} from './table-text-column-with-footer/table-text-column-with-footer-example';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
table {
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<mat-text-column name="name" [footerText]="nameFooterText"></mat-text-column>
<mat-text-column name="price" [footerTextTransform]="getTotal" justify="end"></mat-text-column>
<mat-text-column name="insurance" [footerTextTransform]="getTotal" justify="end"></mat-text-column>
<mat-text-column name="category"></mat-text-column>

<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<tr mat-footer-row *matFooterRowDef="displayedColumns"></tr>
</table>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {Component} from '@angular/core';
import {MatTableDataSource, MatTableModule} from '@angular/material/table';
import {DecimalPipe} from '@angular/common';

export interface Product {
name: string;
price: number;
insurance: number;
category: string;
}

const PRODUCT_DATA: Product[] = [
{name: 'Laptop', price: 999.99, insurance: 100.5, category: 'Electronics'},
{name: 'Phone', price: 699.99, insurance: 50.5, category: 'Electronics'},
{name: 'Tablet', price: 399.99, insurance: 25.5, category: 'Electronics'},
{name: 'Headphones', price: 199.99, insurance: 15, category: 'Accessories'},
{name: 'Charger', price: 49.99, insurance: 0, category: 'Accessories'},
];

/**
* @title Demonstrates the use of `mat-text-column` with footer cells. This example includes a fixed
* footer text for the 'name' column. The 'price' and 'insurance' columns use a text transformation
* function to determine their footer text. The 'category' column has a default empty footer text.
*/
@Component({
selector: 'table-text-column-with-footer-example',
styleUrl: 'table-text-column-with-footer-example.css',
templateUrl: 'table-text-column-with-footer-example.html',
standalone: true,
imports: [MatTableModule],
})
export class TableTextColumnWithFooterExample {
nameFooterText = 'Total';
displayedColumns: string[] = ['name', 'price', 'insurance', 'category'];
dataSource = new MatTableDataSource(PRODUCT_DATA);

decimalPipe = new DecimalPipe('en-US');

/** Function to sum the values of a given column. */
getTotal = (column: string): string => {
const total = PRODUCT_DATA.map(t => t[column as keyof Product] as number).reduce(
(acc, value) => acc + value,
0,
);
return this.decimalPipe.transform(total, '1.2-2') || '';
};
}
Loading
Loading