]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
import ceph nautilus 14.2.2
[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 TemplateRef,
14 ViewChild
15 } from '@angular/core';
16
17 import {
18 DatatableComponent,
19 SortDirection,
20 SortPropDir,
21 TableColumnProp
22 } from '@swimlane/ngx-datatable';
23 import * as _ from 'lodash';
24 import { Observable, timer as observableTimer } from 'rxjs';
25
26 import { CellTemplate } from '../../enum/cell-template.enum';
27 import { CdTableColumn } from '../../models/cd-table-column';
28 import { CdTableFetchDataContext } from '../../models/cd-table-fetch-data-context';
29 import { CdTableSelection } from '../../models/cd-table-selection';
30 import { CdUserConfig } from '../../models/cd-user-config';
31
32 @Component({
33 selector: 'cd-table',
34 templateUrl: './table.component.html',
35 styleUrls: ['./table.component.scss'],
36 changeDetection: ChangeDetectionStrategy.OnPush
37 })
38 export class TableComponent implements AfterContentChecked, OnInit, OnChanges, OnDestroy {
39 @ViewChild(DatatableComponent)
40 table: DatatableComponent;
41 @ViewChild('tableCellBoldTpl')
42 tableCellBoldTpl: TemplateRef<any>;
43 @ViewChild('sparklineTpl')
44 sparklineTpl: TemplateRef<any>;
45 @ViewChild('routerLinkTpl')
46 routerLinkTpl: TemplateRef<any>;
47 @ViewChild('checkIconTpl')
48 checkIconTpl: TemplateRef<any>;
49 @ViewChild('perSecondTpl')
50 perSecondTpl: TemplateRef<any>;
51 @ViewChild('executingTpl')
52 executingTpl: TemplateRef<any>;
53 @ViewChild('classAddingTpl')
54 classAddingTpl: TemplateRef<any>;
55
56 // This is the array with the items to be shown.
57 @Input()
58 data: any[];
59 // Each item -> { prop: 'attribute name', name: 'display name' }
60 @Input()
61 columns: CdTableColumn[];
62 // Each item -> { prop: 'attribute name', dir: 'asc'||'desc'}
63 @Input()
64 sorts?: SortPropDir[];
65 // Method used for setting column widths.
66 @Input()
67 columnMode? = 'flex';
68 // Display the tool header, including reload button, pagination and search fields?
69 @Input()
70 toolHeader? = true;
71 // Display the table header?
72 @Input()
73 header? = true;
74 // Display the table footer?
75 @Input()
76 footer? = true;
77 // Page size to show. Set to 0 to show unlimited number of rows.
78 @Input()
79 limit? = 10;
80
81 /**
82 * Auto reload time in ms - per default every 5s
83 * You can set it to 0, undefined or false to disable the auto reload feature in order to
84 * trigger 'fetchData' if the reload button is clicked.
85 */
86 @Input()
87 autoReload: any = 5000;
88
89 // Which row property is unique for a row. If the identifier is not specified in any
90 // column, then the property name of the first column is used. Defaults to 'id'.
91 @Input()
92 identifier = 'id';
93 // If 'true', then the specified identifier is used anyway, although it is not specified
94 // in any column. Defaults to 'false'.
95 @Input()
96 forceIdentifier = false;
97 // Allows other components to specify which type of selection they want,
98 // e.g. 'single' or 'multi'.
99 @Input()
100 selectionType: string = undefined;
101 // By default selected item details will be updated on table refresh, if data has changed
102 @Input()
103 updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange';
104
105 @Input()
106 autoSave = true;
107
108 // Only needed to set if the classAddingTpl is used
109 @Input()
110 customCss?: { [css: string]: number | string | ((any) => boolean) };
111
112 /**
113 * Should be a function to update the input data if undefined nothing will be triggered
114 *
115 * Sometimes it's useful to only define fetchData once.
116 * Example:
117 * Usage of multiple tables with data which is updated by the same function
118 * What happens:
119 * The function is triggered through one table and all tables will update
120 */
121 @Output()
122 fetchData = new EventEmitter();
123
124 /**
125 * This should be defined if you need access to the selection object.
126 *
127 * Each time the table selection changes, this will be triggered and
128 * the new selection object will be sent.
129 *
130 * @memberof TableComponent
131 */
132 @Output()
133 updateSelection = new EventEmitter();
134
135 /**
136 * Use this variable to access the selected row(s).
137 */
138 selection = new CdTableSelection();
139
140 tableColumns: CdTableColumn[];
141 cellTemplates: {
142 [key: string]: TemplateRef<any>;
143 } = {};
144 search = '';
145 rows = [];
146 loadingIndicator = true;
147 loadingError = false;
148 paginationClasses = {
149 pagerLeftArrow: 'i fa fa-angle-double-left',
150 pagerRightArrow: 'i fa fa-angle-double-right',
151 pagerPrevious: 'i fa fa-angle-left',
152 pagerNext: 'i fa fa-angle-right'
153 };
154 userConfig: CdUserConfig = {};
155 tableName: string;
156 localStorage = window.localStorage;
157 private saveSubscriber;
158 private reloadSubscriber;
159 private updating = false;
160
161 // Internal variable to check if it is necessary to recalculate the
162 // table columns after the browser window has been resized.
163 private currentWidth: number;
164
165 constructor(private ngZone: NgZone, private cdRef: ChangeDetectorRef) {}
166
167 ngOnInit() {
168 // ngx-datatable triggers calculations each time mouse enters a row,
169 // this will prevent that.
170 window.addEventListener(
171 'mouseenter',
172 function(event) {
173 event.stopPropagation();
174 },
175 true
176 );
177
178 this._addTemplates();
179 if (!this.sorts) {
180 // Check whether the specified identifier exists.
181 const exists = _.findIndex(this.columns, ['prop', this.identifier]) !== -1;
182 // Auto-build the sorting configuration. If the specified identifier doesn't exist,
183 // then use the property of the first column.
184 this.sorts = this.createSortingDefinition(
185 exists ? this.identifier : this.columns[0].prop + ''
186 );
187 // If the specified identifier doesn't exist and it is not forced to use it anyway,
188 // then use the property of the first column.
189 if (!exists && !this.forceIdentifier) {
190 this.identifier = this.columns[0].prop + '';
191 }
192 }
193 this.initUserConfig();
194 this.columns.forEach((c) => {
195 if (c.cellTransformation) {
196 c.cellTemplate = this.cellTemplates[c.cellTransformation];
197 }
198 if (!c.flexGrow) {
199 c.flexGrow = c.prop + '' === this.identifier ? 1 : 2;
200 }
201 if (!c.resizeable) {
202 c.resizeable = false;
203 }
204 });
205 this.filterHiddenColumns();
206 // Load the data table content every N ms or at least once.
207 // Force showing the loading indicator if there are subscribers to the fetchData
208 // event. This is necessary because it has been set to False in useData() when
209 // this method was triggered by ngOnChanges().
210 if (this.fetchData.observers.length > 0) {
211 this.loadingIndicator = true;
212 }
213 if (_.isInteger(this.autoReload) && this.autoReload > 0) {
214 this.ngZone.runOutsideAngular(() => {
215 this.reloadSubscriber = observableTimer(0, this.autoReload).subscribe(() => {
216 this.ngZone.run(() => {
217 return this.reloadData();
218 });
219 });
220 });
221 } else {
222 this.reloadData();
223 }
224 }
225
226 initUserConfig() {
227 if (this.autoSave) {
228 this.tableName = this._calculateUniqueTableName(this.columns);
229 this._loadUserConfig();
230 this._initUserConfigAutoSave();
231 }
232 if (!this.userConfig.limit) {
233 this.userConfig.limit = this.limit;
234 }
235 if (!this.userConfig.sorts) {
236 this.userConfig.sorts = this.sorts;
237 }
238 if (!this.userConfig.columns) {
239 this.updateUserColumns();
240 } else {
241 this.columns.forEach((c, i) => {
242 c.isHidden = this.userConfig.columns[i].isHidden;
243 });
244 }
245 }
246
247 _calculateUniqueTableName(columns) {
248 const stringToNumber = (s) => {
249 if (!_.isString(s)) {
250 return 0;
251 }
252 let result = 0;
253 for (let i = 0; i < s.length; i++) {
254 result += s.charCodeAt(i) * i;
255 }
256 return result;
257 };
258 return columns
259 .reduce(
260 (result, value, index) =>
261 (stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1) + result,
262 0
263 )
264 .toString();
265 }
266
267 _loadUserConfig() {
268 const loaded = this.localStorage.getItem(this.tableName);
269 if (loaded) {
270 this.userConfig = JSON.parse(loaded);
271 }
272 }
273
274 _initUserConfigAutoSave() {
275 const source = Observable.create(this._initUserConfigProxy.bind(this));
276 this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this));
277 }
278
279 _initUserConfigProxy(observer) {
280 this.userConfig = new Proxy(this.userConfig, {
281 set(config, prop, value) {
282 config[prop] = value;
283 observer.next(config);
284 return true;
285 }
286 });
287 }
288
289 _saveUserConfig(config) {
290 this.localStorage.setItem(this.tableName, JSON.stringify(config));
291 }
292
293 updateUserColumns() {
294 this.userConfig.columns = this.columns.map((c) => ({
295 prop: c.prop,
296 name: c.name,
297 isHidden: !!c.isHidden
298 }));
299 }
300
301 filterHiddenColumns() {
302 this.tableColumns = this.columns.filter((c) => !c.isHidden);
303 }
304
305 ngOnDestroy() {
306 if (this.reloadSubscriber) {
307 this.reloadSubscriber.unsubscribe();
308 }
309 if (this.saveSubscriber) {
310 this.saveSubscriber.unsubscribe();
311 }
312 }
313
314 ngAfterContentChecked() {
315 // If the data table is not visible, e.g. another tab is active, and the
316 // browser window gets resized, the table and its columns won't get resized
317 // automatically if the tab gets visible again.
318 // https://github.com/swimlane/ngx-datatable/issues/193
319 // https://github.com/swimlane/ngx-datatable/issues/193#issuecomment-329144543
320 if (this.table && this.table.element.clientWidth !== this.currentWidth) {
321 this.currentWidth = this.table.element.clientWidth;
322 this.table.recalculate();
323 }
324 }
325
326 _addTemplates() {
327 this.cellTemplates.bold = this.tableCellBoldTpl;
328 this.cellTemplates.checkIcon = this.checkIconTpl;
329 this.cellTemplates.sparkline = this.sparklineTpl;
330 this.cellTemplates.routerLink = this.routerLinkTpl;
331 this.cellTemplates.perSecond = this.perSecondTpl;
332 this.cellTemplates.executing = this.executingTpl;
333 this.cellTemplates.classAdding = this.classAddingTpl;
334 }
335
336 useCustomClass(value: any): string {
337 if (!this.customCss) {
338 throw new Error('Custom classes are not set!');
339 }
340 const classes = Object.keys(this.customCss);
341 const css = Object.values(this.customCss)
342 .map((v, i) => ((_.isFunction(v) && v(value)) || v === value) && classes[i])
343 .filter((x) => x)
344 .join(' ');
345 return _.isEmpty(css) ? undefined : css;
346 }
347
348 ngOnChanges() {
349 this.useData();
350 }
351
352 setLimit(e) {
353 const value = parseInt(e.target.value, 10);
354 if (value > 0) {
355 this.userConfig.limit = value;
356 }
357 }
358
359 reloadData() {
360 if (!this.updating) {
361 this.loadingError = false;
362 const context = new CdTableFetchDataContext(() => {
363 // Do we have to display the error panel?
364 this.loadingError = context.errorConfig.displayError;
365 // Force data table to show no data?
366 if (context.errorConfig.resetData) {
367 this.data = [];
368 }
369 // Stop the loading indicator and reset the data table
370 // to the correct state.
371 this.useData();
372 });
373 this.fetchData.emit(context);
374 this.updating = true;
375 }
376 }
377
378 refreshBtn() {
379 this.loadingIndicator = true;
380 this.reloadData();
381 }
382
383 rowIdentity() {
384 return (row) => {
385 const id = row[this.identifier];
386 if (_.isUndefined(id)) {
387 throw new Error(`Wrong identifier "${this.identifier}" -> "${id}"`);
388 }
389 return id;
390 };
391 }
392
393 useData() {
394 if (!this.data) {
395 return; // Wait for data
396 }
397 if (this.search.length > 0) {
398 this.updateFilter();
399 } else {
400 this.rows = [...this.data];
401 }
402 this.reset();
403 this.updateSelected();
404 }
405
406 /**
407 * Reset the data table to correct state. This includes:
408 * - Disable loading indicator
409 * - Reset 'Updating' flag
410 */
411 reset() {
412 this.loadingIndicator = false;
413 this.updating = false;
414 }
415
416 /**
417 * After updating the data, we have to update the selected items
418 * because details may have changed,
419 * or some selected items may have been removed.
420 */
421 updateSelected() {
422 if (this.updateSelectionOnRefresh === 'never') {
423 return;
424 }
425 const newSelected = [];
426 this.selection.selected.forEach((selectedItem) => {
427 for (const row of this.data) {
428 if (selectedItem[this.identifier] === row[this.identifier]) {
429 newSelected.push(row);
430 }
431 }
432 });
433 if (
434 this.updateSelectionOnRefresh === 'onChange' &&
435 _.isEqual(this.selection.selected, newSelected)
436 ) {
437 return;
438 }
439 this.selection.selected = newSelected;
440 this.onSelect();
441 }
442
443 onSelect() {
444 this.selection.update();
445 this.updateSelection.emit(_.clone(this.selection));
446 }
447
448 toggleColumn($event: any) {
449 const prop: TableColumnProp = $event.target.name;
450 const hide = !$event.target.checked;
451 if (hide && this.tableColumns.length === 1) {
452 $event.target.checked = true;
453 return;
454 }
455 _.find(this.columns, (c: CdTableColumn) => c.prop === prop).isHidden = hide;
456 this.updateColumns();
457 }
458
459 updateColumns() {
460 this.updateUserColumns();
461 this.filterHiddenColumns();
462 const sortProp = this.userConfig.sorts[0].prop;
463 if (!_.find(this.tableColumns, (c: CdTableColumn) => c.prop === sortProp)) {
464 this.userConfig.sorts = this.createSortingDefinition(this.tableColumns[0].prop);
465 this.table.onColumnSort({ sorts: this.userConfig.sorts });
466 }
467 this.table.recalculate();
468 this.cdRef.detectChanges();
469 }
470
471 createSortingDefinition(prop: TableColumnProp): SortPropDir[] {
472 return [
473 {
474 prop: prop,
475 dir: SortDirection.asc
476 }
477 ];
478 }
479
480 changeSorting({ sorts }) {
481 this.userConfig.sorts = sorts;
482 }
483
484 updateFilter(clearSearch = false) {
485 if (clearSearch) {
486 this.search = '';
487 }
488 // prepare search strings
489 let search = this.search.toLowerCase().replace(/,/g, '');
490 const columns = this.columns.filter((c) => c.cellTransformation !== CellTemplate.sparkline);
491 if (search.match(/['"][^'"]+['"]/)) {
492 search = search.replace(/['"][^'"]+['"]/g, (match: string) => {
493 return match.replace(/(['"])([^'"]+)(['"])/g, '$2').replace(/ /g, '+');
494 });
495 }
496 // update the rows
497 this.rows = this.subSearch(this.data, search.split(' ').filter((s) => s.length > 0), columns);
498 // Whenever the filter changes, always go back to the first page
499 this.table.offset = 0;
500 }
501
502 subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]) {
503 if (currentSearch.length === 0 || data.length === 0) {
504 return data;
505 }
506 const searchTerms: string[] = currentSearch
507 .pop()
508 .replace('+', ' ')
509 .split(':');
510 const columnsClone = [...columns];
511 if (searchTerms.length === 2) {
512 columns = columnsClone.filter((c) => c.name.toLowerCase().indexOf(searchTerms[0]) !== -1);
513 }
514 data = this.basicDataSearch(_.last(searchTerms), data, columns);
515 // Checks if user searches for column but he is still typing
516 return this.subSearch(data, currentSearch, columnsClone);
517 }
518
519 basicDataSearch(searchTerm: string, rows: any[], columns: CdTableColumn[]) {
520 if (searchTerm.length === 0) {
521 return rows;
522 }
523 return rows.filter((row) => {
524 return (
525 columns.filter((col) => {
526 let cellValue: any = _.get(row, col.prop);
527
528 if (!_.isUndefined(col.pipe)) {
529 cellValue = col.pipe.transform(cellValue);
530 }
531 if (_.isUndefined(cellValue) || _.isNull(cellValue)) {
532 return false;
533 }
534
535 if (_.isArray(cellValue)) {
536 cellValue = cellValue.join(' ');
537 } else if (_.isNumber(cellValue) || _.isBoolean(cellValue)) {
538 cellValue = cellValue.toString();
539 }
540 return cellValue.toLowerCase().indexOf(searchTerm) !== -1;
541 }).length > 0
542 );
543 });
544 }
545
546 getRowClass() {
547 // Return the function used to populate a row's CSS classes.
548 return () => {
549 return {
550 clickable: !_.isUndefined(this.selectionType)
551 };
552 };
553 }
554 }