mirror of
https://github.com/ceph/ceph
synced 2026-08-02 07:03:18 +00:00
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:
commit
4fdb541ea6
@ -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>
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user