3 ChangeDetectionStrategy,
16 } from '@angular/core';
23 } from '@swimlane/ngx-datatable';
24 import { getterForProp } from '@swimlane/ngx-datatable/release/utils';
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>;
65 // This is the array with the items to be shown.
68 // Each item -> { prop: 'attribute name', name: 'display name' }
70 columns: CdTableColumn[];
71 // Each item -> { prop: 'attribute name', dir: 'asc'||'desc'}
73 sorts?: SortPropDir[];
74 // Method used for setting column widths.
77 // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions"
79 onlyActionHeader? = false;
80 // Display the tool header, including reload button, pagination and search fields?
83 // Display search field inside tool header?
86 // Display the table header?
89 // Display the table footer?
92 // Page size to show. Set to 0 to show unlimited number of rows.
97 * Auto reload time in ms - per default every 5s
98 * You can set it to 0, undefined or false to disable the auto reload feature in order to
99 * trigger 'fetchData' if the reload button is clicked.
102 autoReload: any = 5000;
104 // Which row property is unique for a row. If the identifier is not specified in any
105 // column, then the property name of the first column is used. Defaults to 'id'.
108 // If 'true', then the specified identifier is used anyway, although it is not specified
109 // in any column. Defaults to 'false'.
111 forceIdentifier = false;
112 // Allows other components to specify which type of selection they want,
113 // e.g. 'single' or 'multi'.
115 selectionType: string = undefined;
116 // By default selected item details will be updated on table refresh, if data has changed
118 updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
123 // Enable this in order to search through the JSON of any used object.
125 searchableObjects = false;
127 // Only needed to set if the classAddingTpl is used
129 customCss?: { [css: string]: number | string | ((any: any) => boolean) };
131 // Columns that aren't displayed but can be used as filters
133 extraFilterableColumns: CdTableColumn[] = [];
136 * Should be a function to update the input data if undefined nothing will be triggered
138 * Sometimes it's useful to only define fetchData once.
140 * Usage of multiple tables with data which is updated by the same function
142 * The function is triggered through one table and all tables will update
145 fetchData = new EventEmitter();
148 * This should be defined if you need access to the selection object.
150 * Each time the table selection changes, this will be triggered and
151 * the new selection object will be sent.
153 * @memberof TableComponent
156 updateSelection = new EventEmitter();
159 * This should be defined if you need access to the applied column filters.
161 * Each time the column filters changes, this will be triggered and
162 * the column filters change event will be sent.
164 * @memberof TableComponent
166 @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>();
169 * Use this variable to access the selected row(s).
171 selection = new CdTableSelection();
173 tableColumns: CdTableColumn[];
176 [key: string]: TemplateRef<any>;
180 loadingIndicator = true;
181 loadingError = false;
182 paginationClasses = {
183 pagerLeftArrow: Icons.leftArrowDouble,
184 pagerRightArrow: Icons.rightArrowDouble,
185 pagerPrevious: Icons.leftArrow,
186 pagerNext: Icons.rightArrow
188 userConfig: CdUserConfig = {};
190 localStorage = window.localStorage;
191 private saveSubscriber: Subscription;
192 private reloadSubscriber: Subscription;
193 private updating = false;
195 // Internal variable to check if it is necessary to recalculate the
196 // table columns after the browser window has been resized.
197 private currentWidth: number;
199 columnFilters: CdTableColumnFilter[] = [];
200 selectedFilter: CdTableColumnFilter;
201 get columnFiltered(): boolean {
202 return _.some(this.columnFilters, (filter) => {
203 return filter.value !== undefined;
207 constructor(private ngZone: NgZone, private cdRef: ChangeDetectorRef) {}
209 static prepareSearch(search: string) {
210 search = search.toLowerCase().replace(/,/g, '');
211 if (search.match(/['"][^'"]+['"]/)) {
212 search = search.replace(/['"][^'"]+['"]/g, (match: string) => {
213 return match.replace(/(['"])([^'"]+)(['"])/g, '$2').replace(/ /g, '+');
216 return search.split(' ').filter((word) => word);
220 // ngx-datatable triggers calculations each time mouse enters a row,
221 // this will prevent that.
222 this.table.element.addEventListener('mouseenter', (e) => e.stopPropagation(), true);
223 this._addTemplates();
225 // Check whether the specified identifier exists.
226 const exists = _.findIndex(this.columns, ['prop', this.identifier]) !== -1;
227 // Auto-build the sorting configuration. If the specified identifier doesn't exist,
228 // then use the property of the first column.
229 this.sorts = this.createSortingDefinition(
230 exists ? this.identifier : this.columns[0].prop + ''
232 // If the specified identifier doesn't exist and it is not forced to use it anyway,
233 // then use the property of the first column.
234 if (!exists && !this.forceIdentifier) {
235 this.identifier = this.columns[0].prop + '';
238 this.initUserConfig();
239 this.columns.forEach((c) => {
240 if (c.cellTransformation) {
241 c.cellTemplate = this.cellTemplates[c.cellTransformation];
244 c.flexGrow = c.prop + '' === this.identifier ? 1 : 2;
247 c.resizeable = false;
251 this.initCheckboxColumn();
252 this.filterHiddenColumns();
253 this.initColumnFilters();
254 this.updateColumnFilterOptions();
255 // Load the data table content every N ms or at least once.
256 // Force showing the loading indicator if there are subscribers to the fetchData
257 // event. This is necessary because it has been set to False in useData() when
258 // this method was triggered by ngOnChanges().
259 if (this.fetchData.observers.length > 0) {
260 this.loadingIndicator = true;
262 if (_.isInteger(this.autoReload) && this.autoReload > 0) {
263 this.ngZone.runOutsideAngular(() => {
264 this.reloadSubscriber = observableTimer(0, this.autoReload).subscribe(() => {
265 this.ngZone.run(() => {
266 return this.reloadData();
277 this.tableName = this._calculateUniqueTableName(this.columns);
278 this._loadUserConfig();
279 this._initUserConfigAutoSave();
281 if (!this.userConfig.limit) {
282 this.userConfig.limit = this.limit;
284 if (!this.userConfig.sorts) {
285 this.userConfig.sorts = this.sorts;
287 if (!this.userConfig.columns) {
288 this.updateUserColumns();
290 this.columns.forEach((c, i) => {
291 c.isHidden = this.userConfig.columns[i].isHidden;
296 _calculateUniqueTableName(columns: any[]) {
297 const stringToNumber = (s: string) => {
298 if (!_.isString(s)) {
302 for (let i = 0; i < s.length; i++) {
303 result += s.charCodeAt(i) * i;
309 (result, value, index) =>
310 (stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1) + result,
317 const loaded = this.localStorage.getItem(this.tableName);
319 this.userConfig = JSON.parse(loaded);
323 _initUserConfigAutoSave() {
324 const source: Observable<any> = Observable.create(this._initUserConfigProxy.bind(this));
325 this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this));
328 _initUserConfigProxy(observer: Subject<any>) {
329 this.userConfig = new Proxy(this.userConfig, {
330 set(config, prop: string, value) {
331 config[prop] = value;
332 observer.next(config);
338 _saveUserConfig(config: any) {
339 this.localStorage.setItem(this.tableName, JSON.stringify(config));
342 updateUserColumns() {
343 this.userConfig.columns = this.columns.map((c) => ({
346 isHidden: !!c.isHidden
351 * Add a column containing a checkbox if selectionType is 'multiClick'.
353 initCheckboxColumn() {
354 if (this.selectionType === 'multiClick') {
355 this.columns.unshift({
361 canAutoResize: false,
362 cellClass: 'cd-datatable-checkbox',
368 filterHiddenColumns() {
369 this.tableColumns = this.columns.filter((c) => !c.isHidden);
372 initColumnFilters() {
373 let filterableColumns = _.filter(this.columns, { filterable: true });
374 filterableColumns = [...filterableColumns, ...this.extraFilterableColumns];
375 this.columnFilters = filterableColumns.map((col: CdTableColumn) => {
379 value: col.filterInitValue
380 ? this.createColumnFilterOption(col.filterInitValue, col.pipe)
384 this.selectedFilter = _.first(this.columnFilters);
387 private createColumnFilterOption(
390 ): { raw: string; formatted: string } {
392 raw: _.toString(value),
393 formatted: pipe ? pipe.transform(value) : _.toString(value)
397 updateColumnFilterOptions() {
398 // update all possible values in a column
399 this.columnFilters.forEach((filter) => {
400 let values: any[] = [];
402 if (_.isUndefined(filter.column.filterOptions)) {
403 // only allow types that can be easily converted into string
404 const pre = _.filter(_.map(this.data, filter.column.prop), (v) => {
405 return (_.isString(v) && v !== '') || _.isBoolean(v) || _.isFinite(v) || _.isDate(v);
407 values = _.sortedUniq(pre.sort());
409 values = filter.column.filterOptions;
412 const options = values.map((v) => this.createColumnFilterOption(v, filter.column.pipe));
414 // In case a previous value is not available anymore
415 if (filter.value && _.isUndefined(_.find(options, { raw: filter.value.raw }))) {
416 filter.value = undefined;
419 filter.options = options;
423 onSelectFilter(filter: CdTableColumnFilter) {
424 this.selectedFilter = filter;
427 onChangeFilter(filter: CdTableColumnFilter, option?: { raw: string; formatted: string }) {
428 filter.value = _.isEqual(filter.value, option) ? undefined : option;
432 doColumnFiltering() {
433 const appliedFilters: CdTableColumnFiltersChange['filters'] = [];
434 let data = [...this.data];
435 let dataOut: any[] = [];
436 this.columnFilters.forEach((filter) => {
437 if (filter.value === undefined) {
440 appliedFilters.push({
441 name: filter.column.name,
442 prop: filter.column.prop,
445 // Separate data to filtered and filtered-out parts.
446 const parts = _.partition(data, (row) => {
447 // Use getter from ngx-datatable to handle props like 'sys_api.size'
448 const valueGetter = getterForProp(filter.column.prop);
449 const value = valueGetter(row, filter.column.prop);
450 if (_.isUndefined(filter.column.filterPredicate)) {
451 // By default, test string equal
452 return `${value}` === filter.value.raw;
454 // Use custom function to filter
455 return filter.column.filterPredicate(row, filter.value.raw);
459 dataOut = [...dataOut, ...parts[1]];
462 this.columnFiltersChanged.emit({
463 filters: appliedFilters,
468 // Remove the selection if previously-selected rows are filtered out.
469 _.forEach(this.selection.selected, (selectedItem) => {
470 if (_.find(data, { [this.identifier]: selectedItem[this.identifier] }) === undefined) {
471 this.selection = new CdTableSelection();
472 this.onSelect(this.selection);
479 if (this.reloadSubscriber) {
480 this.reloadSubscriber.unsubscribe();
482 if (this.saveSubscriber) {
483 this.saveSubscriber.unsubscribe();
487 ngAfterContentChecked() {
488 // If the data table is not visible, e.g. another tab is active, and the
489 // browser window gets resized, the table and its columns won't get resized
490 // automatically if the tab gets visible again.
491 // https://github.com/swimlane/ngx-datatable/issues/193
492 // https://github.com/swimlane/ngx-datatable/issues/193#issuecomment-329144543
493 if (this.table && this.table.element.clientWidth !== this.currentWidth) {
494 this.currentWidth = this.table.element.clientWidth;
495 // Recalculate the sizes of the grid.
496 this.table.recalculate();
497 // Mark the datatable as changed, Angular's change-detection will
498 // do the rest for us => the grid will be redrawn.
499 // Note, the ChangeDetectorRef variable is private, so we need to
500 // use this workaround to access it and make TypeScript happy.
501 const cdRef = _.get(this.table, 'cd');
502 cdRef.markForCheck();
507 this.cellTemplates.bold = this.tableCellBoldTpl;
508 this.cellTemplates.checkIcon = this.checkIconTpl;
509 this.cellTemplates.sparkline = this.sparklineTpl;
510 this.cellTemplates.routerLink = this.routerLinkTpl;
511 this.cellTemplates.perSecond = this.perSecondTpl;
512 this.cellTemplates.executing = this.executingTpl;
513 this.cellTemplates.classAdding = this.classAddingTpl;
514 this.cellTemplates.badge = this.badgeTpl;
515 this.cellTemplates.map = this.mapTpl;
518 useCustomClass(value: any): string {
519 if (!this.customCss) {
520 throw new Error('Custom classes are not set!');
522 const classes = Object.keys(this.customCss);
523 const css = Object.values(this.customCss)
524 .map((v, i) => ((_.isFunction(v) && v(value)) || v === value) && classes[i])
527 return _.isEmpty(css) ? undefined : css;
535 const value = parseInt(e.target.value, 10);
537 this.userConfig.limit = value;
542 if (!this.updating) {
543 this.loadingError = false;
544 const context = new CdTableFetchDataContext(() => {
545 // Do we have to display the error panel?
546 this.loadingError = context.errorConfig.displayError;
547 // Force data table to show no data?
548 if (context.errorConfig.resetData) {
551 // Stop the loading indicator and reset the data table
552 // to the correct state.
555 this.fetchData.emit(context);
556 this.updating = true;
561 this.loadingIndicator = true;
566 return (row: any) => {
567 const id = row[this.identifier];
568 if (_.isUndefined(id)) {
569 throw new Error(`Wrong identifier "${this.identifier}" -> "${id}"`);
577 return; // Wait for data
579 this.updateColumnFilterOptions();
582 this.updateSelected();
586 * Reset the data table to correct state. This includes:
587 * - Disable loading indicator
588 * - Reset 'Updating' flag
591 this.loadingIndicator = false;
592 this.updating = false;
596 * After updating the data, we have to update the selected items
597 * because details may have changed,
598 * or some selected items may have been removed.
601 if (this.updateSelectionOnRefresh === 'never') {
604 const newSelected: any[] = [];
605 this.selection.selected.forEach((selectedItem) => {
606 for (const row of this.data) {
607 if (selectedItem[this.identifier] === row[this.identifier]) {
608 newSelected.push(row);
613 this.updateSelectionOnRefresh === 'onChange' &&
614 _.isEqual(this.selection.selected, newSelected)
618 this.selection.selected = newSelected;
619 this.onSelect(this.selection);
622 onSelect($event: any) {
623 this.selection.selected = $event['selected'];
624 this.updateSelection.emit(_.clone(this.selection));
627 toggleColumn($event: any) {
628 const prop: TableColumnProp = $event.target.name;
629 const hide = !$event.target.checked;
630 if (hide && this.tableColumns.length === 1) {
631 $event.target.checked = true;
634 _.find(this.columns, (c: CdTableColumn) => c.prop === prop).isHidden = hide;
635 this.updateColumns();
639 this.updateUserColumns();
640 this.filterHiddenColumns();
641 const sortProp = this.userConfig.sorts[0].prop;
642 if (!_.find(this.tableColumns, (c: CdTableColumn) => c.prop === sortProp)) {
643 this.userConfig.sorts = this.createSortingDefinition(this.tableColumns[0].prop);
645 this.table.recalculate();
646 this.cdRef.detectChanges();
649 createSortingDefinition(prop: TableColumnProp): SortPropDir[] {
653 dir: SortDirection.asc
658 changeSorting({ sorts }: any) {
659 this.userConfig.sorts = sorts;
668 this.columnFilters.forEach((filter) => {
669 filter.value = undefined;
671 this.selectedFilter = _.first(this.columnFilters);
676 let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data;
678 if (this.search.length > 0) {
679 const columns = this.columns.filter((c) => c.cellTransformation !== CellTemplate.sparkline);
681 rows = this.subSearch(rows, TableComponent.prepareSearch(this.search), columns);
682 // Whenever the filter changes, always go back to the first page
683 this.table.offset = 0;
689 subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]): any[] {
690 if (currentSearch.length === 0 || data.length === 0) {
693 const searchTerms: string[] = currentSearch
697 const columnsClone = [...columns];
698 if (searchTerms.length === 2) {
699 columns = columnsClone.filter((c) => c.name.toLowerCase().indexOf(searchTerms[0]) !== -1);
701 data = this.basicDataSearch(_.last(searchTerms), data, columns);
702 // Checks if user searches for column but he is still typing
703 return this.subSearch(data, currentSearch, columnsClone);
706 basicDataSearch(searchTerm: string, rows: any[], columns: CdTableColumn[]) {
707 if (searchTerm.length === 0) {
710 return rows.filter((row) => {
712 columns.filter((col) => {
713 let cellValue: any = _.get(row, col.prop);
715 if (!_.isUndefined(col.pipe)) {
716 cellValue = col.pipe.transform(cellValue);
718 if (_.isUndefined(cellValue) || _.isNull(cellValue)) {
722 if (_.isArray(cellValue)) {
723 cellValue = cellValue.join(' ');
724 } else if (_.isNumber(cellValue) || _.isBoolean(cellValue)) {
725 cellValue = cellValue.toString();
728 if (_.isObjectLike(cellValue)) {
729 if (this.searchableObjects) {
730 cellValue = JSON.stringify(cellValue);
736 return cellValue.toLowerCase().indexOf(searchTerm) !== -1;
743 // Return the function used to populate a row's CSS classes.
746 clickable: !_.isUndefined(this.selectionType)