Merge pull request #69378 from rhcs-dashboard/custom-filter

mgr/dashboard: add custom filtering rules to the table

Reviewed-by: Afreen Misbah <afreen@ibm.com>
Reviewed-by: Naman Munet <nmunet@redhat.com>
This commit is contained in:
Nizamudeen A 2026-06-17 14:30:15 +05:30 committed by GitHub
commit 4fdb541ea6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 354 additions and 29 deletions

View 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"
@ -66,11 +66,20 @@
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">
@ -98,7 +107,7 @@
<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>
@ -112,18 +121,83 @@
</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>
}
<div cdsRow
class="cds-mt-4 cds-mb-4">
@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>
<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>
}
@ -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) {
<cds-tag-filter
closeButtonLabel="Clear"
i18n-closeButtonLabel
(close)="onRemoveFilter(filter)">
{{ filter.column.name }}={{ filter.value.formatted }}
{{ filter.name }}={{ filter.value }}
</cds-tag-filter>
}
}
@ -203,12 +277,12 @@
<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>

View 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%;
}
}

View 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;

View 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(

View 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 = {

View 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;
}