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)
+ ">
close
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..fe430e0e8 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', () => {
@@ -572,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 2104aa785..eb37120ed 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,19 +424,21 @@ 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')
: '';
- const formattedFinished = data.finished
- ? this.datePipe.transform(data.finished, 'd MMM y H:mm')
- : '';
const searchableFields = [
data.deviceInfo,
@@ -440,26 +449,21 @@ 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,
];
- 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 +539,7 @@ export class ReportsStore extends ComponentStore {
deviceFirmware: '',
results: [],
dateRange: '',
- quickSearch: '',
+ quickSearch: [],
location: '',
linuxEnv: '',
pythonVersion: '',