From e4c708ade83e9dd06da2d874a196ac91bf82b21c Mon Sep 17 00:00:00 2001 From: =?utf8?q?Stephan=20M=C3=BCller?= Date: Thu, 8 Mar 2018 15:37:20 +0100 Subject: [PATCH] mgr/dashboard: Store user table configurations MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit The feature stores sorting, displayed columns and the table limit for each table, that does not forbid the auto saving feature. Signed-off-by: Stephan Müller --- .../table-performance-counter.component.html | 1 + ...able-performance-counter.component.spec.ts | 16 +- .../table-key-value.component.html | 1 + .../datatable/table/table.component.html | 7 +- .../datatable/table/table.component.spec.ts | 227 ++++++++++-------- .../shared/datatable/table/table.component.ts | 113 ++++++++- .../src/app/shared/models/cd-user-config.ts | 9 + 7 files changed, 253 insertions(+), 121 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html index 6564dc1ab13..eff5c856383 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html @@ -1,6 +1,7 @@ {{ row.value | dimless }} {{ row.unit }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts index 58d53ae4e1b..64548e327b0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts @@ -1,21 +1,12 @@ -import { Component, Input } from '@angular/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs/Observable'; - import { PerformanceCounterService } from '../../../shared/api/performance-counter.service'; -import { CdTableColumn } from '../../../shared/models/cd-table-column'; +import { TableComponent } from '../../../shared/datatable/table/table.component'; import { DimlessPipe } from '../../../shared/pipes/dimless.pipe'; import { FormatterService } from '../../../shared/services/formatter.service'; import { TablePerformanceCounterComponent } from './table-performance-counter.component'; -@Component({ selector: 'cd-table', template: '' }) -class TableStubComponent { - @Input() data: any[]; - @Input() columns: CdTableColumn[]; - @Input() autoReload: any = 5000; -} - describe('TablePerformanceCounterComponent', () => { let component: TablePerformanceCounterComponent; let fixture: ComponentFixture; @@ -24,8 +15,9 @@ describe('TablePerformanceCounterComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [TablePerformanceCounterComponent, TableStubComponent, DimlessPipe], + declarations: [TablePerformanceCounterComponent, TableComponent, DimlessPipe], imports: [], + schemas: [NO_ERRORS_SCHEMA], providers: [ { provide: PerformanceCounterService, useValue: fakeService }, DimlessPipe, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html index 87bdf07341b..276f9e07f1e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html @@ -3,6 +3,7 @@ columnMode="flex" [toolHeader]="false" [autoReload]="autoReload" + [autoSave]="false" [header]="false" [footer]="false" [limit]="0" diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html index 0f7f8ab1743..16cf5d7040f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -32,7 +32,7 @@ type="number" min="1" max="9999" - [value]="limit" + [value]="userConfig.limit" (click)="setLimit($event)" (keyup)="setLimit($event)" (blur)="setLimit($event)"> @@ -80,14 +80,15 @@ [selectionType]="selectionType" [selected]="selection.selected" (select)="onSelect()" - [sorts]="sorts" + [sorts]="userConfig.sorts" + (sort)="changeSorting($event)" [columns]="tableColumns" [columnMode]="columnMode" [rows]="rows" [rowClass]="getRowClass()" [headerHeight]="header ? 'auto' : 0" [footerHeight]="footer ? 'auto' : 0" - [limit]="limit > 0 ? limit : undefined" + [limit]="userConfig.limit > 0 ? userConfig.limit : undefined" [loadingIndicator]="loadingIndicator" [rowIdentity]="rowIdentity()" [rowHeight]="'auto'"> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts index 37eaf41bc13..619436628b4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts @@ -24,11 +24,8 @@ describe('TableComponent', () => { return data; }; - const doSearch = (search: string, expectedLength: number, firstObject: object) => { - component.search = search; - component.updateFilter(true); - expect(component.rows.length).toBe(expectedLength); - expect(component.rows[0]).toEqual(firstObject); + const clearLocalStorage = () => { + component.localStorage.clear(); }; beforeEach(async(() => { @@ -45,7 +42,6 @@ describe('TableComponent', () => { beforeEach(() => { component.data = createFakeData(100); - component.useData(); component.columns = [ { prop: 'a', name: 'Index' }, { prop: 'b', name: 'Power ofA' }, @@ -58,100 +54,125 @@ describe('TableComponent', () => { expect(component).toBeTruthy(); }); - it('should have rows', () => { - expect(component.data.length).toBe(100); - expect(component.rows.length).toBe(component.data.length); - }); - - it('should have an int in setLimit parsing a string', () => { - expect(component.limit).toBe(10); - expect(component.limit).toEqual(jasmine.any(Number)); - - const e = { target: { value: '1' } }; - component.setLimit(e); - expect(component.limit).toBe(1); - expect(component.limit).toEqual(jasmine.any(Number)); - e.target.value = '-20'; - component.setLimit(e); - expect(component.limit).toBe(1); - }); + describe('after useData', () => { + beforeEach(() => { + component.useData(); + }); - it('should search for 13', () => { - doSearch('13', 9, { a: 7, b: 49, c: [-7, 'score13'], d: false }); - expect(component.rows[1].a).toBe(13); - expect(component.rows[8].a).toBe(87); - }); + it('should force an identifier', () => { + component.identifier = 'x'; + component.forceIdentifier = true; + component.ngOnInit(); + expect(component.identifier).toBe('x'); + expect(component.sorts[0].prop).toBe('a'); + expect(component.sorts).toEqual(component.createSortingDefinition('a')); + }); - it('should search for true', () => { - doSearch('true', 50, { a: 0, b: 0, c: [-0, 'score6'], d: true }); - expect(component.rows[0].d).toBe(true); - expect(component.rows[1].d).toBe(true); - }); + it('should have rows', () => { + expect(component.data.length).toBe(100); + expect(component.rows.length).toBe(component.data.length); + }); - it('should search for false', () => { - doSearch('false', 50, { a: 1, b: 1, c: [-1, 'score7'], d: false }); - expect(component.rows[0].d).toBe(false); - expect(component.rows[1].d).toBe(false); - }); + it('should have an int in setLimit parsing a string', () => { + expect(component.limit).toBe(10); + expect(component.limit).toEqual(jasmine.any(Number)); + + const e = { target: { value: '1' } }; + component.setLimit(e); + expect(component.userConfig.limit).toBe(1); + expect(component.userConfig.limit).toEqual(jasmine.any(Number)); + e.target.value = '-20'; + component.setLimit(e); + expect(component.userConfig.limit).toBe(1); + }); - it('should test search manipulation', () => { - let searchTerms = []; - spyOn(component, 'subSearch').and.callFake((d, search, c) => { - expect(search).toEqual(searchTerms); + it('should force an identifier', () => { + clearLocalStorage(); + component.identifier = 'x'; + component.forceIdentifier = true; + component.ngOnInit(); + expect(component.identifier).toBe('x'); + expect(component.sorts[0].prop).toBe('a'); + expect(component.sorts).toEqual(component.createSortingDefinition('a')); }); - const searchTest = (s: string, st: string[]) => { - component.search = s; - searchTerms = st; - component.updateFilter(true); - }; - searchTest('a b c', ['a', 'b', 'c']); - searchTest('a+b c', ['a+b', 'c']); - searchTest('a,,,, b,,, c', ['a', 'b', 'c']); - searchTest('a,,,+++b,,, c', ['a+++b', 'c']); - searchTest('"a b c" "d e f", "g, h i"', ['a+b+c', 'd+e++f', 'g+h+i']); - }); - it('should search for multiple values', () => { - doSearch('7 5 3', 5, { a: 57, b: 3249, c: [-7, 'score15'], d: false }); - }); + describe('test search', () => { + const doSearch = (search: string, expectedLength: number, firstObject: object) => { + component.search = search; + component.updateFilter(true); + expect(component.rows.length).toBe(expectedLength); + expect(component.rows[0]).toEqual(firstObject); + }; + + it('should search for 13', () => { + doSearch('13', 9, { a: 7, b: 49, c: [-7, 'score13'], d: false }); + expect(component.rows[1].a).toBe(13); + expect(component.rows[8].a).toBe(87); + }); - it('should search with column filter', () => { - doSearch('power:1369', 1, { a: 37, b: 1369, c: [-7, 'score11'], d: false }); - doSearch('ndex:7 ofa:5 poker:3', 3, { a: 71, b: 5041, c: [-1, 'score13'], d: false }); - }); + it('should search for true', () => { + doSearch('true', 50, { a: 0, b: 0, c: [-0, 'score6'], d: true }); + expect(component.rows[0].d).toBe(true); + expect(component.rows[1].d).toBe(true); + }); - it('should search with through array', () => { - doSearch('array:score21', 6, { a: 15, b: 225, c: [-5, 'score21'], d: false }); - }); + it('should search for false', () => { + doSearch('false', 50, { a: 1, b: 1, c: [-1, 'score7'], d: false }); + expect(component.rows[0].d).toBe(false); + expect(component.rows[1].d).toBe(false); + }); - it('should search with spaces', () => { - doSearch(`'poker array':score21`, 6, { a: 15, b: 225, c: [-5, 'score21'], d: false }); - doSearch('"poker array":score21', 6, { a: 15, b: 225, c: [-5, 'score21'], d: false }); - doSearch('poker+array:score21', 6, { a: 15, b: 225, c: [-5, 'score21'], d: false }); - }); + it('should test search manipulation', () => { + let searchTerms = []; + spyOn(component, 'subSearch').and.callFake((d, search, c) => { + expect(search).toEqual(searchTerms); + }); + const searchTest = (s: string, st: string[]) => { + component.search = s; + searchTerms = st; + component.updateFilter(true); + }; + searchTest('a b c', ['a', 'b', 'c']); + searchTest('a+b c', ['a+b', 'c']); + searchTest('a,,,, b,,, c', ['a', 'b', 'c']); + searchTest('a,,,+++b,,, c', ['a+++b', 'c']); + searchTest('"a b c" "d e f", "g, h i"', ['a+b+c', 'd+e++f', 'g+h+i']); + }); - it('should not search if column name is incomplete', () => { - doSearch(`'poker array'`, 100, { a: 0, b: 0, c: [-0, 'score6'], d: true }); - doSearch('pok', 100, { a: 0, b: 0, c: [-0, 'score6'], d: true }); - doSearch('pok:', 100, { a: 0, b: 0, c: [-0, 'score6'], d: true }); - }); + it('should search for multiple values', () => { + doSearch('7 5 3', 5, { a: 57, b: 3249, c: [-7, 'score15'], d: false }); + }); - it('should restore full table after search', () => { - expect(component.rows.length).toBe(100); - component.search = '13'; - component.updateFilter(true); - expect(component.rows.length).toBe(9); - component.updateFilter(); - expect(component.rows.length).toBe(100); - }); + it('should search with column filter', () => { + doSearch('power:1369', 1, { a: 37, b: 1369, c: [-7, 'score11'], d: false }); + doSearch('ndex:7 ofa:5 poker:3', 3, { a: 71, b: 5041, c: [-1, 'score13'], d: false }); + }); + + it('should search with through array', () => { + doSearch('array:score21', 6, { a: 15, b: 225, c: [-5, 'score21'], d: false }); + }); + + it('should search with spaces', () => { + doSearch(`'poker array':score21`, 6, { a: 15, b: 225, c: [-5, 'score21'], d: false }); + doSearch('"poker array":score21', 6, { a: 15, b: 225, c: [-5, 'score21'], d: false }); + doSearch('poker+array:score21', 6, { a: 15, b: 225, c: [-5, 'score21'], d: false }); + }); + + it('should not search if column name is incomplete', () => { + doSearch(`'poker array'`, 100, { a: 0, b: 0, c: [-0, 'score6'], d: true }); + doSearch('pok', 100, { a: 0, b: 0, c: [-0, 'score6'], d: true }); + doSearch('pok:', 100, { a: 0, b: 0, c: [-0, 'score6'], d: true }); + }); - it('should force an identifier', () => { - component.identifier = 'x'; - component.forceIdentifier = true; - component.ngOnInit(); - expect(component.identifier).toBe('x'); - expect(component.sorts[0].prop).toBe('a'); - expect(component.sorts).toEqual(component.createSortingDefinition('a')); + it('should restore full table after search', () => { + expect(component.rows.length).toBe(100); + component.search = '13'; + component.updateFilter(true); + expect(component.rows.length).toBe(9); + component.updateFilter(); + expect(component.rows.length).toBe(100); + }); + }); }); describe('after ngInit', () => { @@ -164,9 +185,14 @@ describe('TableComponent', () => { }); }; + const equalStorageConfig = () => { + expect(JSON.stringify(component.userConfig)).toBe( + component.localStorage.getItem(component.tableName) + ); + }; + beforeEach(() => { component.ngOnInit(); - component.table.sorts = component.sorts; }); it('should have updated the column definitions', () => { @@ -181,32 +207,43 @@ describe('TableComponent', () => { expect(component.tableColumns).toEqual(component.columns); }); - it('should have a unique identifier which is search for', () => { + it('should have a unique identifier which it searches for', () => { expect(component.identifier).toBe('a'); - expect(component.sorts[0].prop).toBe('a'); - expect(component.sorts).toEqual(component.createSortingDefinition('a')); + expect(component.userConfig.sorts[0].prop).toBe('a'); + expect(component.userConfig.sorts).toEqual(component.createSortingDefinition('a')); + equalStorageConfig(); }); it('should remove column "a"', () => { + expect(component.userConfig.sorts[0].prop).toBe('a'); toggleColumn('a', false); - expect(component.table.sorts[0].prop).toBe('b'); + expect(component.userConfig.sorts[0].prop).toBe('b'); expect(component.tableColumns.length).toBe(3); + equalStorageConfig(); }); it('should not be able to remove all columns', () => { + expect(component.userConfig.sorts[0].prop).toBe('a'); toggleColumn('a', false); toggleColumn('b', false); toggleColumn('c', false); toggleColumn('d', false); - expect(component.table.sorts[0].prop).toBe('d'); + expect(component.userConfig.sorts[0].prop).toBe('d'); expect(component.tableColumns.length).toBe(1); + equalStorageConfig(); }); it('should enable column "a" again', () => { + expect(component.userConfig.sorts[0].prop).toBe('a'); toggleColumn('a', false); toggleColumn('a', true); - expect(component.table.sorts[0].prop).toBe('b'); + expect(component.userConfig.sorts[0].prop).toBe('b'); expect(component.tableColumns.length).toBe(4); + equalStorageConfig(); + }); + + afterEach(() => { + clearLocalStorage(); }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts index 4d587c08f52..fe9035ad2b9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts @@ -25,6 +25,7 @@ import { Observable } from 'rxjs/Observable'; import { CellTemplate } from '../../enum/cell-template.enum'; import { CdTableColumn } from '../../models/cd-table-column'; import { CdTableSelection } from '../../models/cd-table-selection'; +import { CdUserConfig } from '../../models/cd-user-config'; @Component({ selector: 'cd-table', @@ -76,6 +77,8 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O // If `true` selected item details will be updated on table refresh @Input() updateSelectionOnRefresh = true; + @Input() autoSave = true; + /** * Should be a function to update the input data if undefined nothing will be triggered * @@ -115,7 +118,11 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O pagerPrevious: 'i fa fa-angle-left', pagerNext: 'i fa fa-angle-right' }; - private subscriber; + userConfig: CdUserConfig = {}; + tableName: string; + localStorage = window.localStorage; + private saveSubscriber; + private reloadSubscriber; private updating = false; // Internal variable to check if it is necessary to recalculate the @@ -139,7 +146,8 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O this.identifier = this.columns[0].prop + ''; } } - this.columns.map(c => { + this.initUserConfig(); + this.columns.forEach(c => { if (c.cellTransformation) { c.cellTemplate = this.cellTemplates[c.cellTransformation]; } @@ -149,9 +157,8 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O if (!c.resizeable) { c.resizeable = false; } - return c; }); - this.tableColumns = this.columns.filter(c => !c.isHidden); + this.filterHiddenColumns(); // Load the data table content every N ms or at least once. // Force showing the loading indicator if there are subscribers to the fetchData // event. This is necessary because it has been set to False in useData() when @@ -161,7 +168,7 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O } if (_.isInteger(this.autoReload) && (this.autoReload > 0)) { this.ngZone.runOutsideAngular(() => { - this.subscriber = Observable.timer(0, this.autoReload).subscribe(x => { + this.reloadSubscriber = Observable.timer(0, this.autoReload).subscribe(x => { this.ngZone.run(() => { return this.reloadData(); }); @@ -172,9 +179,87 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O } } + initUserConfig () { + if (this.autoSave) { + this.tableName = this._calculateUniqueTableName(this.columns); + this._loadUserConfig(); + this._initUserConfigAutoSave(); + } + if (!this.userConfig.limit) { + this.userConfig.limit = this.limit; + } + if (!this.userConfig.sorts) { + this.userConfig.sorts = this.sorts; + } + if (!this.userConfig.columns) { + this.updateUserColumns(); + } else { + this.columns.forEach((c, i) => { + c.isHidden = this.userConfig.columns[i].isHidden; + }); + } + } + + _calculateUniqueTableName (columns) { + const stringToNumber = (s) => { + if (!_.isString(s)) { + return 0; + } + let result = 0; + for (let i = 0; i < s.length; i++) { + result += s.charCodeAt(i) * i; + } + return result; + }; + return columns.reduce((result, value, index) => + ((stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1)) + result, + 0).toString(); + } + + _loadUserConfig () { + const loaded = this.localStorage.getItem(this.tableName); + if (loaded) { + this.userConfig = JSON.parse(loaded); + } + } + + _initUserConfigAutoSave() { + const source = Observable.create(this._initUserConfigProxy.bind(this)); + this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this)); + } + + _initUserConfigProxy (observer) { + this.userConfig = new Proxy(this.userConfig, { + set(config, prop, value) { + config[prop] = value; + observer.next(config); + return true; + } + }); + } + + _saveUserConfig (config) { + this.localStorage.setItem(this.tableName, JSON.stringify(config)); + } + + updateUserColumns () { + this.userConfig.columns = this.columns.map(c => ({ + prop: c.prop, + name: c.name, + isHidden: !!c.isHidden + })); + } + + filterHiddenColumns () { + this.tableColumns = this.columns.filter(c => !c.isHidden); + } + ngOnDestroy() { - if (this.subscriber) { - this.subscriber.unsubscribe(); + if (this.reloadSubscriber) { + this.reloadSubscriber.unsubscribe(); + } + if (this.saveSubscriber) { + this.saveSubscriber.unsubscribe(); } } @@ -206,7 +291,7 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O setLimit(e) { const value = parseInt(e.target.value, 10); if (value > 0) { - this.limit = value; + this.userConfig.limit = value; } } @@ -282,10 +367,12 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O } updateColumns () { - this.tableColumns = this.columns.filter(c => !c.isHidden); - const sortProp = this.table.sorts[0].prop; + this.updateUserColumns(); + this.filterHiddenColumns(); + const sortProp = this.userConfig.sorts[0].prop; if (!_.find(this.tableColumns, (c: CdTableColumn) => c.prop === sortProp)) { - this.table.onColumnSort({sorts: this.createSortingDefinition(this.tableColumns[0].prop)}); + this.userConfig.sorts = this.createSortingDefinition(this.tableColumns[0].prop); + this.table.onColumnSort({sorts: this.userConfig.sorts}); } this.table.recalculate(); } @@ -299,6 +386,10 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O ]; } + changeSorting ({sorts}) { + this.userConfig.sorts = sorts; + } + updateFilter(event?: any) { if (!event) { this.search = ''; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts new file mode 100644 index 00000000000..2e1a9e1970a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts @@ -0,0 +1,9 @@ +import { SortPropDir } from '@swimlane/ngx-datatable'; + +import { CdTableColumn } from './cd-table-column'; + +export interface CdUserConfig { + limit?: number; + sorts?: SortPropDir[]; + columns?: CdTableColumn[]; +} -- 2.47.3