]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add column filtering feature to cd-table
authorKiefer Chang <kiefer.chang@suse.com>
Tue, 31 Dec 2019 06:32:26 +0000 (14:32 +0800)
committerKiefer Chang <kiefer.chang@suse.com>
Tue, 14 Jan 2020 07:10:10 +0000 (15:10 +0800)
Add column filtering feature to the cd-table component.
- Create filters from columns.
- Create filter options from row data in columns.
- Allow specifying custom functions to do filtering.
- Allow specifying hard-coded options for filters.
- Allow specifying extra filters other than visible columns on table.

Previously, this feature was implemented in inventory component. Refactor
it to move the logic into cd-table, so all tables can use the feature if
needed.

Signed-off-by: Kiefer Chang <kiefer.chang@suse.com>
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.scss
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-table-column-filter.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts

index 43cea2d6935f021ba75e73f11638eba316d1c149..b1200fda06ca0f0e57b1faaca175064b10afa7d8 100644 (file)
@@ -3,6 +3,7 @@
                 i18n>Failed to load data.</cd-alert-panel>
 
 <div class="dataTables_wrapper">
+
   <div *ngIf="onlyActionHeader"
        class="dataTables_header clearfix">
     <div class="cd-datatable-actions">
     <ng-content select=".table-filters"></ng-content>
     <!-- end filters -->
 
+    <!-- column filters -->
+    <div *ngIf="columnFilters.length !== 0"
+         class="btn-group widget-toolbar">
+      <div dropdown
+           class="btn-group">
+        <a dropdownToggle
+           class="btn btn-light dropdown-toggle tc_columnBtn"
+           data-toggle="dropdown">
+          <i [ngClass]="[icons.large, icons.filter]"></i>
+          {{ selectedFilter.column.name }}
+        </a>
+        <ul *dropdownMenu
+            class="dropdown-menu px-1"
+            role="menu">
+          <li *ngFor="let filter of columnFilters"
+              role="menuitem">
+            <a href=""
+               class="dropdown-item"
+               (click)="onSelectFilter(filter); false">{{ filter.column.name }}</a>
+          </li>
+        </ul>
+      </div>
+      <div dropdown
+           class="btn-group">
+        <a dropdownToggle
+           class="btn btn-light dropdown-toggle"
+           [class.disabled]="selectedFilter.options.length === 0"
+           data-toggle="dropdown">
+           {{ selectedFilter.value ? selectedFilter.value.formatted: 'Any' }}
+        </a>
+        <ul *dropdownMenu
+            class="dropdown-menu px-1"
+            role="menu">
+          <li *ngFor="let option of selectedFilter.options"
+              role="menuitem">
+            <a href=""
+               class="dropdown-item"
+               (click)="onChangeFilter(selectedFilter, option); false">{{ option.formatted }}
+              <i *ngIf="selectedFilter.value !== undefined && (selectedFilter.value.raw === option.raw)"
+                 [ngClass]="[icons.check]"></i>
+            </a>
+          </li>
+        </ul>
+      </div>
+    </div>
+    <!-- end column filters -->
+
     <!-- search -->
     <div class="input-group search"
          *ngIf="searchField">
@@ -36,7 +84,7 @@
       <div class="input-group-append">
         <button type="button"
                 class="btn btn-light"
-                (click)="updateFilter(true)">
+                (click)="onClearSearch()">
           <i class="icon-prepend {{ icons.destroy }}"></i>
         </button>
       </div>
     </div>
     <!-- end refresh button -->
   </div>
+  <div class="dataTables_header clearfix"
+       *ngIf="toolHeader && columnFiltered">
+    <!-- filter chips for column filters -->
+    <div class="filter-chips">
+      <span *ngFor="let filter of columnFilters">
+        <span *ngIf="filter.value"
+              class="badge badge-info mr-2">
+          <span class="mr-2">{{ filter.column.name }}: {{ filter.value.formatted }}</span>
+          <a class="badge-remove"
+             (click)="onChangeFilter(filter); false">
+            <i [ngClass]="[icons.destroy]"
+               aria-hidden="true"></i>
+          </a>
+        </span>
+      </span>
+      <a class="tc_clearSelections"
+         href=""
+         (click)="onClearFilters(); false">
+        <ng-container i18n>Clear filters</ng-container>
+      </a>
+    </div>
+    <!-- end filter chips for column filters -->
+  </div>
   <ngx-datatable #table
                  class="bootstrap cd-datatable"
                  [cssClasses]="paginationClasses"
