Skip to content

Commit

Permalink
feat(cdk/table): add optional footer to cdk-text-column/mat-text-column
Browse files Browse the repository at this point in the history
Added two new inputs to CdkTextColumn:
`footerText` (string) and `footerTextTransform` (function), automatically
inherited by MatTextColumn.

If the table does not define a footer, the column footer will be ignored.

Additionally, TextColumnOptions includes a new default function called
`defaultFooterTextTransform`, used when neither `footerTextTransform` nor
`footerText` are provided.

Fixes #24532
  • Loading branch information
jullierme committed Jul 9, 2024
1 parent e6535b7 commit ae88e39
Show file tree
Hide file tree
Showing 11 changed files with 298 additions and 18 deletions.
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,
],
}).compileComponents();
}));

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' = '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 @@ -29,3 +29,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

0 comments on commit ae88e39

Please sign in to comment.