3 ChangeDetectionStrategy,
17 } from '@angular/core';
25 } from '@swimlane/ngx-datatable';
26 import * as _ from 'lodash';
27 import { Observable, Subject, Subscription, timer as observableTimer } from 'rxjs';
29 import { Icons } from '../../../shared/enum/icons.enum';
30 import { CellTemplate } from '../../enum/cell-template.enum';
31 import { CdTableColumn } from '../../models/cd-table-column';
32 import { CdTableColumnFilter } from '../../models/cd-table-column-filter';
33 import { CdTableColumnFiltersChange } from '../../models/cd-table-column-filters-change';
34 import { CdTableFetchDataContext } from '../../models/cd-table-fetch-data-context';
35 import { CdTableSelection } from '../../models/cd-table-selection';
36 import { CdUserConfig } from '../../models/cd-user-config';
40 templateUrl: './table.component.html',
41 styleUrls: ['./table.component.scss'],
42 changeDetection: ChangeDetectionStrategy.OnPush
44 export class TableComponent implements AfterContentChecked, OnInit, OnChanges, OnDestroy {
45 @ViewChild(DatatableComponent, { static: true })
46 table: DatatableComponent;
47 @ViewChild('tableCellBoldTpl', { static: true })
48 tableCellBoldTpl: TemplateRef<any>;
49 @ViewChild('sparklineTpl', { static: true })
50 sparklineTpl: TemplateRef<any>;
51 @ViewChild('routerLinkTpl', { static: true })
52 routerLinkTpl: TemplateRef<any>;
53 @ViewChild('checkIconTpl', { static: true })
54 checkIconTpl: TemplateRef<any>;
55 @ViewChild('perSecondTpl', { static: true })
56 perSecondTpl: TemplateRef<any>;
57 @ViewChild('executingTpl', { static: true })
58 executingTpl: TemplateRef<any>;
59 @ViewChild('classAddingTpl', { static: true })
60 classAddingTpl: TemplateRef<any>;
61 @ViewChild('badgeTpl', { static: true })
62 badgeTpl: TemplateRef<any>;
63 @ViewChild('mapTpl', { static: true })
64 mapTpl: TemplateRef<any>;
65 @ViewChild('truncateTpl', { static: true })
66 truncateTpl: TemplateRef<any>;
67 @ViewChild('rowDetailsTpl', { static: true })
68 rowDetailsTpl: TemplateRef<any>;
70 // This is the array with the items to be shown.
73 // Each item -> { prop: 'attribute name', name: 'display name' }
75 columns: CdTableColumn[];
76 // Each item -> { prop: 'attribute name', dir: 'asc'||'desc'}
78 sorts?: SortPropDir[];
79 // Method used for setting column widths.
82 // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions"
84 onlyActionHeader? = false;
85 // Display the tool header, including reload button, pagination and search fields?
88 // Display search field inside tool header?
91 // Display the table header?
94 // Display the table footer?
97 // Page size to show. Set to 0 to show unlimited number of rows.
100 // Has the row details?
105 * Auto reload time in ms - per default every 5s
106 * You can set it to 0, undefined or false to disable the auto reload feature in order to
107 * trigger 'fetchData' if the reload button is clicked.
108 * You can set it to a negative number to, on top of disabling the auto reload,
109 * prevent triggering fetchData when initializing the table.
112 autoReload: any = 5000;
114 // Which row property is unique for a row. If the identifier is not specified in any
115 // column, then the property name of the first column is used. Defaults to 'id'.
118 // If 'true', then the specified identifier is used anyway, although it is not specified
119 // in any column. Defaults to 'false'.
121 forceIdentifier = false;
122 // Allows other components to specify which type of selection they want,
123 // e.g. 'single' or 'multi'.
125 selectionType: string = undefined;
126 // By default selected item details will be updated on table refresh, if data has changed
128 updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
129 // By default expanded item details will be updated on table refresh, if data has changed
131 updateExpandedOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
136 // Enable this in order to search through the JSON of any used object.
138 searchableObjects = false;
140 // Only needed to set if the classAddingTpl is used
142 customCss?: { [css: string]: number | string | ((any: any) => boolean) };
144 // Columns that aren't displayed but can be used as filters
146 extraFilterableColumns: CdTableColumn[] = [];
149 * Should be a function to update the input data if undefined nothing will be triggered
151 * Sometimes it's useful to only define fetchData once.
153 * Usage of multiple tables with data which is updated by the same function
155 * The function is triggered through one table and all tables will update
158 fetchData = new EventEmitter();
161 * This should be defined if you need access to the selection object.
163 * Each time the table selection changes, this will be triggered and
164 * the new selection object will be sent.
166 * @memberof TableComponent
169 updateSelection = new EventEmitter();
172 setExpandedRow = new EventEmitter();
175 * This should be defined if you need access to the applied column filters.
177 * Each time the column filters changes, this will be triggered and
178 * the column filters change event will be sent.
180 * @memberof TableComponent
182 @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>();
185 * Use this variable to access the selected row(s).
187 selection = new CdTableSelection();
190 * Use this variable to access the expanded row
192 expanded: any = undefined;
194 tableColumns: CdTableColumn[];
197 [key: string]: TemplateRef<any>;
201 loadingIndicator = true;
202 loadingError = false;
203 paginationClasses = {
204 pagerLeftArrow: Icons.leftArrowDouble,
205 pagerRightArrow: Icons.rightArrowDouble,
206 pagerPrevious: Icons.leftArrow,
207 pagerNext: Icons.rightArrow
209 userConfig: CdUserConfig = {};
211 localStorage = window.localStorage;
212 private saveSubscriber: Subscription;
213 private reloadSubscriber: Subscription;
214 private updating = false;
216 // Internal variable to check if it is necessary to recalculate the
217 // table columns after the browser window has been resized.
218 private currentWidth: number;
220 columnFilters: CdTableColumnFilter[] = [];
221 selectedFilter: CdTableColumnFilter;
222 get columnFiltered(): boolean {
223 return _.some(this.columnFilters, (filter) => {
224 return filter.value !== undefined;
228 constructor(private ngZone: NgZone, private cdRef: ChangeDetectorRef) {}
230 static prepareSearch(search: string) {
231 search = search.toLowerCase().replace(/,/g, '');
232 if (search.match(/['"][^'"]+['"]/)) {
233 search = search.replace(/['"][^'"]+['"]/g, (match: string) => {
234 return match.replace(/(['"])([^'"]+)(['"])/g, '$2').replace(/ /g, '+');
237 return search.split(' ').filter((word) => word);
241 // ngx-datatable triggers calculations each time mouse enters a row,
242 // this will prevent that.
243 this.table.element.addEventListener('mouseenter', (e) => e.stopPropagation(), true);
244 this._addTemplates();
246 // Check whether the specified identifier exists.
247 const exists = _.findIndex(this.columns, ['prop', this.identifier]) !== -1;
248 // Auto-build the sorting configuration. If the specified identifier doesn't exist,
249 // then use the property of the first column.
250 this.sorts = this.createSortingDefinition(
251 exists ? this.identifier : this.columns[0].prop + ''
253 // If the specified identifier doesn't exist and it is not forced to use it anyway,
254 // then use the property of the first column.
255 if (!exists && !this.forceIdentifier) {
256 this.identifier = this.columns[0].prop + '';
260 this.initUserConfig();
261 this.columns.forEach((c) => {
262 if (c.cellTransformation) {
263 c.cellTemplate = this.cellTemplates[c.cellTransformation];
266 c.flexGrow = c.prop + '' === this.identifier ? 1 : 2;
269 c.resizeable = false;
273 this.initExpandCollapseColumn(); // If rows have details, add a column to expand or collapse the rows
274 this.initCheckboxColumn();
275 this.filterHiddenColumns();
276 this.initColumnFilters();
277 this.updateColumnFilterOptions();
278 // Load the data table content every N ms or at least once.
279 // Force showing the loading indicator if there are subscribers to the fetchData
280 // event. This is necessary because it has been set to False in useData() when
281 // this method was triggered by ngOnChanges().
282 if (this.fetchData.observers.length > 0) {
283 this.loadingIndicator = true;
285 if (_.isInteger(this.autoReload) && this.autoReload > 0) {
286 this.ngZone.runOutsideAngular(() => {
287 this.reloadSubscriber = observableTimer(0, this.autoReload).subscribe(() => {
288 this.ngZone.run(() => {
289 return this.reloadData();
293 } else if (!this.autoReload) {
302 this.tableName = this._calculateUniqueTableName(this.columns);
303 this._loadUserConfig();
304 this._initUserConfigAutoSave();
306 if (!this.userConfig.limit) {
307 this.userConfig.limit = this.limit;
309 if (!this.userConfig.sorts) {
310 this.userConfig.sorts = this.sorts;
312 if (!this.userConfig.columns) {
313 this.updateUserColumns();
315 this.columns.forEach((c, i) => {
316 c.isHidden = this.userConfig.columns[i].isHidden;
321 _calculateUniqueTableName(columns: any[]) {
322 const stringToNumber = (s: string) => {
323 if (!_.isString(s)) {
327 for (let i = 0; i < s.length; i++) {
328 result += s.charCodeAt(i) * i;
334 (result, value, index) =>
335 (stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1) + result,
342 const loaded = this.localStorage.getItem(this.tableName);
344 this.userConfig = JSON.parse(loaded);
348 _initUserConfigAutoSave() {
349 const source: Observable<any> = Observable.create(this._initUserConfigProxy.bind(this));
350 this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this));
353 _initUserConfigProxy(observer: Subject<any>) {
354 this.userConfig = new Proxy(this.userConfig, {
355 set(config, prop: string, value) {
356 config[prop] = value;
357 observer.next(config);
363 _saveUserConfig(config: any) {
364 this.localStorage.setItem(this.tableName, JSON.stringify(config));
367 updateUserColumns() {
368 this.userConfig.columns = this.columns.map((c) => ({
371 isHidden: !!c.isHidden
376 * Add a column containing a checkbox if selectionType is 'multiClick'.
378 initCheckboxColumn() {
379 if (this.selectionType === 'multiClick') {
380 this.columns.unshift({
386 canAutoResize: false,
387 cellClass: 'cd-datatable-checkbox',
394 * Add a column to expand and collapse the table row if it 'hasDetails'
396 initExpandCollapseColumn() {
397 if (this.hasDetails) {
398 this.columns.unshift({
404 canAutoResize: false,
405 cellClass: 'cd-datatable-expand-collapse',
407 cellTemplate: this.rowDetailsTpl
412 filterHiddenColumns() {
413 this.tableColumns = this.columns.filter((c) => !c.isHidden);
416 initColumnFilters() {
417 let filterableColumns = _.filter(this.columns, { filterable: true });
418 filterableColumns = [...filterableColumns, ...this.extraFilterableColumns];
419 this.columnFilters = filterableColumns.map((col: CdTableColumn) => {
423 value: col.filterInitValue
424 ? this.createColumnFilterOption(col.filterInitValue, col.pipe)
428 this.selectedFilter = _.first(this.columnFilters);
431 private createColumnFilterOption(
434 ): { raw: string; formatted: string } {
436 raw: _.toString(value),
437 formatted: pipe ? pipe.transform(value) : _.toString(value)
441 updateColumnFilterOptions() {
442 // update all possible values in a column
443 this.columnFilters.forEach((filter) => {
444 let values: any[] = [];
446 if (_.isUndefined(filter.column.filterOptions)) {
447 // only allow types that can be easily converted into string
448 const pre = _.filter(_.map(this.data, filter.column.prop), (v) => {
449 return (_.isString(v) && v !== '') || _.isBoolean(v) || _.isFinite(v) || _.isDate(v);
451 values = _.sortedUniq(pre.sort());
453 values = filter.column.filterOptions;
456 const options = values.map((v) => this.createColumnFilterOption(v, filter.column.pipe));
458 // In case a previous value is not available anymore
459 if (filter.value && _.isUndefined(_.find(options, { raw: filter.value.raw }))) {
460 filter.value = undefined;
463 filter.options = options;
467 onSelectFilter(filter: CdTableColumnFilter) {
468 this.selectedFilter = filter;
471 onChangeFilter(filter: CdTableColumnFilter, option?: { raw: string; formatted: string }) {
472 filter.value = _.isEqual(filter.value, option) ? undefined : option;
476 doColumnFiltering() {
477 const appliedFilters: CdTableColumnFiltersChange['filters'] = [];
478 let data = [...this.data];
479 let dataOut: any[] = [];
480 this.columnFilters.forEach((filter) => {
481 if (filter.value === undefined) {
484 appliedFilters.push({
485 name: filter.column.name,
486 prop: filter.column.prop,
489 // Separate data to filtered and filtered-out parts.
490 const parts = _.partition(data, (row) => {
491 // Use getter from ngx-datatable to handle props like 'sys_api.size'
492 const valueGetter = getterForProp(filter.column.prop);
493 const value = valueGetter(row, filter.column.prop);
494 if (_.isUndefined(filter.column.filterPredicate)) {
495 // By default, test string equal
496 return `${value}` === filter.value.raw;
498 // Use custom function to filter
499 return filter.column.filterPredicate(row, filter.value.raw);
503 dataOut = [...dataOut, ...parts[1]];
506 this.columnFiltersChanged.emit({
507 filters: appliedFilters,
512 // Remove the selection if previously-selected rows are filtered out.
513 _.forEach(this.selection.selected, (selectedItem) => {
514 if (_.find(data, { [this.identifier]: selectedItem[this.identifier] }) === undefined) {
515 this.selection = new CdTableSelection();
516 this.onSelect(this.selection);
523 if (this.reloadSubscriber) {
524 this.reloadSubscriber.unsubscribe();
526 if (this.saveSubscriber) {
527 this.saveSubscriber.unsubscribe();
531 ngAfterContentChecked() {
532 // If the data table is not visible, e.g. another tab is active, and the
533 // browser window gets resized, the table and its columns won't get resized
534 // automatically if the tab gets visible again.
535 // https://github.com/swimlane/ngx-datatable/issues/193
536 // https://github.com/swimlane/ngx-datatable/issues/193#issuecomment-329144543
537 if (this.table && this.table.element.clientWidth !== this.currentWidth) {
538 this.currentWidth = this.table.element.clientWidth;
539 // Recalculate the sizes of the grid.
540 this.table.recalculate();
541 // Mark the datatable as changed, Angular's change-detection will
542 // do the rest for us => the grid will be redrawn.
543 // Note, the ChangeDetectorRef variable is private, so we need to
544 // use this workaround to access it and make TypeScript happy.
545 const cdRef = _.get(this.table, 'cd');
546 cdRef.markForCheck();
551 this.cellTemplates.bold = this.tableCellBoldTpl;
552 this.cellTemplates.checkIcon = this.checkIconTpl;
553 this.cellTemplates.sparkline = this.sparklineTpl;
554 this.cellTemplates.routerLink = this.routerLinkTpl;
555 this.cellTemplates.perSecond = this.perSecondTpl;
556 this.cellTemplates.executing = this.executingTpl;
557 this.cellTemplates.classAdding = this.classAddingTpl;
558 this.cellTemplates.badge = this.badgeTpl;
559 this.cellTemplates.map = this.mapTpl;
560 this.cellTemplates.truncate = this.truncateTpl;
563 useCustomClass(value: any): string {
564 if (!this.customCss) {
565 throw new Error('Custom classes are not set!');
567 const classes = Object.keys(this.customCss);
568 const css = Object.values(this.customCss)
569 .map((v, i) => ((_.isFunction(v) && v(value)) || v === value) && classes[i])
572 return _.isEmpty(css) ? undefined : css;
575 ngOnChanges(changes: SimpleChanges) {
576 if (changes.data && changes.data.currentValue) {
582 const value = parseInt(e.target.value, 10);
584 this.userConfig.limit = value;
589 if (!this.updating) {
590 this.loadingError = false;
591 const context = new CdTableFetchDataContext(() => {
592 // Do we have to display the error panel?
593 this.loadingError = context.errorConfig.displayError;
594 // Force data table to show no data?
595 if (context.errorConfig.resetData) {
598 // Stop the loading indicator and reset the data table
599 // to the correct state.
602 this.fetchData.emit(context);
603 this.updating = true;
608 this.loadingIndicator = true;
613 return (row: any) => {
614 const id = row[this.identifier];
615 if (_.isUndefined(id)) {
616 throw new Error(`Wrong identifier "${this.identifier}" -> "${id}"`);
624 return; // Wait for data
626 this.updateColumnFilterOptions();
629 this.updateSelected();
630 this.updateExpanded();
634 * Reset the data table to correct state. This includes:
635 * - Disable loading indicator
636 * - Reset 'Updating' flag
639 this.loadingIndicator = false;
640 this.updating = false;
644 * After updating the data, we have to update the selected items
645 * because details may have changed,
646 * or some selected items may have been removed.
649 if (this.updateSelectionOnRefresh === 'never') {
652 const newSelected: any[] = [];
653 this.selection.selected.forEach((selectedItem) => {
654 for (const row of this.data) {
655 if (selectedItem[this.identifier] === row[this.identifier]) {
656 newSelected.push(row);
661 this.updateSelectionOnRefresh === 'onChange' &&
662 _.isEqual(this.selection.selected, newSelected)
666 this.selection.selected = newSelected;
667 this.onSelect(this.selection);
671 if (_.isUndefined(this.expanded) || this.updateExpandedOnRefresh === 'never') {
675 const expandedId = this.expanded[this.identifier];
676 const newExpanded = _.find(this.data, (row) => expandedId === row[this.identifier]);
678 if (this.updateExpandedOnRefresh === 'onChange' && _.isEqual(this.expanded, newExpanded)) {
682 this.expanded = newExpanded;
683 this.setExpandedRow.emit(newExpanded);
686 onSelect($event: any) {
687 // Ensure we do not process DOM 'select' events.
688 // https://github.com/swimlane/ngx-datatable/issues/899
689 if (_.has($event, 'selected')) {
690 this.selection.selected = $event['selected'];
692 this.updateSelection.emit(_.clone(this.selection));
695 toggleColumn($event: any) {
696 const prop: TableColumnProp = $event.target.name;
697 const hide = !$event.target.checked;
698 if (hide && this.tableColumns.length === 1) {
699 $event.target.checked = true;
702 _.find(this.columns, (c: CdTableColumn) => c.prop === prop).isHidden = hide;
703 this.updateColumns();
707 this.updateUserColumns();
708 this.filterHiddenColumns();
709 const sortProp = this.userConfig.sorts[0].prop;
710 if (!_.find(this.tableColumns, (c: CdTableColumn) => c.prop === sortProp)) {
711 this.userConfig.sorts = this.createSortingDefinition(this.tableColumns[0].prop);
713 this.table.recalculate();
714 this.cdRef.detectChanges();
717 createSortingDefinition(prop: TableColumnProp): SortPropDir[] {
721 dir: SortDirection.asc
726 changeSorting({ sorts }: any) {
727 this.userConfig.sorts = sorts;
736 this.columnFilters.forEach((filter) => {
737 filter.value = undefined;
739 this.selectedFilter = _.first(this.columnFilters);
744 let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data;
746 if (this.search.length > 0 && rows) {
747 const columns = this.columns.filter((c) => c.cellTransformation !== CellTemplate.sparkline);
749 rows = this.subSearch(rows, TableComponent.prepareSearch(this.search), columns);
750 // Whenever the filter changes, always go back to the first page
751 this.table.offset = 0;
757 subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]): any[] {
758 if (currentSearch.length === 0 || data.length === 0) {
761 const searchTerms: string[] = currentSearch.pop().replace(/\+/g, ' ').split(':');
762 const columnsClone = [...columns];
763 if (searchTerms.length === 2) {
764 columns = columnsClone.filter((c) => c.name.toLowerCase().indexOf(searchTerms[0]) !== -1);
766 data = this.basicDataSearch(_.last(searchTerms), data, columns);
767 // Checks if user searches for column but he is still typing
768 return this.subSearch(data, currentSearch, columnsClone);
771 basicDataSearch(searchTerm: string, rows: any[], columns: CdTableColumn[]) {
772 if (searchTerm.length === 0) {
775 return rows.filter((row) => {
777 columns.filter((col) => {
778 let cellValue: any = _.get(row, col.prop);
780 if (!_.isUndefined(col.pipe)) {
781 cellValue = col.pipe.transform(cellValue);
783 if (_.isUndefined(cellValue) || _.isNull(cellValue)) {
787 if (_.isArray(cellValue)) {
788 cellValue = cellValue.join(' ');
789 } else if (_.isNumber(cellValue) || _.isBoolean(cellValue)) {
790 cellValue = cellValue.toString();
793 if (_.isObjectLike(cellValue)) {
794 if (this.searchableObjects) {
795 cellValue = JSON.stringify(cellValue);
801 return cellValue.toLowerCase().indexOf(searchTerm) !== -1;
808 // Return the function used to populate a row's CSS classes.
811 clickable: !_.isUndefined(this.selectionType)
816 toggleExpandRow(row: any, isExpanded: boolean) {
818 // If current row isn't expanded, collapse others
820 this.table.rowDetail.collapseAllRows();
821 this.setExpandedRow.emit(row);
823 // If all rows are closed, emit undefined
824 this.expanded = undefined;
825 this.setExpandedRow.emit(undefined);
827 this.table.rowDetail.toggleExpandRow(row);