From 0834f9a3cda8c8fd41795e04e89c9dd61fc97fa9 Mon Sep 17 00:00:00 2001 From: Kiefer Chang Date: Tue, 31 Dec 2019 14:32:26 +0800 Subject: [PATCH] mgr/dashboard: Add column filtering feature to cd-table 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 --- .../datatable/table/table.component.html | 73 +++++++- .../datatable/table/table.component.scss | 4 + .../datatable/table/table.component.spec.ts | 177 +++++++++++++++++- .../shared/datatable/table/table.component.ts | 173 +++++++++++++++-- .../shared/models/cd-table-column-filter.ts | 7 + .../models/cd-table-column-filters-change.ts | 22 +++ .../src/app/shared/models/cd-table-column.ts | 28 +++ 7 files changed, 466 insertions(+), 18 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts 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 43cea2d6935..b1200fda06c 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 @@ -3,6 +3,7 @@ i18n>Failed to load data.
+
@@ -21,6 +22,53 @@ + + + +
+
+ +
+ + + {{ filter.column.name }}: {{ filter.value.formatted }} + + + + + + + Clear filters + +
+ +
{ 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); }); }); 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 0cb500440b5..cbde65c4aff 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 @@ -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(); + /** * 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 index 00000000000..ccdbe82fc1b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts @@ -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 index 00000000000..40327b1ef2b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts @@ -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[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts index 64cd7db402e..4ed5fdd588f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts @@ -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; } -- 2.47.3