@for (result of results.controls; track result; let i = $index) {
diff --git a/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.scss b/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.scss
index 814016b99..73795ef4f 100644
--- a/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.scss
+++ b/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.scss
@@ -88,6 +88,7 @@
button {
min-width: 38px;
+ min-height: 36px;
margin: 0;
padding: 0 16px;
font-weight: 500;
@@ -96,6 +97,11 @@
::ng-deep .mat-focus-indicator {
display: none;
}
+
+ &:focus-visible {
+ outline: 2px solid colors.$primary;
+ outline-offset: 2px;
+ }
}
}
diff --git a/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.spec.ts b/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.spec.ts
index d866a1f37..65f236e62 100644
--- a/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.spec.ts
+++ b/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.spec.ts
@@ -36,6 +36,8 @@ import { MatNativeDateModule } from '@angular/material/core';
import { DateRange, FilterName, FilterTitle } from '../../../../model/filters';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { of } from 'rxjs';
+import { ResultOfTestrun } from '../../../../model/testrun-status';
+import { TestingType } from '../../../../model/device';
describe('FilterDialogComponent', () => {
let component: FilterDialogComponent;
@@ -115,6 +117,11 @@ describe('FilterDialogComponent', () => {
const mockFormData = {
deviceInfo: 'deviceInfo',
deviceFirmware: '',
+ location: '',
+ linuxEnv: '',
+ pythonVersion: '',
+ kernel: '',
+ assessmentType: '',
results: [],
dateRange: new DateRange(),
};
@@ -130,7 +137,58 @@ describe('FilterDialogComponent', () => {
closeSpy.calls.reset();
});
- it('should not close dialog with invalid date on "confirm" click', () => {
+ it('should close dialog with new filter fields on "confirm" click', () => {
+ fixture.detectChanges();
+ component.filterForm.get('location')?.setValue(' Data Center 1 ');
+ component.filterForm.get('linuxEnv')?.setValue(' Ubuntu 24.04 ');
+ component.filterForm.get('pythonVersion')?.setValue(' 3.11 ');
+ component.filterForm.get('kernel')?.setValue(' 6.8.0 ');
+ component.filterForm
+ .get('assessmentType')
+ ?.setValue(' Pilot Assessment ');
+ component.results.controls[0].setValue(true);
+
+ const mockFormData = {
+ deviceInfo: '',
+ deviceFirmware: '',
+ location: 'Data Center 1',
+ linuxEnv: 'Ubuntu 24.04',
+ pythonVersion: '3.11',
+ kernel: '6.8.0',
+ assessmentType: 'Pilot Assessment',
+ results: [ResultOfTestrun.Compliant],
+ dateRange: new DateRange(),
+ };
+ const closeSpy = spyOn(component.dialogRef, 'close');
+ const confirmButton = compiled.querySelector(
+ '.confirm-button'
+ ) as HTMLButtonElement;
+
+ confirmButton?.click();
+
+ expect(closeSpy).toHaveBeenCalledWith(mockFormData);
+ });
+
+ it('should auto-populate end date with today if start date is set and end date is empty on confirm', () => {
+ fixture.detectChanges();
+ const startDate = new Date('2024-01-01');
+ component.range.start = startDate;
+ component.range.end = null;
+
+ const closeSpy = spyOn(component.dialogRef, 'close');
+ component.confirm();
+
+ expect(closeSpy).toHaveBeenCalledWith(
+ jasmine.objectContaining({
+ dateRange: jasmine.objectContaining({
+ start: startDate,
+ end: jasmine.any(Date),
+ }),
+ })
+ );
+ });
+
+ it('should not close dialog with invalid format on "confirm" click', () => {
fixture.detectChanges();
component.filterForm.get('deviceInfo')?.setValue('as&@3$');
const closeSpy = spyOn(component.dialogRef, 'close');
@@ -145,6 +203,28 @@ describe('FilterDialogComponent', () => {
closeSpy.calls.reset();
});
+ it('should provide form control getters', () => {
+ fixture.detectChanges();
+ expect(component.deviceInfo).toBe(component.filterForm.get('deviceInfo')!);
+ expect(component.deviceFirmware).toBe(
+ component.filterForm.get('deviceFirmware')!
+ );
+ expect(component.location).toBe(component.filterForm.get('location')!);
+ expect(component.linuxEnv).toBe(component.filterForm.get('linuxEnv')!);
+ expect(component.pythonVersion).toBe(
+ component.filterForm.get('pythonVersion')!
+ );
+ expect(component.kernel).toBe(component.filterForm.get('kernel')!);
+ expect(component.assessmentType).toBe(
+ component.filterForm.get('assessmentType')!
+ );
+ expect(component.results).toBe(
+ component.filterForm.controls[
+ 'results'
+ ] as unknown as typeof component.results
+ );
+ });
+
it('should have "invalid_format" error if field does not satisfy validation rules', () => {
[
'very long value very long value very long value very long value very long value very long value very long',
@@ -175,6 +255,33 @@ describe('FilterDialogComponent', () => {
});
});
+ describe('assessment type filter', () => {
+ beforeEach(() => {
+ component.data = {
+ trigger: mockClientRest,
+ filter: FilterName.AssessmentType,
+ title: FilterTitle.AssessmentType,
+ };
+ fixture.detectChanges();
+ });
+
+ it('should render mat-select for assessment type with TestingType options', () => {
+ const select = compiled.querySelector('mat-select');
+ expect(select).toBeTruthy();
+ expect(component.testingTypes).toEqual([
+ TestingType.Pilot,
+ TestingType.Qualification,
+ ]);
+ });
+
+ it('should update assessmentType form control on value change', () => {
+ component.filterForm
+ .get('assessmentType')
+ ?.setValue(TestingType.Qualification);
+ expect(component.assessmentType.value).toBe(TestingType.Qualification);
+ });
+ });
+
describe('date filter', () => {
beforeEach(() => {
component.data = {
@@ -202,6 +309,17 @@ describe('FilterDialogComponent', () => {
expect(component.range).toEqual(range);
});
+ it('#selectedChange should swap dates if selected end is earlier than start', () => {
+ const earlierDate = new Date('Wed Jun 20 2023 00:00:00 GMT+0000');
+ const laterDate = new Date('Thu Jun 22 2023 00:00:00 GMT+0000');
+
+ component.selectedChange(laterDate);
+ component.selectedChange(earlierDate);
+
+ expect(component.range.start).toEqual(earlierDate);
+ expect(component.range.end).toEqual(laterDate);
+ });
+
it('#startDateChanged should set date range data', () => {
const mockDate = new Date('Wed Jun 21 2023 00:00:00 GMT+0000');
component.startDateChanged({
@@ -254,4 +372,171 @@ describe('FilterDialogComponent', () => {
);
});
});
+
+ describe('dialog positioning and sizing', () => {
+ it('should set 360px width for Started filter', () => {
+ const updateSizeSpy = spyOn(component.dialogRef, 'updateSize');
+ component.data = {
+ trigger: mockClientRest,
+ filter: FilterName.Started,
+ title: FilterTitle.Started,
+ };
+
+ component.ngOnInit();
+ expect(updateSizeSpy).toHaveBeenCalledWith('360px');
+ });
+
+ it('should set 240px width and offset position for Results filter without menuRect', () => {
+ const updateSizeSpy = spyOn(component.dialogRef, 'updateSize');
+ const updatePositionSpy = spyOn(component.dialogRef, 'updatePosition');
+ component.data = {
+ trigger: {
+ nativeElement: {
+ getBoundingClientRect: () => ({
+ left: 300,
+ bottom: 80,
+ }),
+ },
+ },
+ filter: FilterName.Results,
+ title: FilterTitle.Results,
+ };
+
+ component.ngOnInit();
+ expect(updateSizeSpy).toHaveBeenCalledWith('240px');
+ expect(updatePositionSpy).toHaveBeenCalledWith({
+ left: '60px',
+ top: '94px',
+ });
+ });
+
+ it('should position dialog to the right of menu with no padding when menuRect is provided', () => {
+ const updatePositionSpy = spyOn(component.dialogRef, 'updatePosition');
+ const menuRect = {
+ left: 50,
+ right: 250,
+ top: 100,
+ bottom: 400,
+ width: 200,
+ height: 300,
+ x: 50,
+ y: 100,
+ toJSON: () => ({}),
+ } as DOMRect;
+
+ component.data = {
+ trigger: mockClientRest,
+ filter: FilterName.DeviceInfo,
+ title: FilterTitle.DeviceInfo,
+ menuRect,
+ };
+
+ component.ngOnInit();
+
+ expect(updatePositionSpy).toHaveBeenCalledWith({
+ left: '250px',
+ top: '100px',
+ });
+ });
+
+ it('should adjust left position if menuRect overflows window width', () => {
+ const updatePositionSpy = spyOn(component.dialogRef, 'updatePosition');
+ const menuRect = {
+ left: 800,
+ right: window.innerWidth + 100,
+ top: 100,
+ bottom: 400,
+ width: 200,
+ height: 300,
+ x: 800,
+ y: 100,
+ toJSON: () => ({}),
+ } as DOMRect;
+
+ component.data = {
+ trigger: mockClientRest,
+ filter: FilterName.DeviceInfo,
+ title: FilterTitle.DeviceInfo,
+ menuRect,
+ };
+
+ component.ngOnInit();
+
+ expect(updatePositionSpy).toHaveBeenCalledWith({
+ left: `${window.innerWidth - 328}px`,
+ top: '100px',
+ });
+ });
+
+ it('should position dialog on the level with menu item when itemRect is provided', () => {
+ const updatePositionSpy = spyOn(component.dialogRef, 'updatePosition');
+ const menuRect = {
+ left: 50,
+ right: 250,
+ top: 100,
+ bottom: 400,
+ width: 200,
+ height: 300,
+ x: 50,
+ y: 100,
+ toJSON: () => ({}),
+ } as DOMRect;
+
+ const itemRect = {
+ left: 50,
+ right: 250,
+ top: 180,
+ bottom: 220,
+ width: 200,
+ height: 40,
+ x: 50,
+ y: 180,
+ toJSON: () => ({}),
+ } as DOMRect;
+
+ component.data = {
+ trigger: mockClientRest,
+ filter: FilterName.DeviceInfo,
+ title: FilterTitle.DeviceInfo,
+ menuRect,
+ itemRect,
+ };
+
+ component.ngOnInit();
+
+ expect(updatePositionSpy).toHaveBeenCalledWith({
+ left: '250px',
+ top: '180px',
+ });
+ });
+
+ it('should fallback to trigger position when menuRect is not provided', () => {
+ const updatePositionSpy = spyOn(component.dialogRef, 'updatePosition');
+ component.data = {
+ trigger: {
+ nativeElement: {
+ getBoundingClientRect: () => ({
+ left: 100,
+ bottom: 80,
+ }),
+ },
+ },
+ filter: FilterName.DeviceInfo,
+ title: FilterTitle.DeviceInfo,
+ };
+
+ component.ngOnInit();
+
+ expect(updatePositionSpy).toHaveBeenCalledWith({
+ left: '100px',
+ top: '94px',
+ });
+ });
+
+ it('should update dialog view on window resize', () => {
+ const updatePositionSpy = spyOn(component.dialogRef, 'updatePosition');
+ component.onResize();
+ expect(updatePositionSpy).toHaveBeenCalled();
+ });
+ });
});
diff --git a/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.ts b/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.ts
index 6dccc0616..d80cb49ee 100644
--- a/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.ts
+++ b/modules/ui/src/app/pages/reports/components/filter-dialog/filter-dialog.component.ts
@@ -64,6 +64,7 @@ import {
ResultOfTestrun,
StatusOfTestrun,
} from '../../../../model/testrun-status';
+import { TestingType } from '../../../../model/device';
import { DeviceValidators } from '../../../devices/components/device-form/device.validators';
class DateErrorStateMatcher implements ErrorStateMatcher {
@@ -86,6 +87,8 @@ interface DialogData {
trigger: ElementRef;
filter: string;
title: string;
+ menuRect?: DOMRect;
+ itemRect?: DOMRect;
}
@Component({
@@ -134,6 +137,11 @@ export class FilterDialogComponent
public readonly FilterName = FilterName;
+ public readonly testingTypes: TestingType[] = [
+ TestingType.Pilot,
+ TestingType.Qualification,
+ ];
+
range: LocalDateRange = new LocalDateRange();
topPosition = 0;
@@ -167,6 +175,26 @@ export class FilterDialogComponent
return this.filterForm.get('deviceFirmware') as AbstractControl;
}
+ get location() {
+ return this.filterForm.get('location') as AbstractControl;
+ }
+
+ get linuxEnv() {
+ return this.filterForm.get('linuxEnv') as AbstractControl;
+ }
+
+ get pythonVersion() {
+ return this.filterForm.get('pythonVersion') as AbstractControl;
+ }
+
+ get kernel() {
+ return this.filterForm.get('kernel') as AbstractControl;
+ }
+
+ get assessmentType() {
+ return this.filterForm.get('assessmentType') as AbstractControl;
+ }
+
ngOnInit() {
this.setDialogView();
this.createFilterForm();
@@ -174,17 +202,7 @@ export class FilterDialogComponent
private setDialogView(): void {
const matDialogConfig: MatDialogConfig = new MatDialogConfig();
- const rect = this.data.trigger?.nativeElement.getBoundingClientRect();
-
- matDialogConfig.position = {
- left:
- this.data.filter === FilterName.Results
- ? `${rect.left - 240}px`
- : `${rect.left}px`,
- top: `${rect.bottom + 14}px`,
- };
- this.topPosition = rect.bottom + this.dialog_actions_height;
if (this.data.filter === FilterName.Started) {
matDialogConfig.width = '360px';
} else if (this.data.filter === FilterName.Results) {
@@ -193,13 +211,50 @@ export class FilterDialogComponent
matDialogConfig.width = '328px';
}
+ const dialogWidthNum = parseInt(matDialogConfig.width, 10) || 328;
+
+ if (this.data.menuRect) {
+ let left = this.data.menuRect.right;
+ if (left + dialogWidthNum > window.innerWidth) {
+ left = Math.max(0, window.innerWidth - dialogWidthNum);
+ }
+ const top = this.data.itemRect
+ ? this.data.itemRect.top
+ : this.data.menuRect.top;
+
+ matDialogConfig.position = {
+ left: `${left}px`,
+ top: `${top}px`,
+ };
+ this.topPosition = top + this.dialog_actions_height;
+ } else {
+ const rect = this.data.trigger?.nativeElement?.getBoundingClientRect?.();
+ if (rect) {
+ matDialogConfig.position = {
+ left:
+ this.data.filter === FilterName.Results
+ ? `${rect.left - 240}px`
+ : `${rect.left}px`,
+ top: `${rect.bottom + 14}px`,
+ };
+ this.topPosition = rect.bottom + this.dialog_actions_height;
+ }
+ }
+
this.dialogRef.updateSize(matDialogConfig.width);
- this.dialogRef.updatePosition(matDialogConfig.position);
+ if (matDialogConfig.position) {
+ this.dialogRef.updatePosition(matDialogConfig.position);
+ }
}
private createFilterForm() {
this.filterForm = this.fb.group({
deviceInfo: ['', [this.deviceValidators.deviceStringFormat()]],
deviceFirmware: ['', [this.deviceValidators.firmwareStringFormat()]],
+ location: ['', []],
+ linuxEnv: ['', []],
+ pythonVersion: ['', []],
+ kernel: ['', []],
+ assessmentType: ['', []],
results: new FormArray(this.resultList.map(() => new FormControl(false))),
});
}
@@ -249,8 +304,13 @@ export class FilterDialogComponent
}
const filtersData = {
- deviceInfo: formData.deviceInfo.trim(),
- deviceFirmware: formData.deviceFirmware.trim(),
+ deviceInfo: formData.deviceInfo?.trim() || '',
+ deviceFirmware: formData.deviceFirmware?.trim() || '',
+ location: formData.location?.trim() || '',
+ linuxEnv: formData.linuxEnv?.trim() || '',
+ pythonVersion: formData.pythonVersion?.trim() || '',
+ kernel: formData.kernel?.trim() || '',
+ assessmentType: formData.assessmentType?.trim() || '',
results,
dateRange: this.range,
};
diff --git a/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.html b/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.html
deleted file mode 100644
index bbc4469dd..000000000
--- a/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.html
+++ /dev/null
@@ -1,45 +0,0 @@
-|
- {{ headerText }}
-
-
- |
-
-
-
-
diff --git a/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.scss b/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.scss
deleted file mode 100644
index 1a77525fc..000000000
--- a/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.scss
+++ /dev/null
@@ -1,43 +0,0 @@
-@use '@angular/material' as mat;
-@use 'm3-theme' as *;
-@use 'colors';
-@use 'variables';
-
-:host {
- display: contents;
-}
-
-th {
- height: var(--mat-table-header-container-height);
- vertical-align: middle;
-}
-
-.filter-button {
- display: flex;
- width: variables.$reports-table-header-size;
- height: variables.$reports-table-header-size;
- justify-content: center;
- align-items: center;
- flex-shrink: 0;
- margin-left: 8px;
- padding: 0;
- border: none;
- background: colors.$white;
- cursor: pointer;
- border-radius: 50%;
- &:hover {
- background-color: rgba(0, 0, 0, 0.04);
- }
- &:active,
- &.active {
- background-color: colors.$secondary-container;
- color: colors.$on-secondary-container;
- &:hover {
- filter: brightness(90%);
- }
- }
-}
-
-.filter-button.active .mat-icon {
- color: mat.get-theme-color($light-theme, primary, 35);
-}
diff --git a/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.spec.ts b/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.spec.ts
deleted file mode 100644
index d530772d3..000000000
--- a/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.spec.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { Component } from '@angular/core';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { FilterHeaderComponent } from './filter-header.component';
-import { MatIconModule } from '@angular/material/icon';
-import { MatSortModule } from '@angular/material/sort';
-import { MatButtonModule } from '@angular/material/button';
-import { MatTableModule } from '@angular/material/table';
-import { CommonModule } from '@angular/common';
-import { By } from '@angular/platform-browser';
-import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
-@Component({
- selector: 'app-dummy-table',
- template: `
-
-
-
-
- | {{ element }} |
-
-
-
-
- `,
- standalone: false,
-})
-export class DummyTableComponent {
- data = ['Row 1', 'Row 2', 'Row 3'];
- displayedColumns = ['testColumn'];
-}
-
-describe('FilterHeaderComponent within mat-table', () => {
- let fixture: ComponentFixture;
- let compiled: HTMLElement;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- declarations: [DummyTableComponent],
- imports: [
- BrowserAnimationsModule,
- FilterHeaderComponent,
- MatIconModule,
- MatSortModule,
- MatButtonModule,
- MatTableModule,
- CommonModule,
- ],
- }).compileComponents();
-
- fixture = TestBed.createComponent(DummyTableComponent);
- fixture.detectChanges();
- compiled = fixture.nativeElement as HTMLElement;
- });
-
- it('should have the filter header component', () => {
- const filterHeader = compiled.querySelector(
- 'app-filter-header'
- ) as HTMLElement;
- expect(filterHeader).toBeTruthy();
- const headerText = filterHeader?.querySelector(
- 'th span'
- ) as HTMLSpanElement;
- expect(headerText?.textContent?.trim()).toBe('Test Header');
- });
-
- it('should emit an event when filter button is clicked in filter header', () => {
- const filterHeader = fixture.debugElement.query(
- By.css('app-filter-header')
- );
- const filterHeaderComponent =
- filterHeader.componentInstance as FilterHeaderComponent;
-
- spyOn(filterHeaderComponent.emitOpenFilter, 'emit');
-
- const button = filterHeader.query(By.css('.filter-button'))
- .nativeElement as HTMLButtonElement;
- button.click();
-
- expect(filterHeaderComponent.emitOpenFilter.emit).toHaveBeenCalledWith({
- event: new PointerEvent('event'),
- filter: 'testFilter',
- title: 'testTitle',
- filterOpened: false,
- });
- });
-});
diff --git a/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.ts b/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.ts
deleted file mode 100644
index 3dfb1e2d1..000000000
--- a/modules/ui/src/app/pages/reports/components/filter-header/filter-header.component.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Component, EventEmitter, Input, Output } from '@angular/core';
-import { MatIconModule } from '@angular/material/icon';
-import { MatButtonModule } from '@angular/material/button';
-import { CommonModule } from '@angular/common';
-import { MatTableModule } from '@angular/material/table';
-import { MatSortModule } from '@angular/material/sort';
-import { MatTooltipModule } from '@angular/material/tooltip';
-
-export interface OpenFilterEvent {
- event: Event;
- filter: string;
- title: string;
- filterOpened: boolean;
-}
-
-@Component({
- selector: 'app-filter-header',
- imports: [
- MatIconModule,
- MatButtonModule,
- CommonModule,
- MatTableModule,
- MatSortModule,
- MatTooltipModule,
- ],
- templateUrl: './filter-header.component.html',
- styleUrl: './filter-header.component.scss',
-})
-export class FilterHeaderComponent {
- @Output() emitOpenFilter = new EventEmitter();
- @Input({ required: true }) filterName!: string;
- @Input({ required: true }) filterTitle!: string;
- @Input({ required: true }) filterOpened!: boolean;
- @Input() hasSorting: boolean = true;
- @Input() filtered: boolean = false;
- @Input({ required: true }) activeFilter!: string;
- @Input() sortActionDescription: string = '';
- @Input({ required: true }) headerText!: string;
- isFilterHovered = false;
-
- openFilter(
- event: Event,
- filter: string,
- title: string,
- filterOpened: boolean
- ): void {
- this.emitOpenFilter.emit({
- event,
- filter,
- title,
- filterOpened,
- });
- }
-}
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
new file mode 100644
index 000000000..afa3b2c1f
--- /dev/null
+++ b/modules/ui/src/app/pages/reports/components/search/search.component.html
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+ @for (item of getActiveFilters(); track getChipTrackId(item)) {
+
+
+ {{ getFilterChipLabel(item.key, item.value) }}
+
+
+
+ }
+
+
+
+
+ @if (inputValue || hasActiveFilters()) {
+
+ }
+
+
+
diff --git a/modules/ui/src/app/pages/reports/components/search/search.component.scss b/modules/ui/src/app/pages/reports/components/search/search.component.scss
new file mode 100644
index 000000000..2d0378f37
--- /dev/null
+++ b/modules/ui/src/app/pages/reports/components/search/search.component.scss
@@ -0,0 +1,293 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@use '@angular/material' as mat;
+@use 'm3-theme' as *;
+@use 'colors';
+@use 'variables';
+
+.search-field {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border-radius: variables.$corner-small;
+ border: 1px solid mat.get-theme-color($light-theme, neutral-variant, 80);
+ background-color: colors.$white;
+ min-height: 48px;
+ padding: 4px 8px 4px 6px;
+ box-sizing: border-box;
+ width: 100%;
+ cursor: text;
+}
+
+.search-menu-trigger {
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ width: 0;
+ height: 0;
+ pointer-events: none;
+}
+
+.search-content {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 0;
+}
+
+.filter-chip {
+ display: inline-flex;
+ min-height: 32px;
+ align-items: center;
+ gap: 4px;
+ background-color: colors.$secondary-container;
+ border-radius: 8px;
+ padding: 4px 4px 4px 10px;
+ font-family: variables.$font-text;
+ font-size: 14px;
+ font-weight: 500;
+ color: colors.$on-secondary-container;
+ cursor: default;
+ max-width: 100%;
+ box-sizing: border-box;
+}
+
+.filter-chip-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.filter-chip-remove {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ padding: 0;
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ min-width: 24px;
+ min-height: 24px;
+ color: colors.$on-secondary-container;
+ flex-shrink: 0;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.1);
+ }
+
+ &:focus-visible {
+ outline: 2px solid colors.$primary;
+ outline-offset: 1px;
+ }
+
+ .chip-close-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ line-height: 16px;
+ }
+}
+
+.search-input {
+ flex: 1 1 100px;
+ min-width: 60px;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-family: variables.$font-text;
+ font-size: 14px;
+ color: colors.$on-surface;
+ height: 32px;
+ padding: 0 4px;
+
+ &::placeholder {
+ color: colors.$on-surface-variant;
+ opacity: 0.8;
+ }
+}
+
+.clear-button {
+ font-family: variables.$font-text;
+ font-size: 14px;
+ font-weight: 500;
+ color: colors.$primary;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 0 8px;
+ height: 32px;
+ min-width: unset;
+ white-space: nowrap;
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: variables.$corner-small;
+
+ ::ng-deep .mdc-button__label {
+ color: colors.$primary;
+ font-family: variables.$font-text;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 20px;
+ }
+
+ &:hover {
+ color: colors.$blue-700;
+
+ ::ng-deep .mdc-button__label {
+ color: colors.$blue-700;
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid colors.$primary;
+ outline-offset: 2px;
+ }
+}
+
+.filter-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 36px;
+ height: 36px;
+ min-width: 36px;
+ min-height: 36px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: colors.$on-surface-variant;
+ border-radius: 50%;
+ box-sizing: border-box;
+
+ ::ng-deep .mdc-button__label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
+ ::ng-deep .mat-mdc-button-touch-target {
+ width: 36px;
+ height: 36px;
+ }
+
+ .filter-icon {
+ font-size: 20px;
+ width: 20px;
+ height: 20px;
+ line-height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+ }
+
+ &.active {
+ color: colors.$on-secondary-container;
+ background-color: colors.$secondary-container;
+
+ .filter-icon {
+ color: colors.$on-secondary-container;
+
+ path {
+ fill: colors.$on-secondary-container;
+ }
+ }
+ }
+
+ &:focus-visible,
+ &.cdk-keyboard-focused {
+ outline: 2px solid colors.$primary;
+ outline-offset: 2px;
+ }
+}
+
+::ng-deep .search-filter-menu {
+ width: 208px;
+ min-width: 208px;
+ max-width: 208px;
+ border-radius: variables.$corner-large;
+ background-color: colors.$surface-container-low;
+
+ @include mat.menu-overrides(
+ (
+ container-color: colors.$surface-container-low,
+ container-shape: variables.$corner-large,
+ item-label-text-color: #1f1f1f,
+ item-label-text-size: 14px,
+ item-label-text-font: variables.$font-text,
+ item-hover-state-layer-color: colors.$secondary-container,
+ item-focus-state-layer-color: colors.$secondary-container,
+ item-leading-spacing: 12px,
+ item-trailing-spacing: 12px,
+ item-with-icon-leading-spacing: 12px,
+ item-with-icon-trailing-spacing: 12px,
+ )
+ );
+
+ .filter-menu-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-left: 12px;
+ padding-right: 12px;
+ font-family: variables.$font-text;
+ font-size: 14px;
+ color: #1f1f1f;
+
+ .mat-mdc-menu-item-text {
+ order: 1;
+ flex: 1 1 auto;
+ }
+
+ .filter-menu-item-icon {
+ order: 2;
+ margin-right: 0;
+ margin-left: auto;
+ color: inherit;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ width: 24px;
+ height: 24px;
+ line-height: 24px;
+ }
+
+ &.active-filter-item {
+ background-color: colors.$secondary-container;
+ color: colors.$on-secondary-container;
+ font-weight: 500;
+ }
+
+ &:not(.cdk-keyboard-focused):not(:focus-visible):not(:hover):not(
+ .active-filter-item
+ ) {
+ background-color: transparent !important;
+ }
+ }
+}
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
new file mode 100644
index 000000000..9a4324975
--- /dev/null
+++ b/modules/ui/src/app/pages/reports/components/search/search.component.spec.ts
@@ -0,0 +1,789 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+ ComponentFixture,
+ TestBed,
+ fakeAsync,
+ tick,
+} from '@angular/core/testing';
+import { FocusMonitor } from '@angular/cdk/a11y';
+import { SearchComponent } from './search.component';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import {
+ DateRange,
+ FilterName,
+ FilterTitle,
+ Filters,
+} from '../../../../model/filters';
+
+describe('SearchComponent', () => {
+ let component: SearchComponent;
+ let fixture: ComponentFixture;
+ let compiled: HTMLElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [SearchComponent, BrowserAnimationsModule],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(SearchComponent);
+ component = fixture.componentInstance;
+ compiled = fixture.nativeElement as HTMLElement;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Active filters & labels', () => {
+ it('should return empty list when no filters are active', () => {
+ component.filters = new Filters();
+ expect(component.getActiveFilters()).toEqual([]);
+ expect(component.hasActiveFilters()).toBeFalse();
+ });
+
+ it('should return empty list when filters is null or undefined', () => {
+ component.filters = null as unknown as Filters;
+ expect(component.getActiveFilters()).toEqual([]);
+ expect(component.hasActiveFilters()).toBeFalse();
+ });
+
+ it('should return active filter items when filters are set', () => {
+ component.filters = {
+ deviceInfo: 'Pixel',
+ deviceFirmware: '1.0',
+ assessmentType: 'Pilot',
+ results: ['Compliant'],
+ dateRange: { start: '10/01/2024', end: '10/05/2024' },
+ quickSearch: ['test'],
+ location: 'Data Center',
+ linuxEnv: 'Ubuntu 24.04',
+ pythonVersion: '3.11',
+ kernel: '6.8.0',
+ };
+
+ const active = component.getActiveFilters();
+ expect(active.length).toBe(10);
+ expect(component.hasActiveFilters()).toBeTrue();
+ });
+
+ describe('isFilterActive', () => {
+ it('should return false when filterOpened is false', () => {
+ component.filterOpened = false;
+ component.activeFilter = 'deviceInfo';
+ expect(component.isFilterActive('deviceInfo')).toBeFalse();
+ });
+
+ it('should return false when activeFilter is empty', () => {
+ component.filterOpened = true;
+ component.activeFilter = '';
+ expect(component.isFilterActive('deviceInfo')).toBeFalse();
+ });
+
+ it('should return false when activeFilter does not match key', () => {
+ component.filterOpened = true;
+ component.activeFilter = 'deviceFirmware';
+ expect(component.isFilterActive('deviceInfo')).toBeFalse();
+ });
+
+ it('should return true when filterOpened is true and activeFilter matches key', () => {
+ component.filterOpened = true;
+ component.activeFilter = 'deviceInfo';
+ expect(component.isFilterActive('deviceInfo')).toBeTrue();
+ });
+
+ it('should return false when search query is applied already but category is not in active state', () => {
+ component.filters = {
+ ...new Filters(),
+ deviceInfo: 'Pixel',
+ };
+ component.filterOpened = false;
+ component.activeFilter = '';
+ expect(component.isFilterActive('deviceInfo')).toBeFalse();
+
+ component.filterOpened = true;
+ component.activeFilter = 'deviceFirmware';
+ expect(component.isFilterActive('deviceInfo')).toBeFalse();
+ expect(component.isFilterActive('deviceFirmware')).toBeTrue();
+ });
+
+ it('should handle Started and dateRange aliases correctly', () => {
+ component.filterOpened = true;
+ component.activeFilter = FilterName.Started;
+ expect(component.isFilterActive(FilterName.DateRange)).toBeTrue();
+ expect(component.isFilterActive(FilterName.Started)).toBeTrue();
+
+ component.activeFilter = FilterName.DateRange;
+ expect(component.isFilterActive(FilterName.Started)).toBeTrue();
+ expect(component.isFilterActive(FilterName.DateRange)).toBeTrue();
+ });
+ });
+
+ it('should format chip labels correctly for all filter types', () => {
+ expect(
+ component.getFilterChipLabel(FilterName.QuickSearch, 'query')
+ ).toBe('search: "query"');
+ expect(
+ component.getFilterChipLabel(FilterName.DeviceInfo, 'Device A')
+ ).toBe('Device: Device A');
+ expect(
+ component.getFilterChipLabel(FilterName.DeviceFirmware, 'v2.1')
+ ).toBe('Firmware: v2.1');
+ expect(
+ component.getFilterChipLabel(
+ FilterName.AssessmentType,
+ 'Pilot Assessment'
+ )
+ ).toBe('Assessment type: Pilot Assessment');
+ expect(
+ component.getFilterChipLabel(FilterName.Location, 'Building 1')
+ ).toBe('Location: Building 1');
+ expect(component.getFilterChipLabel(FilterName.LinuxEnv, 'Ubuntu')).toBe(
+ 'Linux Environment: Ubuntu'
+ );
+ expect(
+ component.getFilterChipLabel(FilterName.PythonVersion, '3.11')
+ ).toBe('Python Version: 3.11');
+ expect(component.getFilterChipLabel(FilterName.Kernel, 'Linux 6.8')).toBe(
+ 'Kernel: Linux 6.8'
+ );
+ expect(
+ component.getFilterChipLabel(FilterName.DateRange, {
+ start: '01/01/2024',
+ end: '01/10/2024',
+ })
+ ).toBe('Started: 01/01/2024 - 01/10/2024');
+ expect(
+ component.getFilterChipLabel(FilterName.Started, {
+ start: '01/01/2024',
+ end: '',
+ })
+ ).toBe('Started: 01/01/2024');
+ expect(
+ component.getFilterChipLabel(
+ FilterName.DateRange,
+ '01/01/2024 - 01/10/2024'
+ )
+ ).toBe('Started: 01/01/2024 - 01/10/2024');
+ expect(
+ component.getFilterChipLabel(FilterName.Results, [
+ 'Compliant',
+ 'Non-compliant',
+ ])
+ ).toBe('Result: Compliant, Non-compliant');
+ expect(component.getFilterChipLabel('unknownKey', 'sampleValue')).toBe(
+ 'sampleValue'
+ );
+ });
+
+ it('should correctly evaluate isValueEmpty', () => {
+ expect(component.isValueEmpty('')).toBeTrue();
+ expect(component.isValueEmpty([])).toBeTrue();
+ expect(component.isValueEmpty(null)).toBeTrue();
+ expect(component.isValueEmpty(undefined as unknown as string)).toBeTrue();
+ expect(component.isValueEmpty(new DateRange())).toBeTrue();
+ expect(component.isValueEmpty({ start: '', end: '' })).toBeTrue();
+
+ expect(component.isValueEmpty('value')).toBeFalse();
+ expect(component.isValueEmpty(['value'])).toBeFalse();
+ expect(
+ component.isValueEmpty({ start: '01/01/2024', end: '01/10/2024' })
+ ).toBeFalse();
+ const dateRange = new DateRange();
+ dateRange.start = '01/01/2024';
+ expect(component.isValueEmpty(dateRange)).toBeFalse();
+ });
+
+ it('should generate accessible labels', () => {
+ expect(component.getChipAriaLabel(FilterName.QuickSearch, 'test')).toBe(
+ 'Filter: search: "test"'
+ );
+ expect(
+ component.getRemoveFilterAriaLabel(FilterName.DeviceInfo, 'Pixel')
+ ).toBe('Clear filter: Device: Pixel');
+ });
+ });
+
+ describe('Filter removal & clearing', () => {
+ let mockFilters: Filters;
+
+ beforeEach(() => {
+ mockFilters = {
+ deviceInfo: 'Pixel',
+ deviceFirmware: '1.0',
+ assessmentType: 'Pilot',
+ results: ['Compliant'],
+ dateRange: { start: '10/01/2024', end: '10/05/2024' },
+ quickSearch: ['test'],
+ location: 'DC-1',
+ linuxEnv: 'Ubuntu',
+ pythonVersion: '3.11',
+ kernel: '6.8',
+ };
+ component.filters = { ...mockFilters };
+ });
+
+ it('should remove deviceInfo filter and handle event', () => {
+ spyOn(component.filterCleared, 'emit');
+ const event = new MouseEvent('click');
+ spyOn(event, 'preventDefault');
+ spyOn(event, 'stopPropagation');
+
+ component.removeFilter(FilterName.DeviceInfo, event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
+ expect(component.filters.deviceInfo).toBe('');
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+
+ it('should remove deviceFirmware filter', () => {
+ spyOn(component.filterCleared, 'emit');
+ component.removeFilter(FilterName.DeviceFirmware);
+
+ expect(component.filters.deviceFirmware).toBe('');
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+
+ it('should remove assessmentType filter', () => {
+ spyOn(component.filterCleared, 'emit');
+ component.removeFilter(FilterName.AssessmentType);
+
+ expect(component.filters.assessmentType).toBe('');
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+
+ it('should remove results filter', () => {
+ spyOn(component.filterCleared, 'emit');
+ component.removeFilter(FilterName.Results);
+
+ expect(component.filters.results).toEqual([]);
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+
+ it('should remove dateRange filter', () => {
+ spyOn(component.filterCleared, 'emit');
+ component.removeFilter(FilterName.DateRange);
+
+ expect(component.filters.dateRange).toBe('');
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+
+ it('should remove started filter', () => {
+ spyOn(component.filterCleared, 'emit');
+ component.removeFilter(FilterName.Started);
+
+ expect(component.filters.dateRange).toBe('');
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+
+ it('should remove quickSearch filter', () => {
+ spyOn(component.filterCleared, 'emit');
+ component.removeFilter(FilterName.QuickSearch);
+
+ 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
+ );
+ });
+
+ it('should remove location filter', () => {
+ spyOn(component.filterCleared, 'emit');
+ component.removeFilter(FilterName.Location);
+
+ expect(component.filters.location).toBe('');
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+
+ it('should remove linuxEnv filter', () => {
+ spyOn(component.filterCleared, 'emit');
+ component.removeFilter(FilterName.LinuxEnv);
+
+ expect(component.filters.linuxEnv).toBe('');
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+
+ it('should remove pythonVersion filter', () => {
+ spyOn(component.filterCleared, 'emit');
+ component.removeFilter(FilterName.PythonVersion);
+
+ expect(component.filters.pythonVersion).toBe('');
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+
+ it('should remove kernel filter', () => {
+ spyOn(component.filterCleared, 'emit');
+ component.removeFilter(FilterName.Kernel);
+
+ expect(component.filters.kernel).toBe('');
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+
+ it('should clear all filters on clearAll and handle event', () => {
+ spyOn(component.filterCleared, 'emit');
+ spyOn(component.searchQueryChanged, 'emit');
+ component.inputValue = 'some text';
+
+ const event = new MouseEvent('click');
+ spyOn(event, 'preventDefault');
+ spyOn(event, 'stopPropagation');
+
+ component.clearAll(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
+ expect(component.inputValue).toBe('');
+ expect(component.filters.deviceInfo).toBe('');
+ expect(component.filters.deviceFirmware).toBe('');
+ expect(component.filters.assessmentType).toBe('');
+ expect(component.filters.results).toEqual([]);
+ expect(component.filters.dateRange).toBe('');
+ expect(component.filters.quickSearch).toEqual([]);
+ expect(component.filters.location).toBe('');
+ expect(component.filters.linuxEnv).toBe('');
+ expect(component.filters.pythonVersion).toBe('');
+ expect(component.filters.kernel).toBe('');
+ expect(component.searchQueryChanged.emit).toHaveBeenCalledWith('');
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ });
+ });
+
+ describe('Input handling', () => {
+ it('should update inputValue on input change', () => {
+ const event = {
+ target: { value: 'my search' },
+ } as unknown as Event;
+
+ component.onInputChange(event);
+ expect(component.inputValue).toBe('my search');
+ });
+
+ it('should add search chip on Enter and clear input', () => {
+ spyOn(component.filterCleared, 'emit');
+ spyOn(component.searchQueryChanged, 'emit');
+ component.inputValue = ' Raspberry Pi ';
+
+ const event = new KeyboardEvent('keydown', { key: 'Enter' });
+ spyOn(event, 'preventDefault');
+
+ component.onEnter(event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(component.filters.quickSearch).toEqual(['Raspberry Pi']);
+ expect(component.searchQueryChanged.emit).toHaveBeenCalledWith(
+ 'Raspberry Pi'
+ );
+ expect(component.filterCleared.emit).toHaveBeenCalledWith(
+ component.filters
+ );
+ 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');
+ component.inputValue = ' ';
+
+ const event = new KeyboardEvent('keydown', { key: 'Enter' });
+ component.onEnter(event);
+
+ expect(component.searchQueryChanged.emit).not.toHaveBeenCalled();
+ expect(component.filterCleared.emit).not.toHaveBeenCalled();
+ });
+
+ it('should remove last filter on Backspace when input is empty', () => {
+ component.filters = {
+ deviceInfo: 'Pixel',
+ deviceFirmware: '1.0',
+ assessmentType: '',
+ results: [],
+ dateRange: '',
+ quickSearch: [],
+ location: '',
+ linuxEnv: '',
+ pythonVersion: '',
+ kernel: '',
+ };
+ component.inputValue = '';
+ spyOn(component, 'removeFilter');
+
+ component.onBackspace();
+
+ 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 = '';
+ spyOn(component, 'removeFilter');
+
+ component.onBackspace();
+
+ expect(component.removeFilter).not.toHaveBeenCalled();
+ });
+
+ it('should not remove last filter on Backspace if input is not empty', () => {
+ component.filters = {
+ deviceInfo: 'Pixel',
+ deviceFirmware: '',
+ assessmentType: '',
+ results: [],
+ dateRange: '',
+ quickSearch: [],
+ location: '',
+ linuxEnv: '',
+ pythonVersion: '',
+ kernel: '',
+ };
+ component.inputValue = 'abc';
+ spyOn(component, 'removeFilter');
+
+ component.onBackspace();
+
+ expect(component.removeFilter).not.toHaveBeenCalled();
+ });
+
+ it('should focus search input on focusInput()', () => {
+ const inputEl = component.searchInput()?.nativeElement;
+ if (inputEl) {
+ spyOn(inputEl, 'focus');
+ component.focusInput();
+ expect(inputEl.focus).toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe('Filter menu & dialog opening', () => {
+ it('should emit emitOpenFilter on openFilterMenu for all menu items', () => {
+ spyOn(component.emitOpenFilter, 'emit');
+ component.filterMenuItems.forEach(item => {
+ const button = document.createElement('button');
+ const event = {
+ currentTarget: button,
+ preventDefault: jasmine.createSpy('preventDefault'),
+ stopPropagation: jasmine.createSpy('stopPropagation'),
+ } as unknown as Event;
+
+ component.openFilterMenu(item.name, item.title, event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
+ expect(component.emitOpenFilter.emit).toHaveBeenCalledWith(
+ jasmine.objectContaining({
+ filter: item.name,
+ title: item.title,
+ filterOpened: component.filterOpened,
+ })
+ );
+ });
+ });
+
+ it('should find closest menu panel if present', () => {
+ spyOn(component.emitOpenFilter, 'emit');
+ const menuPanel = document.createElement('div');
+ menuPanel.classList.add('search-filter-menu');
+ const button = document.createElement('button');
+ menuPanel.appendChild(button);
+ document.body.appendChild(menuPanel);
+
+ const event = {
+ currentTarget: button,
+ preventDefault: () => undefined,
+ stopPropagation: () => undefined,
+ } as unknown as Event;
+
+ component.openFilterMenu(FilterName.Started, FilterTitle.Started, event);
+
+ expect(component.emitOpenFilter.emit).toHaveBeenCalled();
+ document.body.removeChild(menuPanel);
+ });
+ });
+
+ describe('Menu close & focus management', () => {
+ it('should set isMenuOpened to false and restore focus to filter button when closed via keydown', fakeAsync(() => {
+ const focusMonitor = TestBed.inject(FocusMonitor);
+ spyOn(focusMonitor, 'focusVia');
+
+ component.isMenuOpened = true;
+ component.onMenuClosed('keydown');
+
+ expect(component.isMenuOpened).toBeFalse();
+ tick();
+
+ const button = component.filterButton();
+ expect(focusMonitor.focusVia).toHaveBeenCalledWith(button!, 'keyboard');
+ }));
+
+ it('should not restore focus to filter button when closed via click', fakeAsync(() => {
+ const focusMonitor = TestBed.inject(FocusMonitor);
+ spyOn(focusMonitor, 'focusVia');
+
+ component.isMenuOpened = true;
+ component.onMenuClosed('click');
+
+ expect(component.isMenuOpened).toBeFalse();
+ tick();
+
+ expect(focusMonitor.focusVia).not.toHaveBeenCalled();
+ }));
+
+ it('should not restore focus to filter button when closed via tab', fakeAsync(() => {
+ const focusMonitor = TestBed.inject(FocusMonitor);
+ spyOn(focusMonitor, 'focusVia');
+
+ component.isMenuOpened = true;
+ component.onMenuClosed('tab');
+
+ expect(component.isMenuOpened).toBeFalse();
+ tick();
+
+ expect(focusMonitor.focusVia).not.toHaveBeenCalled();
+ }));
+
+ it('should handle menuClosed from trigger without overriding keydown reason', fakeAsync(() => {
+ const focusMonitor = TestBed.inject(FocusMonitor);
+ spyOn(focusMonitor, 'focusVia');
+
+ component.isMenuOpened = true;
+ component.onMenuClosed('keydown');
+ component.onMenuClosed();
+
+ expect(component.isMenuOpened).toBeFalse();
+ tick();
+
+ const button = component.filterButton();
+ expect(focusMonitor.focusVia).toHaveBeenCalledWith(button!, 'keyboard');
+ }));
+ });
+
+ describe('DOM rendering', () => {
+ it('should render search input and filter button', () => {
+ const input = compiled.querySelector('.search-input');
+ const filterBtn = compiled.querySelector('.filter-button');
+
+ expect(input).toBeTruthy();
+ expect(filterBtn).toBeTruthy();
+ });
+
+ it('should highlight filter button with active class when menu is opened', () => {
+ component.filters = new Filters();
+ component.filterOpened = false;
+ component.isMenuOpened = false;
+ fixture.detectChanges();
+
+ const filterBtn = compiled.querySelector('.filter-button');
+ expect(filterBtn?.classList.contains('active')).toBeFalse();
+
+ component.isMenuOpened = true;
+ fixture.detectChanges();
+
+ expect(filterBtn?.classList.contains('active')).toBeTrue();
+ });
+
+ it('should not highlight filter button when menu is closed even if filters are active', () => {
+ component.filters = {
+ ...new Filters(),
+ deviceInfo: 'Pixel',
+ };
+ component.filterOpened = true;
+ component.isMenuOpened = false;
+ fixture.detectChanges();
+
+ const filterBtn = compiled.querySelector('.filter-button');
+ expect(filterBtn?.classList.contains('active')).toBeFalse();
+ });
+
+ it('should render filter chips when filters are present', () => {
+ component.filters = {
+ deviceInfo: 'Pixel',
+ deviceFirmware: '',
+ assessmentType: '',
+ results: ['Compliant'],
+ dateRange: '',
+ quickSearch: ['test'],
+ location: '',
+ linuxEnv: '',
+ pythonVersion: '',
+ kernel: '',
+ };
+ fixture.detectChanges();
+
+ const chips = compiled.querySelectorAll('.filter-chip');
+ 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 = '';
+ fixture.detectChanges();
+ expect(compiled.querySelector('.clear-button')).toBeNull();
+
+ component.inputValue = 'abc';
+ fixture.detectChanges();
+ const clearBtn = compiled.querySelector('.clear-button');
+ expect(clearBtn).toBeTruthy();
+ expect(clearBtn?.textContent?.trim()).toBe('Clear all');
+ });
+
+ it('should trigger removeFilter on chip remove button click, enter, space', () => {
+ component.filters = {
+ deviceInfo: 'Pixel',
+ deviceFirmware: '',
+ assessmentType: '',
+ results: [],
+ dateRange: '',
+ quickSearch: [],
+ location: '',
+ linuxEnv: '',
+ pythonVersion: '',
+ kernel: '',
+ };
+ fixture.detectChanges();
+
+ spyOn(component, 'removeFilter');
+ const removeBtn = compiled.querySelector(
+ '.filter-chip-remove'
+ ) as HTMLButtonElement;
+ expect(removeBtn).toBeTruthy();
+
+ removeBtn.click();
+ expect(component.removeFilter).toHaveBeenCalledWith(
+ 'deviceInfo',
+ jasmine.any(MouseEvent)
+ );
+
+ const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
+ removeBtn.dispatchEvent(enterEvent);
+ expect(component.removeFilter).toHaveBeenCalled();
+
+ const spaceEvent = new KeyboardEvent('keydown', { key: ' ' });
+ removeBtn.dispatchEvent(spaceEvent);
+ expect(component.removeFilter).toHaveBeenCalled();
+ });
+ });
+});
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
new file mode 100644
index 000000000..ed3ce0739
--- /dev/null
+++ b/modules/ui/src/app/pages/reports/components/search/search.component.ts
@@ -0,0 +1,525 @@
+/**
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ Output,
+ viewChild,
+ inject,
+} from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatIconModule } from '@angular/material/icon';
+import { MatButtonModule } from '@angular/material/button';
+import { MatMenuModule, MenuCloseReason } from '@angular/material/menu';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { FocusMonitor, LiveAnnouncer } from '@angular/cdk/a11y';
+import {
+ DateRange,
+ FilterName,
+ FilterTitle,
+ Filters,
+ FilterItem,
+ FilterMenuItem,
+ OpenFilterEvent,
+ FilterValue,
+} from '../../../../model/filters';
+
+export interface ActiveFilterItem {
+ key: string;
+ value: FilterValue;
+}
+
+@Component({
+ selector: 'app-search',
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatIconModule,
+ MatButtonModule,
+ MatMenuModule,
+ MatTooltipModule,
+ ],
+ templateUrl: './search.component.html',
+ styleUrls: ['./search.component.scss'],
+})
+export class SearchComponent {
+ @Input() filters: Filters = new Filters();
+ @Input() filterOpened: boolean = false;
+ @Input() activeFilter: string = '';
+
+ @Output() emitOpenFilter = new EventEmitter();
+ @Output() filterCleared = new EventEmitter();
+ @Output() searchQueryChanged = new EventEmitter();
+
+ readonly searchInput = viewChild>('searchInput');
+ readonly filterButton = viewChild('filterButton', { read: ElementRef });
+ private readonly liveAnnouncer = inject(LiveAnnouncer, { optional: true });
+ private readonly focusMonitor = inject(FocusMonitor);
+
+ public readonly FilterName = FilterName;
+ public readonly FilterTitle = FilterTitle;
+
+ inputValue: string = '';
+ isFocused: boolean = false;
+ isMenuOpened: boolean = false;
+ private lastCloseReason?: MenuCloseReason;
+ filterMenuItems: FilterMenuItem[] = [
+ {
+ displayName: FilterItem.Started,
+ name: FilterName.Started,
+ title: FilterTitle.Started,
+ },
+ {
+ displayName: FilterItem.DeviceInfo,
+ name: FilterName.DeviceInfo,
+ title: FilterTitle.DeviceInfo,
+ },
+ {
+ displayName: FilterItem.DeviceFirmware,
+ name: FilterName.DeviceFirmware,
+ title: FilterTitle.DeviceFirmware,
+ },
+ {
+ displayName: FilterItem.AssessmentType,
+ name: FilterName.AssessmentType,
+ title: FilterTitle.AssessmentType,
+ },
+ {
+ displayName: FilterItem.Results,
+ name: FilterName.Results,
+ title: FilterTitle.Results,
+ },
+ {
+ displayName: FilterItem.Location,
+ name: FilterName.Location,
+ title: FilterTitle.Location,
+ },
+ {
+ displayName: FilterItem.LinuxEnv,
+ name: FilterName.LinuxEnv,
+ title: FilterTitle.LinuxEnv,
+ },
+ {
+ displayName: FilterItem.PythonVersion,
+ name: FilterName.PythonVersion,
+ title: FilterTitle.PythonVersion,
+ },
+ {
+ displayName: FilterItem.Kernel,
+ name: FilterName.Kernel,
+ title: FilterTitle.Kernel,
+ },
+ ];
+
+ onMenuClosed(reason?: MenuCloseReason): void {
+ if (reason !== undefined) {
+ this.lastCloseReason = reason;
+ }
+ this.isMenuOpened = false;
+ if (this.lastCloseReason === 'keydown') {
+ this.lastCloseReason = undefined;
+ setTimeout(() => {
+ const button = this.filterButton();
+ if (button?.nativeElement) {
+ this.focusMonitor.focusVia(button, 'keyboard');
+ }
+ });
+ }
+ }
+
+ focusInput(): void {
+ this.searchInput()?.nativeElement.focus();
+ }
+
+ onInputChange(event: Event): void {
+ const target = event.target as HTMLInputElement;
+ 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) {
+ 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.liveAnnouncer?.announce(
+ `Filter added: search: "${query}"`,
+ 'polite'
+ );
+ this.inputValue = '';
+ if (this.searchInput()?.nativeElement) {
+ this.searchInput()!.nativeElement.value = '';
+ }
+ }
+ }
+
+ onBackspace(): void {
+ if (this.inputValue === '') {
+ const active = this.getActiveFilters();
+ if (active.length > 0) {
+ const lastFilter = active[active.length - 1];
+ if (lastFilter.key === FilterName.QuickSearch) {
+ this.removeFilter(lastFilter.key, undefined, lastFilter.value);
+ } else {
+ this.removeFilter(lastFilter.key);
+ }
+ }
+ }
+ }
+
+ getActiveFilters(): ActiveFilterItem[] {
+ if (!this.filters) {
+ return [];
+ }
+ const items: ActiveFilterItem[] = [];
+ const keys: (keyof Filters)[] = [
+ FilterName.QuickSearch,
+ FilterName.DeviceInfo,
+ FilterName.DeviceFirmware,
+ FilterName.AssessmentType,
+ FilterName.DateRange,
+ FilterName.Results,
+ FilterName.Location,
+ FilterName.LinuxEnv,
+ FilterName.PythonVersion,
+ FilterName.Kernel,
+ ];
+
+ for (const key of keys) {
+ const value = this.filters[key];
+ if (!this.isValueEmpty(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 });
+ }
+ }
+ }
+
+ return items;
+ }
+
+ hasActiveFilters(): boolean {
+ return this.getActiveFilters().length > 0;
+ }
+
+ isFilterActive(key: string): boolean {
+ if (!this.filterOpened || !this.activeFilter) {
+ return false;
+ }
+ if (
+ (this.activeFilter === FilterName.Started ||
+ this.activeFilter === FilterName.DateRange) &&
+ (key === FilterName.Started || key === FilterName.DateRange)
+ ) {
+ return true;
+ }
+ return this.activeFilter === key;
+ }
+
+ isValueEmpty(value: FilterValue): boolean {
+ if (
+ value instanceof DateRange ||
+ (typeof value === 'object' && value !== null && !Array.isArray(value))
+ ) {
+ return !value.start && !value.end;
+ }
+ return value === null || value === undefined || value.length === 0;
+ }
+
+ getFilterChipLabel(key: string, value: FilterValue): string {
+ if (key === FilterName.QuickSearch) {
+ return `search: "${value}"`;
+ }
+ if (key === FilterName.DeviceInfo) {
+ return `Device: ${value}`;
+ }
+ if (key === FilterName.DeviceFirmware) {
+ return `Firmware: ${value}`;
+ }
+ if (key === FilterName.AssessmentType) {
+ return `Assessment type: ${value}`;
+ }
+ if (key === FilterName.Results) {
+ const results = Array.isArray(value)
+ ? value.join(', ')
+ : String(value ?? '');
+ return `Result: ${results}`;
+ }
+ if (key === FilterName.Location) {
+ return `Location: ${value}`;
+ }
+ if (key === FilterName.LinuxEnv) {
+ return `Linux Environment: ${value}`;
+ }
+ if (key === FilterName.PythonVersion) {
+ return `Python Version: ${value}`;
+ }
+ if (key === FilterName.Kernel) {
+ return `Kernel: ${value}`;
+ }
+ if (key === FilterName.DateRange || key === FilterName.Started) {
+ const dateStr = this.formatDateFilter(value);
+ return dateStr ? `Started: ${dateStr}` : 'Started';
+ }
+ if (Array.isArray(value)) {
+ return value.join(', ');
+ }
+ return String(value ?? '');
+ }
+
+ private formatDateItem(date: string | Date | null | undefined): string {
+ if (!date) {
+ return '';
+ }
+ if (date instanceof Date) {
+ if (isNaN(date.getTime())) {
+ return '';
+ }
+ return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
+ }
+ if (typeof date === 'string') {
+ const trimmed = date.trim();
+ if (!trimmed) {
+ return '';
+ }
+ if (
+ /^\d{1,2}\/\d{1,2}\/\d{4}$/.test(trimmed) ||
+ /^\d{4}-\d{2}-\d{2}$/.test(trimmed)
+ ) {
+ return trimmed;
+ }
+ const parsed = new Date(trimmed);
+ if (!isNaN(parsed.getTime())) {
+ return `${parsed.getMonth() + 1}/${parsed.getDate()}/${parsed.getFullYear()}`;
+ }
+ return trimmed;
+ }
+ return String(date);
+ }
+
+ private formatDateFilter(value: FilterValue): string {
+ if (!value) {
+ return '';
+ }
+ if (value instanceof Date) {
+ return this.formatDateItem(value);
+ }
+ if (
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ ('start' in value || 'end' in value)
+ ) {
+ const range = value as {
+ start?: string | Date | null;
+ end?: string | Date | null;
+ };
+ const startStr = this.formatDateItem(range.start);
+ const endStr = this.formatDateItem(range.end);
+
+ if (startStr && endStr) {
+ if (startStr === endStr) {
+ return startStr;
+ }
+ return `${startStr} - ${endStr}`;
+ }
+ if (startStr) {
+ return startStr;
+ }
+ if (endStr) {
+ return endStr;
+ }
+ return '';
+ }
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (trimmed.includes(' - ')) {
+ const [s, e] = trimmed.split(' - ');
+ const sFormatted = this.formatDateItem(s);
+ const eFormatted = this.formatDateItem(e);
+ if (sFormatted && eFormatted && sFormatted !== eFormatted) {
+ return `${sFormatted} - ${eFormatted}`;
+ }
+ return sFormatted || eFormatted || trimmed;
+ }
+ return this.formatDateItem(trimmed);
+ }
+ return String(value);
+ }
+
+ getChipAriaLabel(key: string, value: FilterValue): string {
+ return `Filter: ${this.getFilterChipLabel(key, value)}`;
+ }
+
+ getRemoveFilterAriaLabel(key: string, value: FilterValue): string {
+ return `Clear filter: ${this.getFilterChipLabel(key, value)}`;
+ }
+
+ 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();
+ }
+ const filterKey = key as keyof Filters;
+ const filterValue = value !== undefined ? value : this.filters[filterKey];
+ const filterLabel = this.getFilterChipLabel(key, filterValue);
+
+ switch (key) {
+ case FilterName.DeviceInfo:
+ this.filters.deviceInfo = '';
+ break;
+ case FilterName.DeviceFirmware:
+ this.filters.deviceFirmware = '';
+ break;
+ case FilterName.AssessmentType:
+ this.filters.assessmentType = '';
+ break;
+ case FilterName.Results:
+ this.filters.results = [];
+ break;
+ case FilterName.DateRange:
+ case FilterName.Started:
+ this.filters.dateRange = '';
+ break;
+ case FilterName.QuickSearch:
+ 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 = '';
+ break;
+ case FilterName.LinuxEnv:
+ this.filters.linuxEnv = '';
+ break;
+ case FilterName.PythonVersion:
+ this.filters.pythonVersion = '';
+ break;
+ case FilterName.Kernel:
+ this.filters.kernel = '';
+ break;
+ }
+ this.filterCleared.emit(this.filters);
+ this.liveAnnouncer?.announce(`Filter removed: ${filterLabel}`, 'polite');
+ }
+
+ clearAll(event?: Event): void {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ this.inputValue = '';
+ if (this.searchInput()?.nativeElement) {
+ this.searchInput()!.nativeElement.value = '';
+ }
+ this.filters.deviceInfo = '';
+ this.filters.deviceFirmware = '';
+ this.filters.assessmentType = '';
+ this.filters.results = [];
+ this.filters.dateRange = '';
+ this.filters.quickSearch = [];
+ this.filters.location = '';
+ this.filters.linuxEnv = '';
+ this.filters.pythonVersion = '';
+ this.filters.kernel = '';
+ this.searchQueryChanged.emit('');
+ this.filterCleared.emit(this.filters);
+ this.liveAnnouncer?.announce('All filters cleared', 'polite');
+ }
+
+ openFilterMenu(filter: FilterName, title: FilterTitle, event: Event): void {
+ event.preventDefault();
+ event.stopPropagation();
+ const menuItem = event.currentTarget as HTMLElement;
+ const itemRect = menuItem?.getBoundingClientRect?.();
+ const menuPanel =
+ menuItem?.closest('.mat-mdc-menu-panel') ||
+ menuItem?.closest('.search-filter-menu') ||
+ (document.querySelector('.search-filter-menu') as HTMLElement);
+ const menuRect = menuPanel ? menuPanel.getBoundingClientRect() : itemRect;
+
+ const trigger = this.filterButton()?.nativeElement
+ ? new ElementRef(this.filterButton()!.nativeElement)
+ : new ElementRef(event.currentTarget);
+ this.emitOpenFilter.emit({
+ event: {
+ ...event,
+ currentTarget: trigger.nativeElement,
+ preventDefault: () => event?.preventDefault?.(),
+ stopPropagation: () => event?.stopPropagation?.(),
+ } as unknown as Event,
+ filter,
+ title,
+ filterOpened: this.filterOpened,
+ menuRect,
+ itemRect,
+ });
+ }
+}
diff --git a/modules/ui/src/app/pages/reports/reports-paginator-intl.spec.ts b/modules/ui/src/app/pages/reports/reports-paginator-intl.spec.ts
new file mode 100644
index 000000000..8b9bbf5a2
--- /dev/null
+++ b/modules/ui/src/app/pages/reports/reports-paginator-intl.spec.ts
@@ -0,0 +1,60 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ReportsPaginatorIntl } from './reports-paginator-intl';
+
+describe('ReportsPaginatorIntl', () => {
+ let intl: ReportsPaginatorIntl;
+
+ beforeEach(() => {
+ intl = new ReportsPaginatorIntl();
+ });
+
+ it('should be created with expected labels', () => {
+ expect(intl).toBeTruthy();
+ expect(intl.itemsPerPageLabel).toBe('Items per page:');
+ expect(intl.nextPageLabel).toBe('Next page');
+ expect(intl.previousPageLabel).toBe('Previous page');
+ expect(intl.firstPageLabel).toBe('First page');
+ expect(intl.lastPageLabel).toBe('Last page');
+ });
+
+ describe('getRangeLabel', () => {
+ it('should return "Results 0 of 0" when length is 0', () => {
+ expect(intl.getRangeLabel(0, 10, 0)).toBe('Results 0 of 0');
+ });
+
+ it('should return "Results 0 of 0" when pageSize is 0', () => {
+ expect(intl.getRangeLabel(0, 0, 100)).toBe('Results 0 of 0');
+ });
+
+ it('should return "Results 1-50 of 485" for page 0 with pageSize 50 and length 485', () => {
+ expect(intl.getRangeLabel(0, 50, 485)).toBe('Results 1-50 of 485');
+ });
+
+ it('should return "Results 11-20 of 25" for page 1 with pageSize 10 and length 25', () => {
+ expect(intl.getRangeLabel(1, 10, 25)).toBe('Results 11-20 of 25');
+ });
+
+ it('should return "Results 21-25 of 25" for last page with pageSize 10 and length 25', () => {
+ expect(intl.getRangeLabel(2, 10, 25)).toBe('Results 21-25 of 25');
+ });
+
+ it('should return "Results 1-1 of 1" when length is 1', () => {
+ expect(intl.getRangeLabel(0, 10, 1)).toBe('Results 1-1 of 1');
+ });
+ });
+});
diff --git a/modules/ui/src/app/pages/reports/reports-paginator-intl.ts b/modules/ui/src/app/pages/reports/reports-paginator-intl.ts
new file mode 100644
index 000000000..86f8a936c
--- /dev/null
+++ b/modules/ui/src/app/pages/reports/reports-paginator-intl.ts
@@ -0,0 +1,52 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { MatPaginatorIntl } from '@angular/material/paginator';
+
+/**
+ * Custom internationalization provider for Reports pagination controls
+ * adhering to Google UI guidelines and accessible naming requirements.
+ */
+@Injectable()
+export class ReportsPaginatorIntl extends MatPaginatorIntl {
+ override itemsPerPageLabel = 'Items per page:';
+ override nextPageLabel = 'Next page';
+ override previousPageLabel = 'Previous page';
+ override firstPageLabel = 'First page';
+ override lastPageLabel = 'Last page';
+
+ /**
+ * Generates a human-readable range label indicating current results and total results found.
+ * Example: "Results 1-50 of 485"
+ */
+ override getRangeLabel = (
+ page: number,
+ pageSize: number,
+ length: number
+ ): string => {
+ if (length === 0 || pageSize === 0) {
+ return 'Results 0 of 0';
+ }
+ const safeLength = Math.max(length, 0);
+ const startIndex = page * pageSize;
+ const endIndex =
+ startIndex < safeLength
+ ? Math.min(startIndex + pageSize, safeLength)
+ : startIndex + pageSize;
+ return `Results ${startIndex + 1}-${endIndex} of ${safeLength}`;
+ };
+}
diff --git a/modules/ui/src/app/pages/reports/reports.component.html b/modules/ui/src/app/pages/reports/reports.component.html
index 970b1487f..550a59dc2 100644
--- a/modules/ui/src/app/pages/reports/reports.component.html
+++ b/modules/ui/src/app/pages/reports/reports.component.html
@@ -16,10 +16,25 @@
@if (viewModel$ | async; as vm) {
@if (vm.dataLoaded) {
- Reports
+ Reports
+
+
Reports
[dataSource]="vm.dataSource"
[trackBy]="trackByStarted"
mat-table
+ multiTemplateDataRows
matSort
[matSortActive]="FilterName.Started"
matSortDirection="desc"
+ aria-labelledby="reports-heading"
tabindex="0">
+
+
+ |
+
+
+ |
+
-
-
+ mat-header-cell
+ mat-sort-header
+ sortActionDescription="Sort by Started date"
+ matTooltip="Sort by Started">
+ Started
+
{{ getFormattedDateString(data.started) }}
|
@@ -66,32 +107,26 @@ Reports
-
-
+ mat-header-cell
+ mat-sort-header
+ sortActionDescription="Sort by Device"
+ matTooltip="Sort by Device">
+ Device
+
{{ data.deviceInfo }} |
-
-
+ mat-header-cell
+ mat-sort-header
+ sortActionDescription="Sort by Firmware"
+ matTooltip="Sort by Firmware">
+ Firmware
+
{{ data.deviceFirmware }} |
@@ -110,17 +145,14 @@
Reports
-
-
+ mat-header-cell
+ mat-sort-header
+ sortActionDescription="Sort by Result"
+ matTooltip="Sort by Result">
+ Result
+
Reports
+ fontSet="material-symbols-outlined"
+ aria-hidden="true">
lab_profile
@@ -151,7 +184,8 @@ Reports
+ fontSet="material-symbols-outlined"
+ aria-hidden="true">
archive
@@ -163,7 +197,8 @@ Reports
(removeDevice)="removeReport(data)">
+ fontSet="material-symbols-outlined"
+ aria-hidden="true">
delete
@@ -171,23 +206,95 @@ Reports
|
-
-
- |
+
|
-
|
@@ -216,7 +323,25 @@ Reports
(click)="selectRow(rowElement)"
(keydown.enter)="selectRow(rowElement)"
(keydown.space)="selectRow(rowElement)"> |
+
|
+
+
}
}
@@ -227,3 +352,15 @@ Reports
[message]="message">
+
+
+
+ error
+ {{ EMPTY_DETAIL_TEXT }}
+
+
diff --git a/modules/ui/src/app/pages/reports/reports.component.scss b/modules/ui/src/app/pages/reports/reports.component.scss
index 37ae5a8b2..f46bad8be 100644
--- a/modules/ui/src/app/pages/reports/reports.component.scss
+++ b/modules/ui/src/app/pages/reports/reports.component.scss
@@ -19,26 +19,47 @@
@use 'mixins';
@use 'variables';
+$column-expand-width: 56px;
+
:host {
overflow: hidden;
display: flex;
flex-direction: column;
+ height: 100%;
+ align-self: stretch;
+ box-sizing: border-box;
}
.history-toolbar {
- gap: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 24px;
background: colors.$white;
- height: 74px;
- padding: 24px 0 8px 32px;
+ height: auto;
+ min-height: 74px;
+ padding: 24px 32px 8px 32px;
+ box-sizing: border-box;
+ flex-shrink: 0;
+
+ .title {
+ margin: 0;
+ white-space: nowrap;
+ }
+}
+
+app-search {
+ margin: 24px 32px 16px 32px;
+ flex-shrink: 0;
}
.history-content {
- margin: 10px 32px 39px 32px;
+ margin: 10px 32px 24px 32px;
overflow-y: auto;
- height: -webkit-max-content;
- height: -moz-max-content;
- height: max-content;
- max-height: calc(100vh - 160px);
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
}
.history-content-empty,
@@ -59,6 +80,16 @@
--mat-table-row-item-label-text-color: #{colors.$on-surface-variant};
--mat-table-row-item-outline-color: #{colors.$outline-variant};
+ .mat-column-expand,
+ .mat-column-expandedDetailSpacer {
+ width: $column-expand-width;
+ min-width: $column-expand-width;
+ max-width: $column-expand-width;
+ padding: 0;
+ text-align: center;
+ box-sizing: border-box;
+ }
+
.table-cell-actions-container {
display: flex;
gap: 8px;
@@ -68,29 +99,27 @@
padding-left: 8px;
white-space: nowrap;
}
+}
- .filter-button {
- display: flex;
- width: 24px;
- height: 24px;
- justify-content: center;
- align-items: center;
- flex-shrink: 0;
- margin: 0 2px 0 12px;
- padding: 0;
- border: none;
- background: colors.$white;
- cursor: pointer;
- &:hover {
- background-color: rgba(0, 0, 0, 0.04);
- }
- &:active {
- background-color: rgba(0, 0, 0, 0.12);
- }
+mat-paginator {
+ background-color: #{colors.$surface};
+ color: #{colors.$on-surface-variant};
+ font-family: #{variables.$font-text};
+ position: sticky;
+ bottom: 0;
+ z-index: 2;
+ flex-shrink: 0;
+ margin-top: auto;
+
+ ::ng-deep .mat-mdc-paginator-container {
+ padding: 0 16px;
+ min-height: 52px;
}
- .filter-button.active .mat-icon {
- color: mat.get-theme-color($light-theme, primary, 35);
+ ::ng-deep .mat-mdc-paginator-range-label {
+ margin: 0 24px 0 24px;
+ font-size: 13px;
+ font-weight: 500;
}
}
@@ -99,6 +128,14 @@
td {
padding: 0 8px;
}
+
+ .metadata-accordion-detail .metadata-card {
+ padding-left: 8px;
+
+ .metadata-grid {
+ gap: 0.5rem 0.75rem;
+ }
+ }
}
@container history-content (width < 680px) {
@@ -106,6 +143,26 @@
td {
padding: 0 4px;
}
+
+ mat-paginator {
+ ::ng-deep .mat-mdc-paginator-container {
+ padding: 0 4px;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ ::ng-deep .mat-mdc-paginator-range-label {
+ margin: 0 8px;
+ }
+ }
+
+ .metadata-accordion-detail .metadata-card {
+ padding-left: 4px;
+
+ .metadata-grid {
+ gap: 0.5rem 0.5rem;
+ }
+ }
}
@container history-content (width < 632px) {
@@ -113,67 +170,154 @@
td {
padding: 0 2px;
}
+
+ .metadata-accordion-detail .metadata-card {
+ padding-left: 2px;
+
+ .metadata-grid {
+ gap: 0.5rem 0.35rem;
+ }
+ }
}
-.results-content-empty,
.results-content-filter-empty {
display: flex;
align-items: center;
justify-content: center;
}
-.results-content-empty {
- @include mixins.content-empty;
+::ng-deep .download-report-icon,
+.delete-report-icon {
+ color: var(--mat-table-row-item-label-text-color);
}
-.results-content-empty-message {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 16px;
+.hidden {
+ display: none;
}
-.results-content-empty-message-header {
- font-weight: 400;
- line-height: 28px;
- font-size: 22px;
- color: colors.$black;
+.download-report-zip-icon-container {
+ @include mixins.report-action;
}
-.results-content-empty-message-main {
- font-family: variables.$font-secondary;
- font-weight: 400;
- font-size: 16px;
- line-height: 24px;
- letter-spacing: 0.1px;
- color: #202124;
+::ng-deep .mat-sort-header-container {
+ padding-bottom: 4px;
+ height: variables.$reports-table-header-size;
}
-::ng-deep .download-report-icon,
-.delete-report-icon {
- color: var(--mat-table-row-item-label-text-color);
+.expand-row-button {
+ &:focus-visible {
+ outline: 2px solid colors.$primary;
+ outline-offset: 2px;
+ }
}
-.hidden {
- display: none;
+.history-content {
+ &:focus-visible {
+ outline: 2px solid colors.$primary;
+ outline-offset: -2px;
+ }
}
-.results-content-empty-message-img {
- width: 293px;
- height: 154px;
- background-image: url(/assets/icons/desktop.svg);
-}
+tr.detail-row {
+ height: 0;
-.results-content-filter-empty .results-content-empty-message-main {
- line-height: 20px;
- font-size: 14px;
+ td.expanded-detail-spacer-cell,
+ td.expanded-detail-cell {
+ padding: 0;
+ border-bottom: none;
+ }
+
+ &.expanded-row {
+ td.expanded-detail-spacer-cell,
+ td.expanded-detail-cell {
+ border-bottom: 1px solid #c4c7c5;
+ }
+ }
}
-.download-report-zip-icon-container {
- @include mixins.report-action;
+.metadata-accordion-detail {
+ overflow: hidden;
+
+ .metadata-card {
+ padding: 16px 20px 16px 16px;
+
+ dl.metadata-grid {
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.85rem 1.25rem;
+
+ .metadata-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ min-width: 0;
+
+ .meta-label {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-family: variables.$font-secondary;
+ font-weight: 500;
+ font-size: 12px;
+ line-height: 16px;
+ color: colors.$on-surface-variant;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .meta-value-container {
+ margin: 0;
+ padding: 0;
+ min-width: 0;
+ }
+
+ .meta-value {
+ font-family: variables.$font-secondary;
+ font-weight: 400;
+ font-size: 14px;
+ line-height: 20px;
+ color: colors.$on-surface;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ word-break: break-word;
+ overflow-wrap: break-word;
+
+ &.meta-value-missing {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+
+ .missing-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ line-height: 16px;
+ color: mat.get-theme-color($light-theme, neutral, 40);
+ background-color: colors.$white;
+ border-radius: 50%;
+ flex-shrink: 0;
+ font-variation-settings:
+ 'FILL' 1,
+ 'wght' 400,
+ 'GRAD' 0,
+ 'opsz' 24;
+ }
+ }
+ }
+ }
+ }
+ }
}
-::ng-deep .mat-sort-header-container {
- padding-bottom: 4px;
- height: variables.$reports-table-header-size;
+@media (prefers-reduced-motion: reduce) {
+ .metadata-accordion-detail {
+ transition: none !important;
+ animation: none !important;
+ }
}
diff --git a/modules/ui/src/app/pages/reports/reports.component.spec.ts b/modules/ui/src/app/pages/reports/reports.component.spec.ts
index d95d381fc..0819aa389 100644
--- a/modules/ui/src/app/pages/reports/reports.component.spec.ts
+++ b/modules/ui/src/app/pages/reports/reports.component.spec.ts
@@ -28,16 +28,21 @@ import { LiveAnnouncer } from '@angular/cdk/a11y';
import { MatDialogRef } from '@angular/material/dialog';
import { FilterDialogComponent } from './components/filter-dialog/filter-dialog.component';
import { ElementRef } from '@angular/core';
-import { FilterName, FilterTitle } from '../../model/filters';
+import { FilterName, FilterTitle, Filters } from '../../model/filters';
import SpyObj = jasmine.SpyObj;
import { DATA_SOURCE_INITIAL_VALUE, ReportsStore } from './reports.store';
import {
DATA_SOURCE_FOR_EMPTY_FILTERS,
DATA_SOURCE_INITIAL_VALUE_NOT_EMPTY,
+ FORMATTED_HISTORY,
HISTORY,
} from '../../mocks/reports.mock';
import { MatTableDataSource } from '@angular/material/table';
+import { MatSort } from '@angular/material/sort';
+import { MatPaginator, PageEvent } from '@angular/material/paginator';
+import { Breakpoints } from '@angular/cdk/layout';
import { HistoryTestrun } from '../../model/testrun-status';
+import { SCREEN_SIZE_PAGE_SIZES } from './reports.component';
describe('ReportsComponent', () => {
let component: ReportsComponent;
@@ -53,6 +58,7 @@ describe('ReportsComponent', () => {
) => {
return of({
displayedColumns: [
+ 'expand',
'started',
'duration',
'deviceInfo',
@@ -67,8 +73,14 @@ describe('ReportsComponent', () => {
filteredValues: {
deviceInfo: '',
deviceFirmware: '',
+ assessmentType: '',
results: ['compliant'],
dateRange: '',
+ quickSearch: [],
+ location: '',
+ linuxEnv: '',
+ pythonVersion: '',
+ kernel: '',
},
dataLoaded: dataLoaded,
selectedRow: null,
@@ -85,11 +97,17 @@ describe('ReportsComponent', () => {
'setFilteredValuesDateRange',
'setFilteredValuesDeviceFirmware',
'setFilteredValuesDeviceInfo',
+ 'setFilteredValuesAssessmentType',
+ 'setFilteredValuesQuickSearch',
'setFilteredValuesResults',
+ 'setFilteredValuesLocation',
+ 'setFilteredValuesLinuxEnv',
+ 'setFilteredValuesPythonVersion',
+ 'setFilteredValuesKernel',
'setActiveFiler',
'setFilterOpened',
'updateSort',
- 'getReports',
+ 'updatePaginator',
'getReports',
'fetchReports',
]);
@@ -121,11 +139,38 @@ describe('ReportsComponent', () => {
expect(mockReportsStore.fetchReports).toHaveBeenCalled();
}));
+
+ it('should update sort if sort viewChild is available', () => {
+ const mockSort = new MatSort();
+ spyOn(component, 'sort').and.returnValue(mockSort);
+
+ component.ngOnInit();
+
+ expect(mockReportsStore.updateSort).toHaveBeenCalledWith(mockSort);
+ });
+
+ it('should update paginator if paginator viewChild is available', () => {
+ const mockPaginator = {
+ pageIndex: 0,
+ pageSize: 10,
+ } as unknown as MatPaginator;
+ spyOn(component, 'paginator').and.returnValue(mockPaginator);
+
+ component.ngOnInit();
+
+ expect(mockReportsStore.updatePaginator).toHaveBeenCalledWith(
+ mockPaginator
+ );
+ });
});
- it('#sortData should call liveAnnouncer with sorted direction message', () => {
+ it('#sortData should call liveAnnouncer with sorted direction message and update sort', () => {
+ const mockSort = new MatSort();
+ spyOn(component, 'sort').and.returnValue(mockSort);
+
component.sortData({ active: '', direction: 'desc' });
+ expect(mockReportsStore.updateSort).toHaveBeenCalledWith(mockSort);
expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith(
'Sorted descending'
);
@@ -180,6 +225,8 @@ describe('ReportsComponent', () => {
filter: '',
title: '',
trigger: new ElementRef(event.currentTarget),
+ menuRect: undefined,
+ itemRect: undefined,
},
autoFocus: true,
hasBackdrop: true,
@@ -200,16 +247,26 @@ describe('ReportsComponent', () => {
const mockFilterResults = ['compliant'];
const mockFilterDeviceInfo = 'mockDevice';
const mockFilterDeviceFirmware = 'mockFirmware';
+ const mockFilterAssessmentType = 'mockAssessmentType';
const mockFilterDateRange = {
start: 'Wed Jun 21 2023 00:00:00',
end: 'Thu Jun 22 2023 00:00:00',
};
+ const mockFilterLocation = 'mockLocation';
+ const mockFilterLinuxEnv = 'mockLinuxEnv';
+ const mockFilterPythonVersion = 'mockPythonVersion';
+ const mockFilterKernel = 'mockKernel';
const mockFilteredData = {
results: mockFilterResults,
deviceInfo: mockFilterDeviceInfo,
deviceFirmware: mockFilterDeviceFirmware,
+ assessmentType: mockFilterAssessmentType,
dateRange: mockFilterDateRange,
+ location: mockFilterLocation,
+ linuxEnv: mockFilterLinuxEnv,
+ pythonVersion: mockFilterPythonVersion,
+ kernel: mockFilterKernel,
};
spyOn(component.dialog, 'open').and.returnValue({
@@ -241,6 +298,36 @@ describe('ReportsComponent', () => {
title: FilterTitle.DeviceInfo,
filterOpened: false,
});
+ component.openFilter({
+ event,
+ filter: FilterName.AssessmentType,
+ title: FilterTitle.AssessmentType,
+ filterOpened: false,
+ });
+ component.openFilter({
+ event,
+ filter: FilterName.Location,
+ title: FilterTitle.Location,
+ filterOpened: false,
+ });
+ component.openFilter({
+ event,
+ filter: FilterName.LinuxEnv,
+ title: FilterTitle.LinuxEnv,
+ filterOpened: false,
+ });
+ component.openFilter({
+ event,
+ filter: FilterName.PythonVersion,
+ title: FilterTitle.PythonVersion,
+ filterOpened: false,
+ });
+ component.openFilter({
+ event,
+ filter: FilterName.Kernel,
+ title: FilterTitle.Kernel,
+ filterOpened: false,
+ });
expect(mockReportsStore.setFilteredValuesResults).toHaveBeenCalledWith(
mockFilterResults
);
@@ -250,9 +337,24 @@ describe('ReportsComponent', () => {
expect(
mockReportsStore.setFilteredValuesDeviceFirmware
).toHaveBeenCalledWith(mockFilterDeviceFirmware);
+ expect(
+ mockReportsStore.setFilteredValuesAssessmentType
+ ).toHaveBeenCalledWith(mockFilterAssessmentType);
expect(mockReportsStore.setFilteredValuesDateRange).toHaveBeenCalledWith(
mockFilterDateRange
);
+ expect(mockReportsStore.setFilteredValuesLocation).toHaveBeenCalledWith(
+ mockFilterLocation
+ );
+ expect(mockReportsStore.setFilteredValuesLinuxEnv).toHaveBeenCalledWith(
+ mockFilterLinuxEnv
+ );
+ expect(
+ mockReportsStore.setFilteredValuesPythonVersion
+ ).toHaveBeenCalledWith(mockFilterPythonVersion);
+ expect(mockReportsStore.setFilteredValuesKernel).toHaveBeenCalledWith(
+ mockFilterKernel
+ );
});
describe('#focusNextButton', () => {
@@ -267,9 +369,12 @@ describe('ReportsComponent', () => {
it('should focus next active element if exist', fakeAsync(() => {
const row = window.document.querySelector('tbody tr') as HTMLElement;
row.classList.add('report-selected');
- const nextButton = window.document.querySelector(
- '.report-selected + tr a'
- ) as HTMLButtonElement;
+ const nextButton = (window.document.querySelector(
+ '.report-selected + tr + tr a'
+ ) ||
+ window.document.querySelector(
+ '.report-selected + tr a'
+ )) as HTMLButtonElement;
const buttonFocusSpy = spyOn(nextButton, 'focus');
component.focusNextButton();
@@ -298,6 +403,329 @@ describe('ReportsComponent', () => {
component.removeReport(data);
expect(mockReportsStore.deleteReport).toHaveBeenCalledWith('/report/123');
});
+
+ describe('search query methods', () => {
+ it('applySearchQuery should call setFilteredValuesQuickSearch', () => {
+ component.searchQuery = 'testSearch';
+ component.applySearchQuery();
+
+ expect(
+ mockReportsStore.setFilteredValuesQuickSearch
+ ).toHaveBeenCalledWith('testSearch');
+ });
+
+ it('onSearchQueryChanged should update searchQuery and call applySearchQuery', () => {
+ component.onSearchQueryChanged('newQuery');
+
+ expect(component.searchQuery).toBe('newQuery');
+ expect(
+ mockReportsStore.setFilteredValuesQuickSearch
+ ).toHaveBeenCalledWith('newQuery');
+ });
+
+ it('addSearchTag should set searchQuery and call applySearchQuery', () => {
+ component.addSearchTag('tagSearch');
+
+ expect(component.searchQuery).toBe('tagSearch');
+ expect(
+ mockReportsStore.setFilteredValuesQuickSearch
+ ).toHaveBeenCalledWith('tagSearch');
+ });
+
+ it('clearSearchQuery should reset searchQuery and call applySearchQuery', () => {
+ component.searchQuery = 'someQuery';
+ component.clearSearchQuery();
+
+ expect(component.searchQuery).toBe('');
+ expect(
+ mockReportsStore.setFilteredValuesQuickSearch
+ ).toHaveBeenCalledWith('');
+ });
+ });
+
+ describe('Row expansion methods', () => {
+ it('toggleRowExpand should add and delete row from expandedRows and announce to screen reader', () => {
+ const item = FORMATTED_HISTORY[0];
+ expect(component.isExpanded(item)).toBeFalse();
+
+ component.toggleRowExpand(item);
+ expect(component.isExpanded(item)).toBeTrue();
+ expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith(
+ `Metadata expanded for ${item.deviceInfo}`
+ );
+
+ component.toggleRowExpand(item);
+ expect(component.isExpanded(item)).toBeFalse();
+ expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith(
+ `Metadata collapsed for ${item.deviceInfo}`
+ );
+ });
+
+ it('toggleRowExpand should stop event propagation when event is provided', () => {
+ const item = FORMATTED_HISTORY[0];
+ const event = jasmine.createSpyObj('Event', [
+ 'preventDefault',
+ 'stopPropagation',
+ ]);
+
+ component.toggleRowExpand(item, event);
+
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
+ expect(component.isExpanded(item)).toBeTrue();
+ });
+
+ it('getRowId should return sanitized identifier', () => {
+ const item = {
+ ...FORMATTED_HISTORY[0],
+ started: '2023-06-23T10:11:00',
+ deviceInfo: 'Raspberry Pi / 4',
+ };
+ const id = component.getRowId(item);
+ expect(id).toBe('2023-06-23T10_11_00-Raspberry_Pi___4');
+ });
+ });
+
+ describe('filterCleared', () => {
+ it('should update searchQuery and call store.setFilteredValues', () => {
+ const filters: Filters = {
+ deviceInfo: '',
+ deviceFirmware: '',
+ assessmentType: '',
+ results: [],
+ dateRange: '',
+ quickSearch: ['searchKeyword'],
+ location: '',
+ linuxEnv: '',
+ pythonVersion: '',
+ kernel: '',
+ };
+
+ component.filterCleared(filters);
+
+ expect(component.searchQuery).toBe('searchKeyword');
+ expect(mockReportsStore.setFilteredValues).toHaveBeenCalledWith(
+ filters
+ );
+ });
+
+ it('should fallback to empty searchQuery if quickSearch is missing in filters', () => {
+ const filters = {
+ deviceInfo: '',
+ deviceFirmware: '',
+ results: [],
+ dateRange: '',
+ };
+
+ component.filterCleared(filters as unknown as Filters);
+
+ expect(component.searchQuery).toBe('');
+ expect(mockReportsStore.setFilteredValues).toHaveBeenCalledWith(
+ filters as unknown as Filters
+ );
+ });
+ });
+
+ describe('selectRow and trackByStarted', () => {
+ it('selectRow should call store.setSelectedRow', () => {
+ const row = {} as HistoryTestrun;
+ component.selectRow(row);
+ expect(mockReportsStore.setSelectedRow).toHaveBeenCalledWith(row);
+ });
+
+ it('trackByStarted should return started date of item', () => {
+ expect(component.trackByStarted(0, FORMATTED_HISTORY[0])).toBe(
+ FORMATTED_HISTORY[0].started
+ );
+ });
+ });
+
+ describe('Metadata helper methods', () => {
+ it('should return location from host object', () => {
+ expect(component.getLocation(HISTORY[0])).toBe(
+ 'Data Center Alpha - Rack 12, Bay B'
+ );
+
+ const itemWithoutHost = {
+ ...HISTORY[0],
+ host: null,
+ } as HistoryTestrun;
+ expect(component.getLocation(itemWithoutHost)).toBeUndefined();
+ });
+
+ it('should return linux_env from host object', () => {
+ expect(component.getLinuxEnv(HISTORY[0])).toBe(
+ 'Ubuntu 24.04 LTS (x86_64)'
+ );
+
+ const itemWithoutHost = {
+ ...HISTORY[0],
+ host: null,
+ } as HistoryTestrun;
+ expect(component.getLinuxEnv(itemWithoutHost)).toBeUndefined();
+ });
+
+ it('should return kernel from device object', () => {
+ expect(component.getKernel(HISTORY[0])).toBe('Linux 6.8.0-40-generic');
+
+ const itemWithoutDevice = {
+ ...HISTORY[0],
+ device: null,
+ } as unknown as HistoryTestrun;
+ expect(component.getKernel(itemWithoutDevice)).toBeUndefined();
+ });
+
+ it('should return python_version from host object', () => {
+ expect(component.getPythonVersion(HISTORY[0])).toBe('3.11.2');
+
+ const itemWithoutHost = {
+ ...HISTORY[0],
+ host: null,
+ } as HistoryTestrun;
+ expect(component.getPythonVersion(itemWithoutHost)).toBeUndefined();
+ });
+ });
+
+ describe('Pagination and Responsive Page Size', () => {
+ it('should calculate page size based on width breakpoints', () => {
+ const mockMatched = (query: string): boolean =>
+ query === Breakpoints.XSmall;
+ expect(component.calculatePageSizeFromQueries(mockMatched)).toBe(
+ SCREEN_SIZE_PAGE_SIZES.XSmall
+ );
+
+ const mockSmall = (query: string): boolean =>
+ query === Breakpoints.Small;
+ expect(component.calculatePageSizeFromQueries(mockSmall)).toBe(
+ SCREEN_SIZE_PAGE_SIZES.Small
+ );
+
+ const mockMedium = (query: string): boolean =>
+ query === Breakpoints.Medium;
+ expect(component.calculatePageSizeFromQueries(mockMedium)).toBe(
+ SCREEN_SIZE_PAGE_SIZES.Medium
+ );
+
+ const mockLarge = (query: string): boolean =>
+ query === Breakpoints.Large;
+ expect(component.calculatePageSizeFromQueries(mockLarge)).toBe(
+ SCREEN_SIZE_PAGE_SIZES.Large
+ );
+
+ const mockXLarge = (query: string): boolean =>
+ query === Breakpoints.XLarge;
+ expect(component.calculatePageSizeFromQueries(mockXLarge)).toBe(
+ SCREEN_SIZE_PAGE_SIZES.XLarge
+ );
+ });
+
+ it('should adjust page size based on screen height', () => {
+ const matchLarge = (query: string): boolean =>
+ query === Breakpoints.Large;
+
+ expect(component.calculatePageSizeFromQueries(matchLarge, 500)).toBe(3);
+ expect(component.calculatePageSizeFromQueries(matchLarge, 700)).toBe(5);
+ expect(component.calculatePageSizeFromQueries(matchLarge, 850)).toBe(8);
+ expect(component.calculatePageSizeFromQueries(matchLarge, 1000)).toBe(
+ 11
+ );
+ expect(component.calculatePageSizeFromQueries(matchLarge, 1200)).toBe(
+ 14
+ );
+ });
+
+ it('should calculate page size based on container height when available', () => {
+ const matchLarge = (query: string): boolean =>
+ query === Breakpoints.Large;
+
+ expect(
+ component.calculatePageSizeFromQueries(matchLarge, 950, 622)
+ ).toBe(9);
+ expect(
+ component.calculatePageSizeFromQueries(matchLarge, 1200, 746)
+ ).toBe(12);
+ expect(
+ component.calculatePageSizeFromQueries(matchLarge, 600, 300)
+ ).toBe(3);
+ });
+
+ it('should return larger size on extra large screen with large height', () => {
+ const matchXLarge = (query: string): boolean =>
+ query === Breakpoints.XLarge;
+ expect(component.calculatePageSizeFromQueries(matchXLarge, 1200)).toBe(
+ 15
+ );
+ });
+
+ it('#updatePageSize should update pageSize and call _changePageSize on paginator', () => {
+ const mockPaginator = {
+ pageSize: 10,
+ _changePageSize: jasmine.createSpy('_changePageSize'),
+ } as unknown as MatPaginator;
+ spyOn(component, 'paginator').and.returnValue(mockPaginator);
+
+ component.updatePageSize(20);
+
+ expect(component.pageSize).toBe(20);
+ expect(mockPaginator.pageSize).toBe(20);
+ expect(
+ (mockPaginator as unknown as { _changePageSize: jasmine.Spy })
+ ._changePageSize
+ ).toHaveBeenCalledWith(20);
+ });
+
+ it('#onPageChange should announce results range and page', () => {
+ const pageEvent: PageEvent = {
+ pageIndex: 0,
+ pageSize: 10,
+ length: 485,
+ };
+
+ component.onPageChange(pageEvent);
+
+ expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith(
+ 'Showing results 1 to 10 of 485, page 1 of 49',
+ 'polite'
+ );
+ });
+
+ it('#onPageChange should announce "No results found" when length is 0', () => {
+ const pageEvent: PageEvent = {
+ pageIndex: 0,
+ pageSize: 10,
+ length: 0,
+ };
+
+ component.onPageChange(pageEvent);
+
+ expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith(
+ 'No results found',
+ 'polite'
+ );
+ });
+
+ it('#onPageChange should update pageSize and prevent automatic resize override when user selects page size', () => {
+ const pageEvent: PageEvent = {
+ pageIndex: 0,
+ pageSize: 25,
+ length: 100,
+ };
+
+ component.onPageChange(pageEvent);
+
+ expect(component.pageSize).toBe(25);
+
+ // evaluateScreenSize should not override user-selected page size
+ component.evaluateScreenSize();
+ expect(component.pageSize).toBe(25);
+ });
+
+ it('#onWindowResize should invoke evaluateScreenSize', () => {
+ spyOn(component, 'evaluateScreenSize');
+ component.onWindowResize();
+ expect(component.evaluateScreenSize).toHaveBeenCalled();
+ });
+ });
});
describe('DOM tests', () => {
@@ -371,10 +799,10 @@ describe('ReportsComponent', () => {
expect(link).toBeTruthy();
});
- it('should have filter chips', () => {
- const chips = compiled.querySelector('app-filter-chips');
+ it('should have search component', () => {
+ const search = compiled.querySelector('app-search');
- expect(chips).toBeTruthy();
+ expect(search).toBeTruthy();
});
it('should have empty state when no data satisfy filters', () => {
@@ -403,6 +831,121 @@ describe('ReportsComponent', () => {
expect(mockReportsStore.setSelectedRow).toHaveBeenCalled();
});
+
+ it('should render detail row with spacer cell and expanded detail cell with accessibility attributes', () => {
+ const detailRow = compiled.querySelector('tr.detail-row');
+ const spacerCell = detailRow?.querySelector(
+ '.expanded-detail-spacer-cell'
+ );
+ const detailCell = detailRow?.querySelector('.expanded-detail-cell');
+ const metadataDetail = detailRow?.querySelector(
+ '.metadata-accordion-detail'
+ );
+ const metadataGrid = detailRow?.querySelector('dl.metadata-grid');
+ const expandButton = compiled.querySelector('.expand-row-button');
+
+ expect(detailRow).toBeTruthy();
+ expect(spacerCell).toBeTruthy();
+ expect(detailCell).toBeTruthy();
+ expect(metadataDetail).toBeTruthy();
+ expect(metadataGrid).toBeTruthy();
+ expect(detailCell?.getAttribute('colspan')).toBe('6');
+ expect(expandButton?.getAttribute('aria-expanded')).toBe('false');
+ expect(metadataDetail?.getAttribute('role')).toBe('region');
+ expect(expandButton?.getAttribute('aria-controls')).toBeTruthy();
+ });
+
+ it('should add expanded-row class to detail row when row is expanded', () => {
+ const detailRow = compiled.querySelector('tr.detail-row');
+ expect(detailRow?.classList).not.toContain('expanded-row');
+
+ component.toggleRowExpand(DATA_SOURCE_INITIAL_VALUE_NOT_EMPTY.data[0]);
+ fixture.detectChanges();
+
+ expect(detailRow?.classList).toContain('expanded-row');
+ });
+
+ it('should render metadata values when present', () => {
+ const detailRow = compiled.querySelector('tr.detail-row');
+ const metaValues = detailRow?.querySelectorAll('.meta-value');
+ expect(metaValues?.length).toBeGreaterThanOrEqual(4);
+ });
+
+ it('should render "Could not fetch details" with error icon when metadata is missing', () => {
+ const itemWithoutMeta = {
+ ...DATA_SOURCE_INITIAL_VALUE_NOT_EMPTY.data[0],
+ host: null,
+ location: null,
+ device: {
+ ...DATA_SOURCE_INITIAL_VALUE_NOT_EMPTY.data[0].device,
+ kernel: undefined,
+ location: undefined,
+ linux_env: undefined,
+ },
+ kernel: null,
+ linux_env: null,
+ python_version: null,
+ } as HistoryTestrun;
+
+ expect(component.getLocation(itemWithoutMeta)).toBeFalsy();
+ expect(component.getLinuxEnv(itemWithoutMeta)).toBeFalsy();
+ expect(component.getKernel(itemWithoutMeta)).toBeFalsy();
+ expect(component.getPythonVersion(itemWithoutMeta)).toBeFalsy();
+ });
+
+ it('should render mat-paginator with accessible attributes', () => {
+ const paginator = compiled.querySelector('mat-paginator');
+ expect(paginator).toBeTruthy();
+ expect(paginator?.getAttribute('aria-label')).toBe(
+ 'Select page of reports'
+ );
+ });
+
+ it('should hide page size dropdown on mat-paginator', () => {
+ const paginator = component.paginator();
+ expect(paginator?.hidePageSize).toBeTrue();
+ const pageSizeContainer = compiled.querySelector(
+ '.mat-mdc-paginator-page-size'
+ );
+ expect(pageSizeContainer).toBeNull();
+ });
+
+ it('should hide paginator when there is no data', () => {
+ const emptyDataSource = new MatTableDataSource([]);
+ component.viewModel$ = getViewModel(emptyDataSource, true);
+ fixture.detectChanges();
+
+ const paginator = compiled.querySelector('mat-paginator');
+ expect(paginator?.classList).toContain('hidden');
+ });
+
+ it('should paginate items when results exceed page size', () => {
+ const manyItems: HistoryTestrun[] = Array.from(
+ { length: 25 },
+ (_, i) => ({
+ ...FORMATTED_HISTORY[0],
+ started: `2023-06-${(i + 1).toString().padStart(2, '0')}T10:11:00.123Z`,
+ deviceInfo: `Device ${i + 1}`,
+ })
+ );
+ const manyDataSource = new MatTableDataSource(
+ manyItems
+ );
+ component.viewModel$ = getViewModel(manyDataSource, true);
+ component.pageSize = 10;
+ fixture.detectChanges();
+
+ const paginator = component.paginator();
+ if (paginator) {
+ manyDataSource.paginator = paginator;
+ fixture.detectChanges();
+ }
+
+ const renderedRows = compiled.querySelectorAll(
+ 'tbody tr:not(.detail-row)'
+ );
+ expect(renderedRows.length).toBe(10);
+ });
});
});
});
diff --git a/modules/ui/src/app/pages/reports/reports.component.ts b/modules/ui/src/app/pages/reports/reports.component.ts
index a530f97f1..2e9bc0b1d 100644
--- a/modules/ui/src/app/pages/reports/reports.component.ts
+++ b/modules/ui/src/app/pages/reports/reports.component.ts
@@ -15,13 +15,26 @@
*/
import {
Component,
+ DestroyRef,
ElementRef,
+ HostListener,
+ Injector,
OnDestroy,
OnInit,
- viewChild,
+ afterNextRender,
+ effect,
inject,
+ viewChild,
} from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
+import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
+import {
+ MatPaginator,
+ MatPaginatorIntl,
+ MatPaginatorModule,
+ PageEvent,
+} from '@angular/material/paginator';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TestRunService } from '../../services/test-run.service';
import {
HistoryTestrun,
@@ -29,26 +42,78 @@ import {
TestrunReport,
} from '../../model/testrun-status';
import { CommonModule, DatePipe } from '@angular/common';
+import { FormsModule } from '@angular/forms';
import { MatSort, MatSortModule, Sort } from '@angular/material/sort';
import { Subject, takeUntil, timer } from 'rxjs';
import { MatRow, MatTableModule } from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatInputModule } from '@angular/material/input';
import { tap } from 'rxjs/internal/operators/tap';
-import { FilterName, FilterTitle, Filters } from '../../model/filters';
-import { ReportsStore } from './reports.store';
import {
- FilterHeaderComponent,
+ FilterName,
+ FilterTitle,
+ Filters,
OpenFilterEvent,
-} from './components/filter-header/filter-header.component';
+} from '../../model/filters';
+import { ReportsStore } from './reports.store';
import { MatToolbarModule } from '@angular/material/toolbar';
-import { MatIcon, MatIconModule } from '@angular/material/icon';
-import { FilterChipsComponent } from './components/filter-chips/filter-chips.component';
+import { MatIconModule } from '@angular/material/icon';
+import { SearchComponent } from './components/search/search.component';
import { DownloadReportZipComponent } from '../../components/download-report-zip/download-report-zip.component';
import { DownloadReportPdfComponent } from '../../components/download-report-pdf/download-report-pdf.component';
import { DeleteReportComponent } from './components/delete-report/delete-report.component';
import { FilterDialogComponent } from './components/filter-dialog/filter-dialog.component';
import { EmptyMessageComponent } from '../../components/empty-message/empty-message.component';
import { MatTooltipModule } from '@angular/material/tooltip';
+import { state, style, trigger } from '@angular/animations';
+import { ReportsPaginatorIntl } from './reports-paginator-intl';
+
+export const TABLE_ROW_HEIGHT = 52;
+export const TABLE_OVERHEAD_HEIGHT = 112; // Header row (56px) + Paginator (56px)
+export const PAGE_VERTICAL_OVERHEAD = 410; // Estimated vertical UI overhead outside table data rows
+export const DEFAULT_PAGE_SIZE = 10;
+export const MIN_PAGE_SIZE = 3;
+export const MAX_PAGE_SIZE = 50;
+
+export const SCREEN_SIZE_PAGE_SIZES = {
+ XSmall: 3,
+ Small: 6,
+ Medium: 9,
+ Large: 14,
+ XLarge: 20,
+} as const;
+
+export interface CalculatePageSizeOptions {
+ containerHeight?: number;
+ windowHeight?: number;
+ maxForBreakpoint?: number;
+}
+
+export function calculatePageSize(
+ options: CalculatePageSizeOptions = {}
+): number {
+ const { containerHeight, windowHeight, maxForBreakpoint } = options;
+
+ let availableHeight: number | undefined;
+ if (containerHeight !== undefined && containerHeight > 0) {
+ availableHeight = containerHeight - TABLE_OVERHEAD_HEIGHT;
+ } else if (windowHeight !== undefined && windowHeight > 0) {
+ availableHeight = windowHeight - PAGE_VERTICAL_OVERHEAD;
+ }
+
+ if (availableHeight === undefined) {
+ return maxForBreakpoint ?? DEFAULT_PAGE_SIZE;
+ }
+
+ let count = Math.floor(availableHeight / TABLE_ROW_HEIGHT);
+
+ if (maxForBreakpoint !== undefined && maxForBreakpoint > 0) {
+ count = Math.min(count, maxForBreakpoint);
+ }
+
+ return Math.max(MIN_PAGE_SIZE, Math.min(MAX_PAGE_SIZE, count));
+}
@Component({
selector: 'app-history',
@@ -56,26 +121,44 @@ import { MatTooltipModule } from '@angular/material/tooltip';
styleUrls: ['./reports.component.scss'],
imports: [
CommonModule,
+ FormsModule,
MatTableModule,
MatIconModule,
MatToolbarModule,
MatSortModule,
- FilterChipsComponent,
+ MatButtonModule,
+ MatInputModule,
+ MatPaginatorModule,
+ SearchComponent,
DeleteReportComponent,
DownloadReportZipComponent,
DownloadReportPdfComponent,
- FilterHeaderComponent,
EmptyMessageComponent,
- MatSortModule,
- MatIcon,
MatTooltipModule,
],
- providers: [ReportsStore, DatePipe],
+ providers: [
+ ReportsStore,
+ DatePipe,
+ { provide: MatPaginatorIntl, useClass: ReportsPaginatorIntl },
+ ],
+ animations: [
+ trigger('detailExpand', [
+ state(
+ 'collapsed,void',
+ style({ height: '0px', minHeight: '0', display: 'none' })
+ ),
+ state('expanded', style({ height: '*' })),
+ ]),
+ ],
})
export class ReportsComponent implements OnInit, OnDestroy {
+ EMPTY_DETAIL_TEXT = 'Could not fetch details';
private testRunService = inject(TestRunService);
private datePipe = inject(DatePipe);
private liveAnnouncer = inject(LiveAnnouncer);
+ private breakpointObserver = inject(BreakpointObserver);
+ private destroyRef = inject(DestroyRef);
+ private injector = inject(Injector);
dialog = inject(MatDialog);
private store = inject(ReportsStore);
@@ -83,7 +166,56 @@ export class ReportsComponent implements OnInit, OnDestroy {
public readonly FilterTitle = FilterTitle;
private destroy$: Subject = new Subject();
sort = viewChild(MatSort);
+ paginator = viewChild(MatPaginator);
+ historyContent = viewChild>('historyContent');
viewModel$ = this.store.viewModel$;
+ private resizeObserver?: ResizeObserver;
+
+ pageSize: number = DEFAULT_PAGE_SIZE;
+ private isUserSelectedPageSize = false;
+
+ private readonly paginatorEffect = effect(() => {
+ const paginator = this.paginator();
+ if (paginator) {
+ this.store.updatePaginator(paginator);
+ }
+ });
+
+ private readonly sortEffect = effect(() => {
+ const sort = this.sort();
+ if (sort) {
+ this.store.updateSort(sort);
+ }
+ });
+
+ private readonly historyContentEffect = effect(() => {
+ const historyContent = this.historyContent();
+ if (historyContent?.nativeElement) {
+ this.evaluateScreenSize();
+ this.setupResizeObserver(historyContent.nativeElement);
+ }
+ });
+
+ private setupResizeObserver(element: HTMLElement): void {
+ if (typeof ResizeObserver === 'undefined') {
+ return;
+ }
+ this.resizeObserver?.disconnect();
+ this.resizeObserver = new ResizeObserver(() => {
+ this.evaluateScreenSize();
+ });
+ this.resizeObserver.observe(element);
+ }
+
+ constructor() {
+ this.initResponsivePageSize();
+ afterNextRender(
+ () => {
+ this.evaluateScreenSize();
+ },
+ { injector: this.injector }
+ );
+ }
ngOnInit() {
this.store.fetchReports();
@@ -91,6 +223,184 @@ export class ReportsComponent implements OnInit, OnDestroy {
if (sort) {
this.store.updateSort(sort);
}
+ const paginator = this.paginator();
+ if (paginator) {
+ this.store.updatePaginator(paginator);
+ }
+ }
+
+ @HostListener('window:resize')
+ onWindowResize(): void {
+ this.evaluateScreenSize();
+ }
+
+ private initResponsivePageSize(): void {
+ this.breakpointObserver
+ .observe([
+ Breakpoints.XSmall,
+ Breakpoints.Small,
+ Breakpoints.Medium,
+ Breakpoints.Large,
+ Breakpoints.XLarge,
+ ])
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(() => {
+ this.evaluateScreenSize();
+ });
+
+ this.evaluateScreenSize();
+ }
+
+ evaluateScreenSize(): void {
+ if (this.isUserSelectedPageSize) {
+ return;
+ }
+ const isMatched = (query: string): boolean => {
+ return this.breakpointObserver.isMatched(query);
+ };
+ const windowHeight =
+ typeof window !== 'undefined' ? window.innerHeight : undefined;
+ const containerHeight = this.historyContent()?.nativeElement?.clientHeight;
+ const newSize = this.calculatePageSizeFromQueries(
+ isMatched,
+ windowHeight,
+ containerHeight && containerHeight > 0 ? containerHeight : undefined
+ );
+ this.updatePageSize(newSize);
+ }
+
+ calculatePageSizeFromQueries(
+ isMatched: (query: string) => boolean,
+ windowHeight?: number,
+ containerHeight?: number
+ ): number {
+ let widthSize: number = SCREEN_SIZE_PAGE_SIZES.Medium;
+ if (isMatched(Breakpoints.XSmall)) {
+ widthSize = SCREEN_SIZE_PAGE_SIZES.XSmall;
+ } else if (isMatched(Breakpoints.Small)) {
+ widthSize = SCREEN_SIZE_PAGE_SIZES.Small;
+ } else if (isMatched(Breakpoints.Medium)) {
+ widthSize = SCREEN_SIZE_PAGE_SIZES.Medium;
+ } else if (isMatched(Breakpoints.Large)) {
+ widthSize = SCREEN_SIZE_PAGE_SIZES.Large;
+ } else if (isMatched(Breakpoints.XLarge)) {
+ widthSize = SCREEN_SIZE_PAGE_SIZES.XLarge;
+ }
+
+ return calculatePageSize({
+ containerHeight,
+ windowHeight,
+ maxForBreakpoint: widthSize,
+ });
+ }
+
+ updatePageSize(newSize: number): void {
+ if (this.pageSize !== newSize) {
+ this.pageSize = newSize;
+ const paginator = this.paginator();
+ if (paginator) {
+ paginator.pageSize = newSize;
+ if (
+ typeof (paginator as { _changePageSize?: (size: number) => void })
+ ._changePageSize === 'function'
+ ) {
+ (
+ paginator as { _changePageSize: (size: number) => void }
+ )._changePageSize(newSize);
+ }
+ }
+ }
+ }
+
+ onPageChange(event: PageEvent): void {
+ if (this.pageSize !== event.pageSize) {
+ this.pageSize = event.pageSize;
+ this.isUserSelectedPageSize = true;
+ }
+ if (event.length === 0) {
+ this.liveAnnouncer.announce('No results found', 'polite');
+ return;
+ }
+ const totalPages = Math.ceil(event.length / event.pageSize) || 1;
+ const currentPage = event.pageIndex + 1;
+ const startIndex = event.pageIndex * event.pageSize + 1;
+ const endIndex = Math.min(
+ (event.pageIndex + 1) * event.pageSize,
+ event.length
+ );
+ this.liveAnnouncer.announce(
+ `Showing results ${startIndex} to ${endIndex} of ${event.length}, page ${currentPage} of ${totalPages}`,
+ 'polite'
+ );
+ }
+
+ public expandedRows: Set = new Set();
+ public searchQuery: string = '';
+
+ getRowId(data: HistoryTestrun | TestrunReport): string {
+ const rawId =
+ (data as { id?: string })?.id ||
+ `${data.started || ''}-${(data as HistoryTestrun).deviceInfo || data.device?.model || ''}`;
+ return rawId.toString().replace(/[^a-zA-Z0-9_-]/g, '_');
+ }
+
+ toggleRowExpand(data: HistoryTestrun, event?: Event) {
+ if (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ if (this.expandedRows.has(data)) {
+ this.expandedRows.delete(data);
+ this.liveAnnouncer.announce(
+ `Metadata collapsed for ${data.deviceInfo || 'test run'}`
+ );
+ } else {
+ this.expandedRows.add(data);
+ this.liveAnnouncer.announce(
+ `Metadata expanded for ${data.deviceInfo || 'test run'}`
+ );
+ }
+ }
+
+ isExpanded(data: HistoryTestrun): boolean {
+ return this.expandedRows.has(data);
+ }
+
+ getLocation(data: HistoryTestrun | TestrunReport): string | null | undefined {
+ return data.host?.location;
+ }
+
+ getLinuxEnv(data: HistoryTestrun | TestrunReport): string | null | undefined {
+ return data.host?.linux_env;
+ }
+
+ getKernel(data: HistoryTestrun | TestrunReport): string | null | undefined {
+ return data.device?.kernel;
+ }
+
+ getPythonVersion(
+ data: HistoryTestrun | TestrunReport
+ ): string | null | undefined {
+ return data.host?.python_version;
+ }
+
+ applySearchQuery() {
+ this.store.setFilteredValuesQuickSearch(this.searchQuery);
+ }
+
+ onSearchQueryChanged(query: string) {
+ this.searchQuery = query;
+ this.applySearchQuery();
+ }
+
+ addSearchTag(tag: string) {
+ this.searchQuery = tag;
+ this.applySearchQuery();
+ }
+
+ clearSearchQuery() {
+ this.searchQuery = '';
+ this.applySearchQuery();
}
getFormattedDateString(date: string | null) {
@@ -112,20 +422,29 @@ export class ReportsComponent implements OnInit, OnDestroy {
return this.testRunService.getResultClass(status);
}
- openFilter({ event, filter, title, filterOpened }: OpenFilterEvent) {
+ openFilter({
+ event,
+ filter,
+ title,
+ filterOpened,
+ menuRect,
+ itemRect,
+ }: OpenFilterEvent) {
event.preventDefault();
event.stopPropagation();
const target = new ElementRef(event.currentTarget);
if (!filterOpened) {
- this.openFilterDialog(target, filter, title);
+ this.openFilterDialog(target, filter, title, menuRect, itemRect);
}
}
openFilterDialog(
target: ElementRef,
filter: string,
- title: string
+ title: string,
+ menuRect?: DOMRect,
+ itemRect?: DOMRect
) {
this.store.setFilterOpened(true);
this.store.setActiveFiler(filter);
@@ -135,6 +454,8 @@ export class ReportsComponent implements OnInit, OnDestroy {
filter,
title,
trigger: target,
+ menuRect,
+ itemRect,
},
autoFocus: true,
hasBackdrop: true,
@@ -167,15 +488,38 @@ export class ReportsComponent implements OnInit, OnDestroy {
if (filter === FilterName.Started) {
this.store.setFilteredValuesDateRange(filteredData.dateRange);
}
+ if (filter === FilterName.Location) {
+ this.store.setFilteredValuesLocation(filteredData.location);
+ }
+ if (filter === FilterName.LinuxEnv) {
+ this.store.setFilteredValuesLinuxEnv(filteredData.linuxEnv);
+ }
+ if (filter === FilterName.PythonVersion) {
+ this.store.setFilteredValuesPythonVersion(
+ filteredData.pythonVersion
+ );
+ }
+ if (filter === FilterName.Kernel) {
+ this.store.setFilteredValuesKernel(filteredData.kernel);
+ }
+ if (filter === FilterName.AssessmentType) {
+ this.store.setFilteredValuesAssessmentType(
+ filteredData.assessmentType
+ );
+ }
}
});
}
filterCleared(filters: Filters) {
+ this.searchQuery = Array.isArray(filters.quickSearch)
+ ? (filters.quickSearch[0] ?? '')
+ : filters.quickSearch || '';
this.store.setFilteredValues(filters);
}
ngOnDestroy() {
+ this.resizeObserver?.disconnect();
this.destroy$.next(true);
this.destroy$.unsubscribe();
}
@@ -190,9 +534,12 @@ export class ReportsComponent implements OnInit, OnDestroy {
focusNextButton() {
// Try to focus next interactive element, if exists
- const next = window.document.querySelector(
- '.report-selected + tr a'
- ) as HTMLButtonElement;
+ const next = (window.document.querySelector(
+ '.report-selected + tr + tr a'
+ ) ||
+ window.document.querySelector(
+ '.report-selected + tr a'
+ )) as HTMLButtonElement;
if (next) {
timer(50).subscribe(() => {
next.focus();
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 fc76a45a7..d43068c11 100644
--- a/modules/ui/src/app/pages/reports/reports.store.spec.ts
+++ b/modules/ui/src/app/pages/reports/reports.store.spec.ts
@@ -19,7 +19,9 @@ import SpyObj = jasmine.SpyObj;
import { TestBed } from '@angular/core/testing';
import { skip, take } from 'rxjs';
import { of } from 'rxjs/internal/observable/of';
-import { ReportsStore } from './reports.store';
+import { ReportsComponentState, ReportsStore } from './reports.store';
+import { DateRange } from '../../model/filters';
+import { HistoryTestrun } from '../../model/testrun-status';
import {
EMPTY_FILTERS,
FILTERS,
@@ -30,10 +32,12 @@ import {
import { DatePipe } from '@angular/common';
import { MatRow } from '@angular/material/table';
import { MatSort } from '@angular/material/sort';
+import { MatPaginator } from '@angular/material/paginator';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { selectReports, selectRiskProfiles } from '../../store/selectors';
import { AppState } from '../../store/state';
import { setReports } from '../../store/actions';
+import { TestingType } from '../../model/device';
describe('ReportsStore', () => {
let reportsStore: ReportsStore;
@@ -132,6 +136,7 @@ describe('ReportsStore', () => {
reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
expect(store).toEqual({
displayedColumns: [
+ 'expand',
'started',
'duration',
'deviceInfo',
@@ -147,8 +152,14 @@ describe('ReportsStore', () => {
filteredValues: {
deviceInfo: '',
deviceFirmware: '',
+ assessmentType: '',
results: [],
dateRange: '',
+ quickSearch: [],
+ location: '',
+ linuxEnv: '',
+ pythonVersion: '',
+ kernel: '',
},
dataLoaded: true,
selectedRow: null,
@@ -217,6 +228,25 @@ describe('ReportsStore', () => {
});
});
+ describe('updatePaginator', () => {
+ it('should update store with paginator', done => {
+ const paginator = {
+ pageIndex: 0,
+ pageSize: 10,
+ page: of(),
+ initialized: of(),
+ } as unknown as MatPaginator;
+ store.overrideSelector(selectReports, [...HISTORY]);
+
+ reportsStore.updatePaginator(paginator);
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.dataSource.paginator).toEqual(paginator);
+ done();
+ });
+ });
+ });
+
describe('setFilteredValuesResults', () => {
it('should update store', done => {
const updatedFilters = { ...FILTERS, ...{ results: ['test2'] } };
@@ -253,6 +283,111 @@ describe('ReportsStore', () => {
});
});
+ describe('setFilteredValuesQuickSearch', () => {
+ it('should update store', done => {
+ const updatedFilters = { ...FILTERS, ...{ quickSearch: ['test2'] } };
+ store.overrideSelector(selectReports, [...HISTORY]);
+ reportsStore.setFilteredValues({ ...FILTERS });
+
+ reportsStore.setFilteredValuesQuickSearch(['test2']);
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.filteredValues).toEqual(updatedFilters);
+ expect(store.dataSource.filter).toEqual(
+ JSON.stringify(updatedFilters)
+ );
+ done();
+ });
+ });
+
+ it('should filter by location, kernel, or linux_env', done => {
+ reportsStore.setDataSource([...HISTORY]);
+ reportsStore.setFilteredValuesQuickSearch('Data Center Alpha');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.dataSource.filteredData.length).toBe(3);
+ done();
+ });
+ });
+
+ it('should filter by kernel', done => {
+ reportsStore.setDataSource([...HISTORY]);
+ reportsStore.setFilteredValuesQuickSearch('Linux 6.8');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.dataSource.filteredData.length).toBe(3);
+ done();
+ });
+ });
+
+ it('should filter by linux_env', done => {
+ reportsStore.setDataSource([...HISTORY]);
+ reportsStore.setFilteredValuesQuickSearch('Ubuntu 24.04');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.dataSource.filteredData.length).toBe(3);
+ done();
+ });
+ });
+
+ it('should filter by python_version', done => {
+ reportsStore.setDataSource([...HISTORY]);
+ reportsStore.setFilteredValuesQuickSearch('3.11.2');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.dataSource.filteredData.length).toBe(3);
+ done();
+ });
+ });
+
+ it('should filter by mac address', done => {
+ reportsStore.setDataSource([...HISTORY]);
+ reportsStore.setFilteredValuesQuickSearch('05:07');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.dataSource.filteredData.length).toBe(1);
+ done();
+ });
+ });
+
+ it('should filter by formatted timestamp date', done => {
+ reportsStore.setDataSource([...HISTORY]);
+ reportsStore.setFilteredValuesQuickSearch('Jul 2023');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.dataSource.filteredData.length).toBe(1);
+ 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', () => {
it('should update store', done => {
const updatedFilters = { ...FILTERS, ...{ deviceFirmware: 'test2' } };
@@ -271,6 +406,24 @@ describe('ReportsStore', () => {
});
});
+ describe('setFilteredValuesAssessmentType', () => {
+ it('should update store', done => {
+ const updatedFilters = { ...FILTERS, ...{ assessmentType: 'test2' } };
+ store.overrideSelector(selectReports, [...HISTORY]);
+ reportsStore.setFilteredValues({ ...FILTERS });
+
+ reportsStore.setFilteredValuesAssessmentType('test2');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.filteredValues).toEqual(updatedFilters);
+ expect(store.dataSource.filter).toEqual(
+ JSON.stringify(updatedFilters)
+ );
+ done();
+ });
+ });
+ });
+
describe('setFilteredValuesDateRange', () => {
it('should update store', done => {
const updatedFilters = { ...FILTERS, ...{ dateRange: 'test2' } };
@@ -289,6 +442,78 @@ describe('ReportsStore', () => {
});
});
+ describe('setFilteredValuesLocation', () => {
+ it('should update store', done => {
+ const updatedFilters = { ...FILTERS, ...{ location: 'test2' } };
+ store.overrideSelector(selectReports, [...HISTORY]);
+ reportsStore.setFilteredValues({ ...FILTERS });
+
+ reportsStore.setFilteredValuesLocation('test2');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.filteredValues).toEqual(updatedFilters);
+ expect(store.dataSource.filter).toEqual(
+ JSON.stringify(updatedFilters)
+ );
+ done();
+ });
+ });
+ });
+
+ describe('setFilteredValuesLinuxEnv', () => {
+ it('should update store', done => {
+ const updatedFilters = { ...FILTERS, ...{ linuxEnv: 'test2' } };
+ store.overrideSelector(selectReports, [...HISTORY]);
+ reportsStore.setFilteredValues({ ...FILTERS });
+
+ reportsStore.setFilteredValuesLinuxEnv('test2');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.filteredValues).toEqual(updatedFilters);
+ expect(store.dataSource.filter).toEqual(
+ JSON.stringify(updatedFilters)
+ );
+ done();
+ });
+ });
+ });
+
+ describe('setFilteredValuesPythonVersion', () => {
+ it('should update store', done => {
+ const updatedFilters = { ...FILTERS, ...{ pythonVersion: 'test2' } };
+ store.overrideSelector(selectReports, [...HISTORY]);
+ reportsStore.setFilteredValues({ ...FILTERS });
+
+ reportsStore.setFilteredValuesPythonVersion('test2');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.filteredValues).toEqual(updatedFilters);
+ expect(store.dataSource.filter).toEqual(
+ JSON.stringify(updatedFilters)
+ );
+ done();
+ });
+ });
+ });
+
+ describe('setFilteredValuesKernel', () => {
+ it('should update store', done => {
+ const updatedFilters = { ...FILTERS, ...{ kernel: 'test2' } };
+ store.overrideSelector(selectReports, [...HISTORY]);
+ reportsStore.setFilteredValues({ ...FILTERS });
+
+ reportsStore.setFilteredValuesKernel('test2');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store.filteredValues).toEqual(updatedFilters);
+ expect(store.dataSource.filter).toEqual(
+ JSON.stringify(updatedFilters)
+ );
+ done();
+ });
+ });
+ });
+
describe('setFilteredValues', () => {
it('should update store', done => {
const updatedFilters = { ...EMPTY_FILTERS };
@@ -306,5 +531,145 @@ describe('ReportsStore', () => {
});
});
});
+
+ describe('fetchReports', () => {
+ it('should dispatch fetchReports action', () => {
+ reportsStore.fetchReports();
+ expect(store.dispatch).toHaveBeenCalled();
+ });
+ });
+
+ describe('sortingDataAccessor and formatting helpers', () => {
+ it('sortingDataAccessor should handle string and non-string values', () => {
+ reportsStore.setDataSource([...HISTORY]);
+ const dataSource = (
+ reportsStore as unknown as { get: () => ReportsComponentState }
+ ).get().dataSource;
+
+ expect(
+ dataSource.sortingDataAccessor(
+ { deviceInfo: 'Raspberry Pi' } as HistoryTestrun,
+ 'deviceInfo'
+ )
+ ).toBe('raspberry pi');
+ expect(
+ dataSource.sortingDataAccessor(
+ { count: 123 } as unknown as HistoryTestrun,
+ 'count'
+ )
+ ).toBe(123);
+ });
+
+ it('should handle Pilot test pack and non-complete qualification in test results', done => {
+ const pilotItem = {
+ ...HISTORY[0],
+ device: {
+ ...HISTORY[0].device,
+ test_pack: TestingType.Pilot,
+ },
+ status: 'In Progress',
+ };
+ const incompleteQualItem = {
+ ...HISTORY[0],
+ device: {
+ ...HISTORY[0].device,
+ test_pack: TestingType.Qualification,
+ },
+ status: 'Running',
+ result: null,
+ };
+
+ reportsStore.setDataSource([
+ pilotItem as unknown as HistoryTestrun,
+ incompleteQualItem as unknown as HistoryTestrun,
+ ]);
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(vm => {
+ expect(vm.dataSource.data[0].testResult).toBe('In Progress');
+ expect(vm.dataSource.data[1].testResult).toBe('Running');
+ done();
+ });
+ });
+
+ it('should handle missing started or finished dates in duration calculation', done => {
+ const itemWithoutDates = {
+ ...HISTORY[0],
+ started: null,
+ finished: null,
+ };
+
+ reportsStore.setDataSource([
+ itemWithoutDates as unknown as HistoryTestrun,
+ ]);
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(vm => {
+ expect(vm.dataSource.data[0].duration).toBe('');
+ done();
+ });
+ });
+ });
+
+ describe('DateRange and search query filter variations', () => {
+ it('should filter by date strings in dateRange', done => {
+ reportsStore.setDataSource([...HISTORY]);
+
+ reportsStore.setFilteredValuesDateRange({
+ start: '2023-06-20T00:00:00Z',
+ end: '2023-06-25T23:59:59Z',
+ } as unknown as DateRange);
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(vm => {
+ expect(vm.dataSource.filteredData.length).toBe(2);
+ done();
+ });
+ });
+
+ it('should filter out items outside DateRange', done => {
+ reportsStore.setDataSource([...HISTORY]);
+
+ reportsStore.setFilteredValuesDateRange({
+ start: '2024-01-01T00:00:00Z',
+ end: '2024-01-05T23:59:59Z',
+ } as unknown as DateRange);
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(vm => {
+ expect(vm.dataSource.filteredData.length).toBe(0);
+ done();
+ });
+ });
+
+ it('should return all items when quickSearch is empty or contains only whitespace', done => {
+ reportsStore.setDataSource([...HISTORY]);
+ reportsStore.setFilteredValuesQuickSearch(' ');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(vm => {
+ expect(vm.dataSource.filteredData.length).toBe(3);
+ done();
+ });
+ });
+
+ it('should filter by assessmentType', done => {
+ reportsStore.setDataSource([...HISTORY]);
+ reportsStore.setFilteredValuesAssessmentType('Qualification');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(vm => {
+ expect(vm.dataSource.filteredData.length).toBe(3);
+ expect(vm.dataSource.filteredData[0].program).toContain(
+ 'Qualification'
+ );
+ done();
+ });
+ });
+
+ it('should filter out items when assessmentType does not match', done => {
+ reportsStore.setDataSource([...HISTORY]);
+ reportsStore.setFilteredValuesAssessmentType('Pilot');
+
+ reportsStore.viewModel$.pipe(take(1)).subscribe(vm => {
+ expect(vm.dataSource.filteredData.length).toBe(0);
+ done();
+ });
+ });
+ });
});
});
diff --git a/modules/ui/src/app/pages/reports/reports.store.ts b/modules/ui/src/app/pages/reports/reports.store.ts
index 6dce5e217..cab56aa4b 100644
--- a/modules/ui/src/app/pages/reports/reports.store.ts
+++ b/modules/ui/src/app/pages/reports/reports.store.ts
@@ -14,6 +14,7 @@ import { exhaustMap } from 'rxjs';
import { tap, withLatestFrom } from 'rxjs/operators';
import { DatePipe } from '@angular/common';
import { MatSort } from '@angular/material/sort';
+import { MatPaginator } from '@angular/material/paginator';
import { selectReports, selectRiskProfiles } from '../../store/selectors';
import { Store } from '@ngrx/store';
import { AppState } from '../../store/state';
@@ -81,6 +82,7 @@ export class ReportsStore extends ComponentStore {
dataSource.filterPredicate = this.customFilterPredicate();
dataSource.filter = JSON.stringify(state.filteredValues);
dataSource.sort = state.dataSource.sort;
+ dataSource.paginator = state.dataSource.paginator;
return {
...state,
@@ -147,6 +149,15 @@ export class ReportsStore extends ComponentStore {
);
});
+ updatePaginator = this.effect(paginator$ => {
+ return paginator$.pipe(
+ withLatestFrom(this.dataSource$),
+ tap(([paginator, dataSource]) => {
+ dataSource.paginator = paginator;
+ })
+ );
+ });
+
setFilteredValuesResults = this.effect(results$ => {
return results$.pipe(
withLatestFrom(this.filteredValues$, this.dataSource$),
@@ -159,6 +170,25 @@ export class ReportsStore extends ComponentStore {
);
});
+ 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(
withLatestFrom(this.filteredValues$, this.dataSource$),
@@ -195,6 +225,66 @@ export class ReportsStore extends ComponentStore {
);
});
+ setFilteredValuesLocation = this.effect(location$ => {
+ return location$.pipe(
+ withLatestFrom(this.filteredValues$, this.dataSource$),
+ tap(([location, filteredValues, dataSource]) => {
+ this.updateFilters(dataSource, {
+ ...filteredValues,
+ location,
+ });
+ })
+ );
+ });
+
+ setFilteredValuesLinuxEnv = this.effect(linuxEnv$ => {
+ return linuxEnv$.pipe(
+ withLatestFrom(this.filteredValues$, this.dataSource$),
+ tap(([linuxEnv, filteredValues, dataSource]) => {
+ this.updateFilters(dataSource, {
+ ...filteredValues,
+ linuxEnv,
+ });
+ })
+ );
+ });
+
+ setFilteredValuesPythonVersion = this.effect(pythonVersion$ => {
+ return pythonVersion$.pipe(
+ withLatestFrom(this.filteredValues$, this.dataSource$),
+ tap(([pythonVersion, filteredValues, dataSource]) => {
+ this.updateFilters(dataSource, {
+ ...filteredValues,
+ pythonVersion,
+ });
+ })
+ );
+ });
+
+ setFilteredValuesKernel = this.effect(kernel$ => {
+ return kernel$.pipe(
+ withLatestFrom(this.filteredValues$, this.dataSource$),
+ tap(([kernel, filteredValues, dataSource]) => {
+ this.updateFilters(dataSource, {
+ ...filteredValues,
+ kernel,
+ });
+ })
+ );
+ });
+
+ setFilteredValuesAssessmentType = this.effect(assessmentType$ => {
+ return assessmentType$.pipe(
+ withLatestFrom(this.filteredValues$, this.dataSource$),
+ tap(([assessmentType, filteredValues, dataSource]) => {
+ this.updateFilters(dataSource, {
+ ...filteredValues,
+ assessmentType,
+ });
+ })
+ );
+ });
+
setFilteredValues = this.effect(filteredValues$ => {
return filteredValues$.pipe(
withLatestFrom(this.dataSource$),
@@ -243,20 +333,29 @@ export class ReportsStore extends ComponentStore {
private formateData(data: TestReportsList): HistoryTestrun[] {
return data.map(item => {
+ const firmware = item.device?.firmware || '';
+ const manufacturer = item.device?.manufacturer || '';
+ const model = item.device?.model || '';
+ const deviceInfo = manufacturer
+ ? model
+ ? `${manufacturer} ${model}`
+ : manufacturer
+ : model;
+
return {
...item,
- deviceFirmware: item.device.firmware,
- deviceInfo: item.device.manufacturer + ' ' + item.device.model,
+ deviceFirmware: firmware,
+ deviceInfo: deviceInfo,
testResult: this.getTestResult(item),
duration: this.getDuration(item.started, item.finished),
- program: item.device.test_pack ?? '',
+ program: item.device?.test_pack ?? '',
};
});
}
private getTestResult(item: TestrunReport): string {
let result = '';
- if (item.device.test_pack === TestingType.Qualification) {
+ if (item.device?.test_pack === TestingType.Qualification) {
if (
item.status &&
item.status === StatusOfTestrun.Complete &&
@@ -267,7 +366,7 @@ export class ReportsStore extends ComponentStore {
result = item.status;
}
}
- if (item.device.test_pack === TestingType.Pilot) {
+ if (item.device?.test_pack === TestingType.Pilot) {
result = item.status;
}
return result;
@@ -298,6 +397,10 @@ export class ReportsStore extends ComponentStore {
data.deviceInfo,
searchString.deviceInfo
);
+ const isIncludeSearchQuery = this.filterSearchQuery(
+ data,
+ searchString.quickSearch
+ );
const isIncludeDeviceFirmware = this.filterStringData(
data.deviceFirmware,
searchString.deviceFirmware
@@ -310,18 +413,89 @@ export class ReportsStore extends ComponentStore {
data.started,
searchString
);
+ const isIncludeLocation = this.filterStringData(
+ data.host?.location ?? '',
+ searchString.location ?? ''
+ );
+ const isIncludeLinuxEnv = this.filterStringData(
+ data.host?.linux_env ?? '',
+ searchString.linuxEnv ?? ''
+ );
+ const isIncludePythonVersion = this.filterStringData(
+ data.host?.python_version ?? '',
+ searchString.pythonVersion ?? ''
+ );
+ const isIncludeKernel = this.filterStringData(
+ data.device?.kernel ?? '',
+ searchString.kernel ?? ''
+ );
+ const isIncludeAssessmentType = this.filterStringData(
+ data.program ?? '',
+ searchString.assessmentType ?? ''
+ );
return (
+ isIncludeSearchQuery &&
isIncludeDeviceInfo &&
isIncludeDeviceFirmware &&
isIncludeStatus &&
- isIncludeStartedDate
+ isIncludeStartedDate &&
+ isIncludeLocation &&
+ isIncludeLinuxEnv &&
+ isIncludePythonVersion &&
+ isIncludeKernel &&
+ isIncludeAssessmentType
);
};
return filterPredicate;
}
- private filterStringData(data: string, searchString: string): boolean {
+ private filterSearchQuery(
+ data: HistoryTestrun,
+ searchQuery: string[] | string = []
+ ): boolean {
+ 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 formattedStarted = data.started
+ ? this.datePipe.transform(data.started, 'd MMM y H:mm')
+ : '';
+
+ const searchableFields = [
+ data.deviceInfo,
+ data.deviceFirmware,
+ data.device?.manufacturer,
+ data.device?.model,
+ data.device?.mac_addr,
+ data.device?.firmware,
+ data.device?.kernel,
+ data.device?.test_pack,
+ data.program,
+ data.status,
+ data.testResult,
+ data.result,
+ formattedStarted,
+ data.duration,
+ data.host?.location,
+ data.host?.linux_env,
+ data.host?.python_version,
+ ];
+
+ return queries.every(query =>
+ searchableFields.some(
+ field => field && field.toString().toLowerCase().includes(query)
+ )
+ );
+ }
+
+ private filterStringData(data: string = '', searchString: string): boolean {
return (
data
.toString()
@@ -344,12 +518,16 @@ export class ReportsStore extends ComponentStore {
const startDate = searchString.dateRange.start
? typeof searchString.dateRange.start === 'string'
? Date.parse(searchString.dateRange.start)
- : searchString.dateRange.start.getDate()
+ : typeof (searchString.dateRange.start as Date).getTime === 'function'
+ ? (searchString.dateRange.start as Date).getTime()
+ : new Date(searchString.dateRange.start).getTime()
: 0;
const endDate = searchString.dateRange.end
? typeof searchString.dateRange.end === 'string'
? Date.parse(searchString.dateRange.end)
- : searchString.dateRange.end.getDate()
+ : typeof (searchString.dateRange.end as Date).getTime === 'function'
+ ? (searchString.dateRange.end as Date).getTime()
+ : new Date(searchString.dateRange.end).getTime()
: 0;
const startedDateWithoutTime = new Date(startedDate).toDateString();
@@ -366,15 +544,19 @@ export class ReportsStore extends ComponentStore {
private isFiltersEmpty(filteredValues: Filters) {
return Object.values(filteredValues).every(value => {
- if (value.start === '') {
- return value.end.length === 0;
+ if (
+ value instanceof DateRange ||
+ (typeof value === 'object' && value !== null && !Array.isArray(value))
+ ) {
+ return !value.start && !value.end;
}
- return value.length === 0;
+ return value === null || value === undefined || value.length === 0;
});
}
constructor() {
super({
displayedColumns: [
+ 'expand',
'started',
'duration',
'deviceInfo',
@@ -392,6 +574,12 @@ export class ReportsStore extends ComponentStore {
deviceFirmware: '',
results: [],
dateRange: '',
+ quickSearch: [],
+ location: '',
+ linuxEnv: '',
+ pythonVersion: '',
+ kernel: '',
+ assessmentType: '',
},
dataLoaded: false,
selectedRow: null,
diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html
index f1df53e63..19337c1f6 100644
--- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html
+++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html
@@ -40,7 +40,9 @@
profile.status === ProfileStatus.DRAFT ||
profile.status === ProfileStatus.COPY
) {
-
+
+ edit_note
+
}
@if (profile.status === ProfileStatus.EXPIRED) {
@@ -51,6 +53,11 @@
{{ profile.name }}
+ @if (profile.status === ProfileStatus.DRAFT) {
+
+ {{ ProfileStatus.DRAFT }}
+
+ }
@if (profile.risk) {
{{ profile.risk }} risk
diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts
index 28eb0bf98..713357267 100644
--- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts
+++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts
@@ -19,6 +19,7 @@ import { ProfileItemComponent } from './profile-item.component';
import { PROFILE_MOCK } from '../../../mocks/profile.mock';
import { TestRunService } from '../../../services/test-run.service';
import { LiveAnnouncer } from '@angular/cdk/a11y';
+import { ProfileStatus } from '../../../model/profile';
describe('ProfileItemComponent', () => {
let component: ProfileItemComponent;
@@ -85,4 +86,42 @@ describe('ProfileItemComponent', () => {
expect(profileClickedSpy).toHaveBeenCalled();
});
+
+ it('should display check_circle icon for valid profile', () => {
+ const icon = compiled.querySelector('.profile-item-icon');
+ expect(icon?.textContent?.trim()).toBe('check_circle');
+ });
+
+ it('should display edit_note icon for draft profile', () => {
+ fixture.componentRef.setInput('profile', {
+ ...PROFILE_MOCK,
+ status: ProfileStatus.DRAFT,
+ });
+ fixture.detectChanges();
+
+ const icon = compiled.querySelector('.profile-item-icon');
+ expect(icon?.textContent?.trim()).toBe('edit_note');
+ });
+
+ it('should display edit_note icon for copy profile', () => {
+ fixture.componentRef.setInput('profile', {
+ ...PROFILE_MOCK,
+ status: ProfileStatus.COPY,
+ });
+ fixture.detectChanges();
+
+ const icon = compiled.querySelector('.profile-item-icon');
+ expect(icon?.textContent?.trim()).toBe('edit_note');
+ });
+
+ it('should display error icon for expired profile', () => {
+ fixture.componentRef.setInput('profile', {
+ ...PROFILE_MOCK,
+ status: ProfileStatus.EXPIRED,
+ });
+ fixture.detectChanges();
+
+ const icon = compiled.querySelector('.profile-item-icon');
+ expect(icon?.textContent?.trim()).toBe('error');
+ });
});
diff --git a/modules/ui/src/styles.scss b/modules/ui/src/styles.scss
index 3473b76fd..81f299692 100644
--- a/modules/ui/src/styles.scss
+++ b/modules/ui/src/styles.scss
@@ -74,6 +74,7 @@
item-label-text-size: 16px,
item-label-text-font: variables.$font-text,
item-hover-state-layer-color: colors.$secondary-container,
+ item-focus-state-layer-color: colors.$secondary-container,
item-with-icon-leading-spacing: 24px,
item-with-icon-trailing-spacing: 24px,
)
@@ -254,8 +255,17 @@ body:has(app-help-tip) .app-toolbar-button.app-toolbar-button-help-tips {
}
}
-.filter-button.active .filter-icon path {
- fill: mat.get-theme-color($light-theme, primary, 35);
+.filter-button.active {
+ background-color: colors.$secondary-container;
+ color: colors.$on-secondary-container;
+
+ .filter-icon {
+ color: colors.$on-secondary-container;
+
+ path {
+ fill: colors.$on-secondary-container;
+ }
+ }
}
.cell-result-text {
@@ -305,6 +315,8 @@ body:has(app-help-tip) .app-toolbar-button.app-toolbar-button-help-tips {
border-radius: 200px;
padding: 0 7px;
white-space: nowrap;
+ background: colors.$surface-variant;
+ color: colors.$on-surface-variant;
&.red {
background: colors.$error-container;
color: colors.$on-error-container;
@@ -566,6 +578,72 @@ button:not(.mat-mdc-button-disabled) {
)
);
}
+
+.search-filter-menu {
+ width: 208px;
+ min-width: 208px;
+ max-width: 208px;
+ border-radius: variables.$corner-large;
+ background-color: colors.$surface-container-low;
+
+ @include mat.menu-overrides(
+ (
+ container-color: colors.$surface-container-low,
+ container-shape: variables.$corner-large,
+ item-label-text-color: #1f1f1f,
+ item-label-text-size: 14px,
+ item-label-text-font: variables.$font-text,
+ item-hover-state-layer-color: colors.$secondary-container,
+ item-focus-state-layer-color: colors.$secondary-container,
+ item-leading-spacing: 12px,
+ item-trailing-spacing: 12px,
+ item-with-icon-leading-spacing: 12px,
+ item-with-icon-trailing-spacing: 12px,
+ )
+ );
+
+ .filter-menu-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-left: 12px;
+ padding-right: 12px;
+ font-family: variables.$font-text;
+ font-size: 14px;
+ color: #1f1f1f;
+
+ .mat-mdc-menu-item-text {
+ order: 1;
+ flex: 1 1 auto;
+ }
+
+ .filter-menu-item-icon {
+ order: 2;
+ margin-right: 0;
+ margin-left: auto;
+ color: inherit;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 24px;
+ width: 24px;
+ height: 24px;
+ line-height: 24px;
+ }
+
+ &.active-filter-item {
+ background-color: colors.$secondary-container;
+ color: colors.$on-secondary-container;
+ font-weight: 500;
+ }
+
+ &:not(.cdk-keyboard-focused):not(:focus-visible):not(:hover):not(
+ .active-filter-item
+ ) {
+ background-color: transparent !important;
+ }
+ }
+}
button.mat-mdc-icon-button,
button.mat-mdc-button,
button.mdc-button--unelevated,
diff --git a/modules/ui/src/theming/variables.scss b/modules/ui/src/theming/variables.scss
index 4c5a69004..e8bc0db7c 100644
--- a/modules/ui/src/theming/variables.scss
+++ b/modules/ui/src/theming/variables.scss
@@ -21,6 +21,7 @@ $corner-large: 16px;
$corner-extra-large: 28px;
$corner-extra-extra-large: 48px;
$corner-medium: 12px;
+$corner-small: 8px;
$reports-table-header-size: 32px;
// nav variables
diff --git a/modules/ui/ui.Dockerfile b/modules/ui/ui.Dockerfile
index c6d691f12..5a8009c56 100644
--- a/modules/ui/ui.Dockerfile
+++ b/modules/ui/ui.Dockerfile
@@ -19,4 +19,5 @@ COPY modules/ui/dist/browser /usr/share/nginx/html
EXPOSE 8080
+#nosemgrep
CMD ["nginx", "-g", "daemon off;"]
diff --git a/resources/report/risk_report_styles.css b/resources/report/risk_report_styles.css
index dfe44349c..5067b2db6 100644
--- a/resources/report/risk_report_styles.css
+++ b/resources/report/risk_report_styles.css
@@ -176,7 +176,6 @@
display: inline-block;
width: 340px;
position: relative;
- height: 100%;
}
ul {
diff --git a/resources/report/test_report_styles.css b/resources/report/test_report_styles.css
index d41600c84..78cdb633a 100644
--- a/resources/report/test_report_styles.css
+++ b/resources/report/test_report_styles.css
@@ -1,3 +1,5 @@
+@import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Text:wght@400;500;700&family=Google+Sans+Flex:wght@400;500;700&display=swap');
+
/* Set some global variables */
:root {
--header-height: .75in;
@@ -8,6 +10,7 @@
--summary-height: 2.8in;
--vertical-line-height: calc(var(--summary-height)-.2in);
--vertical-line-pos-x: 25%;
+ --host-metadata-height: 1.2in;
}
@font-face {
@@ -17,6 +20,38 @@
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+ @font-face {
+ font-family: 'Google Sans Text';
+ font-style: normal;
+ font-weight: 400;
+ src: url(https://fonts.gstatic.com/s/googlesanstext/v29/5aUu9-KzpRiLCAt4Unrc-xIKmCU5qEp2iw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+ }
+
+ @font-face {
+ font-family: 'Google Sans Text';
+ font-style: normal;
+ font-weight: 500;
+ src: url(https://fonts.gstatic.com/s/googlesanstext/v29/5aUp9-KzpRiLCAt4Unrc-xIKmCU5oLlVnmhjtg.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+ }
+
+ @font-face {
+ font-family: 'Google Sans Text';
+ font-style: normal;
+ font-weight: 700;
+ src: url(https://fonts.gstatic.com/s/googlesanstext/v29/5aUp9-KzpRiLCAt4Unrc-xIKmCU5oPFTnmhjtg.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+ }
+
+ @font-face {
+ font-family: 'Google Sans Flex';
+ font-style: normal;
+ font-weight: 400 700;
+ src: url(https://fonts.gstatic.com/s/googlesansflex/v22/t5sEIQcYNIWbFgDgAAzZ34auoVyXkJCOvp3SFWJbN5hF8Ju1x6sKCyp0l9sI40swNJwInycYAJzz0m7kJ4qFQOJBOjLvDSndo0SKMpKSTzwliVdHAy4bxTDHg_ugnAakp8ubq8BIo1pdkkXZj4igdvKMDV8.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+ }
+
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
@@ -330,7 +365,7 @@
padding-top: 0.2in;
padding-left: 0.2in;
background-color: #F8F9FA;
- width: 250px;
+ width: 340px;
height: 100.4%;
}
@@ -356,6 +391,14 @@
color: #202124;
}
+ .summary-item-value.kernel-value{
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+ }
+
.summary-item-space {
position: relative;
padding-bottom: 15px;
@@ -364,7 +407,7 @@
.summary-device-modules {
position: absolute;
- left: 3.2in;
+ left: 3.9in;
top: .2in;
}
@@ -391,7 +434,7 @@
position: absolute;
right: 0;
top: 0;
- width: 2.6in;
+ width: 2.2in;
height: 100%;
}
@@ -422,6 +465,75 @@
left: 20px;
}
+ .summary-box-value kernel-value{
+ font-size: 16px;
+ }
+
+ /* Host metadata */
+
+ .host-metadata {
+ position: relative;
+ width: 100%;
+ height: var(--host-metadata-height); /* 2in */
+ margin-top: 0.2in;
+ margin-bottom: 0.25in;
+ box-sizing: border-box;
+ }
+
+ .host-metadata-card {
+ display: flex;
+ flex-direction: column;
+ padding: 16px 24px;
+ gap: 12px;
+ width: 100%;
+ height: 100%;
+ background: #F0F4F8;
+ border-radius: 0;
+ box-sizing: border-box;
+ }
+
+ .host-metadata-title {
+ font-family: 'Google Sans', sans-serif;
+ font-weight: 500;
+ font-size: 22px;
+ line-height: 28px;
+ color: #202124;
+ margin: 0;
+ }
+
+ .host-metadata-row {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 40px;
+ width: 100%;
+ }
+
+ .host-metadata-item {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 0 0 auto;
+ }
+
+ .host-metadata-item .summary-item-label h4 {
+ margin: 0;
+ font-family: 'Google Sans Text', sans-serif;
+ font-size: 12px;
+ font-weight: 500;
+ color: #5F6368;
+ white-space: nowrap;
+ text-transform: capitalize;
+ }
+
+ .host-metadata-item .summary-item-value {
+ font-family: 'Google Sans Flex', sans-serif;
+ font-size: 16px;
+ font-weight: 500;
+ color: #202124;
+ white-space: nowrap;
+ }
+
.result-list-title {
font-size: 24px;
color: black;
diff --git a/resources/test_packs/pilot/report_templates/summary.jinja b/resources/test_packs/pilot/report_templates/summary.jinja
index 47a83a4fe..32eeaf320 100644
--- a/resources/test_packs/pilot/report_templates/summary.jinja
+++ b/resources/test_packs/pilot/report_templates/summary.jinja
@@ -51,4 +51,27 @@