3 ChangeDetectionStrategy,
16 } from '@angular/core';
24 } from '@swimlane/ngx-datatable';
25 import _ from 'lodash';
26 import { Observable, of, Subject, Subscription } from 'rxjs';
28 import { TableStatus } from '~/app/shared/classes/table-status';
29 import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
30 import { Icons } from '~/app/shared/enum/icons.enum';
31 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
32 import { CdTableColumnFilter } from '~/app/shared/models/cd-table-column-filter';
33 import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change';
34 import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
35 import { PageInfo } from '~/app/shared/models/cd-table-paging';
36 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
37 import { CdUserConfig } from '~/app/shared/models/cd-user-config';
38 import { TimerService } from '~/app/shared/services/timer.service';
42 templateUrl: './table.component.html',
43 styleUrls: ['./table.component.scss'],
44 changeDetection: ChangeDetectionStrategy.OnPush
46 export class TableComponent implements AfterContentChecked, OnInit, OnChanges, OnDestroy {
47 @ViewChild(DatatableComponent, { static: true })
48 table: DatatableComponent;
49 @ViewChild('tableCellBoldTpl', { static: true })
50 tableCellBoldTpl: TemplateRef<any>;
51 @ViewChild('sparklineTpl', { static: true })
52 sparklineTpl: TemplateRef<any>;
53 @ViewChild('routerLinkTpl', { static: true })
54 routerLinkTpl: TemplateRef<any>;
55 @ViewChild('checkIconTpl', { static: true })
56 checkIconTpl: TemplateRef<any>;
57 @ViewChild('perSecondTpl', { static: true })
58 perSecondTpl: TemplateRef<any>;
59 @ViewChild('executingTpl', { static: true })
60 executingTpl: TemplateRef<any>;
61 @ViewChild('classAddingTpl', { static: true })
62 classAddingTpl: TemplateRef<any>;
63 @ViewChild('badgeTpl', { static: true })
64 badgeTpl: TemplateRef<any>;
65 @ViewChild('mapTpl', { static: true })
66 mapTpl: TemplateRef<any>;
67 @ViewChild('truncateTpl', { static: true })
68 truncateTpl: TemplateRef<any>;
69 @ViewChild('timeAgoTpl', { static: true })
70 timeAgoTpl: TemplateRef<any>;
71 @ViewChild('rowDetailsTpl', { static: true })
72 rowDetailsTpl: TemplateRef<any>;
74 // This is the array with the items to be shown.
77 // Each item -> { prop: 'attribute name', name: 'display name' }
79 columns: CdTableColumn[];
80 // Each item -> { prop: 'attribute name', dir: 'asc'||'desc'}
82 sorts?: SortPropDir[];
83 // Method used for setting column widths.
86 // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions"
88 onlyActionHeader? = false;
89 // Display the tool header, including reload button, pagination and search fields?
92 // Display search field inside tool header?
95 // Display the table header?
98 // Display the table footer?
101 // Page size to show. Set to 0 to show unlimited number of rows.
106 // Has the row details?
111 * Auto reload time in ms - per default every 5s
112 * You can set it to 0, undefined or false to disable the auto reload feature in order to
113 * trigger 'fetchData' if the reload button is clicked.
114 * You can set it to a negative number to, on top of disabling the auto reload,
115 * prevent triggering fetchData when initializing the table.
120 // Which row property is unique for a row. If the identifier is not specified in any
121 // column, then the property name of the first column is used. Defaults to 'id'.
124 // If 'true', then the specified identifier is used anyway, although it is not specified
125 // in any column. Defaults to 'false'.
127 forceIdentifier = false;
128 // Allows other components to specify which type of selection they want,
129 // e.g. 'single' or 'multi'.
131 selectionType: string = undefined;
132 // By default selected item details will be updated on table refresh, if data has changed
134 updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
135 // By default expanded item details will be updated on table refresh, if data has changed
137 updateExpandedOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
142 // Enable this in order to search through the JSON of any used object.
144 searchableObjects = false;
146 // Only needed to set if the classAddingTpl is used
148 customCss?: { [css: string]: number | string | ((any: any) => boolean) };
150 // Columns that aren't displayed but can be used as filters
152 extraFilterableColumns: CdTableColumn[] = [];
155 status = new TableStatus();
157 // Support server-side pagination/sorting/etc.
162 Only required when serverSide is enabled.
163 It should be provided by the server via "X-Total-Count" HTTP Header
169 * Should be a function to update the input data if undefined nothing will be triggered
171 * Sometimes it's useful to only define fetchData once.
173 * Usage of multiple tables with data which is updated by the same function
175 * The function is triggered through one table and all tables will update
178 fetchData = new EventEmitter<CdTableFetchDataContext>();
181 * This should be defined if you need access to the selection object.
183 * Each time the table selection changes, this will be triggered and
184 * the new selection object will be sent.
186 * @memberof TableComponent
189 updateSelection = new EventEmitter();
192 setExpandedRow = new EventEmitter();
195 * This should be defined if you need access to the applied column filters.
197 * Each time the column filters changes, this will be triggered and
198 * the column filters change event will be sent.
200 * @memberof TableComponent
202 @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>();
205 * Use this variable to access the selected row(s).
207 selection = new CdTableSelection();
210 * Use this variable to access the expanded row
212 expanded: any = undefined;
215 * To prevent making changes to the original columns list, that might change
216 * how the table is renderer a second time, we now clone that list into a
217 * local variable and only use the clone.
219 localColumns: CdTableColumn[];
220 tableColumns: CdTableColumn[];
223 [key: string]: TemplateRef<any>;
227 loadingIndicator = true;
228 paginationClasses = {
229 pagerLeftArrow: Icons.leftArrowDouble,
230 pagerRightArrow: Icons.rightArrowDouble,
231 pagerPrevious: Icons.leftArrow,
232 pagerNext: Icons.rightArrow
234 userConfig: CdUserConfig = {};
236 localStorage = window.localStorage;
237 private saveSubscriber: Subscription;
238 private reloadSubscriber: Subscription;
239 private updating = false;
241 // Internal variable to check if it is necessary to recalculate the
242 // table columns after the browser window has been resized.
243 private currentWidth: number;
245 columnFilters: CdTableColumnFilter[] = [];
246 selectedFilter: CdTableColumnFilter;
247 get columnFiltered(): boolean {
248 return _.some(this.columnFilters, (filter) => {
249 return filter.value !== undefined;
254 // private ngZone: NgZone,
255 private cdRef: ChangeDetectorRef,
256 private timerService: TimerService
259 static prepareSearch(search: string) {
260 search = search.toLowerCase().replace(/,/g, '');
261 if (search.match(/['"][^'"]+['"]/)) {
262 search = search.replace(/['"][^'"]+['"]/g, (match: string) => {
263 return match.replace(/(['"])([^'"]+)(['"])/g, '$2').replace(/ /g, '+');
266 return search.split(' ').filter((word) => word);
270 this.localColumns = _.clone(this.columns);
271 // debounce reloadData method so that search doesn't run api requests
272 // for every keystroke
273 if (this.serverSide) {
274 this.reloadData = _.debounce(this.reloadData, 1000);
277 // ngx-datatable triggers calculations each time mouse enters a row,
278 // this will prevent that.
279 this.table.element.addEventListener('mouseenter', (e) => e.stopPropagation());
280 this._addTemplates();
282 // Check whether the specified identifier exists.
283 const exists = _.findIndex(this.localColumns, ['prop', this.identifier]) !== -1;
284 // Auto-build the sorting configuration. If the specified identifier doesn't exist,
285 // then use the property of the first column.
286 this.sorts = this.createSortingDefinition(
287 exists ? this.identifier : this.localColumns[0].prop + ''
289 // If the specified identifier doesn't exist and it is not forced to use it anyway,
290 // then use the property of the first column.
291 if (!exists && !this.forceIdentifier) {
292 this.identifier = this.localColumns[0].prop + '';
296 this.initUserConfig();
297 this.localColumns.forEach((c) => {
298 if (c.cellTransformation) {
299 c.cellTemplate = this.cellTemplates[c.cellTransformation];
302 c.flexGrow = c.prop + '' === this.identifier ? 1 : 2;
305 c.resizeable = false;
309 this.initExpandCollapseColumn(); // If rows have details, add a column to expand or collapse the rows
310 this.initCheckboxColumn();
311 this.filterHiddenColumns();
312 this.initColumnFilters();
313 this.updateColumnFilterOptions();
314 // Notify all subscribers to reset their current selection.
315 this.updateSelection.emit(new CdTableSelection());
316 // Load the data table content every N ms or at least once.
317 // Force showing the loading indicator if there are subscribers to the fetchData
318 // event. This is necessary because it has been set to False in useData() when
319 // this method was triggered by ngOnChanges().
320 if (this.fetchData.observers.length > 0) {
321 this.loadingIndicator = true;
323 if (_.isInteger(this.autoReload) && this.autoReload > 0) {
324 this.reloadSubscriber = this.timerService
325 .get(() => of(0), this.autoReload)
329 } else if (!this.autoReload) {
335 if (this.selectionType === 'single') {
336 this.table.selectCheck = this.singleSelectCheck.bind(this);
342 this.tableName = this._calculateUniqueTableName(this.localColumns);
343 this._loadUserConfig();
344 this._initUserConfigAutoSave();
346 if (!this.userConfig.limit) {
347 this.userConfig.limit = this.limit;
349 if (!(this.userConfig.offset >= 0)) {
350 this.userConfig.offset = this.table.offset;
352 if (!this.userConfig.search) {
353 this.userConfig.search = this.search;
355 if (!this.userConfig.sorts) {
356 this.userConfig.sorts = this.sorts;
358 if (!this.userConfig.columns) {
359 this.updateUserColumns();
361 this.userConfig.columns.forEach((col) => {
362 for (let i = 0; i < this.localColumns.length; i++) {
363 if (this.localColumns[i].prop === col.prop) {
364 this.localColumns[i].isHidden = col.isHidden;
371 _calculateUniqueTableName(columns: any[]) {
372 const stringToNumber = (s: string) => {
373 if (!_.isString(s)) {
377 for (let i = 0; i < s.length; i++) {
378 result += s.charCodeAt(i) * i;
384 (result, value, index) =>
385 (stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1) + result,
392 const loaded = this.localStorage.getItem(this.tableName);
394 this.userConfig = JSON.parse(loaded);
398 _initUserConfigAutoSave() {
399 const source: Observable<any> = new Observable(this._initUserConfigProxy.bind(this));
400 this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this));
403 _initUserConfigProxy(observer: Subject<any>) {
404 this.userConfig = new Proxy(this.userConfig, {
405 set(config, prop: string, value) {
406 config[prop] = value;
407 observer.next(config);
413 _saveUserConfig(config: any) {
414 this.localStorage.setItem(this.tableName, JSON.stringify(config));
417 updateUserColumns() {
418 this.userConfig.columns = this.localColumns.map((c) => ({
421 isHidden: !!c.isHidden
426 * Add a column containing a checkbox if selectionType is 'multiClick'.
428 initCheckboxColumn() {
429 if (this.selectionType === 'multiClick') {
430 this.localColumns.unshift({
436 canAutoResize: false,
437 cellClass: 'cd-datatable-checkbox',
444 * Add a column to expand and collapse the table row if it 'hasDetails'
446 initExpandCollapseColumn() {
447 if (this.hasDetails) {
448 this.localColumns.unshift({
454 canAutoResize: false,
455 cellClass: 'cd-datatable-expand-collapse',
457 cellTemplate: this.rowDetailsTpl
462 filterHiddenColumns() {
463 this.tableColumns = this.localColumns.filter((c) => !c.isHidden);
466 initColumnFilters() {
467 let filterableColumns = _.filter(this.localColumns, { filterable: true });
468 filterableColumns = [...filterableColumns, ...this.extraFilterableColumns];
469 this.columnFilters = filterableColumns.map((col: CdTableColumn) => {
473 value: col.filterInitValue
474 ? this.createColumnFilterOption(col.filterInitValue, col.pipe)
478 this.selectedFilter = _.first(this.columnFilters);
481 private createColumnFilterOption(
484 ): { raw: string; formatted: string } {
486 raw: _.toString(value),
487 formatted: pipe ? pipe.transform(value) : _.toString(value)
491 updateColumnFilterOptions() {
492 // update all possible values in a column
493 this.columnFilters.forEach((filter) => {
494 let values: any[] = [];
496 if (_.isUndefined(filter.column.filterOptions)) {
497 // only allow types that can be easily converted into string
498 const pre = _.filter(_.map(this.data, filter.column.prop), (v) => {
499 return (_.isString(v) && v !== '') || _.isBoolean(v) || _.isFinite(v) || _.isDate(v);
501 values = _.sortedUniq(pre.sort());
503 values = filter.column.filterOptions;
506 const options = values.map((v) => this.createColumnFilterOption(v, filter.column.pipe));
508 // In case a previous value is not available anymore
509 if (filter.value && _.isUndefined(_.find(options, { raw: filter.value.raw }))) {
510 filter.value = undefined;
513 filter.options = options;
517 onSelectFilter(filter: CdTableColumnFilter) {
518 this.selectedFilter = filter;
521 onChangeFilter(filter: CdTableColumnFilter, option?: { raw: string; formatted: string }) {
522 filter.value = _.isEqual(filter.value, option) ? undefined : option;
526 doColumnFiltering() {
527 const appliedFilters: CdTableColumnFiltersChange['filters'] = [];
528 let data = [...this.data];
529 let dataOut: any[] = [];
530 this.columnFilters.forEach((filter) => {
531 if (filter.value === undefined) {
534 appliedFilters.push({
535 name: filter.column.name,
536 prop: filter.column.prop,
539 // Separate data to filtered and filtered-out parts.
540 const parts = _.partition(data, (row) => {
541 // Use getter from ngx-datatable to handle props like 'sys_api.size'
542 const valueGetter = getterForProp(filter.column.prop);
543 const value = valueGetter(row, filter.column.prop);
544 if (_.isUndefined(filter.column.filterPredicate)) {
545 // By default, test string equal
546 return `${value}` === filter.value.raw;
548 // Use custom function to filter
549 return filter.column.filterPredicate(row, filter.value.raw);
553 dataOut = [...dataOut, ...parts[1]];
556 this.columnFiltersChanged.emit({
557 filters: appliedFilters,
562 // Remove the selection if previously-selected rows are filtered out.
563 _.forEach(this.selection.selected, (selectedItem) => {
564 if (_.find(data, { [this.identifier]: selectedItem[this.identifier] }) === undefined) {
565 this.selection = new CdTableSelection();
566 this.onSelect(this.selection);
573 if (this.reloadSubscriber) {
574 this.reloadSubscriber.unsubscribe();
576 if (this.saveSubscriber) {
577 this.saveSubscriber.unsubscribe();
581 ngAfterContentChecked() {
582 // If the data table is not visible, e.g. another tab is active, and the
583 // browser window gets resized, the table and its columns won't get resized
584 // automatically if the tab gets visible again.
585 // https://github.com/swimlane/ngx-datatable/issues/193
586 // https://github.com/swimlane/ngx-datatable/issues/193#issuecomment-329144543
587 if (this.table && this.table.element.clientWidth !== this.currentWidth) {
588 this.currentWidth = this.table.element.clientWidth;
589 // Recalculate the sizes of the grid.
590 this.table.recalculate();
591 // Mark the datatable as changed, Angular's change-detection will
592 // do the rest for us => the grid will be redrawn.
593 // Note, the ChangeDetectorRef variable is private, so we need to
594 // use this workaround to access it and make TypeScript happy.
595 const cdRef = _.get(this.table, 'cd');
596 cdRef.markForCheck();
601 this.cellTemplates.bold = this.tableCellBoldTpl;
602 this.cellTemplates.checkIcon = this.checkIconTpl;
603 this.cellTemplates.sparkline = this.sparklineTpl;
604 this.cellTemplates.routerLink = this.routerLinkTpl;
605 this.cellTemplates.perSecond = this.perSecondTpl;
606 this.cellTemplates.executing = this.executingTpl;
607 this.cellTemplates.classAdding = this.classAddingTpl;
608 this.cellTemplates.badge = this.badgeTpl;
609 this.cellTemplates.map = this.mapTpl;
610 this.cellTemplates.truncate = this.truncateTpl;
611 this.cellTemplates.timeAgo = this.timeAgoTpl;
614 useCustomClass(value: any): string {
615 if (!this.customCss) {
616 throw new Error('Custom classes are not set!');
618 const classes = Object.keys(this.customCss);
619 const css = Object.values(this.customCss)
620 .map((v, i) => ((_.isFunction(v) && v(value)) || v === value) && classes[i])
623 return _.isEmpty(css) ? undefined : css;
626 ngOnChanges(changes: SimpleChanges) {
627 if (changes.data && changes.data.currentValue) {
633 const value = Number(e.target.value);
635 if (this.maxLimit && value > this.maxLimit) {
636 this.userConfig.limit = this.maxLimit;
637 // change input field to maxLimit
638 e.srcElement.value = this.maxLimit;
640 this.userConfig.limit = value;
643 if (this.serverSide) {
649 if (!this.updating) {
650 this.status = new TableStatus();
651 const context = new CdTableFetchDataContext(() => {
652 // Do we have to display the error panel?
653 if (!!context.errorConfig.displayError) {
654 this.status = new TableStatus('danger', $localize`Failed to load data.`);
656 // Force data table to show no data?
657 if (context.errorConfig.resetData) {
660 // Stop the loading indicator and reset the data table
661 // to the correct state.
664 context.pageInfo.offset = this.userConfig.offset;
665 context.pageInfo.limit = this.userConfig.limit;
666 context.search = this.userConfig.search;
667 if (this.userConfig.sorts?.length) {
668 const sort = this.userConfig.sorts[0];
669 context.sort = `${sort.dir === 'desc' ? '-' : '+'}${sort.prop}`;
671 this.fetchData.emit(context);
672 this.updating = true;
677 this.loadingIndicator = true;
681 changePage(pageInfo: PageInfo) {
682 this.userConfig.offset = pageInfo.offset;
683 this.userConfig.limit = pageInfo.limit;
684 if (this.serverSide) {
689 return (row: any) => {
690 const id = row[this.identifier];
691 if (_.isUndefined(id)) {
692 throw new Error(`Wrong identifier "${this.identifier}" -> "${id}"`);
700 return; // Wait for data
702 this.updateColumnFilterOptions();
705 this.updateSelected();
706 this.updateExpanded();
710 * Reset the data table to correct state. This includes:
711 * - Disable loading indicator
712 * - Reset 'Updating' flag
715 this.loadingIndicator = false;
716 this.updating = false;
720 * After updating the data, we have to update the selected items
721 * because details may have changed,
722 * or some selected items may have been removed.
725 if (this.updateSelectionOnRefresh === 'never') {
728 const newSelected = new Set();
729 this.selection.selected.forEach((selectedItem) => {
730 for (const row of this.data) {
731 if (selectedItem[this.identifier] === row[this.identifier]) {
732 newSelected.add(row);
736 const newSelectedArray = Array.from(newSelected.values());
738 this.updateSelectionOnRefresh === 'onChange' &&
739 _.isEqual(this.selection.selected, newSelectedArray)
743 this.selection.selected = newSelectedArray;
744 this.onSelect(this.selection);
748 if (_.isUndefined(this.expanded) || this.updateExpandedOnRefresh === 'never') {
752 const expandedId = this.expanded[this.identifier];
753 const newExpanded = _.find(this.data, (row) => expandedId === row[this.identifier]);
755 if (this.updateExpandedOnRefresh === 'onChange' && _.isEqual(this.expanded, newExpanded)) {
759 this.expanded = newExpanded;
760 this.setExpandedRow.emit(newExpanded);
763 onSelect($event: any) {
764 // Ensure we do not process DOM 'select' events.
765 // https://github.com/swimlane/ngx-datatable/issues/899
766 if (_.has($event, 'selected')) {
767 this.selection.selected = $event['selected'];
769 this.updateSelection.emit(_.clone(this.selection));
772 private singleSelectCheck(row: any) {
773 return this.selection.selected.indexOf(row) === -1;
776 toggleColumn(column: CdTableColumn) {
777 const prop: TableColumnProp = column.prop;
778 const hide = !column.isHidden;
779 if (hide && this.tableColumns.length === 1) {
780 column.isHidden = true;
783 _.find(this.localColumns, (c: CdTableColumn) => c.prop === prop).isHidden = hide;
784 this.updateColumns();
788 this.updateUserColumns();
789 this.filterHiddenColumns();
790 const sortProp = this.userConfig.sorts[0].prop;
791 if (!_.find(this.tableColumns, (c: CdTableColumn) => c.prop === sortProp)) {
792 this.userConfig.sorts = this.createSortingDefinition(this.tableColumns[0].prop);
794 this.table.recalculate();
795 this.cdRef.detectChanges();
798 createSortingDefinition(prop: TableColumnProp): SortPropDir[] {
802 dir: SortDirection.asc
807 changeSorting({ sorts }: any) {
808 this.userConfig.sorts = sorts;
809 if (this.serverSide) {
810 this.userConfig.offset = 0;
821 this.columnFilters.forEach((filter) => {
822 filter.value = undefined;
824 this.selectedFilter = _.first(this.columnFilters);
829 if (this.serverSide) {
830 if (this.userConfig.search !== this.search) {
831 // if we don't go back to the first page it will try load
832 // a page which could not exists with an especific search
833 this.userConfig.offset = 0;
834 this.userConfig.limit = this.limit;
835 this.userConfig.search = this.search;
836 this.updating = false;
839 this.rows = this.data;
841 let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data;
843 if (this.search.length > 0 && rows) {
844 const columns = this.localColumns.filter(
845 (c) => c.cellTransformation !== CellTemplate.sparkline
848 rows = this.subSearch(rows, TableComponent.prepareSearch(this.search), columns);
849 // Whenever the filter changes, always go back to the first page
850 this.table.offset = 0;
857 subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]): any[] {
858 if (currentSearch.length === 0 || data.length === 0) {
861 const searchTerms: string[] = currentSearch.pop().replace(/\+/g, ' ').split(':');
862 const columnsClone = [...columns];
863 if (searchTerms.length === 2) {
864 columns = columnsClone.filter((c) => c.name.toLowerCase().indexOf(searchTerms[0]) !== -1);
866 data = this.basicDataSearch(_.last(searchTerms), data, columns);
867 // Checks if user searches for column but he is still typing
868 return this.subSearch(data, currentSearch, columnsClone);
871 basicDataSearch(searchTerm: string, rows: any[], columns: CdTableColumn[]) {
872 if (searchTerm.length === 0) {
875 return rows.filter((row) => {
877 columns.filter((col) => {
878 let cellValue: any = _.get(row, col.prop);
880 if (!_.isUndefined(col.pipe)) {
881 cellValue = col.pipe.transform(cellValue);
883 if (_.isUndefined(cellValue) || _.isNull(cellValue)) {
887 if (_.isArray(cellValue)) {
888 cellValue = cellValue.join(' ');
889 } else if (_.isNumber(cellValue) || _.isBoolean(cellValue)) {
890 cellValue = cellValue.toString();
893 if (_.isObjectLike(cellValue)) {
894 if (this.searchableObjects) {
895 cellValue = JSON.stringify(cellValue);
901 return cellValue.toLowerCase().indexOf(searchTerm) !== -1;
908 // Return the function used to populate a row's CSS classes.
911 clickable: !_.isUndefined(this.selectionType)
916 toggleExpandRow(row: any, isExpanded: boolean, event: any) {
917 event.stopPropagation();
919 // If current row isn't expanded, collapse others
921 this.table.rowDetail.collapseAllRows();
922 this.setExpandedRow.emit(row);
924 // If all rows are closed, emit undefined
925 this.expanded = undefined;
926 this.setExpandedRow.emit(undefined);
928 this.table.rowDetail.toggleExpandRow(row);