]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Store user table configurations 20822/head
authorStephan Müller <smueller@suse.com>
Thu, 8 Mar 2018 14:37:20 +0000 (15:37 +0100)
committerStephan Müller <smueller@suse.com>
Tue, 29 May 2018 11:49:55 +0000 (13:49 +0200)
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 <smueller@suse.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts [new file with mode: 0644]

index 6564dc1ab1340d323c6477e7e48621b841ece3f5..eff5c856383291c66c9785952b05d5398bf24b7a 100644 (file)
@@ -1,6 +1,7 @@
 <cd-table [data]="counters"
           [columns]="columns"
           columnMode="flex"
+          [autoSave]="false"
           (fetchData)="getCounters()">
   <ng-template #valueTpl let-row="row">
     {{ row.value | dimless }} {{ row.unit }}
index 58d53ae4e1b052461da1acdcd84f02e79422d724..64548e327b036f0337a49097b56fc96b3c7829aa 100644 (file)
@@ -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<TablePerformanceCounterComponent>;
@@ -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,
index 87bdf07341b2147ee3d705ac10531c12f5d5a492..276f9e07f1e617b6e2f1588d132717ea4878afb6 100644 (file)
@@ -3,6 +3,7 @@
           columnMode="flex"
           [toolHeader]="false"
           [autoReload]="autoReload"
+          [autoSave]="false"
           [header]="false"
           [footer]="false"
           [limit]="0"
index 0f7f8ab17439562395981a870c1a23059efc7ccc..16cf5d7040f3497bd1fcdaff60d399731f98205a 100644 (file)
@@ -32,7 +32,7 @@
              type="number"
              min="1"
              max="9999"
-             [value]="limit"
+             [value]="userConfig.limit"
              (click)="setLimit($event)"
              (keyup)="setLimit($event)"
              (blur)="setLimit($event)">
                  [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'">
index 37eaf41bc13bbbd88c1e08fc646ea1a78d6276f5..619436628b4cee5446a159af34f91e4b2caf7b6c 100644 (file)
@@ -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();
     });
   });
 });
index 4d587c08f526e9a24a6943d6cac66e4e3fc40f0c..fe9035ad2b9374bc067be47c495d0cdf1a2fa749 100644 (file)
@@ -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 (file)
index 0000000..2e1a9e1
--- /dev/null
@@ -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[];
+}