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