</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
<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>
});
});
+ 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;
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';
@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();
@Output()
isCellEditingEvent = new EventEmitter<boolean>();
+ @Output()
+ customFilterChange = new EventEmitter<CdTableCustomColumnFilter[]>();
+
/**
* Use this variable to access the selected row(s).
*/
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);
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(
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() {
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() {
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,
filter.value = undefined;
});
this.selectedFilter = _.first(this.columnFilters);
+ this.customFilters = [];
+ this.stagedCustomFilters = [];
+
+ if (this.customFilter) {
+ this.addCustomFilter();
+ }
+
this.updateFilter();
this.initSelectedColumnFilters();
}
}
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(