]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add custom filtering rules to the table 69378/head
authorNizamudeen A <nia@redhat.com>
Wed, 10 Jun 2026 05:21:33 +0000 (10:51 +0530)
committerNizamudeen A <nia@redhat.com>
Wed, 17 Jun 2026 05:35:01 +0000 (11:05 +0530)
```
<cd-table #table
                id="pool-list"
                [data]="pools"
                [columns]="columns"
                selectionType="single"
                [hasDetails]="true"
                [status]="tableStatus"
                [autoReload]="-1"
                (fetchData)="taskListService.fetch()"
                (setExpandedRow)="setExpandedRow($event)"
                (updateSelection)="updateSelection($event)"
                [customFilter]="true" # set this to true
                (customFilterChange)="onCustomFilterChange($event)" #
get the new rules from here>
```

Fixes: https://tracker.ceph.com/issues/77290
Signed-off-by: Nizamudeen A <nia@redhat.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/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts

index 1460f9ca77a8da3861435d9b6c98e70afba08721..f5a7297b1676fd1593dc554f80156b45680e950a 100644 (file)
@@ -51,7 +51,7 @@
       </cds-table-toolbar-search>
       <!-- end search -->
       <!-- column filters -->
-       @if (columnFilters.length !== 0) {
+       @if (columnFilters.length !== 0 || customFilter) {
         <div cdsPopover
              [isOpen]="openFilterPopover"
              [autoAlign]="true"
                  class="cds--toolbar-action__icon"></svg>
           </cds-icon-button>
           <cds-popover-content [cdsLayer]="0">
+            <div cdsStack="horizontal" class="filter--header">
+              <span class="cds--type-heading-03" i18n>Filters</span>
+              <cds-icon-button kind="ghost"
+                              size="sm"
+                              (click)="toggleFilterPopover()">
+                <cd-icon type="destroy"></cd-icon>
+              </cds-icon-button>
+            </div>
             <div
               cdsGrid
               [fullWidth]="true"
               [condensed]="true"
               [narrow]="true">
+            @if (columnFilters.length !== 0) {
             @for (filter of columnFilters; track filter.column.name; let i = $index) {
             <div
               cdsRow
@@ -84,7 +93,7 @@
                 <cds-text-label>Filter
                   <input cdsText
                          readonly
-                         size="sm"
+                         size="md"
                          [id]="'filter_key_' + i"
                          [name]="'filter_key_' + i"
                          [value]="filter.column.name">
                 <cds-select (valueChange)="onChangeFilter($event, filter)"
                             [id]="'filter_value_' + i"
                             label="Value"
-                            size="sm"
+                            size="md"
                             [attr.data-testid]="'filter-select-' + filter.column.name"
                             i18n-label>
                   <option i18n>Any</option>
                 </cds-select>
               </div>
             </div>
+          }}
+          @if (customFilter) {
+          @for (filter of stagedCustomFilters; track filter.id; let i = $index) {
+            @if (typeof customFilter === 'string') {
+              <div cdsRow
+                   class="cds-mt-4 cds--type-body-02">
+                Filter {{ customFilter }}:
+              </div>
+            }
+            <div cdsRow
+                 [cdsLayer]="1"
+                 class="cds-mt-4">
+              <div cdsCol
+                   [columnNumbers]="{sm: 4, md: 3, lg: 7}">
+                <cds-text-label>Key
+                  <input cdsText
+                         size="md"
+                         [id]="'filter_key_' + i"
+                         [value]="filter.key"
+                         (input)="filter.key = $any($event.target).value">
+                </cds-text-label>
+              </div>
+
+              <div cdsCol
+                   [columnNumbers]="{sm: 4, md: 3, lg: 7}">
+                <cds-text-label>Value
+                  <input cdsText
+                         size="md"
+                         [id]="'filter_value_' + i"
+                         [value]="filter.value"
+                         (input)="filter.value = $any($event.target).value">
+                </cds-text-label>
+            </div>
+
+            @if (i !== 0) {
+            <div cdsCol
+                 [columnNumbers]="{sm: 2, md: 2, lg: 2}"
+                class="cds-mt-6">
+              <cds-icon-button kind="ghost"
+                               size="sm"
+                               (click)="removeCustomFilter(filter.id)">
+                <cd-icon type="trash"></cd-icon>
+              </cds-icon-button>
+            </div>
+            }
+          </div>
+          }
+            @if (stagedCustomFilters.length < 5) {
+            <div cdsRow
+                class="cds-mt-4">
+              <div cdsCol>
+                <button
+                  cdsButton="tertiary"
+                  size="md"
+                  (click)="addCustomFilter()">Add new filter rule +</button>
+              </div>
+            </div>
+            }
           }
-          <div cdsRow
-               class="cds-mt-4 cds-mb-4">
+          </div>
+
+          <div class="filter--footer">
+            <button cdsButton="secondary"
+                    size="lg"
+                    data-testid="clear-filters"
+                    (click)="onClearFilters()">
+              <ng-container i18n>Clear all</ng-container>
+            </button>
 
             <button cdsButton="primary"
-                    size="sm"
+                    size="lg"
                     data-testid="apply-filters"
+                    [disabled]="isApplyFilterDisabled"
                     (click)="onSubmitFilter()">
-              <ng-container i18n>Apply filters</ng-container>
+              <ng-container i18n>Apply</ng-container>
             </button>
           </div>
-          </div>
           </cds-popover-content>
         </div>
       }
         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) {
             <cds-tag-filter
               closeButtonLabel="Clear"
               i18n-closeButtonLabel
               (close)="onRemoveFilter(filter)">
-              {{ filter.column.name }}={{ filter.value.formatted }}
+              {{ filter.name }}={{ filter.value }}
             </cds-tag-filter>
           }
         }
               <div
                 cdsStack="vertical"
                 [gap]="2">
-              @for (filter of activeFilters | slice:3; track filter.column.name) {
+              @for (filter of activeFilters | slice:3; track filter.id) {
                 <cds-tag-filter
                   closeButtonLabel="Clear"
                   i18n-closeButtonLabel
                   (close)="onRemoveFilter(filter)">
-                  {{ filter.column.name }}={{ filter.value.formatted }}
+                  {{ filter.name }}={{ filter.value }}
                 </cds-tag-filter>
               }
               </div>
index 611a3b9e93f6a957a9e67fbc2a51cc05ee86e1d9..a4357ed13b9d62e842826a577b28d15a6bf2dd0d 100644 (file)
@@ -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%;
+  }
 }
index 889439dcc6e9a29e0646604f8f66eba2eb78128c..e92aafaf71d98e49f5545dfa71bfa9b53ca56e48 100644 (file)
@@ -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;
index 7d4c8284784f5f708543fc1d43a5ea3c98f0d975..a261212f324d7271222ec8689bd5f659a9b41dcf 100644 (file)
@@ -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<boolean>();
 
+  @Output()
+  customFilterChange = new EventEmitter<CdTableCustomColumnFilter[]>();
+
   /**
    * 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<string | number, TableItem[]>();
   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(
index 60de24cc8ed41bb96e6ccaae68df443ec946ae05..c5190555b6a02294f92f01a4483d3c1baeb953b4 100644 (file)
@@ -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 = {
index d4d45ac91ba4c144defe928819d5823a476b846f..c02817e9265738d9341a13b9454a61d0ce7bf875 100644 (file)
@@ -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;
+}