From c87f1fd0647f6fa794725b49be1e39fb091d8307 Mon Sep 17 00:00:00 2001 From: kurilova Date: Tue, 8 Sep 2026 10:27:36 +0000 Subject: [PATCH 1/2] Allow to add several quick search filters --- modules/ui/src/app/mocks/reports.mock.ts | 4 +- modules/ui/src/app/model/filters.spec.ts | 2 +- modules/ui/src/app/model/filters.ts | 2 +- .../components/search/search.component.html | 20 +++- .../search/search.component.spec.ts | 107 ++++++++++++++++-- .../components/search/search.component.ts | 73 ++++++++++-- .../app/pages/reports/reports.component.html | 3 +- .../pages/reports/reports.component.spec.ts | 4 +- .../app/pages/reports/reports.component.ts | 4 +- .../app/pages/reports/reports.store.spec.ts | 33 +++++- .../ui/src/app/pages/reports/reports.store.ts | 48 +++++--- 11 files changed, 251 insertions(+), 49 deletions(-) diff --git a/modules/ui/src/app/mocks/reports.mock.ts b/modules/ui/src/app/mocks/reports.mock.ts index 6f9e56889..7562881e7 100644 --- a/modules/ui/src/app/mocks/reports.mock.ts +++ b/modules/ui/src/app/mocks/reports.mock.ts @@ -215,7 +215,7 @@ export const FILTERS = { deviceFirmware: 'test', results: ['test'], dateRange: 'test', - quickSearch: 'test', + quickSearch: ['test'], location: 'test', linuxEnv: 'test', pythonVersion: 'test', @@ -226,7 +226,7 @@ export const EMPTY_FILTERS = { deviceFirmware: '', results: [''], dateRange: '', - quickSearch: '', + quickSearch: [], location: '', linuxEnv: '', pythonVersion: '', diff --git a/modules/ui/src/app/model/filters.spec.ts b/modules/ui/src/app/model/filters.spec.ts index dcc5ee08e..960fc06b9 100644 --- a/modules/ui/src/app/model/filters.spec.ts +++ b/modules/ui/src/app/model/filters.spec.ts @@ -89,7 +89,7 @@ describe('Filters Models', () => { expect(filters.deviceFirmware).toBe(''); expect(filters.results).toEqual([]); expect(filters.dateRange).toBe(''); - expect(filters.quickSearch).toBe(''); + expect(filters.quickSearch).toEqual([]); expect(filters.location).toBe(''); expect(filters.linuxEnv).toBe(''); expect(filters.pythonVersion).toBe(''); diff --git a/modules/ui/src/app/model/filters.ts b/modules/ui/src/app/model/filters.ts index cc7bb7f47..dbdb96e43 100644 --- a/modules/ui/src/app/model/filters.ts +++ b/modules/ui/src/app/model/filters.ts @@ -93,7 +93,7 @@ export class Filters { deviceFirmware = ''; results: string[] = []; dateRange: DateRange | string = ''; - quickSearch = ''; + quickSearch: string[] = []; location = ''; linuxEnv = ''; pythonVersion = ''; diff --git a/modules/ui/src/app/pages/reports/components/search/search.component.html b/modules/ui/src/app/pages/reports/components/search/search.component.html index 8c4cc74d9..889dcb518 100644 --- a/modules/ui/src/app/pages/reports/components/search/search.component.html +++ b/modules/ui/src/app/pages/reports/components/search/search.component.html @@ -42,7 +42,7 @@ (menuClosed)="isMenuOpened = false">
- @for (item of getActiveFilters(); track item.key) { + @for (item of getActiveFilters(); track getChipTrackId(item)) {
+ (click)=" + item.key === FilterName.QuickSearch + ? removeFilter(item.key, $event, item.value) + : removeFilter(item.key, $event) + " + (keydown.enter)=" + item.key === FilterName.QuickSearch + ? removeFilter(item.key, $event, item.value) + : removeFilter(item.key, $event) + " + (keydown.space)=" + item.key === FilterName.QuickSearch + ? removeFilter(item.key, $event, item.value) + : removeFilter(item.key, $event) + ">
diff --git a/modules/ui/src/app/pages/reports/components/search/search.component.spec.ts b/modules/ui/src/app/pages/reports/components/search/search.component.spec.ts index a1e2442a5..71a9e3fa6 100644 --- a/modules/ui/src/app/pages/reports/components/search/search.component.spec.ts +++ b/modules/ui/src/app/pages/reports/components/search/search.component.spec.ts @@ -63,7 +63,7 @@ describe('SearchComponent', () => { deviceFirmware: '1.0', results: ['Compliant'], dateRange: { start: '10/01/2024', end: '10/05/2024' }, - quickSearch: 'test', + quickSearch: ['test'], location: 'Data Center', linuxEnv: 'Ubuntu 24.04', pythonVersion: '3.11', @@ -172,7 +172,7 @@ describe('SearchComponent', () => { deviceFirmware: '1.0', results: ['Compliant'], dateRange: { start: '10/01/2024', end: '10/05/2024' }, - quickSearch: 'test', + quickSearch: ['test'], location: 'DC-1', linuxEnv: 'Ubuntu', pythonVersion: '3.11', @@ -241,7 +241,19 @@ describe('SearchComponent', () => { spyOn(component.filterCleared, 'emit'); component.removeFilter(FilterName.QuickSearch); - expect(component.filters.quickSearch).toBe(''); + expect(component.filters.quickSearch).toEqual([]); + expect(component.filterCleared.emit).toHaveBeenCalledWith( + component.filters + ); + }); + + it('should remove specific quickSearch chip when value is provided', () => { + component.filters.quickSearch = ['first', 'second', 'third']; + spyOn(component.filterCleared, 'emit'); + + component.removeFilter(FilterName.QuickSearch, undefined, 'second'); + + expect(component.filters.quickSearch).toEqual(['first', 'third']); expect(component.filterCleared.emit).toHaveBeenCalledWith( component.filters ); @@ -305,7 +317,7 @@ describe('SearchComponent', () => { expect(component.filters.deviceFirmware).toBe(''); expect(component.filters.results).toEqual([]); expect(component.filters.dateRange).toBe(''); - expect(component.filters.quickSearch).toBe(''); + expect(component.filters.quickSearch).toEqual([]); expect(component.filters.location).toBe(''); expect(component.filters.linuxEnv).toBe(''); expect(component.filters.pythonVersion).toBe(''); @@ -338,7 +350,7 @@ describe('SearchComponent', () => { component.onEnter(event); expect(event.preventDefault).toHaveBeenCalled(); - expect(component.filters.quickSearch).toBe('Raspberry Pi'); + expect(component.filters.quickSearch).toEqual(['Raspberry Pi']); expect(component.searchQueryChanged.emit).toHaveBeenCalledWith( 'Raspberry Pi' ); @@ -348,6 +360,39 @@ describe('SearchComponent', () => { expect(component.inputValue).toBe(''); }); + it('should add multiple search chips on subsequent Enters without replacing previous chip', () => { + spyOn(component.filterCleared, 'emit'); + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + + component.inputValue = 'first'; + component.onEnter(event); + + expect(component.filters.quickSearch).toEqual(['first']); + + component.inputValue = 'second'; + component.onEnter(event); + + expect(component.filters.quickSearch).toEqual(['first', 'second']); + + const active = component.getActiveFilters(); + const searchChips = active.filter( + item => item.key === FilterName.QuickSearch + ); + expect(searchChips.length).toBe(2); + expect(searchChips[0].value).toBe('first'); + expect(searchChips[1].value).toBe('second'); + }); + + it('should not add duplicate search chip if identical query is entered', () => { + component.filters.quickSearch = ['first']; + component.inputValue = 'first'; + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + component.onEnter(event); + + expect(component.filters.quickSearch).toEqual(['first']); + expect(component.inputValue).toBe(''); + }); + it('should not add search chip on Enter if input is empty or whitespace', () => { spyOn(component.filterCleared, 'emit'); spyOn(component.searchQueryChanged, 'emit'); @@ -366,7 +411,7 @@ describe('SearchComponent', () => { deviceFirmware: '1.0', results: [], dateRange: '', - quickSearch: '', + quickSearch: [], location: '', linuxEnv: '', pythonVersion: '', @@ -380,6 +425,19 @@ describe('SearchComponent', () => { expect(component.removeFilter).toHaveBeenCalledWith('deviceFirmware'); }); + it('should remove last quickSearch chip on Backspace when multiple exist', () => { + component.filters = { + ...new Filters(), + quickSearch: ['first', 'second'], + }; + component.inputValue = ''; + spyOn(component, 'removeFilter').and.callThrough(); + + component.onBackspace(); + + expect(component.filters.quickSearch).toEqual(['first']); + }); + it('should not remove last filter on Backspace when no active filters', () => { component.filters = new Filters(); component.inputValue = ''; @@ -396,7 +454,7 @@ describe('SearchComponent', () => { deviceFirmware: '', results: [], dateRange: '', - quickSearch: '', + quickSearch: [], location: '', linuxEnv: '', pythonVersion: '', @@ -481,7 +539,7 @@ describe('SearchComponent', () => { deviceFirmware: '', results: ['Compliant'], dateRange: '', - quickSearch: 'test', + quickSearch: ['test'], location: '', linuxEnv: '', pythonVersion: '', @@ -493,6 +551,37 @@ describe('SearchComponent', () => { expect(chips.length).toBe(3); }); + it('should render multiple search chips and remove only clicked chip from DOM', () => { + component.filters = { + ...new Filters(), + quickSearch: ['apple', 'banana'], + }; + fixture.detectChanges(); + + let chips = compiled.querySelectorAll('.filter-chip'); + expect(chips.length).toBe(2); + expect(chips[0].textContent).toContain('search: "apple"'); + expect(chips[1].textContent).toContain('search: "banana"'); + + spyOn(component, 'removeFilter').and.callThrough(); + const firstRemoveBtn = chips[0].querySelector( + '.filter-chip-remove' + ) as HTMLButtonElement; + firstRemoveBtn.click(); + + expect(component.removeFilter).toHaveBeenCalledWith( + FilterName.QuickSearch, + jasmine.any(MouseEvent), + 'apple' + ); + expect(component.filters.quickSearch).toEqual(['banana']); + + fixture.detectChanges(); + chips = compiled.querySelectorAll('.filter-chip'); + expect(chips.length).toBe(1); + expect(chips[0].textContent).toContain('search: "banana"'); + }); + it('should render clear button when filters or input value are present', () => { component.filters = new Filters(); component.inputValue = ''; @@ -512,7 +601,7 @@ describe('SearchComponent', () => { deviceFirmware: '', results: [], dateRange: '', - quickSearch: '', + quickSearch: [], location: '', linuxEnv: '', pythonVersion: '', diff --git a/modules/ui/src/app/pages/reports/components/search/search.component.ts b/modules/ui/src/app/pages/reports/components/search/search.component.ts index 7a25c962d..b4c2f5dc0 100644 --- a/modules/ui/src/app/pages/reports/components/search/search.component.ts +++ b/modules/ui/src/app/pages/reports/components/search/search.component.ts @@ -127,11 +127,34 @@ export class SearchComponent { this.inputValue = target.value; } + private normalizeQuickSearch(value: FilterValue): string[] { + if (Array.isArray(value)) { + return value.map(v => String(v).trim()).filter(Boolean); + } + if (typeof value === 'string' && value.trim()) { + return [value.trim()]; + } + return []; + } + + getChipTrackId(item: ActiveFilterItem): string { + return `${item.key}_${this.getFilterChipLabel(item.key, item.value)}`; + } + onEnter(event: Event): void { event.preventDefault(); const query = this.inputValue.trim(); if (query) { - this.filters.quickSearch = query; + if (!this.filters) { + this.filters = new Filters(); + } + const currentQueries = this.normalizeQuickSearch( + this.filters.quickSearch + ); + if (!currentQueries.includes(query)) { + currentQueries.push(query); + } + this.filters.quickSearch = currentQueries; this.searchQueryChanged.emit(query); this.filterCleared.emit(this.filters); this.inputValue = ''; @@ -146,7 +169,11 @@ export class SearchComponent { const active = this.getActiveFilters(); if (active.length > 0) { const lastFilter = active[active.length - 1]; - this.removeFilter(lastFilter.key); + if (lastFilter.key === FilterName.QuickSearch) { + this.removeFilter(lastFilter.key, undefined, lastFilter.value); + } else { + this.removeFilter(lastFilter.key); + } } } } @@ -171,7 +198,14 @@ export class SearchComponent { for (const key of keys) { const value = this.filters[key]; if (!this.isValueEmpty(value)) { - items.push({ key, value }); + if (key === FilterName.QuickSearch) { + const queries = this.normalizeQuickSearch(value); + for (const query of queries) { + items.push({ key, value: query }); + } + } else { + items.push({ key, value }); + } } } @@ -248,7 +282,24 @@ export class SearchComponent { return `Clear filter: ${this.getFilterChipLabel(key, value)}`; } - removeFilter(key: string, event?: Event): void { + removeFilter( + key: string, + eventOrValue?: Event | FilterValue, + valueOrEvent?: FilterValue | Event + ): void { + let event: Event | undefined; + let value: FilterValue | undefined; + + if (eventOrValue instanceof Event) { + event = eventOrValue; + value = valueOrEvent as FilterValue; + } else if (valueOrEvent instanceof Event) { + event = valueOrEvent; + value = eventOrValue as FilterValue; + } else { + value = (eventOrValue as FilterValue) ?? (valueOrEvent as FilterValue); + } + if (event) { event.preventDefault(); event.stopPropagation(); @@ -268,8 +319,16 @@ export class SearchComponent { this.filters.dateRange = ''; break; case FilterName.QuickSearch: - this.filters.quickSearch = ''; - //this.searchQueryChanged.emit(''); + if (value !== undefined) { + const currentQueries = this.normalizeQuickSearch( + this.filters.quickSearch + ); + this.filters.quickSearch = currentQueries.filter( + q => q !== String(value) + ); + } else { + this.filters.quickSearch = []; + } break; case FilterName.Location: this.filters.location = ''; @@ -300,7 +359,7 @@ export class SearchComponent { this.filters.deviceFirmware = ''; this.filters.results = []; this.filters.dateRange = ''; - this.filters.quickSearch = ''; + this.filters.quickSearch = []; this.filters.location = ''; this.filters.linuxEnv = ''; this.filters.pythonVersion = ''; diff --git a/modules/ui/src/app/pages/reports/reports.component.html b/modules/ui/src/app/pages/reports/reports.component.html index 55c943285..08a72c0aa 100644 --- a/modules/ui/src/app/pages/reports/reports.component.html +++ b/modules/ui/src/app/pages/reports/reports.component.html @@ -27,8 +27,7 @@

Reports

[filterOpened]="vm.filterOpened" [activeFilter]="vm.activeFilter" (emitOpenFilter)="openFilter($event)" - (filterCleared)="filterCleared($event)" - (searchQueryChanged)="onSearchQueryChanged($event)"> + (filterCleared)="filterCleared($event)">
{ deviceFirmware: '', results: ['compliant'], dateRange: '', - quickSearch: '', + quickSearch: [], location: '', linuxEnv: '', pythonVersion: '', @@ -462,7 +462,7 @@ describe('ReportsComponent', () => { deviceFirmware: '', results: [], dateRange: '', - quickSearch: 'searchKeyword', + quickSearch: ['searchKeyword'], location: '', linuxEnv: '', pythonVersion: '', diff --git a/modules/ui/src/app/pages/reports/reports.component.ts b/modules/ui/src/app/pages/reports/reports.component.ts index 631e4f7a0..2ffe7cd9c 100644 --- a/modules/ui/src/app/pages/reports/reports.component.ts +++ b/modules/ui/src/app/pages/reports/reports.component.ts @@ -281,7 +281,9 @@ export class ReportsComponent implements OnInit, OnDestroy { } filterCleared(filters: Filters) { - this.searchQuery = filters.quickSearch || ''; + this.searchQuery = Array.isArray(filters.quickSearch) + ? (filters.quickSearch[0] ?? '') + : filters.quickSearch || ''; this.store.setFilteredValues(filters); } diff --git a/modules/ui/src/app/pages/reports/reports.store.spec.ts b/modules/ui/src/app/pages/reports/reports.store.spec.ts index 1c2888f0e..7f36e7106 100644 --- a/modules/ui/src/app/pages/reports/reports.store.spec.ts +++ b/modules/ui/src/app/pages/reports/reports.store.spec.ts @@ -153,7 +153,7 @@ describe('ReportsStore', () => { deviceFirmware: '', results: [], dateRange: '', - quickSearch: '', + quickSearch: [], location: '', linuxEnv: '', pythonVersion: '', @@ -264,11 +264,11 @@ describe('ReportsStore', () => { describe('setFilteredValuesQuickSearch', () => { it('should update store', done => { - const updatedFilters = { ...FILTERS, ...{ quickSearch: 'test2' } }; + const updatedFilters = { ...FILTERS, ...{ quickSearch: ['test2'] } }; store.overrideSelector(selectReports, [...HISTORY]); reportsStore.setFilteredValues({ ...FILTERS }); - reportsStore.setFilteredValuesQuickSearch('test2'); + reportsStore.setFilteredValuesQuickSearch(['test2']); reportsStore.viewModel$.pipe(take(1)).subscribe(store => { expect(store.filteredValues).toEqual(updatedFilters); @@ -338,6 +338,33 @@ describe('ReportsStore', () => { done(); }); }); + + it('should apply AND operation across multiple quickSearch queries', done => { + reportsStore.setDataSource([...HISTORY]); + reportsStore.setFilteredValuesQuickSearch([ + 'Data Center Alpha', + '1.2.3', + ]); + + reportsStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store.dataSource.filteredData.length).toBe(1); + expect(store.dataSource.filteredData[0].deviceFirmware).toBe('1.2.3'); + done(); + }); + }); + + it('should return 0 items when one of the AND queries does not match', done => { + reportsStore.setDataSource([...HISTORY]); + reportsStore.setFilteredValuesQuickSearch([ + 'Data Center Alpha', + 'NonexistentDevice', + ]); + + reportsStore.viewModel$.pipe(take(1)).subscribe(store => { + expect(store.dataSource.filteredData.length).toBe(0); + done(); + }); + }); }); describe('setFilteredValuesDeviceFirmware', () => { diff --git a/modules/ui/src/app/pages/reports/reports.store.ts b/modules/ui/src/app/pages/reports/reports.store.ts index 2104aa785..c35ccdc33 100644 --- a/modules/ui/src/app/pages/reports/reports.store.ts +++ b/modules/ui/src/app/pages/reports/reports.store.ts @@ -159,17 +159,24 @@ export class ReportsStore extends ComponentStore { ); }); - setFilteredValuesQuickSearch = this.effect(quickSearch$ => { - return quickSearch$.pipe( - withLatestFrom(this.filteredValues$, this.dataSource$), - tap(([quickSearch, filteredValues, dataSource]) => { - this.updateFilters(dataSource, { - ...filteredValues, - quickSearch, - }); - }) - ); - }); + setFilteredValuesQuickSearch = this.effect( + quickSearch$ => { + return quickSearch$.pipe( + withLatestFrom(this.filteredValues$, this.dataSource$), + tap(([quickSearch, filteredValues, dataSource]) => { + const quickSearchArray = Array.isArray(quickSearch) + ? quickSearch + : quickSearch && quickSearch.trim() + ? [quickSearch.trim()] + : []; + this.updateFilters(dataSource, { + ...filteredValues, + quickSearch: quickSearchArray, + }); + }) + ); + } + ); setFilteredValuesDeviceInfo = this.effect(deviceInfo$ => { return deviceInfo$.pipe( @@ -417,12 +424,17 @@ export class ReportsStore extends ComponentStore { private filterSearchQuery( data: HistoryTestrun, - searchQuery: string = '' + searchQuery: string[] | string = [] ): boolean { - if (!searchQuery || !searchQuery.trim()) { + const queries = Array.isArray(searchQuery) + ? searchQuery.map(q => q.trim().toLowerCase()).filter(Boolean) + : typeof searchQuery === 'string' && searchQuery.trim() + ? [searchQuery.trim().toLowerCase()] + : []; + + if (queries.length === 0) { return true; } - const query = searchQuery.trim().toLowerCase(); const formattedStarted = data.started ? this.datePipe.transform(data.started, 'd MMM y H:mm') @@ -458,8 +470,10 @@ export class ReportsStore extends ComponentStore { data.host?.python_version, ]; - return searchableFields.some( - field => field && field.toString().toLowerCase().includes(query) + return queries.every(query => + searchableFields.some( + field => field && field.toString().toLowerCase().includes(query) + ) ); } @@ -535,7 +549,7 @@ export class ReportsStore extends ComponentStore { deviceFirmware: '', results: [], dateRange: '', - quickSearch: '', + quickSearch: [], location: '', linuxEnv: '', pythonVersion: '', From 8d0c477aa49266faf057157dd3fa0e56061d24da Mon Sep 17 00:00:00 2001 From: kurilova Date: Wed, 9 Sep 2026 08:51:15 +0000 Subject: [PATCH 2/2] Update search fields --- modules/ui/src/app/pages/reports/reports.store.spec.ts | 10 ---------- modules/ui/src/app/pages/reports/reports.store.ts | 10 ---------- 2 files changed, 20 deletions(-) diff --git a/modules/ui/src/app/pages/reports/reports.store.spec.ts b/modules/ui/src/app/pages/reports/reports.store.spec.ts index 7f36e7106..fe430e0e8 100644 --- a/modules/ui/src/app/pages/reports/reports.store.spec.ts +++ b/modules/ui/src/app/pages/reports/reports.store.spec.ts @@ -599,16 +599,6 @@ describe('ReportsStore', () => { }); }); - it('should filter by folder_name, report URL, and export URL in quickSearch', done => { - reportsStore.setDataSource([...HISTORY]); - reportsStore.setFilteredValuesQuickSearch('12345'); - - reportsStore.viewModel$.pipe(take(1)).subscribe(vm => { - expect(vm.dataSource.filteredData.length).toBe(1); - done(); - }); - }); - it('should return all items when quickSearch is empty or contains only whitespace', done => { reportsStore.setDataSource([...HISTORY]); reportsStore.setFilteredValuesQuickSearch(' '); diff --git a/modules/ui/src/app/pages/reports/reports.store.ts b/modules/ui/src/app/pages/reports/reports.store.ts index c35ccdc33..eb37120ed 100644 --- a/modules/ui/src/app/pages/reports/reports.store.ts +++ b/modules/ui/src/app/pages/reports/reports.store.ts @@ -439,9 +439,6 @@ export class ReportsStore extends ComponentStore { const formattedStarted = data.started ? this.datePipe.transform(data.started, 'd MMM y H:mm') : ''; - const formattedFinished = data.finished - ? this.datePipe.transform(data.finished, 'd MMM y H:mm') - : ''; const searchableFields = [ data.deviceInfo, @@ -452,19 +449,12 @@ export class ReportsStore extends ComponentStore { data.device?.firmware, data.device?.kernel, data.device?.test_pack, - data.device?.created_at, data.program, data.status, data.testResult, data.result, - data.started, - data.finished, formattedStarted, - formattedFinished, data.duration, - data.folder_name, - data.report, - data.export, data.host?.location, data.host?.linux_env, data.host?.python_version,