index 2dd838528d27d218257e4e2e0fe4e9b4ea1213cb..e358a9d2fa766d1565233750cfe3d761fb9501b3 100644 (file)
     min-width: 85px;
     padding-right: 8px;
   }
+  .filter-chips {
+    float: right;
+    padding: 0 8px;
+  }
 }
 
 ::ng-deep .cd-datatable {
index 32c095fb51a2bbd2586c209e997ca16288ebe297..c4d9fc9b5fed0a4e57947f9b3a944164bf024bc1 100644 (file)
@@ -8,6 +8,7 @@ import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
 
 import { configureTestBed } from '../../../../testing/unit-test-helper';
 import { ComponentsModule } from '../../components/components.module';
+import { CdTableColumnFilter } from '../../models/cd-table-column-filter';
 import { CdTableFetchDataContext } from '../../models/cd-table-fetch-data-context';
 import { PipesModule } from '../../pipes/pipes.module';
 import { TableComponent } from './table.component';
@@ -50,9 +51,9 @@ describe('TableComponent', () => {
 
     component.data = createFakeData(10);
     component.columns = [
-      { prop: 'a', name: 'Index' },
+      { prop: 'a', name: 'Index', filterable: true },
       { prop: 'b', name: 'Index times ten' },
-      { prop: 'c', name: 'Odd?' }
+      { prop: 'c', name: 'Odd?', filterable: true }
     ];
   });
 
@@ -103,12 +104,180 @@ describe('TableComponent', () => {
     component.ngOnInit();
   });
 
+  describe('test column filtering', () => {
+    let filterIndex: CdTableColumnFilter;
+    let filterOdd: CdTableColumnFilter;
+    let filterCustom: CdTableColumnFilter;
+
+    const expectColumnFilterCreated = (
+      filter: CdTableColumnFilter,
+      prop: string,
+      options: string[],
+      value?: { raw: string; formatted: string }
+    ) => {
+      expect(filter.column.prop).toBe(prop);
+      expect(_.map(filter.options, 'raw')).toEqual(options);
+      expect(filter.value).toEqual(value);
+    };
+
+    const expectColumnFiltered = (
+      changes: { filter: CdTableColumnFilter; value?: string }[],
+      results: any[],
+      search: string = ''
+    ) => {
+      component.search = search;
+      _.forEach(changes, (change) => {
+        component.onChangeFilter(
+          change.filter,
+          change.value ? { raw: change.value, formatted: change.value } : undefined
+        );
+      });
+      expect(component.rows).toEqual(results);
+      component.onClearSearch();
+      component.onClearFilters();
+    };
+
+    describe('with visible columns', () => {
+      beforeEach(() => {
+        component.initColumnFilters();
+        component.updateColumnFilterOptions();
+        filterIndex = component.columnFilters[0];
+        filterOdd = component.columnFilters[1];
+      });
+
+      it('should have filters initialized', () => {
+        expect(component.columnFilters.length).toBe(2);
+        expectColumnFilterCreated(
+          filterIndex,
+          'a',
+          _.map(component.data, (row) => _.toString(row.a))
+        );
+        expectColumnFilterCreated(filterOdd, 'c', ['false', 'true']);
+      });
+
+      it('should add filters', () => {
+        // single
+        expectColumnFiltered([{ filter: filterIndex, value: '1' }], [{ a: 1, b: 10, c: true }]);
+
+        // multiple
+        expectColumnFiltered(
+          [{ filter: filterOdd, value: 'false' }, { filter: filterIndex, value: '2' }],
+          [{ a: 2, b: 20, c: false }]
+        );
+
+        // Clear should work
+        expect(component.rows).toEqual(component.data);
+      });
+
+      it('should remove filters', () => {
+        // single
+        expectColumnFiltered(
+          [
+            { filter: filterOdd, value: 'true' },
+            { filter: filterIndex, value: '1' },
+            { filter: filterIndex, value: undefined }
+          ],
+          [
+            { a: 1, b: 10, c: true },
+            { a: 3, b: 30, c: true },
+            { a: 5, b: 50, c: true },
+            { a: 7, b: 70, c: true },
+            { a: 9, b: 90, c: true }
+          ]
+        );
+
+        // multiple
+        expectColumnFiltered(
+          [
+            { filter: filterOdd, value: 'true' },
+            { filter: filterIndex, value: '1' },
+            { filter: filterIndex, value: undefined },
+            { filter: filterOdd, value: undefined }
+          ],
+          component.data
+        );
+
+        // a selected filter should be removed if it's selected again
+        expectColumnFiltered(
+          [
+            { filter: filterOdd, value: 'true' },
+            { filter: filterIndex, value: '1' },
+            { filter: filterIndex, value: '1' }
+          ],
+          [
+            { a: 1, b: 10, c: true },
+            { a: 3, b: 30, c: true },
+            { a: 5, b: 50, c: true },
+            { a: 7, b: 70, c: true },
+            { a: 9, b: 90, c: true }
+          ]
+        );
+      });
+
+      it('should search from filtered rows', () => {
+        expectColumnFiltered(
+          [{ filter: filterOdd, value: 'true' }],
+          [{ a: 9, b: 90, c: true }],
+          '9'
+        );
+
+        // Clear should work
+        expect(component.rows).toEqual(component.data);
+      });
+    });
+
+    describe('with custom columns', () => {
+      beforeEach(() => {
+        // create a new additional column in data
+        for (let i = 0; i < component.data.length; i++) {
+          const row = component.data[i];
+          row['d'] = row.a;
+        }
+        // create a custom column filter
+        component.extraFilterableColumns = [
+          {
+            name: 'd less than 5',
+            prop: 'd',
+            filterOptions: ['yes', 'no'],
+            filterInitValue: 'yes',
+            filterPredicate: (row, value) => {
+              if (value === 'yes') {
+                return row.d < 5;
+              } else {
+                return row.d >= 5;
+              }
+            }
+          }
+        ];
+        component.initColumnFilters();
+        component.updateColumnFilterOptions();
+        filterIndex = component.columnFilters[0];
+        filterOdd = component.columnFilters[1];
+        filterCustom = component.columnFilters[2];
+      });
+
+      it('should have filters initialized', () => {
+        expect(component.columnFilters.length).toBe(3);
+        expectColumnFilterCreated(filterCustom, 'd', ['yes', 'no'], {
+          raw: 'yes',
+          formatted: 'yes'
+        });
+        component.useData();
+        expect(component.rows).toEqual(_.slice(component.data, 0, 5));
+      });
+
+      it('should remove filters', () => {
+        expectColumnFiltered([{ filter: filterCustom, value: 'no' }], _.slice(component.data, 5));
+      });
+    });
+  });
+
   describe('test search', () => {
     const expectSearch = (keyword: string, expectedResult: object[]) => {
       component.search = keyword;
       component.updateFilter();
       expect(component.rows).toEqual(expectedResult);
-      component.updateFilter(true);
+      component.onClearSearch();
     };
 
     describe('searchableObjects', () => {
@@ -238,7 +407,7 @@ describe('TableComponent', () => {
       component.search = '3';
       component.updateFilter();
       expect(component.rows.length).toBe(1);
-      component.updateFilter(true);
+      component.onClearSearch();
       expect(component.rows.length).toBe(10);
     });
   });
index 0cb500440b57aae310a58dca5ac882d6070ce404..cbde65c4aff5571f8a4a70ab1e4ae6d3b7260788 100644 (file)
@@ -10,6 +10,7 @@ import {
   OnDestroy,
   OnInit,
   Output,
+  PipeTransform,
   TemplateRef,
   ViewChild
 } from '@angular/core';
@@ -20,12 +21,15 @@ import {
   SortPropDir,
   TableColumnProp
 } from '@swimlane/ngx-datatable';
+import { getterForProp } from '@swimlane/ngx-datatable/release/utils';
 import * as _ from 'lodash';
 import { Observable, timer as observableTimer } from 'rxjs';
 
 import { Icons } from '../../../shared/enum/icons.enum';
 import { CellTemplate } from '../../enum/cell-template.enum';
 import { CdTableColumn } from '../../models/cd-table-column';
+import { CdTableColumnFilter } from '../../models/cd-table-column-filter';
+import { CdTableColumnFiltersChange } from '../../models/cd-table-column-filters-change';
 import { CdTableFetchDataContext } from '../../models/cd-table-fetch-data-context';
 import { CdTableSelection } from '../../models/cd-table-selection';
 import { CdUserConfig } from '../../models/cd-user-config';
@@ -122,6 +126,10 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
   @Input()
   customCss?: { [css: string]: number | string | ((any) => boolean) };
 
+  // Columns that aren't displayed but can be used as filters
+  @Input()
+  extraFilterableColumns: CdTableColumn[] = [];
+
   /**
    * Should be a function to update the input data if undefined nothing will be triggered
    *
@@ -145,6 +153,16 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
   @Output()
   updateSelection = new EventEmitter();
 
+  /**
+   * This should be defined if you need access to the applied column filters.
+   *
+   * Each time the column filters changes, this will be triggered and
+   * the column filters change event will be sent.
+   *
+   * @memberof TableComponent
+   */
+  @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>();
+
   /**
    * Use this variable to access the selected row(s).
    */
@@ -176,6 +194,14 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
   // table columns after the browser window has been resized.
   private currentWidth: number;
 
+  columnFilters: CdTableColumnFilter[] = [];
+  selectedFilter: CdTableColumnFilter;
+  get columnFiltered(): boolean {
+    return _.some(this.columnFilters, (filter) => {
+      return filter.value !== undefined;
+    });
+  }
+
   constructor(private ngZone: NgZone, private cdRef: ChangeDetectorRef) {}
 
   static prepareSearch(search: string) {
@@ -222,6 +248,8 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
 
     this.initCheckboxColumn();
     this.filterHiddenColumns();
+    this.initColumnFilters();
+    this.updateColumnFilterOptions();
     // 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
@@ -339,6 +367,112 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
     this.tableColumns = this.columns.filter((c) => !c.isHidden);
   }
 
+  initColumnFilters() {
+    let filterableColumns = _.filter(this.columns, { filterable: true });
+    filterableColumns = [...filterableColumns, ...this.extraFilterableColumns];
+    this.columnFilters = filterableColumns.map((col: CdTableColumn) => {
+      return {
+        column: col,
+        options: [],
+        value: col.filterInitValue
+          ? this.createColumnFilterOption(col.filterInitValue, col.pipe)
+          : undefined
+      };
+    });
+    this.selectedFilter = _.first(this.columnFilters);
+  }
+
+  private createColumnFilterOption(
+    value: any,
+    pipe?: PipeTransform
+  ): { raw: string; formatted: string } {
+    return {
+      raw: _.toString(value),
+      formatted: pipe ? pipe.transform(value) : _.toString(value)
+    };
+  }
+
+  updateColumnFilterOptions() {
+    // update all possible values in a column
+    this.columnFilters.forEach((filter) => {
+      let values: any[] = [];
+
+      if (_.isUndefined(filter.column.filterOptions)) {
+        // only allow types that can be easily converted into string
+        const pre = _.filter(_.map(this.data, filter.column.prop), (v) => {
+          return (_.isString(v) && v !== '') || _.isBoolean(v) || _.isFinite(v) || _.isDate(v);
+        });
+        values = _.sortedUniq(pre.sort());
+      } else {
+        values = filter.column.filterOptions;
+      }
+
+      const options = values.map((v) => this.createColumnFilterOption(v, filter.column.pipe));
+
+      // In case a previous value is not available anymore
+      if (filter.value && _.isUndefined(_.find(options, { raw: filter.value.raw }))) {
+        filter.value = undefined;
+      }
+
+      filter.options = options;
+    });
+  }
+
+  onSelectFilter(filter: CdTableColumnFilter) {
+    this.selectedFilter = filter;
+  }
+
+  onChangeFilter(filter: CdTableColumnFilter, option?: { raw: string; formatted: string }) {
+    filter.value = _.isEqual(filter.value, option) ? undefined : option;
+    this.updateFilter();
+  }
+
+  doColumnFiltering() {
+    const appliedFilters: CdTableColumnFiltersChange['filters'] = [];
+    let data = [...this.data];
+    let dataOut = [];
+    this.columnFilters.forEach((filter) => {
+      if (filter.value === undefined) {
+        return;
+      }
+      appliedFilters.push({
+        name: filter.column.name,
+        prop: filter.column.prop,
+        value: filter.value
+      });
+      // Separate data to filtered and filtered-out parts.
+      const parts = _.partition(data, (row) => {
+        // Use getter from ngx-datatable to handle props like 'sys_api.size'
+        const valueGetter = getterForProp(filter.column.prop);
+        const value = valueGetter(row, filter.column.prop);
+        if (_.isUndefined(filter.column.filterPredicate)) {
+          // By default, test string equal
+          return `${value}` === filter.value.raw;
+        } else {
+          // Use custom function to filter
+          return filter.column.filterPredicate(row, filter.value.raw);
+        }
+      });
+      data = parts[0];
+      dataOut = [...dataOut, ...parts[1]];
+    });
+
+    this.columnFiltersChanged.emit({
+      filters: appliedFilters,
+      data: data,
+      dataOut: dataOut
+    });
+
+    // Remove the selection if previously-selected rows are filtered out.
+    _.forEach(this.selection.selected, (selectedItem) => {
+      if (_.find(data, { [this.identifier]: selectedItem[this.identifier] }) === undefined) {
+        this.selection = new CdTableSelection();
+        this.onSelect(this.selection);
+      }
+    });
+    return data;
+  }
+
   ngOnDestroy() {
     if (this.reloadSubscriber) {
       this.reloadSubscriber.unsubscribe();
@@ -439,11 +573,8 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
     if (!this.data) {
       return; // Wait for data
     }
-    if (this.search.length > 0) {
-      this.updateFilter();
-    } else {
-      this.rows = [...this.data];
-    }
+    this.updateColumnFilterOptions();
+    this.updateFilter();
     this.reset();
     this.updateSelected();
   }
@@ -525,15 +656,31 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
     this.userConfig.sorts = sorts;
   }
 
-  updateFilter(clearSearch = false) {
-    if (clearSearch) {
-      this.search = '';
+  onClearSearch() {
+    this.search = '';
+    this.updateFilter();
+  }
+
+  onClearFilters() {
+    this.columnFilters.forEach((filter) => {
+      filter.value = undefined;
+    });
+    this.selectedFilter = _.first(this.columnFilters);
+    this.updateFilter();
+  }
+
+  updateFilter() {
+    let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data;
+
+    if (this.search.length > 0) {
+      const columns = this.columns.filter((c) => c.cellTransformation !== CellTemplate.sparkline);
+      // update the rows
+      rows = this.subSearch(rows, TableComponent.prepareSearch(this.search), columns);
+      // Whenever the filter changes, always go back to the first page
+      this.table.offset = 0;
     }
-    const columns = this.columns.filter((c) => c.cellTransformation !== CellTemplate.sparkline);
-    // update the rows
-    this.rows = this.subSearch(this.data, TableComponent.prepareSearch(this.search), columns);
-    // Whenever the filter changes, always go back to the first page
-    this.table.offset = 0;
+
+    this.rows = rows;
   }
 
   subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]) {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts
new file mode 100644 (file)
index 0000000..ccdbe82
--- /dev/null
@@ -0,0 +1,7 @@
+import { CdTableColumn } from './cd-table-column';
+
+export interface CdTableColumnFilter {
+  column: CdTableColumn;
+  options: { raw: string; formatted: string }[]; // possible options of a filter
+  value?: { raw: string; formatted: string }; // selected option
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts
new file mode 100644 (file)
index 0000000..40327b1
--- /dev/null
@@ -0,0 +1,22 @@
+import { TableColumnProp } from '@swimlane/ngx-datatable/release/types/table-column.type';
+
+export interface CdTableColumnFiltersChange {
+  /**
+   * Applied filters.
+   */
+  filters: {
+    name: string;
+    prop: TableColumnProp;
+    value: { raw: string; formatted: string };
+  }[];
+
+  /**
+   * Filtered data.
+   */
+  data: any[];
+
+  /**
+   * Filtered out data.
+   */
+  dataOut: any[];
+}
index 64cd7db402ef6d44a1283ee9940f0cba553d1c71..4ed5fdd588fe4ddaadf51b92875699e49f9b8dc8 100644 (file)
@@ -7,4 +7,32 @@ export interface CdTableColumn extends TableColumn {
   isHidden?: boolean;
   prop: TableColumnProp; // Enforces properties to get sortable columns
   customTemplateConfig?: any; // Custom configuration used by cell templates.
+
+  /**
+   * Add a filter for the column if true.
+   *
+   * By default, options for the filter are deduced from values of the column.
+   */
+  filterable?: boolean;
+
+  /**
+   * Use these options for filter rather than deducing from values of the column.
+   *
+   * If there is a pipe function associated with the column, pipe function is applied
+   * to the options before displaying them.
+   */
+  filterOptions?: any[];
+
+  /**
+   * Default applied option, should be value in filterOptions.
+   */
+  filterInitValue?: any;
+
+  /**
+   * Specify a custom function for filtering.
+   *
+   * By default, the filter compares if values are string-equal with options. Specify
+   * a customize function if that's not desired. Return true to include a row.
+   */
+  filterPredicate?: (row: any, value: any) => boolean;
 }