ipsos-components
Version:
Material Design components for Angular
430 lines (368 loc) • 15.2 kB
text/typescript
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {
Attribute,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChild,
ContentChildren,
Directive,
ElementRef,
EmbeddedViewRef,
Input,
isDevMode,
IterableChangeRecord,
IterableDiffer,
IterableDiffers,
NgIterable,
QueryList,
TrackByFunction,
ViewChild,
ViewContainerRef,
ViewEncapsulation,
} from '@angular/core';
import {CollectionViewer, DataSource} from '@angular/cdk/collections';
import {CdkCellOutlet, CdkCellOutletRowContext, CdkHeaderRowDef, CdkRowDef} from './row';
import {takeUntil} from 'rxjs/operators/takeUntil';
import {BehaviorSubject} from 'rxjs/BehaviorSubject';
import {Subscription} from 'rxjs/Subscription';
import {Subject} from 'rxjs/Subject';
import {CdkCellDef, CdkColumnDef, CdkHeaderCellDef} from './cell';
import {
getTableDuplicateColumnNameError,
getTableMissingMatchingRowDefError,
getTableMissingRowDefsError,
getTableMultipleDefaultRowDefsError,
getTableUnknownColumnError
} from './table-errors';
/**
* Provides a handle for the table to grab the view container's ng-container to insert data rows.
* @docs-private
*/
export class RowPlaceholder {
constructor(public viewContainer: ViewContainerRef) { }
}
/**
* Provides a handle for the table to grab the view container's ng-container to insert the header.
* @docs-private
*/
export class HeaderRowPlaceholder {
constructor(public viewContainer: ViewContainerRef) { }
}
/**
* The table template that can be used by the mat-table. Should not be used outside of the
* material library.
*/
export const CDK_TABLE_TEMPLATE = `
<ng-container headerRowPlaceholder></ng-container>
<ng-container rowPlaceholder></ng-container>`;
/**
* Class used to conveniently type the embedded view ref for rows with a context.
* @docs-private
*/
abstract class RowViewRef<T> extends EmbeddedViewRef<CdkCellOutletRowContext<T>> { }
/**
* A data table that connects with a data source to retrieve data of type `T` and renders
* a header row and data rows. Updates the rows when new data is provided by the data source.
*/
export class CdkTable<T> implements CollectionViewer {
/** Subject that emits when the component has been destroyed. */
private _onDestroy = new Subject<void>();
/** Latest data provided by the data source through the connect interface. */
private _data: NgIterable<T> = [];
/** Subscription that listens for the data provided by the data source. */
private _renderChangeSubscription: Subscription | null;
/** Map of all the user's defined columns (header and data cell template) identified by name. */
private _columnDefsByName = new Map<string, CdkColumnDef>();
/** Differ used to find the changes in the data provided by the data source. */
private _dataDiffer: IterableDiffer<T>;
/** Stores the row definition that does not have a when predicate. */
private _defaultRowDef: CdkRowDef<T> | null;
/**
* Tracking function that will be used to check the differences in data changes. Used similarly
* to `ngFor` `trackBy` function. Optimize row operations by identifying a row based on its data
* relative to the function to know if a row should be added/removed/moved.
* Accepts a function that takes two parameters, `index` and `item`.
*/
get trackBy(): TrackByFunction<T> { return this._trackByFn; }
set trackBy(fn: TrackByFunction<T>) {
if (isDevMode() &&
fn != null && typeof fn !== 'function' &&
<any>console && <any>console.warn) {
console.warn(`trackBy must be a function, but received ${JSON.stringify(fn)}.`);
}
this._trackByFn = fn;
}
private _trackByFn: TrackByFunction<T>;
/**
* Provides a stream containing the latest data array to render. Influenced by the table's
* stream of view window (what rows are currently on screen).
*/
get dataSource(): DataSource<T> { return this._dataSource; }
set dataSource(dataSource: DataSource<T>) {
if (this._dataSource !== dataSource) {
this._switchDataSource(dataSource);
}
}
private _dataSource: DataSource<T>;
// TODO(andrewseguin): Remove max value as the end index
// and instead calculate the view on init and scroll.
/**
* Stream containing the latest information on what rows are being displayed on screen.
* Can be used by the data source to as a heuristic of what data should be provided.
*/
viewChange =
new BehaviorSubject<{start: number, end: number}>({start: 0, end: Number.MAX_VALUE});
// Placeholders within the table's template where the header and data rows will be inserted.
_rowPlaceholder: RowPlaceholder;
_headerRowPlaceholder: HeaderRowPlaceholder;
/**
* The column definitions provided by the user that contain what the header and cells should
* render for each column.
*/
_columnDefs: QueryList<CdkColumnDef>;
/** Template definition used as the header container. */
_headerDef: CdkHeaderRowDef;
/** Set of template definitions that used as the data row containers. */
_rowDefs: QueryList<CdkRowDef<T>>;
constructor(private readonly _differs: IterableDiffers,
private readonly _changeDetectorRef: ChangeDetectorRef,
elementRef: ElementRef,
role: string) {
if (!role) {
elementRef.nativeElement.setAttribute('role', 'grid');
}
}
ngOnInit() {
// TODO(andrewseguin): Setup a listener for scrolling, emit the calculated view to viewChange
this._dataDiffer = this._differs.find([]).create(this._trackByFn);
}
ngAfterContentInit() {
if (!this._headerDef && !this._rowDefs.length) {
throw getTableMissingRowDefsError();
}
this._cacheColumnDefsByName();
this._columnDefs.changes.subscribe(() => this._cacheColumnDefsByName());
this._renderHeaderRow();
}
ngAfterContentChecked() {
this._renderUpdatedColumns();
const defaultRowDefs = this._rowDefs.filter(def => !def.when);
if (defaultRowDefs.length > 1) { throw getTableMultipleDefaultRowDefsError(); }
this._defaultRowDef = defaultRowDefs[0];
if (this.dataSource && !this._renderChangeSubscription) {
this._observeRenderChanges();
}
}
ngOnDestroy() {
this._rowPlaceholder.viewContainer.clear();
this._headerRowPlaceholder.viewContainer.clear();
this._onDestroy.next();
this._onDestroy.complete();
if (this.dataSource) {
this.dataSource.disconnect(this);
}
}
/** Update the map containing the content's column definitions. */
private _cacheColumnDefsByName() {
this._columnDefsByName.clear();
this._columnDefs.forEach(columnDef => {
if (this._columnDefsByName.has(columnDef.name)) {
throw getTableDuplicateColumnNameError(columnDef.name);
}
this._columnDefsByName.set(columnDef.name, columnDef);
});
}
/**
* Check if the header or rows have changed what columns they want to display. If there is a diff,
* then re-render that section.
*/
private _renderUpdatedColumns() {
// Re-render the rows when the row definition columns change.
this._rowDefs.forEach(def => {
if (!!def.getColumnsDiff()) {
// Reset the data to an empty array so that renderRowChanges will re-render all new rows.
this._dataDiffer.diff([]);
this._rowPlaceholder.viewContainer.clear();
this._renderRowChanges();
}
});
// Re-render the header row if there is a difference in its columns.
if (this._headerDef.getColumnsDiff()) {
this._headerRowPlaceholder.viewContainer.clear();
this._renderHeaderRow();
}
}
/**
* Switch to the provided data source by resetting the data and unsubscribing from the current
* render change subscription if one exists. If the data source is null, interpret this by
* clearing the row placeholder. Otherwise start listening for new data.
*/
private _switchDataSource(dataSource: DataSource<T>) {
this._data = [];
if (this.dataSource) {
this.dataSource.disconnect(this);
}
// Stop listening for data from the previous data source.
if (this._renderChangeSubscription) {
this._renderChangeSubscription.unsubscribe();
this._renderChangeSubscription = null;
}
// Remove the table's rows if there is now no data source
if (!dataSource) {
this._rowPlaceholder.viewContainer.clear();
}
this._dataSource = dataSource;
}
/** Set up a subscription for the data provided by the data source. */
private _observeRenderChanges() {
this._renderChangeSubscription = this.dataSource.connect(this).pipe(takeUntil(this._onDestroy))
.subscribe(data => {
this._data = data;
this._renderRowChanges();
});
}
/**
* Create the embedded view for the header template and place it in the header row view container.
*/
private _renderHeaderRow() {
const cells = this._getHeaderCellTemplatesForRow(this._headerDef);
if (!cells.length) { return; }
// TODO(andrewseguin): add some code to enforce that exactly
// one CdkCellOutlet was instantiated as a result
// of `createEmbeddedView`.
this._headerRowPlaceholder.viewContainer
.createEmbeddedView(this._headerDef.template, {cells});
cells.forEach(cell => {
if (CdkCellOutlet.mostRecentCellOutlet) {
CdkCellOutlet.mostRecentCellOutlet._viewContainer.createEmbeddedView(cell.template, {});
}
});
this._changeDetectorRef.markForCheck();
}
/**
* Check for changes made in the data and render each change (row added/removed/moved) and update
* row contexts.
*/
private _renderRowChanges() {
const changes = this._dataDiffer.diff(this._data);
if (!changes) { return; }
const viewContainer = this._rowPlaceholder.viewContainer;
changes.forEachOperation(
(record: IterableChangeRecord<T>, adjustedPreviousIndex: number, currentIndex: number) => {
if (record.previousIndex == null) {
this._insertRow(record.item, currentIndex);
} else if (currentIndex == null) {
viewContainer.remove(adjustedPreviousIndex);
} else {
const view = <RowViewRef<T>>viewContainer.get(adjustedPreviousIndex);
viewContainer.move(view!, currentIndex);
}
});
// Update the meta context of a row's context data (index, count, first, last, ...)
this._updateRowIndexContext();
// Update rows that did not get added/removed/moved but may have had their identity changed,
// e.g. if trackBy matched data on some property but the actual data reference changed.
changes.forEachIdentityChange((record: IterableChangeRecord<T>) => {
const rowView = <RowViewRef<T>>viewContainer.get(record.currentIndex!);
rowView.context.$implicit = record.item;
});
}
/**
* Finds the matching row definition that should be used for this row data. If there is only
* one row definition, it is returned. Otherwise, find the row definition that has a when
* predicate that returns true with the data. If none return true, return the default row
* definition.
*/
_getRowDef(data: T, i: number): CdkRowDef<T> {
if (this._rowDefs.length == 1) { return this._rowDefs.first; }
let rowDef = this._rowDefs.find(def => def.when && def.when(i, data)) || this._defaultRowDef;
if (!rowDef) { throw getTableMissingMatchingRowDefError(); }
return rowDef;
}
/**
* Create the embedded view for the data row template and place it in the correct index location
* within the data row view container.
*/
private _insertRow(rowData: T, index: number) {
const row = this._getRowDef(rowData, index);
// Row context that will be provided to both the created embedded row view and its cells.
const context: CdkCellOutletRowContext<T> = {$implicit: rowData};
// TODO(andrewseguin): add some code to enforce that exactly one
// CdkCellOutlet was instantiated as a result of `createEmbeddedView`.
this._rowPlaceholder.viewContainer.createEmbeddedView(row.template, context, index);
this._getCellTemplatesForRow(row).forEach(cell => {
if (CdkCellOutlet.mostRecentCellOutlet) {
CdkCellOutlet.mostRecentCellOutlet._viewContainer
.createEmbeddedView(cell.template, context);
}
});
this._changeDetectorRef.markForCheck();
}
/**
* Updates the index-related context for each row to reflect any changes in the index of the rows,
* e.g. first/last/even/odd.
*/
private _updateRowIndexContext() {
const viewContainer = this._rowPlaceholder.viewContainer;
for (let index = 0, count = viewContainer.length; index < count; index++) {
const viewRef = viewContainer.get(index) as RowViewRef<T>;
viewRef.context.index = index;
viewRef.context.count = count;
viewRef.context.first = index === 0;
viewRef.context.last = index === count - 1;
viewRef.context.even = index % 2 === 0;
viewRef.context.odd = !viewRef.context.even;
}
}
/**
* Returns the cell template definitions to insert into the header
* as defined by its list of columns to display.
*/
private _getHeaderCellTemplatesForRow(headerDef: CdkHeaderRowDef): CdkHeaderCellDef[] {
if (!headerDef.columns) { return []; }
return headerDef.columns.map(columnId => {
const column = this._columnDefsByName.get(columnId);
if (!column) {
throw getTableUnknownColumnError(columnId);
}
return column.headerCell;
});
}
/**
* Returns the cell template definitions to insert in the provided row
* as defined by its list of columns to display.
*/
private _getCellTemplatesForRow(rowDef: CdkRowDef<T>): CdkCellDef[] {
if (!rowDef.columns) { return []; }
return rowDef.columns.map(columnId => {
const column = this._columnDefsByName.get(columnId);
if (!column) {
throw getTableUnknownColumnError(columnId);
}
return column.cell;
});
}
}