3 ChangeDetectionStrategy,
16 } from '@angular/core';
24 } from '@swimlane/ngx-datatable';
25 import * as _ from 'lodash';
26 import { Observable, Subject, Subscription, timer as observableTimer } from 'rxjs';
28 import { Icons } from '../../../shared/enum/icons.enum';
29 import { CellTemplate } from '../../enum/cell-template.enum';
30 import { CdTableColumn } from '../../models/cd-table-column';
31 import { CdTableColumnFilter } from '../../models/cd-table-column-filter';
32 import { CdTableColumnFiltersChange } from '../../models/cd-table-column-filters-change';
33 import { CdTableFetchDataContext } from '../../models/cd-table-fetch-data-context';
34 import { CdTableSelection } from '../../models/cd-table-selection';
35 import { CdUserConfig } from '../../models/cd-user-config';
39 templateUrl: './table.component.html',
40 styleUrls: ['./table.component.scss'],
41 changeDetection: ChangeDetectionStrategy.OnPush
43 export class TableComponent implements AfterContentChecked, OnInit, OnChanges, OnDestroy {
44 @ViewChild(DatatableComponent, { static: true })
45 table: DatatableComponent;
46 @ViewChild('tableCellBoldTpl', { static: true })
47 tableCellBoldTpl: TemplateRef<any>;
48 @ViewChild('sparklineTpl', { static: true })
49 sparklineTpl: TemplateRef<any>;
50 @ViewChild('routerLinkTpl', { static: true })
51 routerLinkTpl: TemplateRef<any>;
52 @ViewChild('checkIconTpl', { static: true })
53 checkIconTpl: TemplateRef<any>;
54 @ViewChild('perSecondTpl', { static: true })
55 perSecondTpl: TemplateRef<any>;
56 @ViewChild('executingTpl', { static: true })
57 executingTpl: TemplateRef<any>;
58 @ViewChild('classAddingTpl', { static: true })
59 classAddingTpl: TemplateRef<any>;
60 @ViewChild('badgeTpl', { static: true })
61 badgeTpl: TemplateRef<any>;
62 @ViewChild('mapTpl', { static: true })
63 mapTpl: TemplateRef<any>;
64 @ViewChild('truncateTpl', { static: true })
65 truncateTpl: TemplateRef<any>;
66 @ViewChild('rowDetailsTpl', { static: true })
67 rowDetailsTpl: TemplateRef<any>;
69 // This is the array with the items to be shown.
72 // Each item -> { prop: 'attribute name', name: 'display name' }
74 columns: CdTableColumn[];
75 // Each item -> { prop: 'attribute name', dir: 'asc'||'desc'}
77 sorts?: SortPropDir[];
78 // Method used for setting column widths.
81 // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions"
83 onlyActionHeader? = false;
84 // Display the tool header, including reload button, pagination and search fields?
87 // Display search field inside tool header?
90 // Display the table header?
93 // Display the table footer?
96 // Page size to show. Set to 0 to show unlimited number of rows.
99 // Has the row details?
104 * Auto reload time in ms - per default every 5s
105 * You can set it to 0, undefined or false to disable the auto reload feature in order to
106 * trigger 'fetchData' if the reload button is clicked.
107 * You can set it to a negative number to, on top of disabling the auto reload,
108 * prevent triggering fetchData when initializing the table.
111 autoReload: any = 5000;
113 // Which row property is unique for a row. If the identifier is not specified in any
114 // column, then the property name of the first column is used. Defaults to 'id'.
117 // If 'true', then the specified identifier is used anyway, although it is not specified
118 // in any column. Defaults to 'false'.
120 forceIdentifier = false;
121 // Allows other components to specify which type of selection they want,
122 // e.g. 'single' or 'multi'.
124 selectionType: string = undefined;
125 // By default selected item details will be updated on table refresh, if data has changed
127 updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
128 // By default expanded item details will be updated on table refresh, if data has changed
130 updateExpandedOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
135 // Enable this in order to search through the JSON of any used object.
137 searchableObjects = false;
139 // Only needed to set if the classAddingTpl is used
141 customCss?: { [css: string]: number | string | ((any: any) => boolean) };
143 // Columns that aren't displayed but can be used as filters
145 extraFilterableColumns: CdTableColumn[] = [];
148 * Should be a function to update the input data if undefined nothing will be triggered
150 * Sometimes it's useful to only define fetchData once.
152 * Usage of multiple tables with data which is updated by the same function
154 * The function is triggered through one table and all tables will update
157 fetchData = new EventEmitter();
160 * This should be defined if you need access to the selection object.
162 * Each time the table selection changes, this will be triggered and
163 * the new selection object will be sent.
165 * @memberof TableComponent
168 updateSelection = new EventEmitter();
171 setExpandedRow = new EventEmitter();
174 * This should be defined if you need access to the applied column filters.
176 * Each time the column filters changes, this will be triggered and
177 * the column filters change event will be sent.
179 * @memberof TableComponent
181 @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>();
184 * Use this variable to access the selected row(s).
186 selection = new CdTableSelection();
189 * Use this variable to access the expanded row
191 expanded: any = undefined;
193 tableColumns: CdTableColumn[];
196 [key: string]: TemplateRef<any>;
200 loadingIndicator = true;
201 loadingError = false;
202 paginationClasses = {
203 pagerLeftArrow: Icons.leftArrowDouble,
204 pagerRightArrow: Icons.rightArrowDouble,
205 pagerPrevious: Icons.leftArrow,
206 pagerNext: Icons.rightArrow
208 userConfig: CdUserConfig = {};
210 localStorage = window.localStorage;
211 private saveSubscriber: Subscription;
212 private reloadSubscriber: Subscription;
213 private updating = false;
215 // Internal variable to check if it is necessary to recalculate the
216 // table columns after the browser window has been resized.
217 private currentWidth: number;
219 columnFilters: CdTableColumnFilter[] = [];
220 selectedFilter: CdTableColumnFilter;
221 get columnFiltered(): boolean {
222 return _.some(this.columnFilters, (filter) => {
223 return filter.value !== undefined;
227 constructor(private ngZone: NgZone, private cdRef: ChangeDetectorRef) {}
229 static prepareSearch(search: string) {
230 search = search.toLowerCase().replace(/,/g, '');
231 if (search.match(/['"][^'"]+['"]/)) {
232 search = search.replace(/['"][^'"]+['"]/g, (match: string) => {
233 return match.replace(/(['"])([^'"]+)(['"])/g, '$2').replace(/ /g, '+');
236 return search.split(' ').filter((word) => word);
240 // ngx-datatable triggers calculations each time mouse enters a row,
241 // this will prevent that.
242 this.table.element.addEventListener('mouseenter', (e) => e.stopPropagation(), true);
243 this._addTemplates();
245 // Check whether the specified identifier exists.
246 const exists = _.findIndex(this.columns, ['prop', this.identifier]) !== -1;
247 // Auto-build the sorting configuration. If the specified identifier doesn't exist,
248 // then use the property of the first column.
249 this.sorts = this.createSortingDefinition(
250 exists ? this.identifier : this.columns[0].prop + ''
252 // If the specified identifier doesn't exist and it is not forced to use it anyway,
253 // then use the property of the first column.
254 if (!exists && !this.forceIdentifier) {
255 this.identifier = this.columns[0].prop + '';
259 this.initUserConfig();
260 this.columns.forEach((c) => {
261 if (c.cellTransformation) {
262 c.cellTemplate = this.cellTemplates[c.cellTransformation];
265 c.flexGrow = c.prop + '' === this.identifier ? 1 : 2;
268 c.resizeable = false;
272 this.initExpandCollapseColumn(); // If rows have details, add a column to expand or collapse the rows
273 this.initCheckboxColumn();
274 this.filterHiddenColumns();
275 this.initColumnFilters();
276 this.updateColumnFilterOptions();
277 // Load the data table content every N ms or at least once.
278 // Force showing the loading indicator if there are subscribers to the fetchData
279 // event. This is necessary because it has been set to False in useData() when
280 // this method was triggered by ngOnChanges().
281 if (this.fetchData.observers.length > 0) {
282 this.loadingIndicator = true;
284 if (_.isInteger(this.autoReload) && this.autoReload > 0) {
285 this.ngZone.runOutsideAngular(() => {
286 this.reloadSubscriber = observableTimer(0, this.autoReload).subscribe(() => {
287 this.ngZone.run(() => {
288 return this.reloadData();
292 } else if (!this.autoReload) {
301 this.tableName = this._calculateUniqueTableName(this.columns);
302 this._loadUserConfig();
303 this._initUserConfigAutoSave();
305 if (!this.userConfig.limit) {
306 this.userConfig.limit = this.limit;
308 if (!this.userConfig.sorts) {
309 this.userConfig.sorts = this.sorts;
311 if (!this.userConfig.columns) {
312 this.updateUserColumns();
314 this.columns.forEach((c, i) => {
315 c.isHidden = this.userConfig.columns[i].isHidden;
320 _calculateUniqueTableName(columns: any[]) {
321 const stringToNumber = (s: string) => {
322 if (!_.isString(s)) {
326 for (let i = 0; i < s.length; i++) {
327 result += s.charCodeAt(i) * i;
333 (result, value, index) =>
334 (stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1) + result,
341 const loaded = this.localStorage.getItem(this.tableName);
343 this.userConfig = JSON.parse(loaded);
347 _initUserConfigAutoSave() {
348 const source: Observable<any> = Observable.create(this._initUserConfigProxy.bind(this));
349 this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this));
352 _initUserConfigProxy(observer: Subject<any>) {
353 this.userConfig = new Proxy(this.userConfig, {
354 set(config, prop: string, value) {
355 config[prop] = value;
356 observer.next(config);
362 _saveUserConfig(config: any) {
363 this.localStorage.setItem(this.tableName, JSON.stringify(config));
366 updateUserColumns() {
367 this.userConfig.columns = this.columns.map((c) => ({
370 isHidden: !!c.isHidden
375 * Add a column containing a checkbox if selectionType is 'multiClick'.
377 initCheckboxColumn() {
378 if (this.selectionType === 'multiClick') {
379 this.columns.unshift({
385 canAutoResize: false,
386 cellClass: 'cd-datatable-checkbox',
393 * Add a column to expand and collapse the table row if it 'hasDetails'
395 initExpandCollapseColumn() {
396 if (this.hasDetails) {
397 this.columns.unshift({
403 canAutoResize: false,
404 cellClass: 'cd-datatable-expand-collapse',
406 cellTemplate: this.rowDetailsTpl
411 filterHiddenColumns() {
412 this.tableColumns = this.columns.filter((c) => !c.isHidden);
415 initColumnFilters() {
416 let filterableColumns = _.filter(this.columns, { filterable: true });
417 filterableColumns = [...filterableColumns, ...this.extraFilterableColumns];
418 this.columnFilters = filterableColumns.map((col: CdTableColumn) => {
422 value: col.filterInitValue
423 ? this.createColumnFilterOption(col.filterInitValue, col.pipe)
427 this.selectedFilter = _.first(this.columnFilters);
430 private createColumnFilterOption(
433 ): { raw: string; formatted: string } {
435 raw: _.toString(value),
436 formatted: pipe ? pipe.transform(value) : _.toString(value)
440 updateColumnFilterOptions() {
441 // update all possible values in a column
442 this.columnFilters.forEach((filter) => {
443 let values: any[] = [];
445 if (_.isUndefined(filter.column.filterOptions)) {
446 // only allow types that can be easily converted into string
447 const pre = _.filter(_.map(this.data, filter.column.prop), (v) => {
448 return (_.isString(v) && v !== '') || _.isBoolean(v) || _.isFinite(v) || _.isDate(v);
450 values = _.sortedUniq(pre.sort());
452 values = filter.column.filterOptions;
455 const options = values.map((v) => this.createColumnFilterOption(v, filter.column.pipe));
457 // In case a previous value is not available anymore
458 if (filter.value && _.isUndefined(_.find(options, { raw: filter.value.raw }))) {
459 filter.value = undefined;
462 filter.options = options;
466 onSelectFilter(filter: CdTableColumnFilter) {
467 this.selectedFilter = filter;
470 onChangeFilter(filter: CdTableColumnFilter, option?: { raw: string; formatted: string }) {
471 filter.value = _.isEqual(filter.value, option) ? undefined : option;
475 doColumnFiltering() {
476 const appliedFilters: CdTableColumnFiltersChange['filters'] = [];
477 let data = [...this.data];
478 let dataOut: any[] = [];
479 this.columnFilters.forEach((filter) => {
480 if (filter.value === undefined) {
483 appliedFilters.push({
484 name: filter.column.name,
485 prop: filter.column.prop,
488 // Separate data to filtered and filtered-out parts.
489 const parts = _.partition(data, (row) => {
490 // Use getter from ngx-datatable to handle props like 'sys_api.size'
491 const valueGetter = getterForProp(filter.column.prop);
492 const value = valueGetter(row, filter.column.prop);
493 if (_.isUndefined(filter.column.filterPredicate)) {
494 // By default, test string equal
495 return `${value}` === filter.value.raw;
497 // Use custom function to filter
498 return filter.column.filterPredicate(row, filter.value.raw);
502 dataOut = [...dataOut, ...parts[1]];
505 this.columnFiltersChanged.emit({
506 filters: appliedFilters,
511 // Remove the selection if previously-selected rows are filtered out.
512 _.forEach(this.selection.selected, (selectedItem) => {
513 if (_.find(data, { [this.identifier]: selectedItem[this.identifier] }) === undefined) {
514 this.selection = new CdTableSelection();
515 this.onSelect(this.selection);
522 if (this.reloadSubscriber) {
523 this.reloadSubscriber.unsubscribe();
525 if (this.saveSubscriber) {
526 this.saveSubscriber.unsubscribe();
530 ngAfterContentChecked() {
531 // If the data table is not visible, e.g. another tab is active, and the
532 // browser window gets resized, the table and its columns won't get resized
533 // automatically if the tab gets visible again.
534 // https://github.com/swimlane/ngx-datatable/issues/193
535 // https://github.com/swimlane/ngx-datatable/issues/193#issuecomment-329144543
536 if (this.table && this.table.element.clientWidth !== this.currentWidth) {
537 this.currentWidth = this.table.element.clientWidth;
538 // Recalculate the sizes of the grid.
539 this.table.recalculate();
540 // Mark the datatable as changed, Angular's change-detection will
541 // do the rest for us => the grid will be redrawn.
542 // Note, the ChangeDetectorRef variable is private, so we need to
543 // use this workaround to access it and make TypeScript happy.
544 const cdRef = _.get(this.table, 'cd');
545 cdRef.markForCheck();
550 this.cellTemplates.bold = this.tableCellBoldTpl;
551 this.cellTemplates.checkIcon = this.checkIconTpl;
552 this.cellTemplates.sparkline = this.sparklineTpl;
553 this.cellTemplates.routerLink = this.routerLinkTpl;
554 this.cellTemplates.perSecond = this.perSecondTpl;
555 this.cellTemplates.executing = this.executingTpl;
556 this.cellTemplates.classAdding = this.classAddingTpl;
557 this.cellTemplates.badge = this.badgeTpl;
558 this.cellTemplates.map = this.mapTpl;
559 this.cellTemplates.truncate = this.truncateTpl;
562 useCustomClass(value: any): string {
563 if (!this.customCss) {
564 throw new Error('Custom classes are not set!');
566 const classes = Object.keys(this.customCss);
567 const css = Object.values(this.customCss)
568 .map((v, i) => ((_.isFunction(v) && v(value)) || v === value) && classes[i])
571 return _.isEmpty(css) ? undefined : css;
579 const value = parseInt(e.target.value, 10);
581 this.userConfig.limit = value;
586 if (!this.updating) {
587 this.loadingError = false;
588 const context = new CdTableFetchDataContext(() => {
589 // Do we have to display the error panel?
590 this.loadingError = context.errorConfig.displayError;
591 // Force data table to show no data?
592 if (context.errorConfig.resetData) {
595 // Stop the loading indicator and reset the data table
596 // to the correct state.
599 this.fetchData.emit(context);
600 this.updating = true;
605 this.loadingIndicator = true;
610 return (row: any) => {
611 const id = row[this.identifier];
612 if (_.isUndefined(id)) {
613 throw new Error(`Wrong identifier "${this.identifier}" -> "${id}"`);
621 return; // Wait for data
623 this.updateColumnFilterOptions();
626 this.updateSelected();
627 this.updateExpanded();
631 * Reset the data table to correct state. This includes:
632 * - Disable loading indicator
633 * - Reset 'Updating' flag
636 this.loadingIndicator = false;
637 this.updating = false;
641 * After updating the data, we have to update the selected items
642 * because details may have changed,
643 * or some selected items may have been removed.
646 if (this.updateSelectionOnRefresh === 'never') {
649 const newSelected: any[] = [];
650 this.selection.selected.forEach((selectedItem) => {
651 for (const row of this.data) {
652 if (selectedItem[this.identifier] === row[this.identifier]) {
653 newSelected.push(row);
658 this.updateSelectionOnRefresh === 'onChange' &&
659 _.isEqual(this.selection.selected, newSelected)
663 this.selection.selected = newSelected;
664 this.onSelect(this.selection);
668 if (_.isUndefined(this.expanded) || this.updateExpandedOnRefresh === 'never') {
672 const expandedId = this.expanded[this.identifier];
673 const newExpanded = _.find(this.data, (row) => expandedId === row[this.identifier]);
675 if (this.updateExpandedOnRefresh === 'onChange' && _.isEqual(this.expanded, newExpanded)) {
679 this.expanded = newExpanded;
680 this.setExpandedRow.emit(newExpanded);
683 onSelect($event: any) {
684 this.selection.selected = $event['selected'];
685 this.updateSelection.emit(_.clone(this.selection));
688 toggleColumn($event: any) {
689 const prop: TableColumnProp = $event.target.name;
690 const hide = !$event.target.checked;
691 if (hide && this.tableColumns.length === 1) {
692 $event.target.checked = true;
695 _.find(this.columns, (c: CdTableColumn) => c.prop === prop).isHidden = hide;
696 this.updateColumns();
700 this.updateUserColumns();
701 this.filterHiddenColumns();
702 const sortProp = this.userConfig.sorts[0].prop;
703 if (!_.find(this.tableColumns, (c: CdTableColumn) => c.prop === sortProp)) {
704 this.userConfig.sorts = this.createSortingDefinition(this.tableColumns[0].prop);
706 this.table.recalculate();
707 this.cdRef.detectChanges();
710 createSortingDefinition(prop: TableColumnProp): SortPropDir[] {
714 dir: SortDirection.asc
719 changeSorting({ sorts }: any) {
720 this.userConfig.sorts = sorts;
729 this.columnFilters.forEach((filter) => {
730 filter.value = undefined;
732 this.selectedFilter = _.first(this.columnFilters);
737 let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data;
739 if (this.search.length > 0 && rows) {
740 const columns = this.columns.filter((c) => c.cellTransformation !== CellTemplate.sparkline);
742 rows = this.subSearch(rows, TableComponent.prepareSearch(this.search), columns);
743 // Whenever the filter changes, always go back to the first page
744 this.table.offset = 0;
750 subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]): any[] {
751 if (currentSearch.length === 0 || data.length === 0) {
754 const searchTerms: string[] = currentSearch.pop().replace(/\+/g, ' ').split(':');
755 const columnsClone = [...columns];
756 if (searchTerms.length === 2) {
757 columns = columnsClone.filter((c) => c.name.toLowerCase().indexOf(searchTerms[0]) !== -1);
759 data = this.basicDataSearch(_.last(searchTerms), data, columns);
760 // Checks if user searches for column but he is still typing
761 return this.subSearch(data, currentSearch, columnsClone);
764 basicDataSearch(searchTerm: string, rows: any[], columns: CdTableColumn[]) {
765 if (searchTerm.length === 0) {
768 return rows.filter((row) => {
770 columns.filter((col) => {
771 let cellValue: any = _.get(row, col.prop);
773 if (!_.isUndefined(col.pipe)) {
774 cellValue = col.pipe.transform(cellValue);
776 if (_.isUndefined(cellValue) || _.isNull(cellValue)) {
780 if (_.isArray(cellValue)) {
781 cellValue = cellValue.join(' ');
782 } else if (_.isNumber(cellValue) || _.isBoolean(cellValue)) {
783 cellValue = cellValue.toString();
786 if (_.isObjectLike(cellValue)) {
787 if (this.searchableObjects) {
788 cellValue = JSON.stringify(cellValue);
794 return cellValue.toLowerCase().indexOf(searchTerm) !== -1;
801 // Return the function used to populate a row's CSS classes.
804 clickable: !_.isUndefined(this.selectionType)
809 toggleExpandRow(row: any, isExpanded: boolean) {
811 // If current row isn't expanded, collapse others
813 this.table.rowDetail.collapseAllRows();
814 this.setExpandedRow.emit(row);
816 // If all rows are closed, emit undefined
817 this.expanded = undefined;
818 this.setExpandedRow.emit(undefined);
820 this.table.rowDetail.toggleExpandRow(row);