From: Nizamudeen A Date: Wed, 10 Jun 2026 05:21:33 +0000 (+0530) Subject: mgr/dashboard: add custom filtering rules to the table X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=c7c62383d8d2d27aca82d9bfa0a32b809817d7b4;p=ceph.git mgr/dashboard: add custom filtering rules to the table ``` ``` Fixes: https://tracker.ceph.com/issues/77290 Signed-off-by: Nizamudeen A --- 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 1460f9ca77a..f5a7297b167 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 @@ -51,7 +51,7 @@ - @if (columnFilters.length !== 0) { + @if (columnFilters.length !== 0 || customFilter) {
+
+ Filters + + + +
+ @if (columnFilters.length !== 0) { @for (filter of columnFilters; track filter.column.name; let i = $index) {
Filter @@ -98,7 +107,7 @@ @@ -112,18 +121,83 @@
+ }} + @if (customFilter) { + @for (filter of stagedCustomFilters; track filter.id; let i = $index) { + @if (typeof customFilter === 'string') { +
+ Filter {{ customFilter }}: +
+ } +
+
+ Key + + +
+ +
+ Value + + +
+ + @if (i !== 0) { +
+ + + +
+ } +
+ } + @if (stagedCustomFilters.length < 5) { +
+
+ +
+
+ } } -
+
+ + -
} @@ -183,13 +257,13 @@ class="align-items-center" cdsStack="horizontal" [gap]="2"> - @for (filter of activeFilters | slice:0:3; track filter.column.name) { + @for (filter of activeFilters | slice:0:3; track filter.id) { @if (filter.value) { - {{ filter.column.name }}={{ filter.value.formatted }} + {{ filter.name }}={{ filter.value }} } } @@ -203,12 +277,12 @@
- @for (filter of activeFilters | slice:3; track filter.column.name) { + @for (filter of activeFilters | slice:3; track filter.id) { - {{ filter.column.name }}={{ filter.value.formatted }} + {{ filter.name }}={{ filter.value }} }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss index 611a3b9e93f..a4357ed13b9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss @@ -111,8 +111,22 @@ tr:hover .edit-btn { white-space: nowrap; } -.filter-wrapper { - display: inline-flex; +.filter--header { align-items: center; - gap: 0.5rem; + border-bottom: 1px solid var(--cds-ui-03); + display: flex; + justify-content: space-between; + padding: var(--cds-spacing-04); +} + +.filter--footer { + display: flex; + margin-top: var(--cds-spacing-08); + width: 100%; + + button { + flex: 1; + justify-content: center; + max-width: 50%; + } } 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 889439dcc6e..e92aafaf71d 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 @@ -280,6 +280,57 @@ describe('TableComponent', () => { }); }); + describe('test custom filtering', () => { + beforeEach(() => { + component.customFilter = true; + component.customFilters = []; + component.stagedCustomFilters = []; + spyOn(component.customFilterChange, 'emit'); + spyOn(component, 'updateFilter'); + }); + + it('should toggle popover and add an empty rule', () => { + component.toggleFilterPopover(); + + expect(component.openFilterPopover).toBe(true); + expect(component.stagedCustomFilters.length).toBe(1); + expect(component.stagedCustomFilters[0]).toEqual({ id: 0, key: '', value: '' }); + + // toggling again should close but not add a new rule + component.toggleFilterPopover(); + expect(component.openFilterPopover).toBe(false); + expect(component.stagedCustomFilters.length).toBe(1); + }); + + it('should manually add new custom filters', () => { + component.addCustomFilter(); + component.addCustomFilter(); + expect(component.stagedCustomFilters.length).toBe(2); + expect(component.stagedCustomFilters[0]).toEqual({ id: 0, key: '', value: '' }); + expect(component.stagedCustomFilters[1]).toEqual({ id: 1, key: '', value: '' }); + }); + + it('should remove a filter by its id', () => { + component.addCustomFilter(); + component.addCustomFilter(); + component.addCustomFilter(); + + component.removeCustomFilter(1); + expect(component.stagedCustomFilters.length).toBe(2); + expect(component.stagedCustomFilters[0]).toEqual({ id: 0, key: '', value: '' }); + expect(component.stagedCustomFilters[1]).toEqual({ id: 2, key: '', value: '' }); + }); + + it('should emit custom filters on submit', () => { + component.addCustomFilter(); + component.stagedCustomFilters[0] = { id: 0, key: 'foo', value: 'bar' }; + component.onSubmitFilter(); + expect(component.customFilterChange.emit).toHaveBeenCalledWith([ + { id: 0, key: 'foo', value: 'bar' } + ]); + }); + }); + describe('test search', () => { const expectSearch = (keyword: string, expectedResult: object[]) => { component.search = keyword; 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 7d4c8284784..a261212f324 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 @@ -26,10 +26,12 @@ import { Icons, IconSize, EMPTY_STATE_IMAGE } from '~/app/shared/enum/icons.enum import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { + CdTableActiveColumnFilter, CdTableColumnFilter, CdTableColumnFilterOption, CdTableColumnSelectedFilter, - CdTableColumnStagedFilter + CdTableColumnStagedFilter, + CdTableCustomColumnFilter } from '~/app/shared/models/cd-table-column-filter'; import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change'; import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; @@ -197,6 +199,14 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr @Input() extraFilterableColumns: CdTableColumn[] = []; + /* + Used to set custom filters on the table. + boolean - if you just want to enable custom filters with default settings + string - if you want to enable custom filters and set a specific col prop to be used as filter name + */ + @Input() + customFilter: boolean | string = false; + @Input() status = new TableStatus(); @@ -293,6 +303,9 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr @Output() isCellEditingEvent = new EventEmitter(); + @Output() + customFilterChange = new EventEmitter(); + /** * Use this variable to access the selected row(s). */ @@ -428,10 +441,15 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr columnFilters: CdTableColumnFilter[] = []; selectedFilter: CdTableColumnFilter; get columnFiltered(): boolean { - return _.some(this.columnFilters, (filter: any) => { + const hasStandardFilters = _.some(this.columnFilters, (filter: any) => { return filter.value !== undefined; }); + const hasCustomFilters = this.customFilters && this.customFilters.length > 0; + + return hasStandardFilters || hasCustomFilters; } + customFilters: CdTableCustomColumnFilter[] = []; + private nextFilterId = 0; private previousRows = new Map(); private debouncedSearch = this.reloadData.bind(this); @@ -442,9 +460,32 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr openFilterPopover = false; stagedFilters: CdTableColumnStagedFilter = {}; selectedFilters: CdTableColumnSelectedFilter = {}; + stagedCustomFilters: CdTableCustomColumnFilter[] = []; get activeFilters() { - return this.columnFilters.filter((filter) => filter.value); + const standard: CdTableActiveColumnFilter[] = this.columnFilters + .filter((filter) => filter.value) + .map((filter) => ({ + isCustom: false, + id: `std_${filter.column.name}`, + name: filter.column.name, + value: filter.value.formatted, + original: filter + })); + + const custom: CdTableActiveColumnFilter[] = (this.customFilters || []).map((filter) => ({ + isCustom: true, + id: `cst_${filter.id}`, + name: filter.key, + value: filter.value, + original: filter + })); + + return [...standard, ...custom]; + } + + get isApplyFilterDisabled(): boolean { + return this.stagedCustomFilters.some((filter) => !filter.key.trim() || !filter.value.trim()); } constructor( @@ -769,6 +810,27 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr toggleFilterPopover() { this.openFilterPopover = !this.openFilterPopover; + + if (this.openFilterPopover) { + this.stagedCustomFilters = _.cloneDeep(this.customFilters || []); + + if (this.customFilter && this.customFilters.length === 0) { + this.addCustomFilter(); + } + } + } + + addCustomFilter() { + this.stagedCustomFilters = [ + ...this.stagedCustomFilters, + { id: this.nextFilterId++, key: '', value: '' } + ]; + } + + removeCustomFilter(idToRemove: number) { + this.stagedCustomFilters = this.stagedCustomFilters.filter( + (filter) => filter.id !== idToRemove + ); } initColumnFilters() { @@ -852,21 +914,41 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr this.selectedFilter = filter; } }); - this.stagedFilters = {}; + + if (this.customFilter) { + this.customFilters = this.stagedCustomFilters.filter( + (customFilter: CdTableCustomColumnFilter) => + customFilter.key.trim() !== '' && customFilter.value.trim() !== '' + ); + this.customFilterChange.emit(this.customFilters); + } + this.updateFilter(); this.openFilterPopover = false; } - onRemoveFilter(filter: CdTableColumnFilter) { - const filterName = filter.column.name; - filter.value = undefined; - this.selectedFilters[filterName] = undefined; - delete this.stagedFilters[filterName]; - if (this.selectedFilter?.column.name === filterName) { - this.selectedFilter = undefined; + onRemoveFilter(filter: CdTableActiveColumnFilter) { + if (filter.isCustom) { + this.customFilters = (this.customFilters || []).filter( + (customFilter: CdTableCustomColumnFilter) => customFilter.id !== filter.original.id + ); + this.stagedCustomFilters = this.stagedCustomFilters.filter( + (customFilter: CdTableCustomColumnFilter) => customFilter.id !== filter.original.id + ); + + this.customFilterChange.emit(this.customFilters); + this.updateFilter(); + } else { + const filterName = filter.original.name; + filter.original.value = undefined; + this.selectedFilters[filterName] = undefined; + delete this.stagedFilters[filterName]; + if (this.selectedFilter?.column.name === filterName) { + this.selectedFilter = undefined; + } + this.updateFilter(); } - this.updateFilter(); } doColumnFiltering() { @@ -897,6 +979,85 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr dataOut = [...dataOut, ...parts[1]]; }); + if (this.customFilter && this.customFilters.length > 0) { + this.customFilters.forEach((customFilter: CdTableCustomColumnFilter) => { + const matchingColumn = this.localColumns.find( + (col: CdTableColumn) => + col.name && col.name.toLowerCase() === customFilter.key.trim().toLowerCase() + ); + const resolvedProp = matchingColumn + ? (matchingColumn.prop as string) + : customFilter.key.trim(); + const displayKeyName = matchingColumn ? matchingColumn.name : customFilter.key; + + appliedFilters.push({ + name: displayKeyName, + prop: typeof this.customFilter === 'string' ? this.customFilter : resolvedProp, + value: { raw: customFilter.value, formatted: customFilter.value } + }); + }); + + // grouping filters by keys so we can filter the data with all the given filters + const groupedFilters = _.groupBy( + this.customFilters, + (customFilter: CdTableCustomColumnFilter) => customFilter.key.trim().toLowerCase() + ); + const filterGroups = Object.values(groupedFilters); + + const parts = _.partition(data, (row) => { + return filterGroups.every((group) => { + return group.some((customFilter: CdTableCustomColumnFilter) => { + const matchingColumn = this.localColumns.find( + (col: CdTableColumn) => + col.name && col.name.toLowerCase() === customFilter.key.trim().toLowerCase() + ); + + const filterKey = matchingColumn + ? (matchingColumn.prop as string) + : customFilter.key.trim(); + const rawKey = customFilter.key.trim(); + const filterValue = customFilter.value; + + if (_.has(row, filterKey)) { + if (`${_.get(row, filterKey)}` === filterValue) return true; + } + + // this is only when the customFilter is for a column that has a + // key-value pair for its values. + // eg: tags of objects in s3. + if (typeof this.customFilter === 'string' && _.has(row, this.customFilter)) { + const nestedData = _.get(row, this.customFilter); + + // when the data is an array of objects or strings, + // we need to check each element in the array. + if (_.isArray(nestedData)) { + return _.some(nestedData, (key) => { + if (_.isObject(key) && _.has(key, rawKey)) { + return `${(key as any)[rawKey]}` === filterValue; + } + if (typeof key === 'string') { + return key === `${rawKey}:${filterValue}` || key === `${rawKey}=${filterValue}`; + } + return false; + }); + } + + // if object is a simple dict + if (_.isObject(nestedData) && !_.isArray(nestedData)) { + if (_.has(nestedData, rawKey)) { + if (`${(nestedData as any)[rawKey]}` === filterValue) return true; + } + } + } + return false; + }); + }); + }); + + data = parts[0]; + dataOut = [...dataOut, ...parts[1]]; + } + this.columnFiltersChanged.emit({ filters: appliedFilters, data: data, @@ -1340,6 +1501,13 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr filter.value = undefined; }); this.selectedFilter = _.first(this.columnFilters); + this.customFilters = []; + this.stagedCustomFilters = []; + + if (this.customFilter) { + this.addCustomFilter(); + } + this.updateFilter(); this.initSelectedColumnFilters(); } @@ -1357,7 +1525,7 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr } this.rows = this.data; } else { - let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data; + let rows = this.doColumnFiltering(); if (this.search.length > 0 && rows?.length) { const columns = this.localColumns.filter( diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index 60de24cc8ed..c5190555b6a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -174,7 +174,8 @@ export const ICON_TYPE = { leftArrow: 'caret--left', rightArrow: 'caret--right', locked: 'locked', - cloudMonitoring: 'cloud--monitoring' + cloudMonitoring: 'cloud--monitoring', + trash: 'trash-can' } as const; export const EMPTY_STATE_IMAGE = { 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 index d4d45ac91ba..c02817e9265 100644 --- 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 @@ -4,6 +4,8 @@ export interface CdTableColumnFilter { column: CdTableColumn; options: CdTableColumnFilterOption[]; // possible options of a filter value?: CdTableColumnFilterOption; // selected option + id?: string; + name?: string; } export interface CdTableColumnStagedFilter { @@ -18,3 +20,18 @@ export interface CdTableColumnFilterOption { raw: string; formatted: string; } + +export interface CdTableCustomColumnFilter { + id: number; + key: string; + value: string; + name?: string; +} + +export interface CdTableActiveColumnFilter { + id: string; + name: string; + value: string; + isCustom: boolean; + original: CdTableColumnFilter | CdTableCustomColumnFilter; +}