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>
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">
<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"
min-width: 85px;
padding-right: 8px;
}
+ .filter-chips {
+ float: right;
+ padding: 0 8px;
+ }
}
::ng-deep .cd-datatable {
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';
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 }
];
});
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', () => {
component.search = '3';
component.updateFilter();
expect(component.rows.length).toBe(1);
- component.updateFilter(true);
+ component.onClearSearch();
expect(component.rows.length).toBe(10);
});
});
OnDestroy,
OnInit,
Output,
+ PipeTransform,
TemplateRef,
ViewChild
} from '@angular/core';
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';
@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
*
@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).
*/
// 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) {
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
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();
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();
}
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[]) {
--- /dev/null
+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
+}
--- /dev/null
+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[];
+}
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;
}