3 ChangeDetectionStrategy,
17 } from '@angular/core';
25 } from '@swimlane/ngx-datatable';
26 import _ from 'lodash';
27 import { Observable, Subject, Subscription, timer as observableTimer } from 'rxjs';
29 import { TableStatus } from '~/app/shared/classes/table-status';
30 import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
31 import { Icons } from '~/app/shared/enum/icons.enum';
32 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
33 import { CdTableColumnFilter } from '~/app/shared/models/cd-table-column-filter';
34 import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change';
35 import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
36 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
37 import { CdUserConfig } from '~/app/shared/models/cd-user-config';
41 templateUrl: './table.component.html',
42 styleUrls: ['./table.component.scss'],
43 changeDetection: ChangeDetectionStrategy.OnPush
45 export class TableComponent implements AfterContentChecked, OnInit, OnChanges, OnDestroy {
46 @ViewChild(DatatableComponent, { static: true })
47 table: DatatableComponent;
48 @ViewChild('tableCellBoldTpl', { static: true })
49 tableCellBoldTpl: TemplateRef<any>;
50 @ViewChild('sparklineTpl', { static: true })
51 sparklineTpl: TemplateRef<any>;
52 @ViewChild('routerLinkTpl', { static: true })
53 routerLinkTpl: TemplateRef<any>;
54 @ViewChild('checkIconTpl', { static: true })
55 checkIconTpl: TemplateRef<any>;
56 @ViewChild('perSecondTpl', { static: true })
57 perSecondTpl: TemplateRef<any>;
58 @ViewChild('executingTpl', { static: true })
59 executingTpl: TemplateRef<any>;
60 @ViewChild('classAddingTpl', { static: true })
61 classAddingTpl: TemplateRef<any>;
62 @ViewChild('badgeTpl', { static: true })
63 badgeTpl: TemplateRef<any>;
64 @ViewChild('mapTpl', { static: true })
65 mapTpl: TemplateRef<any>;
66 @ViewChild('truncateTpl', { static: true })
67 truncateTpl: TemplateRef<any>;
68 @ViewChild('rowDetailsTpl', { static: true })
69 rowDetailsTpl: TemplateRef<any>;
71 // This is the array with the items to be shown.
74 // Each item -> { prop: 'attribute name', name: 'display name' }
76 columns: CdTableColumn[];
77 // Each item -> { prop: 'attribute name', dir: 'asc'||'desc'}
79 sorts?: SortPropDir[];
80 // Method used for setting column widths.
83 // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions"
85 onlyActionHeader? = false;
86 // Display the tool header, including reload button, pagination and search fields?
89 // Display search field inside tool header?
92 // Display the table header?
95 // Display the table footer?
98 // Page size to show. Set to 0 to show unlimited number of rows.
101 // Has the row details?
106 * Auto reload time in ms - per default every 5s
107 * You can set it to 0, undefined or false to disable the auto reload feature in order to
108 * trigger 'fetchData' if the reload button is clicked.
109 * You can set it to a negative number to, on top of disabling the auto reload,
110 * prevent triggering fetchData when initializing the table.
113 autoReload: any = 5000;
115 // Which row property is unique for a row. If the identifier is not specified in any
116 // column, then the property name of the first column is used. Defaults to 'id'.
119 // If 'true', then the specified identifier is used anyway, although it is not specified
120 // in any column. Defaults to 'false'.
122 forceIdentifier = false;
123 // Allows other components to specify which type of selection they want,
124 // e.g. 'single' or 'multi'.
126 selectionType: string = undefined;
127 // By default selected item details will be updated on table refresh, if data has changed
129 updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
130 // By default expanded item details will be updated on table refresh, if data has changed
132 updateExpandedOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
137 // Enable this in order to search through the JSON of any used object.
139 searchableObjects = false;
141 // Only needed to set if the classAddingTpl is used
143 customCss?: { [css: string]: number | string | ((any: any) => boolean) };
145 // Columns that aren't displayed but can be used as filters
147 extraFilterableColumns: CdTableColumn[] = [];
150 status = new TableStatus();
153 * Should be a function to update the input data if undefined nothing will be triggered
155 * Sometimes it's useful to only define fetchData once.
157 * Usage of multiple tables with data which is updated by the same function
159 * The function is triggered through one table and all tables will update
162 fetchData = new EventEmitter();
165 * This should be defined if you need access to the selection object.
167 * Each time the table selection changes, this will be triggered and
168 * the new selection object will be sent.
170 * @memberof TableComponent
173 updateSelection = new EventEmitter();
176 setExpandedRow = new EventEmitter();
179 * This should be defined if you need access to the applied column filters.
181 * Each time the column filters changes, this will be triggered and
182 * the column filters change event will be sent.
184 * @memberof TableComponent
186 @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>();
189 * Use this variable to access the selected row(s).
191 selection = new CdTableSelection();
194 * Use this variable to access the expanded row
196 expanded: any = undefined;
199 * To prevent making changes to the original columns list, that might change
200 * how the table is renderer a second time, we now clone that list into a
201 * local variable and only use the clone.
203 localColumns: CdTableColumn[];
204 tableColumns: CdTableColumn[];
207 [key: string]: TemplateRef<any>;
211 loadingIndicator = true;
212 paginationClasses = {
213 pagerLeftArrow: Icons.leftArrowDouble,
214 pagerRightArrow: Icons.rightArrowDouble,
215 pagerPrevious: Icons.leftArrow,
216 pagerNext: Icons.rightArrow
218 userConfig: CdUserConfig = {};
220 localStorage = window.localStorage;
221 private saveSubscriber: Subscription;
222 private reloadSubscriber: Subscription;
223 private updating = false;
225 // Internal variable to check if it is necessary to recalculate the
226 // table columns after the browser window has been resized.
227 private currentWidth: number;
229 columnFilters: CdTableColumnFilter[] = [];
230 selectedFilter: CdTableColumnFilter;
231 get columnFiltered(): boolean {
232 return _.some(this.columnFilters, (filter) => {
233 return filter.value !== undefined;
237 constructor(private ngZone: NgZone, private cdRef: ChangeDetectorRef) {}
239 static prepareSearch(search: string) {
240 search = search.toLowerCase().replace(/,/g, '');
241 if (search.match(/['"][^'"]+['"]/)) {
242 search = search.replace(/['"][^'"]+['"]/g, (match: string) => {
243 return match.replace(/(['"])([^'"]+)(['"])/g, '$2').replace(/ /g, '+');
246 return search.split(' ').filter((word) => word);
250 this.localColumns = _.clone(this.columns);
252 // ngx-datatable triggers calculations each time mouse enters a row,
253 // this will prevent that.
254 this.table.element.addEventListener('mouseenter', (e) => e.stopPropagation());
255 this._addTemplates();
257 // Check whether the specified identifier exists.
258 const exists = _.findIndex(this.localColumns, ['prop', this.identifier]) !== -1;
259 // Auto-build the sorting configuration. If the specified identifier doesn't exist,
260 // then use the property of the first column.
261 this.sorts = this.createSortingDefinition(
262 exists ? this.identifier : this.localColumns[0].prop + ''
264 // If the specified identifier doesn't exist and it is not forced to use it anyway,
265 // then use the property of the first column.
266 if (!exists && !this.forceIdentifier) {
267 this.identifier = this.localColumns[0].prop + '';
271 this.initUserConfig();
272 this.localColumns.forEach((c) => {
273 if (c.cellTransformation) {
274 c.cellTemplate = this.cellTemplates[c.cellTransformation];
277 c.flexGrow = c.prop + '' === this.identifier ? 1 : 2;
280 c.resizeable = false;
284 this.initExpandCollapseColumn(); // If rows have details, add a column to expand or collapse the rows
285 this.initCheckboxColumn();
286 this.filterHiddenColumns();
287 this.initColumnFilters();
288 this.updateColumnFilterOptions();
289 // Notify all subscribers to reset their current selection.
290 this.updateSelection.emit(new CdTableSelection());
291 // Load the data table content every N ms or at least once.
292 // Force showing the loading indicator if there are subscribers to the fetchData
293 // event. This is necessary because it has been set to False in useData() when
294 // this method was triggered by ngOnChanges().
295 if (this.fetchData.observers.length > 0) {
296 this.loadingIndicator = true;
298 if (_.isInteger(this.autoReload) && this.autoReload > 0) {
299 this.ngZone.runOutsideAngular(() => {
300 this.reloadSubscriber = observableTimer(0, this.autoReload).subscribe(() => {
301 this.ngZone.run(() => {
302 return this.reloadData();
306 } else if (!this.autoReload) {
312 if (this.selectionType === 'single') {
313 this.table.selectCheck = this.singleSelectCheck.bind(this);
319 this.tableName = this._calculateUniqueTableName(this.localColumns);
320 this._loadUserConfig();
321 this._initUserConfigAutoSave();
323 if (!this.userConfig.limit) {
324 this.userConfig.limit = this.limit;
326 if (!this.userConfig.sorts) {
327 this.userConfig.sorts = this.sorts;
329 if (!this.userConfig.columns) {
330 this.updateUserColumns();
332 this.userConfig.columns.forEach((col) => {
333 for (let i = 0; i < this.localColumns.length; i++) {
334 if (this.localColumns[i].prop === col.prop) {
335 this.localColumns[i].isHidden = col.isHidden;
342 _calculateUniqueTableName(columns: any[]) {
343 const stringToNumber = (s: string) => {
344 if (!_.isString(s)) {
348 for (let i = 0; i < s.length; i++) {
349 result += s.charCodeAt(i) * i;
355 (result, value, index) =>
356 (stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1) + result,
363 const loaded = this.localStorage.getItem(this.tableName);
365 this.userConfig = JSON.parse(loaded);
369 _initUserConfigAutoSave() {
370 const source: Observable<any> = new Observable(this._initUserConfigProxy.bind(this));
371 this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this));
374 _initUserConfigProxy(observer: Subject<any>) {
375 this.userConfig = new Proxy(this.userConfig, {
376 set(config, prop: string, value) {
377 config[prop] = value;
378 observer.next(config);
384 _saveUserConfig(config: any) {
385 this.localStorage.setItem(this.tableName, JSON.stringify(config));
388 updateUserColumns() {
389 this.userConfig.columns = this.localColumns.map((c) => ({
392 isHidden: !!c.isHidden
397 * Add a column containing a checkbox if selectionType is 'multiClick'.
399 initCheckboxColumn() {
400 if (this.selectionType === 'multiClick') {
401 this.localColumns.unshift({
407 canAutoResize: false,
408 cellClass: 'cd-datatable-checkbox',
415 * Add a column to expand and collapse the table row if it 'hasDetails'
417 initExpandCollapseColumn() {
418 if (this.hasDetails) {
419 this.localColumns.unshift({
425 canAutoResize: false,
426 cellClass: 'cd-datatable-expand-collapse',
428 cellTemplate: this.rowDetailsTpl
433 filterHiddenColumns() {
434 this.tableColumns = this.localColumns.filter((c) => !c.isHidden);
437 initColumnFilters() {
438 let filterableColumns = _.filter(this.localColumns, { filterable: true });
439 filterableColumns = [...filterableColumns, ...this.extraFilterableColumns];
440 this.columnFilters = filterableColumns.map((col: CdTableColumn) => {
444 value: col.filterInitValue
445 ? this.createColumnFilterOption(col.filterInitValue, col.pipe)
449 this.selectedFilter = _.first(this.columnFilters);
452 private createColumnFilterOption(
455 ): { raw: string; formatted: string } {
457 raw: _.toString(value),
458 formatted: pipe ? pipe.transform(value) : _.toString(value)
462 updateColumnFilterOptions() {
463 // update all possible values in a column
464 this.columnFilters.forEach((filter) => {
465 let values: any[] = [];
467 if (_.isUndefined(filter.column.filterOptions)) {
468 // only allow types that can be easily converted into string
469 const pre = _.filter(_.map(this.data, filter.column.prop), (v) => {
470 return (_.isString(v) && v !== '') || _.isBoolean(v) || _.isFinite(v) || _.isDate(v);
472 values = _.sortedUniq(pre.sort());
474 values = filter.column.filterOptions;
477 const options = values.map((v) => this.createColumnFilterOption(v, filter.column.pipe));
479 // In case a previous value is not available anymore
480 if (filter.value && _.isUndefined(_.find(options, { raw: filter.value.raw }))) {
481 filter.value = undefined;
484 filter.options = options;
488 onSelectFilter(filter: CdTableColumnFilter) {
489 this.selectedFilter = filter;
492 onChangeFilter(filter: CdTableColumnFilter, option?: { raw: string; formatted: string }) {
493 filter.value = _.isEqual(filter.value, option) ? undefined : option;
497 doColumnFiltering() {
498 const appliedFilters: CdTableColumnFiltersChange['filters'] = [];
499 let data = [...this.data];
500 let dataOut: any[] = [];
501 this.columnFilters.forEach((filter) => {
502 if (filter.value === undefined) {
505 appliedFilters.push({
506 name: filter.column.name,
507 prop: filter.column.prop,
510 // Separate data to filtered and filtered-out parts.
511 const parts = _.partition(data, (row) => {
512 // Use getter from ngx-datatable to handle props like 'sys_api.size'
513 const valueGetter = getterForProp(filter.column.prop);
514 const value = valueGetter(row, filter.column.prop);
515 if (_.isUndefined(filter.column.filterPredicate)) {
516 // By default, test string equal
517 return `${value}` === filter.value.raw;
519 // Use custom function to filter
520 return filter.column.filterPredicate(row, filter.value.raw);
524 dataOut = [...dataOut, ...parts[1]];
527 this.columnFiltersChanged.emit({
528 filters: appliedFilters,
533 // Remove the selection if previously-selected rows are filtered out.
534 _.forEach(this.selection.selected, (selectedItem) => {
535 if (_.find(data, { [this.identifier]: selectedItem[this.identifier] }) === undefined) {
536 this.selection = new CdTableSelection();
537 this.onSelect(this.selection);
544 if (this.reloadSubscriber) {
545 this.reloadSubscriber.unsubscribe();
547 if (this.saveSubscriber) {
548 this.saveSubscriber.unsubscribe();
552 ngAfterContentChecked() {
553 // If the data table is not visible, e.g. another tab is active, and the
554 // browser window gets resized, the table and its columns won't get resized
555 // automatically if the tab gets visible again.
556 // https://github.com/swimlane/ngx-datatable/issues/193
557 // https://github.com/swimlane/ngx-datatable/issues/193#issuecomment-329144543
558 if (this.table && this.table.element.clientWidth !== this.currentWidth) {
559 this.currentWidth = this.table.element.clientWidth;
560 // Recalculate the sizes of the grid.
561 this.table.recalculate();
562 // Mark the datatable as changed, Angular's change-detection will
563 // do the rest for us => the grid will be redrawn.
564 // Note, the ChangeDetectorRef variable is private, so we need to
565 // use this workaround to access it and make TypeScript happy.
566 const cdRef = _.get(this.table, 'cd');
567 cdRef.markForCheck();
572 this.cellTemplates.bold = this.tableCellBoldTpl;
573 this.cellTemplates.checkIcon = this.checkIconTpl;
574 this.cellTemplates.sparkline = this.sparklineTpl;
575 this.cellTemplates.routerLink = this.routerLinkTpl;
576 this.cellTemplates.perSecond = this.perSecondTpl;
577 this.cellTemplates.executing = this.executingTpl;
578 this.cellTemplates.classAdding = this.classAddingTpl;
579 this.cellTemplates.badge = this.badgeTpl;
580 this.cellTemplates.map = this.mapTpl;
581 this.cellTemplates.truncate = this.truncateTpl;
584 useCustomClass(value: any): string {
585 if (!this.customCss) {
586 throw new Error('Custom classes are not set!');
588 const classes = Object.keys(this.customCss);
589 const css = Object.values(this.customCss)
590 .map((v, i) => ((_.isFunction(v) && v(value)) || v === value) && classes[i])
593 return _.isEmpty(css) ? undefined : css;
596 ngOnChanges(changes: SimpleChanges) {
597 if (changes.data && changes.data.currentValue) {
603 const value = parseInt(e.target.value, 10);
605 this.userConfig.limit = value;
610 if (!this.updating) {
611 this.status = new TableStatus();
612 const context = new CdTableFetchDataContext(() => {
613 // Do we have to display the error panel?
614 if (!!context.errorConfig.displayError) {
615 this.status = new TableStatus('danger', $localize`Failed to load data.`);
617 // Force data table to show no data?
618 if (context.errorConfig.resetData) {
621 // Stop the loading indicator and reset the data table
622 // to the correct state.
625 this.fetchData.emit(context);
626 this.updating = true;
631 this.loadingIndicator = true;
636 return (row: any) => {
637 const id = row[this.identifier];
638 if (_.isUndefined(id)) {
639 throw new Error(`Wrong identifier "${this.identifier}" -> "${id}"`);
647 return; // Wait for data
649 this.updateColumnFilterOptions();
652 this.updateSelected();
653 this.updateExpanded();
657 * Reset the data table to correct state. This includes:
658 * - Disable loading indicator
659 * - Reset 'Updating' flag
662 this.loadingIndicator = false;
663 this.updating = false;
667 * After updating the data, we have to update the selected items
668 * because details may have changed,
669 * or some selected items may have been removed.
672 if (this.updateSelectionOnRefresh === 'never') {
675 const newSelected = new Set();
676 this.selection.selected.forEach((selectedItem) => {
677 for (const row of this.data) {
678 if (selectedItem[this.identifier] === row[this.identifier]) {
679 newSelected.add(row);
683 const newSelectedArray = Array.from(newSelected.values());
685 this.updateSelectionOnRefresh === 'onChange' &&
686 _.isEqual(this.selection.selected, newSelectedArray)
690 this.selection.selected = newSelectedArray;
691 this.onSelect(this.selection);
695 if (_.isUndefined(this.expanded) || this.updateExpandedOnRefresh === 'never') {
699 const expandedId = this.expanded[this.identifier];
700 const newExpanded = _.find(this.data, (row) => expandedId === row[this.identifier]);
702 if (this.updateExpandedOnRefresh === 'onChange' && _.isEqual(this.expanded, newExpanded)) {
706 this.expanded = newExpanded;
707 this.setExpandedRow.emit(newExpanded);
710 onSelect($event: any) {
711 // Ensure we do not process DOM 'select' events.
712 // https://github.com/swimlane/ngx-datatable/issues/899
713 if (_.has($event, 'selected')) {
714 this.selection.selected = $event['selected'];
716 this.updateSelection.emit(_.clone(this.selection));
719 private singleSelectCheck(row: any) {
720 return this.selection.selected.indexOf(row) === -1;
723 toggleColumn(column: CdTableColumn) {
724 const prop: TableColumnProp = column.prop;
725 const hide = !column.isHidden;
726 if (hide && this.tableColumns.length === 1) {
727 column.isHidden = true;
730 _.find(this.localColumns, (c: CdTableColumn) => c.prop === prop).isHidden = hide;
731 this.updateColumns();
735 this.updateUserColumns();
736 this.filterHiddenColumns();
737 const sortProp = this.userConfig.sorts[0].prop;
738 if (!_.find(this.tableColumns, (c: CdTableColumn) => c.prop === sortProp)) {
739 this.userConfig.sorts = this.createSortingDefinition(this.tableColumns[0].prop);
741 this.table.recalculate();
742 this.cdRef.detectChanges();
745 createSortingDefinition(prop: TableColumnProp): SortPropDir[] {
749 dir: SortDirection.asc
754 changeSorting({ sorts }: any) {
755 this.userConfig.sorts = sorts;
764 this.columnFilters.forEach((filter) => {
765 filter.value = undefined;
767 this.selectedFilter = _.first(this.columnFilters);
772 let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data;
774 if (this.search.length > 0 && rows) {
775 const columns = this.localColumns.filter(
776 (c) => c.cellTransformation !== CellTemplate.sparkline
779 rows = this.subSearch(rows, TableComponent.prepareSearch(this.search), columns);
780 // Whenever the filter changes, always go back to the first page
781 this.table.offset = 0;
787 subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]): any[] {
788 if (currentSearch.length === 0 || data.length === 0) {
791 const searchTerms: string[] = currentSearch.pop().replace(/\+/g, ' ').split(':');
792 const columnsClone = [...columns];
793 if (searchTerms.length === 2) {
794 columns = columnsClone.filter((c) => c.name.toLowerCase().indexOf(searchTerms[0]) !== -1);
796 data = this.basicDataSearch(_.last(searchTerms), data, columns);
797 // Checks if user searches for column but he is still typing
798 return this.subSearch(data, currentSearch, columnsClone);
801 basicDataSearch(searchTerm: string, rows: any[], columns: CdTableColumn[]) {
802 if (searchTerm.length === 0) {
805 return rows.filter((row) => {
807 columns.filter((col) => {
808 let cellValue: any = _.get(row, col.prop);
810 if (!_.isUndefined(col.pipe)) {
811 cellValue = col.pipe.transform(cellValue);
813 if (_.isUndefined(cellValue) || _.isNull(cellValue)) {
817 if (_.isArray(cellValue)) {
818 cellValue = cellValue.join(' ');
819 } else if (_.isNumber(cellValue) || _.isBoolean(cellValue)) {
820 cellValue = cellValue.toString();
823 if (_.isObjectLike(cellValue)) {
824 if (this.searchableObjects) {
825 cellValue = JSON.stringify(cellValue);
831 return cellValue.toLowerCase().indexOf(searchTerm) !== -1;
838 // Return the function used to populate a row's CSS classes.
841 clickable: !_.isUndefined(this.selectionType)
846 toggleExpandRow(row: any, isExpanded: boolean, event: any) {
847 event.stopPropagation();
849 // If current row isn't expanded, collapse others
851 this.table.rowDetail.collapseAllRows();
852 this.setExpandedRow.emit(row);
854 // If all rows are closed, emit undefined
855 this.expanded = undefined;
856 this.setExpandedRow.emit(undefined);
858 this.table.rowDetail.toggleExpandRow(row);