]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
import 15.2.0 Octopus source
[ceph.git] / ceph / src / pybind / mgr / dashboard / frontend / src / app / shared / datatable / table / table.component.ts
1 import {
2 AfterContentChecked,
3 ChangeDetectionStrategy,
4 ChangeDetectorRef,
5 Component,
6 EventEmitter,
7 Input,
8 NgZone,
9 OnChanges,
10 OnDestroy,
11 OnInit,
12 Output,
13 PipeTransform,
14 TemplateRef,
15 ViewChild
16 } from '@angular/core';
17
18 import {
19 DatatableComponent,
20 SortDirection,
21 SortPropDir,
22 TableColumnProp
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';
27
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';
36
37 @Component({
38 selector: 'cd-table',
39 templateUrl: './table.component.html',
40 styleUrls: ['./table.component.scss'],
41 changeDetection: ChangeDetectionStrategy.OnPush
42 })
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
65 // This is the array with the items to be shown.
66 @Input()
67 data: any[];
68 // Each item -> { prop: 'attribute name', name: 'display name' }
69 @Input()
70 columns: CdTableColumn[];
71 // Each item -> { prop: 'attribute name', dir: 'asc'||'desc'}
72 @Input()
73 sorts?: SortPropDir[];
74 // Method used for setting column widths.
75 @Input()
76 columnMode? = 'flex';
77 // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions"
78 @Input()
79 onlyActionHeader? = false;
80 // Display the tool header, including reload button, pagination and search fields?
81 @Input()
82 toolHeader? = true;
83 // Display search field inside tool header?
84 @Input()
85 searchField? = true;
86 // Display the table header?
87 @Input()
88 header? = true;
89 // Display the table footer?
90 @Input()
91 footer? = true;
92 // Page size to show. Set to 0 to show unlimited number of rows.
93 @Input()
94 limit? = 10;
95
96 /**
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.
100 */
101 @Input()
102 autoReload: any = 5000;
103
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'.
106 @Input()
107 identifier = 'id';
108 // If 'true', then the specified identifier is used anyway, although it is not specified
109 // in any column. Defaults to 'false'.
110 @Input()
111 forceIdentifier = false;
112 // Allows other components to specify which type of selection they want,
113 // e.g. 'single' or 'multi'.
114 @Input()
115 selectionType: string = undefined;
116 // By default selected item details will be updated on table refresh, if data has changed
117 @Input()
118 updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
119
120 @Input()
121 autoSave = true;
122
123 // Enable this in order to search through the JSON of any used object.
124 @Input()
125 searchableObjects = false;
126
127 // Only needed to set if the classAddingTpl is used
128 @Input()
129 customCss?: { [css: string]: number | string | ((any: any) => boolean) };
130
131 // Columns that aren't displayed but can be used as filters
132 @Input()
133 extraFilterableColumns: CdTableColumn[] = [];
134
135 /**
136 * Should be a function to update the input data if undefined nothing will be triggered
137 *
138 * Sometimes it's useful to only define fetchData once.
139 * Example:
140 * Usage of multiple tables with data which is updated by the same function
141 * What happens:
142 * The function is triggered through one table and all tables will update
143 */
144 @Output()
145 fetchData = new EventEmitter();
146
147 /**
148 * This should be defined if you need access to the selection object.
149 *
150 * Each time the table selection changes, this will be triggered and
151 * the new selection object will be sent.
152 *
153 * @memberof TableComponent
154 */
155 @Output()
156 updateSelection = new EventEmitter();
157
158 /**
159 * This should be defined if you need access to the applied column filters.
160 *
161 * Each time the column filters changes, this will be triggered and
162 * the column filters change event will be sent.
163 *
164 * @memberof TableComponent
165 */
166 @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>();
167
168 /**
169 * Use this variable to access the selected row(s).
170 */
171 selection = new CdTableSelection();
172
173 tableColumns: CdTableColumn[];
174 icons = Icons;
175 cellTemplates: {
176 [key: string]: TemplateRef<any>;
177 } = {};
178 search = '';
179 rows: 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
187 };
188 userConfig: CdUserConfig = {};
189 tableName: string;
190 localStorage = window.localStorage;
191 private saveSubscriber: Subscription;
192 private reloadSubscriber: Subscription;
193 private updating = false;
194
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;
198
199 columnFilters: CdTableColumnFilter[] = [];
200 selectedFilter: CdTableColumnFilter;
201 get columnFiltered(): boolean {
202 return _.some(this.columnFilters, (filter) => {
203 return filter.value !== undefined;
204 });
205 }
206
207 constructor(private ngZone: NgZone, private cdRef: ChangeDetectorRef) {}
208
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, '+');
214 });
215 }
216 return search.split(' ').filter((word) => word);
217 }
218
219 ngOnInit() {
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();
224 if (!this.sorts) {
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 + ''
231 );
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 + '';
236 }
237 }
238 this.initUserConfig();
239 this.columns.forEach((c) => {
240 if (c.cellTransformation) {
241 c.cellTemplate = this.cellTemplates[c.cellTransformation];
242 }
243 if (!c.flexGrow) {
244 c.flexGrow = c.prop + '' === this.identifier ? 1 : 2;
245 }
246 if (!c.resizeable) {
247 c.resizeable = false;
248 }
249 });
250
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;
261 }
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();
267 });
268 });
269 });
270 } else {
271 this.reloadData();
272 }
273 }
274
275 initUserConfig() {
276 if (this.autoSave) {
277 this.tableName = this._calculateUniqueTableName(this.columns);
278 this._loadUserConfig();
279 this._initUserConfigAutoSave();
280 }
281 if (!this.userConfig.limit) {
282 this.userConfig.limit = this.limit;
283 }
284 if (!this.userConfig.sorts) {
285 this.userConfig.sorts = this.sorts;
286 }
287 if (!this.userConfig.columns) {
288 this.updateUserColumns();
289 } else {
290 this.columns.forEach((c, i) => {
291 c.isHidden = this.userConfig.columns[i].isHidden;
292 });
293 }
294 }
295
296 _calculateUniqueTableName(columns: any[]) {
297 const stringToNumber = (s: string) => {
298 if (!_.isString(s)) {
299 return 0;
300 }
301 let result = 0;
302 for (let i = 0; i < s.length; i++) {
303 result += s.charCodeAt(i) * i;
304 }
305 return result;
306 };
307 return columns
308 .reduce(
309 (result, value, index) =>
310 (stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1) + result,
311 0
312 )
313 .toString();
314 }
315
316 _loadUserConfig() {
317 const loaded = this.localStorage.getItem(this.tableName);
318 if (loaded) {
319 this.userConfig = JSON.parse(loaded);
320 }
321 }
322
323 _initUserConfigAutoSave() {
324 const source: Observable<any> = Observable.create(this._initUserConfigProxy.bind(this));
325 this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this));
326 }
327
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);
333 return true;
334 }
335 });
336 }
337
338 _saveUserConfig(config: any) {
339 this.localStorage.setItem(this.tableName, JSON.stringify(config));
340 }
341
342 updateUserColumns() {
343 this.userConfig.columns = this.columns.map((c) => ({
344 prop: c.prop,
345 name: c.name,
346 isHidden: !!c.isHidden
347 }));
348 }
349
350 /**
351 * Add a column containing a checkbox if selectionType is 'multiClick'.
352 */
353 initCheckboxColumn() {
354 if (this.selectionType === 'multiClick') {
355 this.columns.unshift({
356 prop: undefined,
357 resizeable: false,
358 sortable: false,
359 draggable: false,
360 checkboxable: true,
361 canAutoResize: false,
362 cellClass: 'cd-datatable-checkbox',
363 width: 30
364 });
365 }
366 }
367
368 filterHiddenColumns() {
369 this.tableColumns = this.columns.filter((c) => !c.isHidden);
370 }
371
372 initColumnFilters() {
373 let filterableColumns = _.filter(this.columns, { filterable: true });
374 filterableColumns = [...filterableColumns, ...this.extraFilterableColumns];
375 this.columnFilters = filterableColumns.map((col: CdTableColumn) => {
376 return {
377 column: col,
378 options: [],
379 value: col.filterInitValue
380 ? this.createColumnFilterOption(col.filterInitValue, col.pipe)
381 : undefined
382 };
383 });
384 this.selectedFilter = _.first(this.columnFilters);
385 }
386
387 private createColumnFilterOption(
388 value: any,
389 pipe?: PipeTransform
390 ): { raw: string; formatted: string } {
391 return {
392 raw: _.toString(value),
393 formatted: pipe ? pipe.transform(value) : _.toString(value)
394 };
395 }
396
397 updateColumnFilterOptions() {
398 // update all possible values in a column
399 this.columnFilters.forEach((filter) => {
400 let values: any[] = [];
401
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);
406 });
407 values = _.sortedUniq(pre.sort());
408 } else {
409 values = filter.column.filterOptions;
410 }
411
412 const options = values.map((v) => this.createColumnFilterOption(v, filter.column.pipe));
413
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;
417 }
418
419 filter.options = options;
420 });
421 }
422
423 onSelectFilter(filter: CdTableColumnFilter) {
424 this.selectedFilter = filter;
425 }
426
427 onChangeFilter(filter: CdTableColumnFilter, option?: { raw: string; formatted: string }) {
428 filter.value = _.isEqual(filter.value, option) ? undefined : option;
429 this.updateFilter();
430 }
431
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) {
438 return;
439 }
440 appliedFilters.push({
441 name: filter.column.name,
442 prop: filter.column.prop,
443 value: filter.value
444 });
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;
453 } else {
454 // Use custom function to filter
455 return filter.column.filterPredicate(row, filter.value.raw);
456 }
457 });
458 data = parts[0];
459 dataOut = [...dataOut, ...parts[1]];
460 });
461
462 this.columnFiltersChanged.emit({
463 filters: appliedFilters,
464 data: data,
465 dataOut: dataOut
466 });
467
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);
473 }
474 });
475 return data;
476 }
477
478 ngOnDestroy() {
479 if (this.reloadSubscriber) {
480 this.reloadSubscriber.unsubscribe();
481 }
482 if (this.saveSubscriber) {
483 this.saveSubscriber.unsubscribe();
484 }
485 }
486
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();
503 }
504 }
505
506 _addTemplates() {
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;
516 }
517
518 useCustomClass(value: any): string {
519 if (!this.customCss) {
520 throw new Error('Custom classes are not set!');
521 }
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])
525 .filter((x) => x)
526 .join(' ');
527 return _.isEmpty(css) ? undefined : css;
528 }
529
530 ngOnChanges() {
531 this.useData();
532 }
533
534 setLimit(e: any) {
535 const value = parseInt(e.target.value, 10);
536 if (value > 0) {
537 this.userConfig.limit = value;
538 }
539 }
540
541 reloadData() {
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) {
549 this.data = [];
550 }
551 // Stop the loading indicator and reset the data table
552 // to the correct state.
553 this.useData();
554 });
555 this.fetchData.emit(context);
556 this.updating = true;
557 }
558 }
559
560 refreshBtn() {
561 this.loadingIndicator = true;
562 this.reloadData();
563 }
564
565 rowIdentity() {
566 return (row: any) => {
567 const id = row[this.identifier];
568 if (_.isUndefined(id)) {
569 throw new Error(`Wrong identifier "${this.identifier}" -> "${id}"`);
570 }
571 return id;
572 };
573 }
574
575 useData() {
576 if (!this.data) {
577 return; // Wait for data
578 }
579 this.updateColumnFilterOptions();
580 this.updateFilter();
581 this.reset();
582 this.updateSelected();
583 }
584
585 /**
586 * Reset the data table to correct state. This includes:
587 * - Disable loading indicator
588 * - Reset 'Updating' flag
589 */
590 reset() {
591 this.loadingIndicator = false;
592 this.updating = false;
593 }
594
595 /**
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.
599 */
600 updateSelected() {
601 if (this.updateSelectionOnRefresh === 'never') {
602 return;
603 }
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);
609 }
610 }
611 });
612 if (
613 this.updateSelectionOnRefresh === 'onChange' &&
614 _.isEqual(this.selection.selected, newSelected)
615 ) {
616 return;
617 }
618 this.selection.selected = newSelected;
619 this.onSelect(this.selection);
620 }
621
622 onSelect($event: any) {
623 this.selection.selected = $event['selected'];
624 this.updateSelection.emit(_.clone(this.selection));
625 }
626
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;
632 return;
633 }
634 _.find(this.columns, (c: CdTableColumn) => c.prop === prop).isHidden = hide;
635 this.updateColumns();
636 }
637
638 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);
644 }
645 this.table.recalculate();
646 this.cdRef.detectChanges();
647 }
648
649 createSortingDefinition(prop: TableColumnProp): SortPropDir[] {
650 return [
651 {
652 prop: prop,
653 dir: SortDirection.asc
654 }
655 ];
656 }
657
658 changeSorting({ sorts }: any) {
659 this.userConfig.sorts = sorts;
660 }
661
662 onClearSearch() {
663 this.search = '';
664 this.updateFilter();
665 }
666
667 onClearFilters() {
668 this.columnFilters.forEach((filter) => {
669 filter.value = undefined;
670 });
671 this.selectedFilter = _.first(this.columnFilters);
672 this.updateFilter();
673 }
674
675 updateFilter() {
676 let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data;
677
678 if (this.search.length > 0) {
679 const columns = this.columns.filter((c) => c.cellTransformation !== CellTemplate.sparkline);
680 // update the rows
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;
684 }
685
686 this.rows = rows;
687 }
688
689 subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]): any[] {
690 if (currentSearch.length === 0 || data.length === 0) {
691 return data;
692 }
693 const searchTerms: string[] = currentSearch
694 .pop()
695 .replace(/\+/g, ' ')
696 .split(':');
697 const columnsClone = [...columns];
698 if (searchTerms.length === 2) {
699 columns = columnsClone.filter((c) => c.name.toLowerCase().indexOf(searchTerms[0]) !== -1);
700 }
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);
704 }
705
706 basicDataSearch(searchTerm: string, rows: any[], columns: CdTableColumn[]) {
707 if (searchTerm.length === 0) {
708 return rows;
709 }
710 return rows.filter((row) => {
711 return (
712 columns.filter((col) => {
713 let cellValue: any = _.get(row, col.prop);
714
715 if (!_.isUndefined(col.pipe)) {
716 cellValue = col.pipe.transform(cellValue);
717 }
718 if (_.isUndefined(cellValue) || _.isNull(cellValue)) {
719 return false;
720 }
721
722 if (_.isArray(cellValue)) {
723 cellValue = cellValue.join(' ');
724 } else if (_.isNumber(cellValue) || _.isBoolean(cellValue)) {
725 cellValue = cellValue.toString();
726 }
727
728 if (_.isObjectLike(cellValue)) {
729 if (this.searchableObjects) {
730 cellValue = JSON.stringify(cellValue);
731 } else {
732 return false;
733 }
734 }
735
736 return cellValue.toLowerCase().indexOf(searchTerm) !== -1;
737 }).length > 0
738 );
739 });
740 }
741
742 getRowClass() {
743 // Return the function used to populate a row's CSS classes.
744 return () => {
745 return {
746 clickable: !_.isUndefined(this.selectionType)
747 };
748 };
749 }
750 }